mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 04:42:01 +08:00
Compare commits
1 Commits
codex/code
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2afd1c0077 |
@@ -29,11 +29,6 @@ actions:
|
||||
- openclaw
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
blacksmith:
|
||||
org: openclaw
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
|
||||
4
.github/actionlint.yaml
vendored
4
.github/actionlint.yaml
vendored
@@ -14,10 +14,6 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-latest
|
||||
- blacksmith-12vcpu-macos-latest
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
- blacksmith-6vcpu-macos-26
|
||||
- blacksmith-12vcpu-macos-26
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
|
||||
14
.github/actions/setup-node-env/action.yml
vendored
14
.github/actions/setup-node-env/action.yml
vendored
@@ -20,13 +20,9 @@ inputs:
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore the pnpm store with actions/cache.
|
||||
description: Whether to restore and save the pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
save-actions-cache:
|
||||
description: Whether to save the pnpm store with actions/cache after install when no exact cache restored.
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -49,7 +45,6 @@ runs:
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
id: setup-pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
@@ -135,10 +130,3 @@ runs:
|
||||
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.setup-pnpm.outputs.store-path }}
|
||||
key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}
|
||||
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether actions/cache should restore the pnpm store.
|
||||
description: Whether actions/cache should cache the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
@@ -24,15 +24,6 @@ outputs:
|
||||
project-dir:
|
||||
description: Directory containing the packageManager file used for pnpm resolution.
|
||||
value: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
store-cache-hit:
|
||||
description: Whether the pnpm store cache restored an exact key.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-hit }}
|
||||
store-cache-primary-key:
|
||||
description: Exact pnpm store cache key used for restore/save.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-primary-key }}
|
||||
store-path:
|
||||
description: Resolved pnpm store path.
|
||||
value: ${{ steps.pnpm-store.outputs.path }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -90,15 +81,14 @@ runs:
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
id: pnpm-store-cache
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
|
||||
@@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
|
||||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in ${roots[@]+"${roots[@]}"}; do
|
||||
for node_root in "${roots[@]}"; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then
|
||||
|
||||
86
.github/workflows/ci.yml
vendored
86
.github/workflows/ci.yml
vendored
@@ -414,73 +414,13 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
# Warm the lockfile- and pnpm-pinned store once before Linux Node shards fan out.
|
||||
# On a cold key this job owns the save, so later shards restore the exact key.
|
||||
pnpm-store-warmup:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
save-actions-cache: "true"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -712,7 +652,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -801,7 +741,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -881,7 +821,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -1033,7 +973,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -1139,8 +1079,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -1232,7 +1172,7 @@ jobs:
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1270,8 +1210,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -1437,7 +1377,7 @@ jobs:
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -1655,7 +1595,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-15' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1704,7 +1644,7 @@ jobs:
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -20,7 +20,7 @@ permissions:
|
||||
jobs:
|
||||
macos:
|
||||
name: Critical Security (macOS)
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -480,35 +480,6 @@ jobs:
|
||||
fi
|
||||
exit 1
|
||||
|
||||
plan_release_workflow_matrices:
|
||||
needs: validate_selected_ref
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
docker_e2e_count: ${{ steps.plan.outputs.docker_e2e_count }}
|
||||
docker_e2e_matrix: ${{ steps.plan.outputs.docker_e2e_matrix }}
|
||||
docker_e2e_omitted_json: ${{ steps.plan.outputs.docker_e2e_omitted_json }}
|
||||
live_models_count: ${{ steps.plan.outputs.live_models_count }}
|
||||
live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }}
|
||||
live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }}
|
||||
steps:
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Plan release workflow matrices
|
||||
id: plan
|
||||
env:
|
||||
DOCKER_LANES: ${{ inputs.docker_lanes }}
|
||||
INCLUDE_LIVE_SUITES: ${{ inputs.include_live_suites }}
|
||||
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
|
||||
LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_providers }}
|
||||
LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }}
|
||||
run: node scripts/plan-release-workflow-matrix.mjs >> "$GITHUB_OUTPUT"
|
||||
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
@@ -665,15 +636,72 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.docker_e2e_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- chunk_id: core
|
||||
label: core
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-core
|
||||
label: package/update core
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: plugins-runtime-plugins
|
||||
label: plugins/runtime plugins
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-services
|
||||
label: plugins/runtime services
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-a
|
||||
label: plugins/runtime install A
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-b
|
||||
label: plugins/runtime install B
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-c
|
||||
label: plugins/runtime install C
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-d
|
||||
label: plugins/runtime install D
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-e
|
||||
label: plugins/runtime install E
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-f
|
||||
label: plugins/runtime install F
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-g
|
||||
label: plugins/runtime install G
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-h
|
||||
label: plugins/runtime install H
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1603,14 +1631,42 @@ jobs:
|
||||
|
||||
validate_live_models_docker:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0'
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.live_models_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- provider_label: Anthropic
|
||||
providers: anthropic
|
||||
profiles: stable full
|
||||
- provider_label: Google
|
||||
providers: google
|
||||
profiles: stable full
|
||||
- provider_label: MiniMax
|
||||
providers: minimax
|
||||
profiles: stable full
|
||||
- provider_label: OpenAI
|
||||
providers: openai
|
||||
profiles: beta minimum stable full
|
||||
- provider_label: OpenCode
|
||||
providers: opencode-go
|
||||
profiles: full
|
||||
- provider_label: OpenRouter
|
||||
providers: openrouter
|
||||
profiles: full
|
||||
- provider_label: xAI
|
||||
providers: xai
|
||||
profiles: full
|
||||
- provider_label: Z.ai
|
||||
providers: zai
|
||||
profiles: full
|
||||
- provider_label: Fireworks
|
||||
providers: fireworks
|
||||
profiles: full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1688,8 +1744,6 @@ jobs:
|
||||
- name: Validate provider credential
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
shell: bash
|
||||
env:
|
||||
LIVE_MODEL_PROVIDERS: ${{ matrix.providers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1706,7 +1760,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
case "${{ matrix.providers }}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
@@ -1717,7 +1771,7 @@ jobs:
|
||||
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
|
||||
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
|
||||
*)
|
||||
echo "Unhandled live model provider shard: ${LIVE_MODEL_PROVIDERS}" >&2
|
||||
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
2
.github/workflows/website-installer-sync.yml
vendored
2
.github/workflows/website-installer-sync.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
|
||||
|
||||
macos-installer:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -8,7 +8,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
|
||||
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
|
||||
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682)
|
||||
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
|
||||
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
|
||||
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
|
||||
@@ -20,7 +19,6 @@ Docs: https://docs.openclaw.ai
|
||||
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
|
||||
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
- PDF/tools: use ClawPDF for PDF extraction and surface MCP structured content in agent tool results. (#87670)
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -30,8 +28,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
|
||||
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, and sandbox stat fields.
|
||||
- Providers/agents: preserve seeded Anthropic signatures, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, and recover empty preflight compaction. (#87593)
|
||||
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
|
||||
|
||||
2
apps/swabble/.github/workflows/ci.yml
vendored
2
apps/swabble/.github/workflows/ci.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@@ -50,7 +50,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
||||
"lit",
|
||||
"linkedom",
|
||||
"openclaw",
|
||||
"clawpdf",
|
||||
"pdfjs-dist",
|
||||
] as const;
|
||||
|
||||
const rootBundledPluginRuntimeDependencies = [
|
||||
@@ -70,7 +70,7 @@ const rootBundledPluginRuntimeDependencies = [
|
||||
"minimatch",
|
||||
"node-edge-tts",
|
||||
"openshell",
|
||||
"clawpdf",
|
||||
"pdfjs-dist",
|
||||
"tokenjuice",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1120,8 +1120,6 @@ Hide raw command/exec text while keeping compact progress lines:
|
||||
|
||||
`channels.slack.streaming.nativeTransport` controls Slack native text streaming when `channels.slack.streaming.mode` is `partial` (default: `true`).
|
||||
|
||||
Slack native progress task cards are opt-in for progress mode. Set `channels.slack.streaming.progress.nativeTaskCards` to `true` with `channels.slack.streaming.mode="progress"` to send a Slack-native plan/task card while work is running, then update the same task card at completion. Without this flag, progress mode keeps the portable draft-preview behavior.
|
||||
|
||||
- A reply thread must be available for native text streaming and Slack assistant thread status to appear. Thread selection still follows `replyToMode`.
|
||||
- Channel, group-chat, and top-level DM roots can still use the normal draft preview when native streaming is unavailable or no reply thread exists.
|
||||
- Top-level Slack DMs stay off-thread by default, so they do not show Slack's thread-style native stream/status preview; OpenClaw posts and edits a draft preview in the DM instead.
|
||||
@@ -1144,24 +1142,6 @@ Use draft preview instead of Slack native text streaming:
|
||||
}
|
||||
```
|
||||
|
||||
Opt in to Slack native progress task cards:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
nativeTaskCards: true,
|
||||
render: "rich",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Legacy keys:
|
||||
|
||||
- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`.
|
||||
|
||||
@@ -127,10 +127,10 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Linux Node test shards, bundled plugin test shards, `check-additional-*` shards, `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
| `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` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path. During `preflight`, `scripts/ci-runner-labels.mjs` checks recent queued and in-progress Actions runs for queued Blacksmith jobs. If a specific Blacksmith label already has queued jobs, downstream jobs that would use that exact label fall back to the matching GitHub-hosted runner (`ubuntu-24.04`, `windows-2025`, `macos-15`, or `macos-26`) for that run only. Other Blacksmith sizes in the same OS family stay on their primary labels. If the API probe fails, no fallback is applied.
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path. During `preflight`, `scripts/ci-runner-labels.mjs` checks recent queued and in-progress Actions runs for queued Blacksmith jobs. If a specific Blacksmith label already has queued jobs, downstream jobs that would use that exact label fall back to the matching GitHub-hosted runner (`ubuntu-24.04`, `windows-2025`, or `macos-latest`) for that run only. Other Blacksmith sizes in the same OS family stay on their primary labels. If the API probe fails, no fallback is applied.
|
||||
|
||||
## Local equivalents
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ is available, then fall back to `latest`.
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run in one managed npm project per plugin with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm projects inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
|
||||
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover unless they match an official plugin id.
|
||||
|
||||
@@ -192,10 +192,10 @@ is available, then fall back to `latest`.
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records.
|
||||
|
||||
Use `npm-pack:<path.tgz>` when the file is an npm-pack tarball and you want
|
||||
to test the same per-plugin managed npm project path used by registry
|
||||
installs, including `package-lock.json` verification, hoisted dependency
|
||||
scanning, and npm install records. Plain archive paths still install as local
|
||||
archives under the plugin extensions root.
|
||||
to test the same managed npm-root install path used by registry installs,
|
||||
including `package-lock.json` verification, hoisted dependency scanning, and
|
||||
npm install records. Plain archive paths still install as local archives
|
||||
under the plugin extensions root.
|
||||
|
||||
Claude marketplace installs are also supported.
|
||||
|
||||
@@ -435,7 +435,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed
|
||||
|
||||
Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path.
|
||||
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under a managed plugin npm project or the legacy flat managed npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
|
||||
<Warning>
|
||||
`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out.
|
||||
|
||||
@@ -110,7 +110,7 @@ openclaw sessions cleanup --json
|
||||
- `--dry-run`: preview how many entries would be pruned/capped without writing.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing or header-only/empty, even if they would not normally age/count out yet.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing, even if they would not normally age/count out yet.
|
||||
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
|
||||
- `--active-key <key>`: protect a specific active key from disk-budget eviction. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance.
|
||||
- `--agent <id>`: run cleanup for one configured agent store.
|
||||
|
||||
@@ -65,10 +65,6 @@ OpenClaw loads skills from these locations (highest precedence first):
|
||||
- Bundled (shipped with the install)
|
||||
- Extra skill folders: `skills.load.extraDirs`
|
||||
|
||||
Skill roots can contain grouped folders such as
|
||||
`<workspace>/skills/personal/foo/SKILL.md`; the skill is still exposed by its
|
||||
flat frontmatter name, for example `foo`.
|
||||
|
||||
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
|
||||
|
||||
## Runtime boundaries
|
||||
|
||||
@@ -141,8 +141,8 @@ layer stack for Telegram direct, Discord group, and heartbeat turns. That stack
|
||||
includes a pinned Codex `gpt-5.5` model prompt fixture generated from Codex's
|
||||
model catalog/cache shape, the Codex happy-path permission developer text,
|
||||
OpenClaw developer instructions, turn-scoped collaboration-mode instructions
|
||||
for cron and heartbeat turns when OpenClaw provides them, user turn input, and
|
||||
references to the dynamic tool specs.
|
||||
when OpenClaw provides them, user turn input, and references to the dynamic tool
|
||||
specs.
|
||||
|
||||
Refresh the pinned Codex model prompt fixture with
|
||||
`pnpm prompt:snapshots:sync-codex-model`. By default, the script looks for
|
||||
@@ -178,19 +178,18 @@ prompt surface that matches their lifetime:
|
||||
- `BOOTSTRAP.md` (only on brand-new workspaces)
|
||||
- `MEMORY.md` when present
|
||||
|
||||
On the native Codex harness, OpenClaw avoids repeating standing workspace files
|
||||
On the native Codex harness, OpenClaw avoids repeating stable workspace files
|
||||
in every user turn. Codex loads `AGENTS.md` through its own project-doc
|
||||
discovery. `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md` are forwarded as
|
||||
Codex thread developer instructions. `HEARTBEAT.md` content is not injected;
|
||||
heartbeat turns get a collaboration-mode note pointing to the file when it
|
||||
exists and is non-empty. `MEMORY.md` stays out of Codex thread developer
|
||||
instructions because it changes often: when memory tools are available for that
|
||||
workspace, Codex turns get a small workspace-memory note and should use
|
||||
`memory_search` or `memory_get` when durable memory is relevant. If tools are
|
||||
disabled, memory search is unavailable, or the active workspace differs from the
|
||||
agent memory workspace, `MEMORY.md` falls back to the normal bounded
|
||||
turn-context path. Active `BOOTSTRAP.md` content keeps the normal turn-context
|
||||
role for now.
|
||||
Codex developer instructions. `HEARTBEAT.md` content is not injected; heartbeat
|
||||
turns get a collaboration-mode note pointing to the file when it exists and is
|
||||
non-empty. `MEMORY.md` content from the configured agent workspace is not pasted
|
||||
into every native Codex turn; when memory tools are available for that workspace,
|
||||
Codex turns get a small workspace-memory note and should use `memory_search` or
|
||||
`memory_get` when durable memory is relevant. If tools are disabled, memory
|
||||
search is unavailable, or the active workspace differs from the agent memory
|
||||
workspace, `MEMORY.md` falls back to the normal bounded turn-context path. Active
|
||||
`BOOTSTRAP.md` content keeps the normal turn-context role for now.
|
||||
|
||||
On non-Codex harnesses, bootstrap files continue to be composed into the
|
||||
OpenClaw prompt according to their existing gates. `HEARTBEAT.md` is omitted on
|
||||
@@ -259,10 +258,6 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
|
||||
location (workspace, managed, or bundled). If no skills are eligible, the
|
||||
Skills section is omitted.
|
||||
|
||||
The location can point at a nested skill, such as
|
||||
`skills/personal/foo/SKILL.md`. Nesting is only organizational; the prompt still
|
||||
uses the flat skill name from `SKILL.md` frontmatter.
|
||||
|
||||
Eligibility includes skill metadata gates, runtime environment/config checks,
|
||||
and the effective agent skill allowlist when `agents.defaults.skills` or
|
||||
`agents.list[].skills` is configured.
|
||||
|
||||
@@ -175,9 +175,9 @@ Current behavior:
|
||||
rasterized into images and passed to the model, and the injected file block uses
|
||||
the placeholder `[PDF content rendered to images]`.
|
||||
|
||||
PDF parsing is provided by the bundled `document-extract` plugin, which uses
|
||||
`clawpdf` and its packaged PDFium WebAssembly runtime for text extraction and
|
||||
page rendering.
|
||||
PDF parsing is provided by the bundled `document-extract` plugin, which uses the
|
||||
Node-friendly `pdfjs-dist` legacy build (no worker). The modern PDF.js build
|
||||
expects browser workers/DOM globals, so it is not used in the Gateway.
|
||||
|
||||
URL fetch defaults:
|
||||
|
||||
|
||||
@@ -562,14 +562,8 @@ terminal summary, and sanitized error text.
|
||||
- `sessionKey` is required.
|
||||
- The gateway derives trusted runtime context from the session server-side instead of accepting
|
||||
caller-supplied auth or delivery context.
|
||||
- The response is a session-scoped server-derived projection of the active inventory,
|
||||
including core, plugin, channel, and already-discovered MCP server tools.
|
||||
- `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the
|
||||
final tool policy, but it does not create MCP runtimes, connect transports, or issue
|
||||
`tools/list`. If no matching warm catalog exists, the response may include a notice such as
|
||||
`mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`.
|
||||
- Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or
|
||||
`source="mcp"`.
|
||||
- The response is session-scoped and reflects what the active conversation can use right now,
|
||||
including core, plugin, and channel tools.
|
||||
- Operators may call `tools.invoke` (`operator.write`) to invoke one available tool through the
|
||||
same gateway policy path as `/tools/invoke`.
|
||||
- `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and
|
||||
|
||||
@@ -71,13 +71,12 @@ Live tests are split into two layers so we can isolate failures:
|
||||
- Run a small completion per model (and targeted regressions where needed)
|
||||
- How to enable:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- Set `OPENCLAW_LIVE_MODELS=modern`, `small`, or `all` (alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
|
||||
- Set `OPENCLAW_LIVE_MODELS=modern` (or `all`, alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
|
||||
- How to select models:
|
||||
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M2.7, Grok 4.3)
|
||||
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, OpenRouter Qwen/GLM, and Z.AI GLM)
|
||||
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
|
||||
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.5,openai-codex/gpt-5.5,anthropic/claude-opus-4-6,..."` (comma allowlist)
|
||||
- Modern/all and small sweeps default to their curated caps; set `OPENCLAW_LIVE_MAX_MODELS=0` for an exhaustive selected-profile sweep or a positive number for a smaller cap.
|
||||
- Modern/all sweeps default to a curated high-signal cap; set `OPENCLAW_LIVE_MAX_MODELS=0` for an exhaustive modern sweep or a positive number for a smaller cap.
|
||||
- Exhaustive sweeps use `OPENCLAW_LIVE_TEST_TIMEOUT_MS` for the whole direct-model test timeout. Default: 60 minutes.
|
||||
- Direct-model probes run with 20-way parallelism by default; set `OPENCLAW_LIVE_MODEL_CONCURRENCY` to override.
|
||||
- How to select providers:
|
||||
@@ -340,12 +339,6 @@ Narrow, explicit allowlists are fastest and least flaky:
|
||||
- Single model, direct (no gateway):
|
||||
- `OPENCLAW_LIVE_MODELS="openai/gpt-5.5" pnpm test:live src/agents/models.profiles.live.test.ts`
|
||||
|
||||
- Small-model direct profile:
|
||||
- `OPENCLAW_LIVE_MODELS=small pnpm test:live src/agents/models.profiles.live.test.ts`
|
||||
|
||||
- Ollama Cloud API smoke:
|
||||
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_OLLAMA=1 OPENCLAW_LIVE_OLLAMA_BASE_URL=https://ollama.com OPENCLAW_LIVE_OLLAMA_MODEL=glm-5.1:cloud OPENCLAW_LIVE_OLLAMA_WEB_SEARCH=0 pnpm test:live -- extensions/ollama/ollama.live.test.ts`
|
||||
|
||||
- Single model, gateway smoke:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ Update and plugin tests protect these contracts:
|
||||
plugin state.
|
||||
- Plugin installs work from local directories, git repos, npm packages, and the
|
||||
ClawHub registry path.
|
||||
- Plugin npm dependencies are installed in one managed npm project per plugin,
|
||||
scanned before trust, and removed through npm during uninstall so hoisted
|
||||
dependencies do not linger.
|
||||
- Plugin npm dependencies are installed in the managed npm root, scanned before
|
||||
trust, and removed through npm during uninstall so hoisted dependencies do not
|
||||
linger.
|
||||
- Plugin update is stable when nothing changed: install records, resolved
|
||||
source, installed dependency layout, and enabled state stay intact.
|
||||
|
||||
@@ -276,9 +276,9 @@ can fail for the right reason:
|
||||
- Registry/package source behavior: `test:docker:plugins` fixture or ClawHub
|
||||
fixture server.
|
||||
- Dependency layout or cleanup behavior: assert both runtime execution and the
|
||||
filesystem boundary. npm dependencies may be hoisted inside the plugin's
|
||||
managed npm project, so tests should prove that project is scanned/cleaned
|
||||
instead of assuming only the plugin package-local `node_modules` tree.
|
||||
filesystem boundary. npm dependencies may be hoisted under the managed npm
|
||||
root, so tests should prove the root is scanned/cleaned instead of assuming a
|
||||
package-local `node_modules` tree.
|
||||
|
||||
Keep new Docker fixtures hermetic by default. Use local fixture registries and
|
||||
fake packages unless the point of the test is live registry behavior.
|
||||
|
||||
@@ -84,12 +84,11 @@ When debugging real providers/models (requires real creds):
|
||||
- Codex on-demand install smoke: `pnpm test:docker:codex-on-demand`
|
||||
- Installs the packaged OpenClaw tarball in Docker, runs OpenAI API-key
|
||||
onboarding, and verifies the Codex plugin plus `@openai/codex` dependency
|
||||
were downloaded into the managed npm project root on demand.
|
||||
were downloaded into the managed npm root on demand.
|
||||
- Live plugin tool dependency smoke: `pnpm test:docker:live-plugin-tool`
|
||||
- Packs a fixture plugin with a real `slugify` dependency, installs it through
|
||||
`npm-pack:`, verifies the dependency under the managed npm project root,
|
||||
then asks a live OpenAI model to call the plugin tool and return the hidden
|
||||
slug.
|
||||
`npm-pack:`, verifies the dependency under the managed npm root, then asks a
|
||||
live OpenAI model to call the plugin tool and return the hidden slug.
|
||||
- Crestodian rescue command smoke: `pnpm test:live:crestodian-rescue-channel`
|
||||
- Opt-in belt-and-suspenders check for the message-channel rescue command
|
||||
surface. It exercises `/crestodian status`, queues a persistent model
|
||||
@@ -739,13 +738,13 @@ plugin validation checklist, see
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run only their matching profile-key live file inside the repo Docker image (`src/agents/models.profiles.live.test.ts` and `src/gateway/gateway-models.profiles.live.test.ts`), mounting your local config dir, workspace, and optional profile env file. The matching local entrypoints are `test:live:models-profiles` and `test:live:gateway-profiles`.
|
||||
- Docker live runners keep their own practical caps where needed:
|
||||
`test:docker:live-models` defaults to the curated supported high-signal set, and
|
||||
- Docker live runners default to a smaller smoke cap so a full Docker sweep stays practical:
|
||||
`test:docker:live-models` defaults to `OPENCLAW_LIVE_MAX_MODELS=12`, and
|
||||
`test:docker:live-gateway` defaults to `OPENCLAW_LIVE_GATEWAY_SMOKE=1`,
|
||||
`OPENCLAW_LIVE_GATEWAY_MAX_MODELS=8`,
|
||||
`OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=45000`, and
|
||||
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Set `OPENCLAW_LIVE_MAX_MODELS`
|
||||
or the gateway env vars when you explicitly want a smaller cap or larger scan.
|
||||
`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`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials.
|
||||
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. Profiles are ordered by breadth: `smoke`, `package`, `product`, and `full`. See [Testing updates and plugins](/help/testing-updates-plugins) for the package/update/plugin contract, published-upgrade survivor matrix, release defaults, and failure triage.
|
||||
- Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command.
|
||||
|
||||
@@ -281,9 +281,8 @@ fresh OpenClaw session.
|
||||
**A Computer Use tool says `Native hook relay unavailable`.** The Codex-native
|
||||
tool hook could not reach an active OpenClaw relay through the local bridge or
|
||||
Gateway fallback. Start a fresh OpenClaw session with `/new` or `/reset`. If it
|
||||
works once and then fails again on a later tool call, `/new` is only clearing the
|
||||
current attempt; restart the Codex app-server or OpenClaw Gateway so old threads
|
||||
and hook registrations are dropped, then retry in a fresh session.
|
||||
keeps happening, restart the gateway so old app-server threads and hook
|
||||
registrations are dropped, then retry.
|
||||
|
||||
**Turn-start auto-install refuses a source.** This is intentional. Add the
|
||||
source with explicit `/codex computer-use install --source <marketplace-source>`
|
||||
|
||||
@@ -738,11 +738,9 @@ protocol version.
|
||||
the Codex thread is still trying to use a native hook relay id that OpenClaw no
|
||||
longer has registered. This is a native Codex hook transport problem, not an ACP
|
||||
backend, provider, GitHub, or shell-command failure. Start a fresh session in
|
||||
the affected chat with `/new` or `/reset`, then retry a harmless command. If that
|
||||
works once but the next native tool call fails again, treat `/new` as a temporary
|
||||
workaround only: copy the prompt into a fresh session after restarting the Codex
|
||||
app-server or OpenClaw Gateway so old threads are dropped and native hook
|
||||
registrations are recreated.
|
||||
the affected chat with `/new` or `/reset`, then retry a harmless command. If the
|
||||
same fresh session still fails, restart the Codex app-server or OpenClaw Gateway
|
||||
so native hook registrations are recreated.
|
||||
|
||||
**A non-Codex model uses the built-in harness:** that is expected unless
|
||||
provider or model runtime policy routes it to another harness. Plain non-OpenAI
|
||||
|
||||
@@ -34,36 +34,34 @@ OpenClaw owns only the plugin lifecycle:
|
||||
|
||||
OpenClaw uses stable per-source roots:
|
||||
|
||||
- npm packages install into per-plugin projects under
|
||||
`~/.openclaw/npm/projects/<encoded-package>`
|
||||
- npm packages install under `~/.openclaw/npm`
|
||||
- git packages clone under `~/.openclaw/git`
|
||||
- local/path/archive installs are copied or referenced without dependency repair
|
||||
|
||||
npm installs run in that per-plugin project root with:
|
||||
npm installs run in the npm root with:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/npm/projects/<encoded-package>
|
||||
cd ~/.openclaw/npm
|
||||
npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
```
|
||||
|
||||
`openclaw plugins install npm-pack:<path.tgz>` uses that same per-plugin npm
|
||||
project root for a local npm-pack tarball. OpenClaw reads the tarball's npm
|
||||
metadata, adds it to the managed project as a copied `file:` dependency, runs
|
||||
the normal npm install, and then verifies the installed lockfile metadata before
|
||||
trusting the plugin.
|
||||
`openclaw plugins install npm-pack:<path.tgz>` uses that same managed npm root
|
||||
for a local npm-pack tarball. OpenClaw reads the tarball's npm metadata, adds it
|
||||
to the managed root as a copied `file:` dependency, runs the normal npm install,
|
||||
and then verifies the installed lockfile metadata before trusting the plugin.
|
||||
This is intended for package-acceptance and release-candidate proof where a
|
||||
local pack artifact should behave like the registry artifact it simulates.
|
||||
|
||||
npm may hoist transitive dependencies to the per-plugin project's
|
||||
`node_modules` beside the plugin package. OpenClaw scans the managed project
|
||||
root before trusting the install and removes that project during uninstall, so
|
||||
hoisted runtime dependencies stay inside that plugin's cleanup boundary.
|
||||
npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside
|
||||
the plugin package. OpenClaw scans the managed npm root before trusting the
|
||||
install and uses npm to remove npm-managed packages during uninstall, so hoisted
|
||||
runtime dependencies stay inside the managed cleanup boundary.
|
||||
|
||||
Published npm plugin packages can ship `npm-shrinkwrap.json`. npm uses that
|
||||
publishable lockfile during install, and OpenClaw's managed npm project root
|
||||
supports it through the normal npm install path. OpenClaw-owned publishable
|
||||
plugin packages must include a package-local shrinkwrap generated from that
|
||||
plugin package's published dependency graph:
|
||||
publishable lockfile during install, and OpenClaw's managed npm root supports it
|
||||
through the normal npm install path. OpenClaw-owned publishable plugin packages
|
||||
must include a package-local shrinkwrap generated from that plugin package's
|
||||
published dependency graph:
|
||||
|
||||
```bash
|
||||
pnpm deps:shrinkwrap:generate
|
||||
@@ -89,11 +87,11 @@ instead of embedding every platform binary in the plugin tarball. The root
|
||||
|
||||
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
|
||||
dependency. OpenClaw does not let npm install a separate registry copy of the
|
||||
host package into a managed project, because stale host packages can affect npm
|
||||
peer resolution inside that plugin. Managed npm installs skip npm peer
|
||||
resolution/materialization and OpenClaw reasserts plugin-local
|
||||
`node_modules/openclaw` links for installed packages that declare the host peer
|
||||
after install or update.
|
||||
host package into the managed root, because stale host packages can affect npm
|
||||
peer resolution during later plugin installs. Managed npm installs skip npm peer
|
||||
resolution/materialization for the shared root and OpenClaw reasserts
|
||||
plugin-local `node_modules/openclaw` links for installed packages that declare
|
||||
the host peer after install, update, or uninstall.
|
||||
|
||||
git installs clone or refresh the repository, then run:
|
||||
|
||||
@@ -157,7 +155,7 @@ not a supported way to prepare bundled plugin dependencies.
|
||||
| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `npm install -g openclaw` | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows |
|
||||
| Git checkout plus `pnpm install` | `extensions/<id>` workspace packages | The pnpm workspace, including each plugin package's own dependencies |
|
||||
| `openclaw plugins install ...` | Managed npm project/git/ClawHub root | The plugin install/update flow |
|
||||
| `openclaw plugins install ...` | Managed npm/git/ClawHub plugin root | The plugin install/update flow |
|
||||
|
||||
## Legacy cleanup
|
||||
|
||||
@@ -170,7 +168,4 @@ stage directories, and package-local pnpm stores. Packaged postinstall also
|
||||
removes those global symlinks before pruning the legacy target roots so upgrades
|
||||
do not leave dangling ESM package imports.
|
||||
|
||||
Older npm installs also used a shared `~/.openclaw/npm/node_modules` root.
|
||||
Current install, update, uninstall, and doctor flows still recognize that legacy
|
||||
flat root only for recovery and cleanup. New npm installs should create
|
||||
per-plugin project roots instead.
|
||||
These paths are legacy debris only. New installs should not create them.
|
||||
|
||||
@@ -71,8 +71,8 @@ pnpm openclaw onboard --mode local
|
||||
Verify the installed package under the state directory:
|
||||
|
||||
```bash
|
||||
find "$OPENCLAW_STATE_DIR/npm/projects" -path '*/node_modules/@openclaw/codex/package.json' -print
|
||||
grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/projects"/*/package-lock.json
|
||||
find "$OPENCLAW_STATE_DIR/npm/node_modules" -maxdepth 3 -name package.json -print
|
||||
grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/package-lock.json"
|
||||
```
|
||||
|
||||
For live provider E2E, source the real API key from a trusted shell or CI secret
|
||||
|
||||
@@ -1197,10 +1197,9 @@ Important examples:
|
||||
| `openclaw.install.clawhubSpec` / `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
|
||||
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
|
||||
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
|
||||
Manifest metadata decides which provider/channel/setup choices appear in
|
||||
onboarding before runtime loads. `package.json#openclaw.install` tells
|
||||
@@ -1212,17 +1211,6 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
|
||||
newer-but-valid values skip external plugins on older hosts. Bundled source
|
||||
plugins are assumed to be co-versioned with the host checkout.
|
||||
|
||||
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
|
||||
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
|
||||
package was built against. It can be stricter than `minHostVersion` when a
|
||||
plugin package needs a newer API but still keeps a lower install hint for other
|
||||
flows. Official OpenClaw release sync bumps existing official plugin API floors
|
||||
to the OpenClaw release version by default, but plugin-only releases can keep a
|
||||
lower floor when the package intentionally supports older hosts. Do not use the
|
||||
package version alone as the compatibility contract. `peerDependencies.openclaw`
|
||||
remains npm package metadata; OpenClaw uses the `openclaw.compat.pluginApi`
|
||||
contract for install compatibility decisions.
|
||||
|
||||
Official install-on-demand metadata should use `clawhubSpec` when the plugin is
|
||||
published on ClawHub; onboarding treats that as the preferred remote source and
|
||||
records ClawHub artifact facts after install. `npmSpec` remains the compatibility
|
||||
|
||||
@@ -246,22 +246,11 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMyChannelRuntime",
|
||||
},
|
||||
registerSetupRuntime(api) {
|
||||
api.registerHttpRoute({
|
||||
path: "/my-channel/events",
|
||||
auth: "plugin",
|
||||
handler: async (req, res) => {
|
||||
/* setup-safe route */
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use that bundled contract only when setup flows truly need a lightweight runtime
|
||||
setter or setup-safe gateway surface before the full channel entry loads.
|
||||
`registerSetupRuntime` runs only for `"setup-runtime"` loads; keep it limited to
|
||||
config-only routes or methods that must exist before deferred full activation.
|
||||
setter before the full channel entry loads.
|
||||
|
||||
## Registration mode
|
||||
|
||||
|
||||
@@ -534,7 +534,7 @@ openclaw plugins install <package-name>
|
||||
```
|
||||
|
||||
<Info>
|
||||
For npm-sourced installs, `openclaw plugins install` installs the package into a per-plugin project under `~/.openclaw/npm/projects` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds.
|
||||
For npm-sourced installs, `openclaw plugins install` installs the package under `~/.openclaw/npm` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -82,7 +82,7 @@ openclaw onboard --non-interactive \
|
||||
|
||||
## Custom Fireworks model ids
|
||||
|
||||
OpenClaw accepts any Fireworks model or router id at runtime. Use the exact id shown by Fireworks and prefix it with `fireworks/`. Dynamic resolution clones the Fire Pass template (text + image input, OpenAI-compatible API, default cost zero) and disables thinking automatically when the id matches the Kimi pattern. GLM dynamic ids are marked text-only unless you configure a custom model entry with image input.
|
||||
OpenClaw accepts any Fireworks model or router id at runtime. Use the exact id shown by Fireworks and prefix it with `fireworks/`. Dynamic resolution clones the Fire Pass template (text + image input, OpenAI-compatible API, default cost zero) and disables thinking automatically when the id matches the Kimi pattern.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -62,29 +62,14 @@ openclaw onboard --auth-choice nvidia-api-key --nvidia-api-key "nvapi-..."
|
||||
}
|
||||
```
|
||||
|
||||
## Featured catalog
|
||||
## Built-in catalog
|
||||
|
||||
When an NVIDIA API key is configured, OpenClaw setup and model-selection paths
|
||||
try NVIDIA's public featured-model catalog from
|
||||
`https://assets.ngc.nvidia.com/products/api-catalog/featured-models.json` and
|
||||
caches the ranked result for 24 hours. New featured models from build.nvidia.com
|
||||
therefore appear in setup and model-selection surfaces without waiting for an
|
||||
OpenClaw release.
|
||||
|
||||
The fetch uses a fixed HTTPS host policy for `assets.ngc.nvidia.com`. If no
|
||||
NVIDIA API key is configured, or if that public catalog is unavailable or
|
||||
malformed, OpenClaw falls back to the bundled catalog below.
|
||||
|
||||
## Bundled fallback catalog
|
||||
|
||||
| Model ref | Name | Context | Max output | Notes |
|
||||
| ------------------------------------------ | ---------------------------- | ------- | ---------- | --------------------------------- |
|
||||
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.7` | Minimax M2.7 | 196,608 | 8,192 | Featured fallback |
|
||||
| `nvidia/z-ai/glm-5.1` | GLM 5.1 | 202,752 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.5` | MiniMax M2.5 | 196,608 | 8,192 | Deprecated, upgrade compatibility |
|
||||
| `nvidia/z-ai/glm5` | GLM-5 | 202,752 | 8,192 | Deprecated, upgrade compatibility |
|
||||
| Model ref | Name | Context | Max output |
|
||||
| ------------------------------------------ | ---------------------------- | ------- | ---------- |
|
||||
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 |
|
||||
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 |
|
||||
| `nvidia/minimaxai/minimax-m2.5` | Minimax M2.5 | 196,608 | 8,192 |
|
||||
| `nvidia/z-ai/glm5` | GLM 5 | 202,752 | 8,192 |
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
@@ -95,11 +80,8 @@ malformed, OpenClaw falls back to the bundled catalog below.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Catalog and pricing">
|
||||
OpenClaw prefers NVIDIA's public featured-model catalog when NVIDIA auth is
|
||||
configured and caches it for 24 hours. The bundled fallback catalog is static
|
||||
and keeps deprecated shipped refs for upgrade compatibility. Costs default to
|
||||
`0` in source since NVIDIA currently offers free API access for the listed
|
||||
models.
|
||||
The bundled catalog is static. Costs default to `0` in source since NVIDIA
|
||||
currently offers free API access for the listed models.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAI-compatible endpoint">
|
||||
|
||||
@@ -141,12 +141,6 @@ vYYYY.M.D-beta.N` from the matching `release/YYYY.M.D` branch. The helper runs
|
||||
exports, and plugin SDK API baseline. `pnpm release:check` re-runs those
|
||||
guards in check mode and reports every generated drift failure it finds in one
|
||||
pass before running package release checks.
|
||||
- Plugin version sync updates official plugin package versions and existing
|
||||
`openclaw.compat.pluginApi` floors to the OpenClaw release version by
|
||||
default. Treat that field as the plugin SDK/runtime API floor, not just a copy
|
||||
of the package version: for plugin-only releases that intentionally remain
|
||||
compatible with older OpenClaw hosts, keep the floor at the oldest supported
|
||||
host API and document that choice in the plugin release proof.
|
||||
- Run the manual `Full Release Validation` workflow before release approval to
|
||||
kick off all pre-release test boxes from one entrypoint. It accepts a branch,
|
||||
tag, or full commit SHA, dispatches manual `CI`, and dispatches
|
||||
|
||||
@@ -8,10 +8,6 @@ title: "Tests"
|
||||
- Full testing kit (suites, live, Docker): [Testing](/help/testing)
|
||||
- Update and plugin package validation: [Testing updates and plugins](/help/testing-updates-plugins)
|
||||
|
||||
- Routine local test order:
|
||||
1. `pnpm test:changed` for changed-scope Vitest proof.
|
||||
2. `pnpm test <path-or-filter>` for one file, directory, or explicit target.
|
||||
3. `pnpm test` only when you intentionally need the full local Vitest suite.
|
||||
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don't collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
|
||||
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). This is a default-unit-lane coverage gate, not whole-repo all-file coverage. Thresholds are 70% lines/functions/statements and 55% branches. Because `coverage.all` is false and the default lane scopes coverage includes to non-fast unit tests with sibling source files, the gate measures source owned by this lane instead of every transitive import it happens to load.
|
||||
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
|
||||
@@ -21,7 +17,7 @@ title: "Tests"
|
||||
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
|
||||
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store.
|
||||
- Control UI mocked E2E: use `pnpm test:ui:e2e` for the Vitest + Playwright lane that starts the Vite Control UI and drives a real Chromium page against a mocked Gateway WebSocket. Tests live in `ui/src/**/*.e2e.test.ts`; shared mocks and controls live in `ui/src/test-helpers/control-ui-e2e.ts`. `pnpm test:e2e` includes this lane. In Codex worktrees, prefer `node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/chat-flow.e2e.test.ts` for tiny targeted proof after dependencies are installed, or Testbox/Crabbox for broader GUI proof.
|
||||
@@ -43,7 +39,6 @@ title: "Tests"
|
||||
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
|
||||
- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. The Test Performance Agent uses this as its baseline before attempting slow-test fixes.
|
||||
- `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json`: compares grouped reports after a performance-focused change.
|
||||
- `pnpm test:docker:timings <summary.json>` inspects slow Docker lanes after a Docker all run; use `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands from the same artifacts.
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
- `pnpm test:e2e`: Runs the repo E2E aggregate: gateway end-to-end smoke tests plus the Control UI mocked browser E2E lane.
|
||||
- `pnpm test:e2e:gateway`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
|
||||
@@ -837,9 +837,8 @@ permission modes, see
|
||||
<Note>
|
||||
`Command blocked by PreToolUse hook: Native hook relay unavailable` belongs to
|
||||
the native Codex hook relay, not ACP/acpx. In a bound Codex chat, start a fresh
|
||||
session with `/new` or `/reset`; if it works once and then returns on the next
|
||||
native tool call, restart the Codex app-server or OpenClaw Gateway instead of
|
||||
repeating `/new`. See [Codex harness troubleshooting](/plugins/codex-harness#troubleshooting).
|
||||
session with `/new` or `/reset`; if it persists, restart the Codex app-server or
|
||||
OpenClaw Gateway. See [Codex harness troubleshooting](/plugins/codex-harness#troubleshooting).
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
@@ -21,16 +21,6 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
|
||||
mkdir -p ~/.openclaw/workspace/skills/hello-world
|
||||
```
|
||||
|
||||
You can group skills in subfolders when your library grows:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/workspace/skills/personal/hello-world
|
||||
```
|
||||
|
||||
Group folders are only organizational. The skill is still named by
|
||||
`SKILL.md` frontmatter, so `name: hello-world` is invoked as
|
||||
`/hello-world`.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Write SKILL.md">
|
||||
@@ -50,7 +40,7 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
|
||||
```
|
||||
|
||||
Use hyphen-case with lowercase letters, digits, and hyphens for the skill
|
||||
`name`. Keep the leaf folder name and frontmatter `name` aligned.
|
||||
`name`. Keep the folder name and frontmatter `name` aligned.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -62,15 +52,7 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
|
||||
</Step>
|
||||
|
||||
<Step title="Load the skill">
|
||||
Verify the skill loaded:
|
||||
|
||||
```bash
|
||||
openclaw skills list
|
||||
```
|
||||
|
||||
OpenClaw watches nested `SKILL.md` files under skills roots. If the watcher
|
||||
is disabled or you are continuing an existing session, start a new session
|
||||
so the model receives the refreshed skills list:
|
||||
Start a new session so OpenClaw picks up the skill:
|
||||
|
||||
```bash
|
||||
# From chat
|
||||
@@ -80,6 +62,12 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Verify the skill loaded:
|
||||
|
||||
```bash
|
||||
openclaw skills list
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Test it">
|
||||
@@ -146,10 +134,6 @@ Once a basic skill works, these fields help make it reliable and portable:
|
||||
| Bundled (shipped with OpenClaw) | Low | Global |
|
||||
| `skills.load.extraDirs` | Lowest | Custom shared folders |
|
||||
|
||||
Each skills root can contain direct skill folders such as
|
||||
`skills/hello-world/SKILL.md` or grouped folders such as
|
||||
`skills/personal/hello-world/SKILL.md`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Skills reference](/tools/skills) — loading, precedence, and gating rules
|
||||
|
||||
@@ -53,10 +53,6 @@ Analysis prompt.
|
||||
Page filter like `1-5` or `1,3,7-9`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="password" type="string">
|
||||
Password for encrypted PDFs in extraction fallback mode.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="model" type="string">
|
||||
Optional model override in `provider/model` form.
|
||||
</ParamField>
|
||||
@@ -70,7 +66,6 @@ Input notes:
|
||||
- `pdf` and `pdfs` are merged and deduplicated before loading.
|
||||
- If no PDF input is provided, the tool errors.
|
||||
- `pages` is parsed as 1-based page numbers, deduped, sorted, and clamped to the configured max pages.
|
||||
- `password` applies to every PDF in the request and is only used by extraction fallback mode.
|
||||
- `maxBytesMb` defaults to `agents.defaults.pdfMaxBytesMb` or `10`.
|
||||
|
||||
## Supported PDF references
|
||||
@@ -97,7 +92,6 @@ The tool sends raw PDF bytes directly to provider APIs.
|
||||
Native mode limits:
|
||||
|
||||
- `pages` is not supported. If set, the tool returns an error.
|
||||
- `password` is not supported. Use a non-native model to analyze encrypted PDFs.
|
||||
- Multi-PDF input is supported; each PDF is sent as a native document block /
|
||||
inline PDF part before the prompt.
|
||||
|
||||
@@ -114,14 +108,13 @@ Flow:
|
||||
Fallback details:
|
||||
|
||||
- Page image extraction uses a pixel budget of `4,000,000`.
|
||||
- Encrypted PDFs can be opened with the top-level `password` parameter.
|
||||
- If the target model does not support image input and there is no extractable text, the tool errors.
|
||||
- If text extraction succeeds but image extraction would require vision on a
|
||||
text-only model, OpenClaw drops the rendered images and continues with the
|
||||
extracted text.
|
||||
- Extraction fallback uses the bundled `document-extract` plugin. The plugin owns
|
||||
`clawpdf`, which provides text extraction and image rendering through PDFium
|
||||
WebAssembly.
|
||||
`pdfjs-dist`; `@napi-rs/canvas` is used only when image rendering fallback is
|
||||
available.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -196,17 +189,6 @@ Page-filtered fallback model:
|
||||
}
|
||||
```
|
||||
|
||||
Encrypted PDF with extraction fallback:
|
||||
|
||||
```json
|
||||
{
|
||||
"pdf": "/tmp/locked.pdf",
|
||||
"password": "example-password",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
"prompt": "Summarize this contract"
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Tools Overview](/tools) - all available agent tools
|
||||
|
||||
@@ -29,19 +29,6 @@ OpenClaw loads skills from these sources, **highest precedence first**:
|
||||
|
||||
If a skill name conflicts, the highest source wins.
|
||||
|
||||
Skill roots can be organized with folders. A skill is discovered when a
|
||||
`SKILL.md` appears under a configured skills root, so these are both valid:
|
||||
|
||||
```text
|
||||
<workspace>/skills/research/SKILL.md
|
||||
<workspace>/skills/personal/research/SKILL.md
|
||||
```
|
||||
|
||||
The folder path is only for organization. The skill's visible name, slash
|
||||
command, and allowlist key come from `SKILL.md` frontmatter `name` (or the skill
|
||||
directory name when `name` is missing), so a nested skill with `name: research`
|
||||
is still invoked as `/research`, not `/personal/research`.
|
||||
|
||||
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
|
||||
skill roots. In Codex harness mode, local app-server launches use isolated
|
||||
per-agent Codex homes, so skills in the operator's personal `~/.codex/skills`
|
||||
@@ -162,11 +149,9 @@ all local agents unless agent skill allowlists narrow visibility. The separate
|
||||
`clawhub` CLI also installs into `./skills` under your current working
|
||||
directory (or falls back to the configured OpenClaw workspace). OpenClaw picks
|
||||
that up as `<workspace>/skills` on the next session.
|
||||
Configured skill roots also support grouped layouts, such as
|
||||
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be kept
|
||||
under shared folders without broad recursive scanning. Use flat frontmatter
|
||||
names when grouping, for example `skills/imported/research/SKILL.md` with
|
||||
`name: research`.
|
||||
Configured skill roots also support one grouping level, such as
|
||||
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be
|
||||
kept under a shared folder without broad recursive scanning.
|
||||
|
||||
Git and local directory installs expect a `SKILL.md` at the source root. The
|
||||
install slug comes from `SKILL.md` frontmatter `name` when it is a valid slug,
|
||||
@@ -211,11 +196,6 @@ Prefer sandboxed runs for untrusted inputs and risky tools. See
|
||||
</Warning>
|
||||
|
||||
- Workspace, project-agent, and extra-dir skill discovery only accepts skill roots whose resolved realpath stays inside the configured root unless `skills.load.allowSymlinkTargets` explicitly trusts a target root. Bundled skills always stay contained. Managed `~/.openclaw/skills` and personal `~/.agents/skills` roots may contain symlinked skill folders installed by ClawHub or another local skill manager, but every `SKILL.md` realpath must still stay inside its resolved skill directory.
|
||||
- Nested discovery is bounded. OpenClaw scans grouped skill folders under
|
||||
skills roots such as `<workspace>/skills`, `<workspace>/.agents/skills`,
|
||||
`~/.agents/skills`, and `~/.openclaw/skills`, but skips hidden directories,
|
||||
`node_modules`, oversized `SKILL.md` files, escaped symlinks, and suspiciously
|
||||
large directory trees.
|
||||
- Gateway private archive installs are off by default. When explicitly enabled,
|
||||
they require a committed zip upload containing `SKILL.md` and reuse the same
|
||||
archive extraction, path traversal, symlink, force, and rollback protections as
|
||||
@@ -508,10 +488,6 @@ layouts where a skill root contains a symlink, for example
|
||||
symlinks from local skill managers by default, but the target list is still
|
||||
matched after realpath resolution and should stay narrow when configured.
|
||||
|
||||
The watcher covers nested `SKILL.md` files under grouped skill roots. Adding or
|
||||
editing `skills/personal/foo/SKILL.md` refreshes the snapshot the same way as
|
||||
editing `skills/foo/SKILL.md`.
|
||||
|
||||
### Remote macOS nodes (Linux gateway)
|
||||
|
||||
If the Gateway runs on Linux but a **macOS node** is connected with
|
||||
|
||||
@@ -639,7 +639,7 @@ still need normal device approval for scope upgrades.
|
||||
- Sub-agent announce is **best-effort**. If the gateway restarts, pending "announce back" work is lost.
|
||||
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
||||
- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- OpenClaw-managed sub-agent context only injects `AGENTS.md` and `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). Codex-native subagents inherit the native Codex thread's developer instructions, including `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md`, so the main agent identity stays session-scoped instead of being replayed on every turn. `MEMORY.md` stays in the memory-tool or bounded turn-context path because it changes often.
|
||||
- Sub-agent context only injects `AGENTS.md` and `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). Codex-native subagents follow the same boundary: `TOOLS.md` stays in inherited Codex thread instructions, while parent-only persona, identity, and user files are injected as turn-scoped collaboration instructions so children do not clone them.
|
||||
- Maximum nesting depth is 5 (`maxSpawnDepth` range: 1–5). Depth 2 is recommended for most use cases.
|
||||
- `maxChildrenPerAgent` caps active children per session (default `5`, range `1–20`).
|
||||
|
||||
|
||||
@@ -60,15 +60,12 @@ Normal agent-run final answers should be durable because the embedded runtime wr
|
||||
## Control UI agents tools panel
|
||||
|
||||
- The Control UI `/agents` Tools panel has two separate views:
|
||||
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows a server-derived
|
||||
read-only projection of the current session inventory, including core, plugin, channel-owned,
|
||||
and already-discovered MCP server tools.
|
||||
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current
|
||||
session can actually use at runtime, including core, plugin, and channel-owned tools.
|
||||
- **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and
|
||||
catalog semantics.
|
||||
- Runtime availability is session-scoped. Switching sessions on the same agent can change the
|
||||
**Available Right Now** list. If configured MCP servers have not been connected or were changed
|
||||
since the last discovery, the panel shows a notice instead of silently starting MCP transports
|
||||
from the read path.
|
||||
**Available Right Now** list.
|
||||
- The config editor does not imply runtime availability; effective access still follows policy
|
||||
precedence (`allow`/`deny`, per-agent and provider/channel overrides).
|
||||
|
||||
|
||||
@@ -592,12 +592,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
|
||||
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.4",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
|
||||
10
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
10
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -11,7 +11,7 @@
|
||||
"@aws-sdk/client-bedrock": "3.1053.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1053.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.44",
|
||||
"@smithy/node-http-handler": "4.7.4",
|
||||
"@smithy/node-http-handler": "4.7.3",
|
||||
"@smithy/shared-ini-file-loader": "4.5.4",
|
||||
"@smithy/types": "4.14.2"
|
||||
}
|
||||
@@ -528,12 +528,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
|
||||
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
|
||||
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.4",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@aws-sdk/client-bedrock": "3.1053.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1053.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.44",
|
||||
"@smithy/node-http-handler": "4.7.4",
|
||||
"@smithy/node-http-handler": "4.7.3",
|
||||
"@smithy/shared-ini-file-loader": "4.5.4",
|
||||
"@smithy/types": "4.14.2"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_MODEL_ALIASES } from "./cli-constants
|
||||
const DEFAULT_CLAUDE_MODEL_BY_FAMILY: Record<string, string> = {
|
||||
opus: "claude-opus-4-7",
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
haiku: "claude-haiku-4-5",
|
||||
haiku: "claude-sonnet-4-6",
|
||||
};
|
||||
|
||||
export type ClaudeCliAnthropicModelRefs = {
|
||||
@@ -117,10 +117,6 @@ function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
if (normalized.startsWith("claude-sonnet-4-6") || normalized.startsWith("claude-sonnet-4.6")) {
|
||||
return null;
|
||||
}
|
||||
// claude-haiku-4-5 is a current production model and must not be migrated.
|
||||
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized === "claude-opus-4" ||
|
||||
hasAnyRetiredVersionPrefix(normalized, [
|
||||
@@ -144,6 +140,8 @@ function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
"claude-sonnet-4.1",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4.0",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
]) ||
|
||||
/^claude-sonnet-4-20\d{6}/.test(normalized)
|
||||
) {
|
||||
@@ -174,6 +172,7 @@ function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
normalized === "sonnet-3.7" ||
|
||||
normalized === "sonnet-3.5" ||
|
||||
normalized === "sonnet-3" ||
|
||||
normalized === "haiku-4.5" ||
|
||||
normalized === "haiku-3.5" ||
|
||||
normalized === "haiku-3"
|
||||
) {
|
||||
|
||||
@@ -55,28 +55,6 @@ describe("anthropic Claude model refs", () => {
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-sonnet-4-7")).toBe(
|
||||
"anthropic/claude-sonnet-4-7",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5")).toBe(
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the current claude-haiku-4-5 model and its bare alias", () => {
|
||||
// claude-haiku-4-5 is a current production model (not retired), so neither
|
||||
// its full ref, its dotted variant, nor the bare "haiku" family alias must
|
||||
// be rewritten to sonnet.
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5")).toBe(
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4.5")).toBe(
|
||||
"anthropic/claude-haiku-4.5",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5@anthropic:work")).toBe(
|
||||
"anthropic/claude-haiku-4-5@anthropic:work",
|
||||
);
|
||||
// Genuinely retired Claude 3 Haiku still upgrades to the current sonnet.
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-3-5-haiku-20241022")).toBe(
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ describe("bonjour package manifest", () => {
|
||||
fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
||||
) as PackageManifest;
|
||||
|
||||
expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.9");
|
||||
expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.9");
|
||||
expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.8");
|
||||
expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("1.3.8");
|
||||
expect(pluginPackageJson.devDependencies?.["@homebridge/ciao"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@homebridge/ciao": "1.3.9"
|
||||
"@homebridge/ciao": "1.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
browserPluginNodeHostCommands,
|
||||
browserPluginReload,
|
||||
@@ -25,7 +25,6 @@ const runtimeApiMocks = vi.hoisted(() => ({
|
||||
handleBrowserGatewayRequest: vi.fn(),
|
||||
registerBrowserCli: vi.fn(),
|
||||
runBrowserProxyCommand: vi.fn(async () => "ok"),
|
||||
stopBrowserControlService: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./register.runtime.js", async () => {
|
||||
@@ -45,22 +44,10 @@ vi.mock("./src/cli/browser-cli.js", () => ({
|
||||
registerBrowserCli: runtimeApiMocks.registerBrowserCli,
|
||||
}));
|
||||
|
||||
vi.mock("./src/control-service.js", () => ({
|
||||
stopBrowserControlService: runtimeApiMocks.stopBrowserControlService,
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
await import("./register.runtime.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function createApi() {
|
||||
const registerCli = vi.fn();
|
||||
const registerGatewayMethod = vi.fn();
|
||||
@@ -202,7 +189,7 @@ describe("browser plugin", () => {
|
||||
expect(runtimeApiMocks.collectBrowserSecurityAuditFindings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers a lazy browser control service", async () => {
|
||||
it("lazy-loads the browser service on start", async () => {
|
||||
const { api, registerService } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
@@ -216,43 +203,10 @@ describe("browser plugin", () => {
|
||||
expect(typeof service?.stop).toBe("function");
|
||||
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
|
||||
|
||||
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
|
||||
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
|
||||
|
||||
await service.stop({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
|
||||
expect(runtimeApiMocks.stopBrowserControlService).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("eager-loads the browser control service when explicitly requested", async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
|
||||
const { api, registerService } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
const service = mockCallArg(registerService) as {
|
||||
id: string;
|
||||
start: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
|
||||
expect(runtimeApiMocks.createBrowserPluginService).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
for (const value of ["false", "", "disabled"]) {
|
||||
it(`keeps browser control service env value ${JSON.stringify(value)} lazy`, async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", value);
|
||||
const { api, registerService } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
const service = mockCallArg(registerService) as {
|
||||
id: string;
|
||||
start: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
|
||||
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("declares setup auto-enable reasons for browser config surfaces", () => {
|
||||
const probe = registerBrowserAutoEnableProbe();
|
||||
|
||||
|
||||
@@ -13,12 +13,6 @@ import {
|
||||
} from "./src/browser-gateway-contract.js";
|
||||
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
|
||||
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
|
||||
}
|
||||
|
||||
const BROWSER_CLI_DESCRIPTOR = {
|
||||
name: "browser",
|
||||
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
|
||||
@@ -90,19 +84,14 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
return {
|
||||
id: "browser-control",
|
||||
start: async (ctx) => {
|
||||
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) {
|
||||
return;
|
||||
}
|
||||
const loaded = await loadService();
|
||||
await loaded.start(ctx);
|
||||
},
|
||||
stop: async (ctx) => {
|
||||
if (!service) {
|
||||
const { stopBrowserControlService } = await import("./src/control-service.js");
|
||||
await stopBrowserControlService().catch(() => {});
|
||||
if (!service?.stop) {
|
||||
return;
|
||||
}
|
||||
await service.stop?.(ctx);
|
||||
await service.stop(ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clickChromeMcpElement,
|
||||
buildChromeMcpArgs,
|
||||
decodeChromeMcpStderrTail,
|
||||
ensureChromeMcpAvailable,
|
||||
evaluateChromeMcpScript,
|
||||
listChromeMcpTabs,
|
||||
@@ -452,14 +451,6 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(message).not.toContain(userDataDir);
|
||||
});
|
||||
|
||||
it("keeps Chrome MCP stderr tails within the byte cap without splitting UTF-8", () => {
|
||||
const output = decodeChromeMcpStderrTail(Buffer.from(`${"x".repeat(8191)}é`));
|
||||
|
||||
expect(output).toMatch(/é$/);
|
||||
expect(output).not.toContain("<22>");
|
||||
expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(8192);
|
||||
});
|
||||
|
||||
it("parses new_page text responses and returns the created tab", async () => {
|
||||
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
@@ -123,18 +123,6 @@ const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||
let sessionFactory: ChromeMcpSessionFactory | null = null;
|
||||
let chromeMcpProcessCleanupDepsForTest: ChromeMcpProcessCleanupDeps | null = null;
|
||||
|
||||
export function decodeChromeMcpStderrTail(buffer: Buffer): string {
|
||||
if (buffer.length <= CHROME_MCP_STDERR_MAX_BYTES) {
|
||||
return buffer.toString("utf8").trim();
|
||||
}
|
||||
|
||||
let start = buffer.length - CHROME_MCP_STDERR_MAX_BYTES;
|
||||
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) {
|
||||
start++;
|
||||
}
|
||||
return buffer.subarray(start).toString("utf8").trim();
|
||||
}
|
||||
|
||||
function asPages(value: unknown): ChromeMcpStructuredPage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -420,7 +408,7 @@ function drainStderr(transport: StdioClientTransport): () => string {
|
||||
}
|
||||
});
|
||||
stream.on("error", () => {});
|
||||
return () => decodeChromeMcpStderrTail(Buffer.concat(chunks));
|
||||
return () => Buffer.concat(chunks).toString("utf8").trim().slice(-CHROME_MCP_STDERR_MAX_BYTES);
|
||||
}
|
||||
|
||||
function redactChromeMcpDiagnosticText(text: string): string {
|
||||
|
||||
@@ -91,11 +91,6 @@ describe("port allocation", () => {
|
||||
}
|
||||
expect(allocateCdpPort(usedPorts)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects fractional or out-of-range allocation ranges", () => {
|
||||
expect(allocateCdpPort(new Set(), { start: 20000.5, end: 20002 })).toBeNull();
|
||||
expect(allocateCdpPort(new Set(), { start: 20000, end: 65536 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsedPorts", () => {
|
||||
@@ -129,17 +124,6 @@ describe("getUsedPorts", () => {
|
||||
const used = getUsedPorts(profiles);
|
||||
expect(used.size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores invalid numeric cdpPort values", () => {
|
||||
const profiles = {
|
||||
fractional: { cdpPort: 18800.5 },
|
||||
zero: { cdpPort: 0 },
|
||||
outOfRange: { cdpPort: 65536 },
|
||||
valid: { cdpPort: 18801 },
|
||||
};
|
||||
const used = getUsedPorts(profiles);
|
||||
expect(used).toEqual(new Set([18801]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("port collision prevention", () => {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
export const CDP_PORT_RANGE_START = 18800;
|
||||
export const CDP_PORT_RANGE_END = 18899;
|
||||
const MAX_TCP_PORT = 65_535;
|
||||
|
||||
const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
@@ -31,7 +30,7 @@ export function allocateCdpPort(
|
||||
): number | null {
|
||||
const start = range?.start ?? CDP_PORT_RANGE_START;
|
||||
const end = range?.end ?? CDP_PORT_RANGE_END;
|
||||
if (!isValidTcpPort(start) || !isValidTcpPort(end)) {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (start > end) {
|
||||
@@ -45,10 +44,6 @@ export function allocateCdpPort(
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidTcpPort(port: number): boolean {
|
||||
return Number.isSafeInteger(port) && port > 0 && port <= MAX_TCP_PORT;
|
||||
}
|
||||
|
||||
export function getUsedPorts(
|
||||
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
|
||||
): Set<number> {
|
||||
@@ -57,7 +52,7 @@ export function getUsedPorts(
|
||||
}
|
||||
const used = new Set<number>();
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (typeof profile.cdpPort === "number" && isValidTcpPort(profile.cdpPort)) {
|
||||
if (typeof profile.cdpPort === "number") {
|
||||
used.add(profile.cdpPort);
|
||||
continue;
|
||||
}
|
||||
@@ -73,7 +68,7 @@ export function getUsedPorts(
|
||||
: parsed.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
if (isValidTcpPort(port)) {
|
||||
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
|
||||
used.add(port);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as browserCliSharedModule from "../browser-cli-shared.js";
|
||||
import {
|
||||
createBrowserProgram,
|
||||
getBrowserCliRuntime,
|
||||
getBrowserCliRuntimeCapture,
|
||||
} from "../browser-cli.test-support.js";
|
||||
import * as cliCoreApiModule from "../core-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callBrowserRequest: vi.fn(async () => ({ url: "https://example.test" })),
|
||||
}));
|
||||
|
||||
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
|
||||
const browserCliRuntime = getBrowserCliRuntime();
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
|
||||
browserCliRuntime.writeJson,
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
|
||||
|
||||
const { registerBrowserElementCommands } = await import("./register.element.js");
|
||||
|
||||
function createElementProgram(): Command {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
registerBrowserElementCommands(browser, parentOpts);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("browser element commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("rejects non-decimal coordinate values before dispatch", async () => {
|
||||
const program = createElementProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "click-coords", "0x10", "20"], { from: "user" }),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const capture = getBrowserCliRuntimeCapture();
|
||||
expect(capture.runtimeErrors.join("\n")).toContain("Invalid x: must be a finite number");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-decimal delay and timeout options", async () => {
|
||||
const delayProgram = createElementProgram();
|
||||
await expect(
|
||||
delayProgram.parseAsync(["browser", "click-coords", "10", "20", "--delay-ms", "1e3"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--delay-ms must be a non-negative integer.");
|
||||
|
||||
const timeoutProgram = createElementProgram();
|
||||
await expect(
|
||||
timeoutProgram.parseAsync(["browser", "scrollintoview", "ref-1", "--timeout-ms", "0x1000"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,18 +13,9 @@ export function registerBrowserElementCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
const parseDecimalNumber = (value: string): number | undefined => {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const parseRequiredNumber = (value: string, label: string): number | undefined => {
|
||||
const parsed = parseDecimalNumber(value);
|
||||
if (parsed === undefined) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be a finite number`));
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
@@ -32,24 +23,6 @@ export function registerBrowserElementCommands(
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parseNonNegativeIntegerOption = (value: string, flag: string): number => {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${flag} must be a non-negative integer.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const parsePositiveIntegerOption = (value: string, flag: string): number => {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${flag} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const runElementAction = async (params: {
|
||||
cmd: Command;
|
||||
body: Record<string, unknown>;
|
||||
@@ -120,9 +93,7 @@ export function registerBrowserElementCommands(
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--double", "Double click", false)
|
||||
.option("--button <left|right|middle>", "Mouse button to use")
|
||||
.option("--delay-ms <ms>", "Delay between mouse down/up", (v: string) =>
|
||||
parseNonNegativeIntegerOption(v, "--delay-ms"),
|
||||
)
|
||||
.option("--delay-ms <ms>", "Delay between mouse down/up", (v: string) => Number(v))
|
||||
.action(async (xRaw: string, yRaw: string, opts, cmd) => {
|
||||
const x = parseRequiredNumber(xRaw, "x");
|
||||
const y = parseRequiredNumber(yRaw, "y");
|
||||
@@ -207,7 +178,7 @@ export function registerBrowserElementCommands(
|
||||
.argument("<ref>", "Ref id from snapshot")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--timeout-ms <ms>", "How long to wait for scroll (default: 20000)", (v: string) =>
|
||||
parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
Number(v),
|
||||
)
|
||||
.action(async (ref: string | undefined, opts, cmd) => {
|
||||
const refValue = requireRef(ref);
|
||||
|
||||
@@ -71,24 +71,6 @@ describe("browser action input file/download commands", () => {
|
||||
expect(getLastRequestOptions()?.timeoutMs).toBeGreaterThan(25000);
|
||||
});
|
||||
|
||||
it("rejects non-decimal file and download timeouts before dispatch", async () => {
|
||||
const downloadProgram = createActionInputProgram();
|
||||
await expect(
|
||||
downloadProgram.parseAsync(
|
||||
["browser", "download", "ref-1", "file.txt", "--timeout-ms", "1e3"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
|
||||
const waitProgram = createActionInputProgram();
|
||||
await expect(
|
||||
waitProgram.parseAsync(["browser", "waitfordownload", "--timeout-ms", "0x1000"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects conflicting dialog actions without arming the hook", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
|
||||
@@ -12,15 +12,6 @@ import { resolveBrowserActionContext, withBrowserActionTimeoutSlack } from "./sh
|
||||
|
||||
const DEFAULT_BROWSER_HOOK_TIMEOUT_MS = 120000;
|
||||
|
||||
function parsePositiveIntegerOption(value: string, flag: string): number {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${flag} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function normalizeUploadPaths(paths: string[]): Promise<string[]> {
|
||||
const result = await resolveExistingPathsWithinRoot({
|
||||
rootDir: DEFAULT_UPLOAD_DIR,
|
||||
@@ -109,7 +100,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next file chooser (default: 120000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
try {
|
||||
@@ -148,7 +139,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next download (default: 120000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||
await runDownloadCommand(cmd, opts, {
|
||||
@@ -171,7 +162,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the download to start (default: 120000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||
await runDownloadCommand(cmd, opts, {
|
||||
@@ -194,7 +185,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next dialog (default: 120000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||
|
||||
@@ -65,20 +65,6 @@ describe("browser action input wait command", () => {
|
||||
expect(options?.timeoutMs).toBeGreaterThan(21000);
|
||||
});
|
||||
|
||||
it("rejects non-decimal wait numeric options before sending the wait request", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "wait", "--time", "1e3"], { from: "user" }),
|
||||
).rejects.toThrow("--time must be a non-negative integer.");
|
||||
await expect(
|
||||
program.parseAsync(["browser", "wait", "--text", "Ready", "--timeout-ms", "0x1000"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unsupported load states before sending the wait request", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
@@ -115,15 +101,4 @@ describe("browser action input evaluate command", () => {
|
||||
expect(request?.body?.timeoutMs).toBe(30000);
|
||||
expect(options?.timeoutMs).toBeGreaterThan(30000);
|
||||
});
|
||||
|
||||
it("rejects non-decimal evaluate timeouts before dispatch", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "evaluate", "--fn", "() => true", "--timeout-ms", "1e3"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,24 +12,6 @@ import {
|
||||
const DEFAULT_WAIT_CONDITION_TIMEOUT_MS = 20000;
|
||||
type BrowserWaitLoadState = "load" | "domcontentloaded" | "networkidle";
|
||||
|
||||
function parseNonNegativeIntegerOption(value: string, flag: string): number {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${flag} must be a non-negative integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(value: string, flag: string): number {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${flag} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBrowserWaitLoadState(value: unknown): BrowserWaitLoadState | undefined {
|
||||
const load = normalizeOptionalString(value);
|
||||
switch (load) {
|
||||
@@ -81,9 +63,7 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.command("wait")
|
||||
.description("Wait for time, selector, URL, load state, or JS conditions")
|
||||
.argument("[selector]", "CSS selector to wait for (visible)")
|
||||
.option("--time <ms>", "Wait for N milliseconds", (v: string) =>
|
||||
parseNonNegativeIntegerOption(v, "--time"),
|
||||
)
|
||||
.option("--time <ms>", "Wait for N milliseconds", (v: string) => Number(v))
|
||||
.option("--text <value>", "Wait for text to appear")
|
||||
.option("--text-gone <value>", "Wait for text to disappear")
|
||||
.option("--url <pattern>", "Wait for URL (supports globs like **/dash)")
|
||||
@@ -92,7 +72,7 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for each condition (default: 20000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (selector: string | undefined, opts, cmd) => {
|
||||
@@ -142,7 +122,7 @@ export function registerBrowserFormWaitEvalCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to allow the evaluate function to run (default: 20000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as browserCliResizeModule from "../browser-cli-resize.js";
|
||||
import {
|
||||
createBrowserProgram,
|
||||
getBrowserCliRuntime,
|
||||
getBrowserCliRuntimeCapture,
|
||||
} from "../browser-cli.test-support.js";
|
||||
import * as cliCoreApiModule from "../core-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runBrowserResizeWithOutput: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.spyOn(browserCliResizeModule, "runBrowserResizeWithOutput").mockImplementation(
|
||||
mocks.runBrowserResizeWithOutput,
|
||||
);
|
||||
const browserCliRuntime = getBrowserCliRuntime();
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
|
||||
browserCliRuntime.writeJson,
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
|
||||
|
||||
const { registerBrowserNavigationCommands } = await import("./register.navigation.js");
|
||||
|
||||
function createNavigationProgram(): Command {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
registerBrowserNavigationCommands(browser, parentOpts);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("browser navigation commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runBrowserResizeWithOutput.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("rejects non-decimal resize dimensions before dispatch", async () => {
|
||||
const program = createNavigationProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "resize", "1e3", "768"], { from: "user" }),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const capture = getBrowserCliRuntimeCapture();
|
||||
expect(capture.runtimeErrors.join("\n")).toContain("Invalid width: must be a positive integer");
|
||||
expect(mocks.runBrowserResizeWithOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,10 @@ export function registerBrowserNavigationCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
) {
|
||||
const parsePositiveInteger = (value: unknown, label: string): number | undefined => {
|
||||
const raw = typeof value === "string" ? value.trim() : String(value);
|
||||
const parsed = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be a positive integer`));
|
||||
const parseRequiredNumber = (value: unknown, label: string): number | undefined => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be a finite number`));
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
@@ -55,12 +54,12 @@ export function registerBrowserNavigationCommands(
|
||||
browser
|
||||
.command("resize")
|
||||
.description("Resize the viewport")
|
||||
.argument("<width>", "Viewport width")
|
||||
.argument("<height>", "Viewport height")
|
||||
.argument("<width>", "Viewport width", (v: string) => Number(v))
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (width: string, height: string, opts, cmd) => {
|
||||
const normalizedWidth = parsePositiveInteger(width, "width");
|
||||
const normalizedHeight = parsePositiveInteger(height, "height");
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const normalizedWidth = parseRequiredNumber(width, "width");
|
||||
const normalizedHeight = parseRequiredNumber(height, "height");
|
||||
if (normalizedWidth === undefined || normalizedHeight === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as browserCliSharedModule from "./browser-cli-shared.js";
|
||||
import {
|
||||
createBrowserProgram,
|
||||
getBrowserCliRuntime,
|
||||
getBrowserCliRuntimeCapture,
|
||||
} from "./browser-cli.test-support.js";
|
||||
import * as cliCoreApiModule from "./core-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callBrowserRequest: vi.fn<
|
||||
(
|
||||
opts?: unknown,
|
||||
req?: unknown,
|
||||
extra?: { timeoutMs?: number },
|
||||
) => Promise<Record<string, unknown>>
|
||||
>(async () => ({ response: { body: "ok" } })),
|
||||
}));
|
||||
|
||||
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
|
||||
const browserCliRuntime = getBrowserCliRuntime();
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(browserCliRuntime.log);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
|
||||
browserCliRuntime.writeJson,
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
|
||||
|
||||
const { registerBrowserActionObserveCommands } = await import("./browser-cli-actions-observe.js");
|
||||
|
||||
function createActionObserveProgram(): Command {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
registerBrowserActionObserveCommands(browser, parentOpts);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("browser action observe commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("rejects non-decimal responsebody numeric flags before dispatch", async () => {
|
||||
const program = createActionObserveProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "responsebody", "**/api", "--timeout-ms", "1e3"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--timeout-ms must be a positive integer.");
|
||||
await expect(
|
||||
program.parseAsync(["browser", "responsebody", "**/api", "--max-chars", "-1"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("--max-chars must be a positive integer.");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes responsebody limits through to the request and outer timeout", async () => {
|
||||
const program = createActionObserveProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
["browser", "responsebody", "**/api", "--timeout-ms", "30000", "--max-chars", "100"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
|
||||
| { body?: { timeoutMs?: number; maxChars?: number } }
|
||||
| undefined;
|
||||
const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
|
||||
| { timeoutMs?: number }
|
||||
| undefined;
|
||||
expect(request?.body?.timeoutMs).toBe(30000);
|
||||
expect(request?.body?.maxChars).toBe(100);
|
||||
expect(options?.timeoutMs).toBe(30000);
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,6 @@ function runBrowserObserve(action: () => Promise<void>) {
|
||||
});
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(value: string, flag: string): number {
|
||||
const trimmed = value.trim();
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${flag} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function registerBrowserActionObserveCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
@@ -88,10 +79,10 @@ export function registerBrowserActionObserveCommands(
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the response (default: 20000)",
|
||||
(v: string) => parsePositiveIntegerOption(v, "--timeout-ms"),
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--max-chars <n>", "Max body chars to return (default: 200000)", (v: string) =>
|
||||
parsePositiveIntegerOption(v, "--max-chars"),
|
||||
Number(v),
|
||||
)
|
||||
.action(async (url: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
|
||||
@@ -159,26 +159,6 @@ describe("browser cli snapshot defaults", () => {
|
||||
expect(params?.query?.urls).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-integer snapshot numeric options before dispatch", async () => {
|
||||
await expect(runSnapshot(["--limit", "1e3"])).rejects.toThrow("__exit__:1");
|
||||
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain(
|
||||
"Invalid --limit: must be an integer >= 1",
|
||||
);
|
||||
|
||||
resetRuntimeCapture();
|
||||
await expect(runSnapshot(["--depth", "-1"])).rejects.toThrow("__exit__:1");
|
||||
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain(
|
||||
"Invalid --depth: must be an integer >= 0",
|
||||
);
|
||||
|
||||
expect(sharedMocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes zero snapshot depth because root depth is valid", async () => {
|
||||
const params = await runSnapshot(["--depth", "0"]);
|
||||
expect(params?.query?.depth).toBe(0);
|
||||
});
|
||||
|
||||
it("sends screenshot request with trimmed target id and jpeg type", async () => {
|
||||
const params = await runBrowserInspect(["screenshot", " tab-1 ", "--type", "jpeg"], true);
|
||||
expect(params?.path).toBe("/screenshot");
|
||||
|
||||
@@ -10,24 +10,6 @@ import {
|
||||
type SnapshotResult,
|
||||
} from "./core-api.js";
|
||||
|
||||
function parseOptionalIntegerOption(
|
||||
value: string | undefined,
|
||||
label: string,
|
||||
opts: { min: number },
|
||||
): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = value.trim();
|
||||
const parsed = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < opts.min) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be an integer >= ${opts.min}`));
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function registerBrowserInspectCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
@@ -78,12 +60,12 @@ export function registerBrowserInspectCommands(
|
||||
.description("Capture a snapshot (default: ai; aria is the accessibility tree)")
|
||||
.option("--format <aria|ai>", "Snapshot format (default: ai)", "ai")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--limit <n>", "Max nodes (default: 500/800)")
|
||||
.option("--limit <n>", "Max nodes (default: 500/800)", (v: string) => Number(v))
|
||||
.option("--mode <efficient>", "Snapshot preset (efficient)")
|
||||
.option("--efficient", "Use the efficient snapshot preset", false)
|
||||
.option("--interactive", "Role snapshot: interactive elements only", false)
|
||||
.option("--compact", "Role snapshot: compact output", false)
|
||||
.option("--depth <n>", "Role snapshot: max depth")
|
||||
.option("--depth <n>", "Role snapshot: max depth", (v: string) => Number(v))
|
||||
.option("--selector <sel>", "Role snapshot: scope to CSS selector")
|
||||
.option("--frame <sel>", "Role snapshot: scope to an iframe selector")
|
||||
.option("--labels", "Include viewport label overlay screenshot", false)
|
||||
@@ -103,22 +85,14 @@ export function registerBrowserInspectCommands(
|
||||
? "efficient"
|
||||
: undefined;
|
||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||
const limit = parseOptionalIntegerOption(opts.limit, "--limit", { min: 1 });
|
||||
const depth = parseOptionalIntegerOption(opts.depth, "--depth", { min: 0 });
|
||||
if (
|
||||
(opts.limit !== undefined && limit === undefined) ||
|
||||
(opts.depth !== undefined && depth === undefined)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const query: Record<string, string | number | boolean | undefined> = {
|
||||
format,
|
||||
targetId: normalizeOptionalString(opts.targetId),
|
||||
limit,
|
||||
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
|
||||
interactive: opts.interactive ? true : undefined,
|
||||
compact: opts.compact ? true : undefined,
|
||||
depth,
|
||||
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
|
||||
selector: normalizeOptionalString(opts.selector),
|
||||
frame: normalizeOptionalString(opts.frame),
|
||||
labels: opts.labels ? true : undefined,
|
||||
|
||||
@@ -261,30 +261,6 @@ describe("browser manage output", () => {
|
||||
expect(output).not.toContain("supersecrettokenvalue1234567890");
|
||||
});
|
||||
|
||||
it("rejects non-integer tab indexes without calling browser actions", async () => {
|
||||
const program = createBrowserManageProgram();
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "tab", "select", "1.9"], { from: "user" }),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain(
|
||||
"index must be a positive integer",
|
||||
);
|
||||
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
await expect(
|
||||
program.parseAsync(["browser", "tab", "close", "abc"], { from: "user" }),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain(
|
||||
"index must be a positive integer",
|
||||
);
|
||||
expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ path: "/tabs/action" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints a readable browser doctor report", async () => {
|
||||
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
|
||||
if (req.path === "/") {
|
||||
|
||||
@@ -112,11 +112,6 @@ function runBrowserCommand(action: () => Promise<void>) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseTabIndex(value: string): number {
|
||||
const trimmed = value.trim();
|
||||
return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
||||
}
|
||||
|
||||
function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
|
||||
if (json) {
|
||||
defaultRuntime.writeJson({ tabs });
|
||||
@@ -495,40 +490,41 @@ export function registerBrowserManageCommands(
|
||||
tab
|
||||
.command("select")
|
||||
.description("Focus tab by index (1-based)")
|
||||
.argument("<index>", "Tab index (1-based)", parseTabIndex)
|
||||
.argument("<index>", "Tab index (1-based)", (v: string) => Number(v))
|
||||
.action(async (index: number, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
if (!Number.isSafeInteger(index) || index < 1) {
|
||||
defaultRuntime.error(danger("index must be a positive integer"));
|
||||
if (!Number.isFinite(index) || index < 1) {
|
||||
defaultRuntime.error(danger("index must be a positive number"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await callTabAction(parent, profile, {
|
||||
action: "select",
|
||||
index: index - 1,
|
||||
index: Math.floor(index) - 1,
|
||||
});
|
||||
if (printJsonResult(parent, result)) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`selected tab ${index}`);
|
||||
defaultRuntime.log(`selected tab ${Math.floor(index)}`);
|
||||
});
|
||||
});
|
||||
|
||||
tab
|
||||
.command("close")
|
||||
.description("Close tab by index (1-based); default: first tab")
|
||||
.argument("[index]", "Tab index (1-based)", parseTabIndex)
|
||||
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
|
||||
.action(async (index: number | undefined, _opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
if (typeof index === "number" && (!Number.isSafeInteger(index) || index < 1)) {
|
||||
defaultRuntime.error(danger("index must be a positive integer"));
|
||||
const idx =
|
||||
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
|
||||
if (typeof idx === "number" && idx < 0) {
|
||||
defaultRuntime.error(danger("index must be >= 1"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const idx = typeof index === "number" ? index - 1 : undefined;
|
||||
await runBrowserCommand(async () => {
|
||||
const result = await callTabAction(parent, profile, { action: "close", index: idx });
|
||||
if (printJsonResult(parent, result)) {
|
||||
|
||||
@@ -156,14 +156,6 @@ describe("browser state option collisions", () => {
|
||||
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("rejects non-decimal viewport dimensions before resize dispatch", async () => {
|
||||
await runBrowserCommand(["set", "viewport", "1e3", "768"]);
|
||||
|
||||
expect(mocks.runBrowserResizeWithOutput).not.toHaveBeenCalled();
|
||||
expectErrorMessage("Invalid width: must be a positive integer");
|
||||
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("errors when set media receives an invalid value", async () => {
|
||||
await runBrowserCommand(["set", "media", "sepia"]);
|
||||
|
||||
@@ -172,31 +164,6 @@ describe("browser state option collisions", () => {
|
||||
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("rejects invalid geolocation numbers before dispatch", async () => {
|
||||
await runBrowserCommand(["set", "geo", "48.208", "16.373", "--accuracy", "fast"]);
|
||||
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
expectErrorMessage("Invalid --accuracy: must be a finite number");
|
||||
expect(getBrowserCliRuntime().exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("passes valid decimal geolocation numbers", async () => {
|
||||
const request = await runBrowserCommandAndGetRequest([
|
||||
"set",
|
||||
"geo",
|
||||
"48.2082",
|
||||
"16.3738",
|
||||
"--accuracy",
|
||||
"12.5",
|
||||
]);
|
||||
|
||||
expect(request.body).toMatchObject({
|
||||
latitude: 48.2082,
|
||||
longitude: 16.3738,
|
||||
accuracy: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("errors when headers JSON is missing", async () => {
|
||||
await runBrowserCommand(["set", "headers"]);
|
||||
|
||||
|
||||
@@ -14,33 +14,6 @@ function parseOnOff(raw: string): boolean | null {
|
||||
return parsed === undefined ? null : parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown, label: string): number | undefined {
|
||||
const raw = typeof value === "string" ? value.trim() : String(value);
|
||||
const parsed = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be a positive integer`));
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFiniteNumberOption(value: string | undefined, label: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = value.trim();
|
||||
const parsed = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/i.test(raw)
|
||||
? Number(raw)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
defaultRuntime.error(danger(`Invalid ${label}: must be a finite number`));
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function runBrowserCommand(action: () => Promise<void>) {
|
||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
@@ -85,15 +58,10 @@ export function registerBrowserStateCommands(
|
||||
set
|
||||
.command("viewport")
|
||||
.description("Set viewport size (alias for resize)")
|
||||
.argument("<width>", "Viewport width")
|
||||
.argument("<height>", "Viewport height")
|
||||
.argument("<width>", "Viewport width", (v: string) => Number(v))
|
||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (widthRaw: string, heightRaw: string, opts, cmd) => {
|
||||
const width = parsePositiveInteger(widthRaw, "width");
|
||||
const height = parsePositiveInteger(heightRaw, "height");
|
||||
if (width === undefined || height === undefined) {
|
||||
return;
|
||||
}
|
||||
.action(async (width: number, height: number, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const profile = parent?.browserProfile;
|
||||
await runBrowserCommand(async () => {
|
||||
@@ -205,39 +173,27 @@ export function registerBrowserStateCommands(
|
||||
.command("geo")
|
||||
.description("Set geolocation (and grant permission)")
|
||||
.option("--clear", "Clear geolocation + permissions", false)
|
||||
.argument("[latitude]", "Latitude")
|
||||
.argument("[longitude]", "Longitude")
|
||||
.option("--accuracy <m>", "Accuracy in meters")
|
||||
.argument("[latitude]", "Latitude", (v: string) => Number(v))
|
||||
.argument("[longitude]", "Longitude", (v: string) => Number(v))
|
||||
.option("--accuracy <m>", "Accuracy in meters", (v: string) => Number(v))
|
||||
.option("--origin <origin>", "Origin to grant permissions for")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(
|
||||
async (latitudeRaw: string | undefined, longitudeRaw: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const latitude = parseFiniteNumberOption(latitudeRaw, "latitude");
|
||||
const longitude = parseFiniteNumberOption(longitudeRaw, "longitude");
|
||||
const accuracy = parseFiniteNumberOption(opts.accuracy, "--accuracy");
|
||||
if (
|
||||
(latitudeRaw !== undefined && latitude === undefined) ||
|
||||
(longitudeRaw !== undefined && longitude === undefined) ||
|
||||
(opts.accuracy !== undefined && accuracy === undefined)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runBrowserSetRequest({
|
||||
parent,
|
||||
path: "/set/geolocation",
|
||||
body: {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
origin: normalizeOptionalString(opts.origin),
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: normalizeOptionalString(opts.targetId),
|
||||
},
|
||||
successMessage: opts.clear ? "geolocation cleared" : "geolocation set",
|
||||
});
|
||||
},
|
||||
);
|
||||
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
await runBrowserSetRequest({
|
||||
parent,
|
||||
path: "/set/geolocation",
|
||||
body: {
|
||||
latitude: Number.isFinite(latitude) ? latitude : undefined,
|
||||
longitude: Number.isFinite(longitude) ? longitude : undefined,
|
||||
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
|
||||
origin: normalizeOptionalString(opts.origin),
|
||||
clear: Boolean(opts.clear),
|
||||
targetId: normalizeOptionalString(opts.targetId),
|
||||
},
|
||||
successMessage: opts.clear ? "geolocation cleared" : "geolocation set",
|
||||
});
|
||||
});
|
||||
|
||||
set
|
||||
.command("media")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "./config/config.js";
|
||||
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
|
||||
import { createBrowserPluginService } from "./plugin-service.js";
|
||||
@@ -18,25 +18,15 @@ type StartLazyPluginServiceModuleParamsWithValidator = {
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
startLazyPluginServiceModule: vi.fn(async (_params: StartLazyPluginServiceModuleParams) => null),
|
||||
stopBrowserControlService: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./sdk-node-runtime.js", () => ({
|
||||
startLazyPluginServiceModule: runtimeMocks.startLazyPluginServiceModule,
|
||||
}));
|
||||
|
||||
vi.mock("./control-service.js", () => ({
|
||||
stopBrowserControlService: runtimeMocks.stopBrowserControlService,
|
||||
}));
|
||||
|
||||
describe("createBrowserPluginService", () => {
|
||||
beforeEach(() => {
|
||||
runtimeMocks.startLazyPluginServiceModule.mockClear();
|
||||
runtimeMocks.stopBrowserControlService.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function getStartParams(): StartLazyPluginServiceModuleParamsWithValidator {
|
||||
@@ -51,27 +41,7 @@ describe("createBrowserPluginService", () => {
|
||||
return { validateOverrideSpecifier: params.validateOverrideSpecifier };
|
||||
}
|
||||
|
||||
it("does not start the control server during gateway startup by default", async () => {
|
||||
const service = createBrowserPluginService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
expect(runtimeMocks.startLazyPluginServiceModule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const value of ["0", "", "disabled"]) {
|
||||
it(`does not start the control server for eager env value ${JSON.stringify(value)}`, async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", value);
|
||||
const service = createBrowserPluginService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
|
||||
expect(runtimeMocks.startLazyPluginServiceModule).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("passes a browser override validator to the eager service loader", async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
|
||||
it("passes a browser override validator to the lazy service loader", async () => {
|
||||
const service = createBrowserPluginService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
@@ -81,7 +51,6 @@ describe("createBrowserPluginService", () => {
|
||||
});
|
||||
|
||||
it("rejects unsafe browser override specifiers", async () => {
|
||||
vi.stubEnv("OPENCLAW_EAGER_BROWSER_CONTROL_SERVER", "1");
|
||||
const service = createBrowserPluginService();
|
||||
|
||||
await service.start(SERVICE_CONTEXT);
|
||||
@@ -97,14 +66,6 @@ describe("createBrowserPluginService", () => {
|
||||
"Refusing unsafe browser control override specifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops an on-demand browser runtime even when startup stayed lazy", async () => {
|
||||
const service = createBrowserPluginService();
|
||||
|
||||
await service.stop?.(SERVICE_CONTEXT);
|
||||
|
||||
expect(runtimeMocks.stopBrowserControlService).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefaultBrowserPluginEnabled", () => {
|
||||
|
||||
@@ -5,13 +5,8 @@ import {
|
||||
} from "./sdk-node-runtime.js";
|
||||
|
||||
type BrowserControlHandle = LazyPluginServiceHandle | null;
|
||||
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
|
||||
const UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER = /^(?:data|http|https|node):/i;
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
|
||||
}
|
||||
|
||||
function validateBrowserControlOverrideSpecifier(specifier: string): string {
|
||||
const trimmed = specifier.trim();
|
||||
if (UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER.test(trimmed)) {
|
||||
@@ -26,9 +21,6 @@ export function createBrowserPluginService(): OpenClawPluginService {
|
||||
return {
|
||||
id: "browser-control",
|
||||
start: async () => {
|
||||
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) {
|
||||
return;
|
||||
}
|
||||
if (handle) {
|
||||
return;
|
||||
}
|
||||
@@ -48,12 +40,10 @@ export function createBrowserPluginService(): OpenClawPluginService {
|
||||
stop: async () => {
|
||||
const current = handle;
|
||||
handle = null;
|
||||
if (current) {
|
||||
await current.stop().catch(() => {});
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const { stopBrowserControlService } = await import("./control-service.js");
|
||||
await stopBrowserControlService().catch(() => {});
|
||||
await current.stop().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@ import {
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
|
||||
const CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
const CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set(["tools.md"]);
|
||||
const CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
"identity.md",
|
||||
"soul.md",
|
||||
"tools.md",
|
||||
"user.md",
|
||||
]);
|
||||
const CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set<string>();
|
||||
const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
...CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
...CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
@@ -635,7 +634,7 @@ function renderCodexWorkspaceBootstrapPromptContext(
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. SOUL.md, IDENTITY.md, TOOLS.md, and USER.md are provided as Codex thread developer instructions so standing workspace guidance is not repeated in every turn. MEMORY.md stays in turn-scoped memory context, and HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"",
|
||||
"# Project Context",
|
||||
"",
|
||||
|
||||
@@ -714,23 +714,6 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
marketplaceSource: "github:example/plugins",
|
||||
},
|
||||
);
|
||||
|
||||
for (const value of ["0x10", "1e3"]) {
|
||||
expectFields(
|
||||
resolveCodexComputerUseConfig({
|
||||
pluginConfig: {},
|
||||
env: {
|
||||
OPENCLAW_CODEX_COMPUTER_USE: "1",
|
||||
OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS: value,
|
||||
},
|
||||
}),
|
||||
"computer use config",
|
||||
{
|
||||
enabled: true,
|
||||
marketplaceDiscoveryTimeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows plugin config to opt in to guardian-reviewed local execution", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStart
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
const UNIX_CODEX_REQUIREMENTS_PATH = "/etc/codex/requirements.toml";
|
||||
const WINDOWS_CODEX_REQUIREMENTS_SUFFIX = "\\OpenAI\\Codex\\requirements.toml";
|
||||
const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/;
|
||||
|
||||
type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
type CodexAppServerPolicyMode = "yolo" | "guardian";
|
||||
@@ -1036,11 +1035,10 @@ function readBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
}
|
||||
|
||||
function readNumberEnv(value: string | undefined): number | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || !PLAIN_DECIMAL_NUMBER_RE.test(trimmed)) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ function startOptions(
|
||||
}
|
||||
|
||||
function managedCommandPath(root: string, platform: NodeJS.Platform): string {
|
||||
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
||||
return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
|
||||
return path.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
|
||||
}
|
||||
|
||||
describe("managed Codex app-server binary", () => {
|
||||
@@ -97,52 +96,6 @@ describe("managed Codex app-server binary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("finds Codex bins hoisted into an isolated npm project root", async () => {
|
||||
const projectRoot = path.join("/tmp", "state", "npm", "projects", "openclaw-codex-hash");
|
||||
const pluginRoot = path.join(projectRoot, "node_modules", "@openclaw", "codex");
|
||||
const installedCommand = managedCommandPath(projectRoot, "linux");
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
platform: "linux",
|
||||
pluginRoot,
|
||||
pathExists,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: installedCommand,
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds Windows Codex shims hoisted into an isolated npm project root", async () => {
|
||||
const projectRoot = path.win32.join(
|
||||
"C:\\",
|
||||
"Users",
|
||||
"test",
|
||||
".openclaw",
|
||||
"npm",
|
||||
"projects",
|
||||
"openclaw-codex-hash",
|
||||
);
|
||||
const pluginRoot = path.win32.join(projectRoot, "node_modules", "@openclaw", "codex");
|
||||
const installedCommand = managedCommandPath(projectRoot, "win32");
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
platform: "win32",
|
||||
pluginRoot,
|
||||
pathExists,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: installedCommand,
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the resolved Codex package bin when no command shim exists", async () => {
|
||||
const installRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-codex-package-"));
|
||||
const pluginRoot = path.join(installRoot, "dist", "extensions", "codex");
|
||||
|
||||
@@ -90,7 +90,7 @@ function resolveManagedCodexAppServerCandidateRoots(
|
||||
platform: NodeJS.Platform,
|
||||
): string[] {
|
||||
const pathApi = pathForPlatform(platform);
|
||||
const directRoots = [
|
||||
return [
|
||||
pluginRoot,
|
||||
pathApi.dirname(pluginRoot),
|
||||
pathApi.dirname(pathApi.dirname(pluginRoot)),
|
||||
@@ -98,32 +98,6 @@ function resolveManagedCodexAppServerCandidateRoots(
|
||||
? pathApi.dirname(pathApi.dirname(pathApi.dirname(pluginRoot)))
|
||||
: null,
|
||||
].filter((root): root is string => Boolean(root));
|
||||
return [
|
||||
...new Set([...directRoots, ...resolveNearestNodeModulesProjectRoots(directRoots, platform)]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveNearestNodeModulesProjectRoots(
|
||||
roots: readonly string[],
|
||||
platform: NodeJS.Platform,
|
||||
): string[] {
|
||||
const pathApi = pathForPlatform(platform);
|
||||
const projectRoots: string[] = [];
|
||||
for (const root of roots) {
|
||||
let current = pathApi.resolve(root);
|
||||
while (true) {
|
||||
if (pathApi.basename(current) === "node_modules") {
|
||||
projectRoots.push(pathApi.dirname(current));
|
||||
break;
|
||||
}
|
||||
const parent = pathApi.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return projectRoots;
|
||||
}
|
||||
|
||||
function resolveManagedCodexPackageBinCandidates(
|
||||
|
||||
@@ -25,14 +25,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import {
|
||||
buildCodexOpenClawPromptContext,
|
||||
buildCodexSystemPromptReport,
|
||||
buildCodexWorkspaceBootstrapContext,
|
||||
getCodexWorkspaceMemoryToolNames,
|
||||
prependCodexOpenClawPromptContext,
|
||||
renderCodexWorkspaceMemoryReference,
|
||||
} from "./attempt-context.js";
|
||||
import * as authBridge from "./auth-bridge.js";
|
||||
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
@@ -229,75 +221,6 @@ async function buildDynamicToolsForTest(
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCodexTurnContextForTest(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
workspaceDir: string,
|
||||
) {
|
||||
const sessionAgentId = "main";
|
||||
const agentTools = await buildDynamicToolsForTest(params, workspaceDir);
|
||||
const toolBridge = createCodexDynamicToolBridge({
|
||||
tools: agentTools,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const dynamicTools = toolBridge.availableSpecs;
|
||||
const memoryToolNames = getCodexWorkspaceMemoryToolNames(dynamicTools);
|
||||
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
sessionAgentId,
|
||||
memoryToolNames,
|
||||
});
|
||||
const threadDeveloperInstructions = [
|
||||
testing.buildDeveloperInstructions(params, { dynamicTools }),
|
||||
workspaceBootstrapContext.developerInstructions,
|
||||
]
|
||||
.filter((section) => section?.trim())
|
||||
.join("\n\n");
|
||||
const openClawPromptContext = buildCodexOpenClawPromptContext({
|
||||
params,
|
||||
workspacePromptContext: workspaceBootstrapContext.promptContext,
|
||||
workspaceMemoryReference: renderCodexWorkspaceMemoryReference({
|
||||
files: workspaceBootstrapContext.memoryReferenceFiles ?? [],
|
||||
toolNames: workspaceBootstrapContext.memoryToolNames,
|
||||
}),
|
||||
});
|
||||
const codexTurnPromptText = prependCodexOpenClawPromptContext(
|
||||
params.prompt,
|
||||
openClawPromptContext,
|
||||
);
|
||||
const turnStartParams = buildTurnStartParams(params, {
|
||||
threadId: "thread-1",
|
||||
cwd: workspaceDir,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({}),
|
||||
promptText: codexTurnPromptText,
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
});
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
const inputText = turnStartParams.input?.find((item) => item.type === "text")?.text ?? "";
|
||||
const systemPromptReport = buildCodexSystemPromptReport({
|
||||
attempt: params,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
workspaceDir,
|
||||
developerInstructions: [threadDeveloperInstructions, collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n"),
|
||||
workspaceBootstrapContext,
|
||||
skillsPrompt: "",
|
||||
tools: dynamicTools,
|
||||
});
|
||||
return {
|
||||
collaborationInstructions,
|
||||
inputText,
|
||||
systemPromptReport,
|
||||
threadDeveloperInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
function createCodexToolBridgeForTest(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
tools: RuntimeDynamicToolForTest[],
|
||||
@@ -1713,63 +1636,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves workspace instruction files when before_prompt_build replaces Codex developer instructions", async () => {
|
||||
const beforePromptBuild = vi.fn(async () => ({
|
||||
systemPrompt: "hook replacement codex system",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const soulGuidance = "Soul guidance that must stay session-scoped.";
|
||||
const identityGuidance = "Identity guidance that must stay session-scoped.";
|
||||
const toolGuidance = "Tool guidance that must stay session-scoped.";
|
||||
const userProfile = "User profile that must stay session-scoped.";
|
||||
const memorySummary = "Memory summary that must stay turn-scoped.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), soulGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), identityGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
expect(beforePromptBuild).toHaveBeenCalledOnce();
|
||||
const threadStart = harness.requests.find((request) => request.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as { developerInstructions?: string };
|
||||
expect(threadStartParams.developerInstructions).toContain("hook replacement codex system");
|
||||
expect(threadStartParams.developerInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadStartParams.developerInstructions).toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(toolGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(userProfile);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(memorySummary);
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
collaborationMode?: { settings?: { developer_instructions?: string | null } };
|
||||
};
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
expect(turnStartParams.input?.[0]?.text ?? "").toContain(memorySummary);
|
||||
expect(result.systemPromptReport?.systemPrompt.chars).toBe(
|
||||
(threadStartParams.developerInstructions ?? "").length,
|
||||
);
|
||||
});
|
||||
|
||||
it("projects mirrored history when starting Codex without a native thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -1904,6 +1770,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const identityGuidance = "Identity guidance goes here.";
|
||||
const toolGuidance = "Tool guidance goes here.";
|
||||
const userProfile = "User profile goes here.";
|
||||
const heartbeatChecklist = "Heartbeat checklist goes here.";
|
||||
const memorySummary = "Memory summary goes here.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), agentsGuidance);
|
||||
@@ -1911,37 +1778,62 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), identityGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
|
||||
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), heartbeatChecklist);
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
const {
|
||||
collaborationInstructions,
|
||||
inputText,
|
||||
systemPromptReport,
|
||||
threadDeveloperInstructions,
|
||||
} = await buildCodexTurnContextForTest(params, workspaceDir);
|
||||
|
||||
expect(threadDeveloperInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadDeveloperInstructions).toContain(soulGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(identityGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(toolGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(userProfile);
|
||||
expect(threadDeveloperInstructions).not.toContain(memorySummary);
|
||||
expect(threadDeveloperInstructions).not.toContain("Codex loads AGENTS.md natively");
|
||||
expect(threadDeveloperInstructions).not.toContain(agentsGuidance);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
const threadStart = harness.requests.find((request) => request.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as {
|
||||
config?: { instructions?: string };
|
||||
developerInstructions?: string;
|
||||
};
|
||||
const config = threadStartParams.config;
|
||||
|
||||
expect(threadStartParams.developerInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadStartParams.developerInstructions).not.toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(toolGuidance);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(userProfile);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(heartbeatChecklist);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(memorySummary);
|
||||
expect(threadStartParams.developerInstructions).not.toContain("Codex loads AGENTS.md natively");
|
||||
expect(threadStartParams.developerInstructions).not.toContain(agentsGuidance);
|
||||
expect(config?.instructions).toBeUndefined();
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
collaborationMode?: {
|
||||
settings?: {
|
||||
developer_instructions?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
expect(collaborationInstructions).toContain("# Collaboration Mode: Default");
|
||||
expect(collaborationInstructions).toContain("request_user_input availability");
|
||||
expect(collaborationInstructions).toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).toContain(soulGuidance);
|
||||
expect(collaborationInstructions).toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(heartbeatChecklist);
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toContain("OpenClaw runtime context for this turn:");
|
||||
expect(inputText).not.toContain("does not override Codex system/developer instructions");
|
||||
expect(inputText).not.toContain("not developer policy");
|
||||
@@ -1949,6 +1841,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).not.toContain(identityGuidance);
|
||||
expect(inputText).not.toContain(toolGuidance);
|
||||
expect(inputText).not.toContain(userProfile);
|
||||
expect(inputText).not.toContain(heartbeatChecklist);
|
||||
expect(inputText).not.toContain(memorySummary);
|
||||
expect(inputText).toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).toContain("MEMORY.md exists in the active agent workspace");
|
||||
@@ -1957,14 +1850,13 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).not.toContain("Codex loads AGENTS.md natively");
|
||||
expect(inputText).not.toContain(agentsGuidance);
|
||||
expect(inputText).toContain("Current user request:\nhello");
|
||||
expect(systemPromptReport.systemPrompt.chars).toBe(
|
||||
[threadDeveloperInstructions, collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n").length,
|
||||
expect(result.systemPromptReport?.systemPrompt.chars).toBe(
|
||||
[threadStartParams.developerInstructions ?? "", collaborationInstructions].join("\n\n")
|
||||
.length,
|
||||
);
|
||||
|
||||
const fileStats = new Map(
|
||||
systemPromptReport.injectedWorkspaceFiles.map((file) => [file.name, file]),
|
||||
result.systemPromptReport?.injectedWorkspaceFiles.map((file) => [file.name, file]) ?? [],
|
||||
);
|
||||
expect(fileStats.get("SOUL.md")).toMatchObject({
|
||||
rawChars: soulGuidance.length,
|
||||
@@ -1991,6 +1883,11 @@ describe("runCodexAppServerAttempt", () => {
|
||||
injectedChars: 0,
|
||||
truncated: false,
|
||||
});
|
||||
expect(fileStats.get("HEARTBEAT.md")).toMatchObject({
|
||||
rawChars: heartbeatChecklist.length,
|
||||
injectedChars: 0,
|
||||
truncated: false,
|
||||
});
|
||||
expect(fileStats.get("AGENTS.md")).toMatchObject({
|
||||
rawChars: agentsGuidance.length,
|
||||
injectedChars: agentsGuidance.length,
|
||||
@@ -1998,70 +1895,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends workspace bootstrap instructions through Codex app-server payloads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentsGuidance = "Follow AGENTS guidance.";
|
||||
const soulGuidance = "Soul voice goes here.";
|
||||
const identityGuidance = "Identity guidance goes here.";
|
||||
const toolGuidance = "Tool guidance goes here.";
|
||||
const userProfile = "User profile goes here.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), agentsGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), soulGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), identityGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
const threadStart = harness.requests.find((request) => request.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as {
|
||||
config?: { instructions?: string };
|
||||
developerInstructions?: string;
|
||||
};
|
||||
expect(threadStartParams.config?.instructions).toBeUndefined();
|
||||
expect(threadStartParams.developerInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadStartParams.developerInstructions).toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(toolGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(userProfile);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(agentsGuidance);
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
collaborationMode?: {
|
||||
settings?: {
|
||||
developer_instructions?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toBe("hello");
|
||||
expect(inputText).not.toContain(agentsGuidance);
|
||||
expect(result.systemPromptReport?.systemPrompt.chars).toBe(
|
||||
[threadStartParams.developerInstructions ?? "", collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n").length,
|
||||
);
|
||||
});
|
||||
|
||||
it("injects bounded MEMORY.md when memory tools are unavailable", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -2102,22 +1935,29 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("memory_get")]);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
|
||||
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
};
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).toContain("memory_get");
|
||||
expect(inputText).not.toContain("memory_search");
|
||||
expect(inputText).not.toContain(memorySummary);
|
||||
|
||||
const fileStats = new Map(
|
||||
systemPromptReport.injectedWorkspaceFiles.map((file) => [file.name, file]),
|
||||
result.systemPromptReport?.injectedWorkspaceFiles.map((file) => [file.name, file]) ?? [],
|
||||
);
|
||||
expect(fileStats.get("MEMORY.md")).toMatchObject({
|
||||
rawChars: memorySummary.length,
|
||||
@@ -2183,9 +2023,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
];
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -2200,16 +2040,23 @@ describe("runCodexAppServerAttempt", () => {
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
|
||||
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
};
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).not.toContain(memorySummary);
|
||||
expect(inputText).toContain(hookContext);
|
||||
|
||||
const fileStats = new Map(
|
||||
systemPromptReport.injectedWorkspaceFiles.map((file) => [file.name, file]),
|
||||
result.systemPromptReport?.injectedWorkspaceFiles.map((file) => [file.name, file]) ?? [],
|
||||
);
|
||||
expect(fileStats.get("MEMORY.md")).toMatchObject({
|
||||
rawChars: memorySummary.trimEnd().length,
|
||||
@@ -2250,20 +2097,27 @@ describe("runCodexAppServerAttempt", () => {
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
|
||||
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
};
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).not.toContain(rootMemory);
|
||||
expect(inputText).toContain(nestedMemory);
|
||||
|
||||
const files = systemPromptReport.injectedWorkspaceFiles;
|
||||
const files = result.systemPromptReport?.injectedWorkspaceFiles ?? [];
|
||||
const rootMemoryStats = files.find(
|
||||
(file) => file.path === path.join(workspaceDir, "MEMORY.md"),
|
||||
);
|
||||
@@ -2290,20 +2144,27 @@ describe("runCodexAppServerAttempt", () => {
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, path.join(tempDir, "memory-workspace"));
|
||||
|
||||
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
};
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).not.toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).toContain(memorySummary);
|
||||
|
||||
const fileStats = new Map(
|
||||
systemPromptReport.injectedWorkspaceFiles.map((file) => [file.name, file]),
|
||||
result.systemPromptReport?.injectedWorkspaceFiles.map((file) => [file.name, file]) ?? [],
|
||||
);
|
||||
expect(fileStats.get("MEMORY.md")).toMatchObject({
|
||||
rawChars: memorySummary.length,
|
||||
|
||||
@@ -640,6 +640,7 @@ export async function runCodexAppServerAttempt(
|
||||
buildDeveloperInstructions(params, {
|
||||
dynamicTools: toolBridge.availableSpecs,
|
||||
}),
|
||||
workspaceBootstrapContext.developerInstructions,
|
||||
);
|
||||
const openClawPromptContext = buildCodexOpenClawPromptContext({
|
||||
params,
|
||||
@@ -770,14 +771,9 @@ export async function runCodexAppServerAttempt(
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
}).settings.developer_instructions ?? undefined;
|
||||
const buildCodexThreadDeveloperInstructions = () =>
|
||||
joinPresentSections(
|
||||
promptBuild.developerInstructions,
|
||||
workspaceBootstrapContext.developerInstructions,
|
||||
);
|
||||
const buildRenderedCodexDeveloperInstructions = () =>
|
||||
joinPresentSections(
|
||||
buildCodexThreadDeveloperInstructions(),
|
||||
promptBuild.developerInstructions,
|
||||
buildCodexTurnCollaborationDeveloperInstructions(),
|
||||
);
|
||||
const systemPromptReport = buildCodexSystemPromptReport({
|
||||
@@ -875,7 +871,7 @@ export async function runCodexAppServerAttempt(
|
||||
effectiveWorkspace,
|
||||
effectiveCwd,
|
||||
dynamicTools: toolBridge.specs,
|
||||
developerInstructions: buildCodexThreadDeveloperInstructions(),
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
|
||||
bundleMcpThreadConfig,
|
||||
nativeToolSurfaceEnabled,
|
||||
@@ -918,7 +914,7 @@ export async function runCodexAppServerAttempt(
|
||||
recordCodexTrajectoryContext(trajectoryRecorder, {
|
||||
attempt: params,
|
||||
cwd: effectiveCwd,
|
||||
developerInstructions: buildRenderedCodexDeveloperInstructions(),
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
prompt: codexTurnPromptText,
|
||||
tools: toolBridge.availableSpecs,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
withCachedMigrationConfigRuntime,
|
||||
writeMigrationReport,
|
||||
} from "openclaw/plugin-sdk/migration-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationItem,
|
||||
@@ -363,13 +362,9 @@ function hasOpenAiCuratedMarketplace(response: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function targetCodexMarketplaceDiscoveryTimeoutMs(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const configured = parseStrictNonNegativeInteger(
|
||||
env[TARGET_CODEX_MARKETPLACE_DISCOVERY_TIMEOUT_ENV],
|
||||
);
|
||||
if (configured !== undefined) {
|
||||
function targetCodexMarketplaceDiscoveryTimeoutMs(): number {
|
||||
const configured = Number(process.env[TARGET_CODEX_MARKETPLACE_DISCOVERY_TIMEOUT_ENV]);
|
||||
if (Number.isFinite(configured) && configured >= 0) {
|
||||
return configured;
|
||||
}
|
||||
return TARGET_CODEX_MARKETPLACE_DISCOVERY_TIMEOUT_MS;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
|
||||
import { targetCodexMarketplaceDiscoveryTimeoutMs } from "./apply.js";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
|
||||
const appServerRequest = vi.hoisted(() => vi.fn());
|
||||
@@ -175,27 +174,6 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("buildCodexMigrationProvider", () => {
|
||||
it("parses target marketplace discovery timeout env strictly", () => {
|
||||
expect(
|
||||
targetCodexMarketplaceDiscoveryTimeoutMs({
|
||||
OPENCLAW_CODEX_MIGRATION_PLUGIN_LIST_TIMEOUT_MS: "0",
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
targetCodexMarketplaceDiscoveryTimeoutMs({
|
||||
OPENCLAW_CODEX_MIGRATION_PLUGIN_LIST_TIMEOUT_MS: "250",
|
||||
}),
|
||||
).toBe(250);
|
||||
|
||||
for (const value of ["0x10", "1e3", "2.5"]) {
|
||||
expect(
|
||||
targetCodexMarketplaceDiscoveryTimeoutMs({
|
||||
OPENCLAW_CODEX_MIGRATION_PLUGIN_LIST_TIMEOUT_MS: value,
|
||||
}),
|
||||
).toBe(30_000);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
appServerRequest.mockRejectedValue(new Error("codex app-server unavailable"));
|
||||
});
|
||||
|
||||
101
extensions/diffs/npm-shrinkwrap.json
generated
101
extensions/diffs/npm-shrinkwrap.json
generated
@@ -8,18 +8,18 @@
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.28",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.2.3",
|
||||
"@pierre/diffs": "1.2.2",
|
||||
"@pierre/theme": "1.0.3",
|
||||
"@shikijs/langs": "4.1.0",
|
||||
"@shikijs/langs": "3.23.0",
|
||||
"playwright-core": "1.60.0",
|
||||
"typebox": "1.1.38",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@pierre/diffs": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.3.tgz",
|
||||
"integrity": "sha512-ul83DHH1yqgGxJAw2tqQm2gDO+oQsaF82ZVocwJYfXAm2FhZyyKPTdtv6jswR4A5eF/ILPjiQxyfScMhQcofbA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.2.tgz",
|
||||
"integrity": "sha512-MvWLv2oSOJOF8oYXWLdhicguHM11G/VNWu6OPR5ZETolp2NM2/KPQG3cZTnKpJ6ImqEHwvw6Gl6z2gmmy2FQmQ==",
|
||||
"license": "apache-2.0",
|
||||
"dependencies": {
|
||||
"@pierre/theme": "1.0.3",
|
||||
@@ -55,16 +55,6 @@
|
||||
"hast-util-to-html": "^9.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/core/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-javascript": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz",
|
||||
@@ -76,16 +66,6 @@
|
||||
"oniguruma-to-es": "^4.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-javascript/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-oniguruma": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz",
|
||||
@@ -96,26 +76,13 @@
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-oniguruma/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/langs": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.1.0.tgz",
|
||||
"integrity": "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==",
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
|
||||
"integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"@shikijs/types": "3.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes": {
|
||||
@@ -127,16 +94,6 @@
|
||||
"@shikijs/types": "3.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/transformers": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz",
|
||||
@@ -147,7 +104,7 @@
|
||||
"@shikijs/types": "3.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/transformers/node_modules/@shikijs/types": {
|
||||
"node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
@@ -157,19 +114,6 @@
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/types": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz",
|
||||
"integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/vscode-textmate": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
||||
@@ -269,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -545,25 +489,6 @@
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/shiki/node_modules/@shikijs/langs": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
|
||||
"integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shiki/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/space-separated-tokens": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.2.3",
|
||||
"@pierre/diffs": "1.2.2",
|
||||
"@pierre/theme": "1.0.3",
|
||||
"@shikijs/langs": "4.1.0",
|
||||
"@shikijs/langs": "3.23.0",
|
||||
"playwright-core": "1.60.0",
|
||||
"typebox": "1.1.38",
|
||||
"zod": "4.4.3"
|
||||
|
||||
46
extensions/discord/npm-shrinkwrap.json
generated
46
extensions/discord/npm-shrinkwrap.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "0.19.2",
|
||||
"discord-api-types": "0.38.48",
|
||||
"https-proxy-agent": "9.0.0",
|
||||
"libopus-wasm": "0.1.0",
|
||||
"typebox": "1.1.38",
|
||||
"undici": "8.3.0",
|
||||
@@ -373,6 +374,32 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.38.48",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz",
|
||||
@@ -382,6 +409,19 @@
|
||||
"scripts/actions/documentation"
|
||||
]
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
|
||||
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "9.0.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/libopus-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.1.0.tgz",
|
||||
@@ -391,6 +431,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prism-media": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "0.19.2",
|
||||
"discord-api-types": "0.38.48",
|
||||
"https-proxy-agent": "9.0.0",
|
||||
"libopus-wasm": "0.1.0",
|
||||
"typebox": "1.1.38",
|
||||
"undici": "8.3.0",
|
||||
|
||||
@@ -100,32 +100,6 @@ describe("fetchDiscord", () => {
|
||||
expect(message).not.toContain("<html");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["hex", "0x10"],
|
||||
["fractional", "1.5"],
|
||||
["overflow", `1${"0".repeat(309)}`],
|
||||
])("rejects invalid Retry-After header values: %s", async (_label, header) => {
|
||||
const fetcher = withFetchPreconnect(
|
||||
async () =>
|
||||
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
|
||||
status: 429,
|
||||
headers: { "content-type": "text/html", "retry-after": header },
|
||||
}),
|
||||
);
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await fetchDiscord("/oauth2/applications/@me", "test", fetcher, {
|
||||
retry: { attempts: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(DiscordApiError);
|
||||
expect((error as DiscordApiError).retryAfter).toBe(60);
|
||||
});
|
||||
|
||||
it("retries rate limits before succeeding", async () => {
|
||||
let calls = 0;
|
||||
const fetcher = withFetchPreconnect(async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type RetryConfig,
|
||||
} from "openclaw/plugin-sdk/retry-runtime";
|
||||
import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js";
|
||||
import { parseRetryAfterHeaderSeconds } from "./retry-after.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
const DISCORD_API_RETRY_DEFAULTS = {
|
||||
@@ -52,7 +51,15 @@ function parseRetryAfterSeconds(text: string, response: Response): number | unde
|
||||
if (!header) {
|
||||
return undefined;
|
||||
}
|
||||
return parseRetryAfterHeaderSeconds(header);
|
||||
const parsed = Number(header);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
const retryAt = Date.parse(header);
|
||||
if (!Number.isFinite(retryAt)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, (retryAt - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
|
||||
|
||||
@@ -82,6 +82,8 @@ function createModalFieldComponent(
|
||||
customId = field.id;
|
||||
override options = options;
|
||||
override required = field.required;
|
||||
override minValues = field.minValues;
|
||||
override maxValues = field.maxValues;
|
||||
}
|
||||
return new DynamicRadioGroup();
|
||||
}
|
||||
|
||||
@@ -62,23 +62,10 @@ function readOptionalStringArray(value: unknown, label: string): string[] | unde
|
||||
return value.map((entry, index) => readString(entry, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function readOptionalInteger(
|
||||
value: unknown,
|
||||
label: string,
|
||||
bounds?: { min?: number; max?: number },
|
||||
): number | undefined {
|
||||
if (value == null) {
|
||||
function readOptionalNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new Error(`${label} must be an integer`);
|
||||
}
|
||||
if (bounds?.min !== undefined && value < bounds.min) {
|
||||
throw new Error(`${label} must be at least ${bounds.min}`);
|
||||
}
|
||||
if (bounds?.max !== undefined && value > bounds.max) {
|
||||
throw new Error(`${label} must be at most ${bounds.max}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -210,8 +197,8 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
|
||||
type,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
placeholder: readOptionalString(obj.placeholder),
|
||||
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }),
|
||||
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }),
|
||||
minValues: readOptionalNumber(obj.minValues),
|
||||
maxValues: readOptionalNumber(obj.maxValues),
|
||||
options: parseSelectOptions(obj.options, `${label}.options`),
|
||||
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
||||
};
|
||||
@@ -237,29 +224,18 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod
|
||||
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
|
||||
throw new Error(`${label}.options is required for ${type} fields`);
|
||||
}
|
||||
if (type === "radio" && (obj.minValues != null || obj.maxValues != null)) {
|
||||
throw new Error(`${label}.minValues/maxValues are not supported for radio fields`);
|
||||
}
|
||||
const required = typeof obj.required === "boolean" ? obj.required : undefined;
|
||||
const maxValues = type === "checkbox" ? 10 : 25;
|
||||
return {
|
||||
type,
|
||||
name: normalizeModalFieldName(readOptionalString(obj.name), index),
|
||||
label: readString(obj.label, `${label}.label`),
|
||||
description: readOptionalString(obj.description),
|
||||
placeholder: readOptionalString(obj.placeholder),
|
||||
required,
|
||||
required: typeof obj.required === "boolean" ? obj.required : undefined,
|
||||
options,
|
||||
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, {
|
||||
min: required === false ? 0 : 1,
|
||||
max: maxValues,
|
||||
}),
|
||||
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, {
|
||||
min: 1,
|
||||
max: maxValues,
|
||||
}),
|
||||
minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }),
|
||||
maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }),
|
||||
minValues: readOptionalNumber(obj.minValues),
|
||||
maxValues: readOptionalNumber(obj.maxValues),
|
||||
minLength: readOptionalNumber(obj.minLength),
|
||||
maxLength: readOptionalNumber(obj.maxLength),
|
||||
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,95 +106,6 @@ describe("discord components", () => {
|
||||
).toThrow("options");
|
||||
});
|
||||
|
||||
it("rejects malformed component count and length limits", () => {
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
select: {
|
||||
type: "string",
|
||||
minValues: -1,
|
||||
options: [{ label: "One", value: "one" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("components.blocks[0].select.minValues");
|
||||
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [{ type: "text", label: "Name", maxLength: 0 }],
|
||||
},
|
||||
}),
|
||||
).toThrow("components.modal.fields[0].maxLength");
|
||||
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "Priority",
|
||||
minValues: 0,
|
||||
options: [{ label: "High", value: "high" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow("components.modal.fields[0].minValues");
|
||||
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [
|
||||
{
|
||||
type: "checkbox",
|
||||
label: "Choices",
|
||||
maxValues: 25,
|
||||
options: [{ label: "One", value: "one" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow("components.modal.fields[0].maxValues");
|
||||
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
select: {
|
||||
type: "string",
|
||||
maxValues: 0,
|
||||
options: [{ label: "One", value: "one" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("components.blocks[0].select.maxValues");
|
||||
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [
|
||||
{
|
||||
type: "radio",
|
||||
label: "Choice",
|
||||
minValues: 1,
|
||||
options: [{ label: "One", value: "one" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow("components.modal.fields[0].minValues/maxValues");
|
||||
});
|
||||
|
||||
it("requires attachment references for file blocks", () => {
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "../retry-after.js";
|
||||
|
||||
export function readDiscordCode(body: unknown): number | undefined {
|
||||
const value =
|
||||
body && typeof body === "object" && "code" in body
|
||||
@@ -22,14 +20,34 @@ export function readDiscordMessage(body: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function readRetryAfterHeader(value: string | null, now = Date.now()): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) {
|
||||
return seconds;
|
||||
}
|
||||
const retryAt = Date.parse(value);
|
||||
return Number.isFinite(retryAt) ? (retryAt - now) / 1000 : undefined;
|
||||
}
|
||||
|
||||
function coerceRetryAfterSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" && typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const seconds = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.max(0, seconds) : undefined;
|
||||
}
|
||||
|
||||
export function readRetryAfter(body: unknown, response: Response, fallbackSeconds = 0): number {
|
||||
const bodyValue =
|
||||
body && typeof body === "object" && "retry_after" in body
|
||||
? (body as { retry_after?: unknown }).retry_after
|
||||
: undefined;
|
||||
return (
|
||||
parseDiscordRetryAfterBodySeconds(bodyValue) ??
|
||||
parseRetryAfterHeaderSeconds(response.headers.get("Retry-After")) ??
|
||||
coerceRetryAfterSeconds(bodyValue) ??
|
||||
coerceRetryAfterSeconds(readRetryAfterHeader(response.headers.get("Retry-After"))) ??
|
||||
fallbackSeconds
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readHeaderNumber, readResetAt } from "./rest-routes.js";
|
||||
|
||||
describe("Discord REST rate limit header parsing", () => {
|
||||
it("rejects non-decimal numeric header forms", () => {
|
||||
const headers = new Headers({
|
||||
"X-RateLimit-Limit": "0x10",
|
||||
"X-RateLimit-Remaining": "1e3",
|
||||
"X-RateLimit-Reset-After": `1${"0".repeat(309)}`,
|
||||
});
|
||||
|
||||
expect(readHeaderNumber(headers, "X-RateLimit-Limit")).toBeUndefined();
|
||||
expect(readHeaderNumber(headers, "X-RateLimit-Remaining")).toBeUndefined();
|
||||
expect(readHeaderNumber(headers, "X-RateLimit-Reset-After")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps decimal reset headers working", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-28T12:00:00.000Z"));
|
||||
try {
|
||||
const response = new Response(null, {
|
||||
headers: { "X-RateLimit-Reset-After": "0.125" },
|
||||
});
|
||||
|
||||
expect(readResetAt(response)).toBe(Date.now() + 125);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
type QueryValue = string | number | boolean;
|
||||
|
||||
const RATE_LIMIT_HEADER_NUMBER_RE = /^\d+(?:\.\d+)?$/;
|
||||
|
||||
export function createRouteKey(method: string, path: string): string {
|
||||
return `${method.toUpperCase()} ${path.split("?")[0] ?? path}`;
|
||||
}
|
||||
@@ -27,11 +25,7 @@ export function readHeaderNumber(headers: Headers, name: string): number | undef
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!RATE_LIMIT_HEADER_NUMBER_RE.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -532,23 +532,6 @@ describe("RequestClient", () => {
|
||||
await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["hex", "0x10"],
|
||||
["fractional", "1.5"],
|
||||
["overflow", `1${"0".repeat(309)}`],
|
||||
])("rejects invalid Retry-After numeric strings: %s", async (_label, header) => {
|
||||
const client = new RequestClient("test-token", {
|
||||
queueRequests: false,
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify({ message: "Slow down", retry_after: "1e3", global: false }), {
|
||||
status: 429,
|
||||
headers: { "Retry-After": header },
|
||||
}),
|
||||
});
|
||||
|
||||
await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 1 });
|
||||
});
|
||||
|
||||
it("tracks invalid requests and exposes bucket scheduler metrics", async () => {
|
||||
const client = new RequestClient("test-token", {
|
||||
queueRequests: false,
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchDiscordGatewayInfo,
|
||||
resolveDiscordGatewayInfoTimeoutMs,
|
||||
resolveGatewayInfoWithFallback,
|
||||
} from "./gateway-metadata.js";
|
||||
import { fetchDiscordGatewayInfo, resolveGatewayInfoWithFallback } from "./gateway-metadata.js";
|
||||
|
||||
describe("Discord gateway metadata", () => {
|
||||
it("resolves gateway info timeouts from strict integer config and env values", () => {
|
||||
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
|
||||
expect(
|
||||
resolveDiscordGatewayInfoTimeoutMs({
|
||||
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "90000" },
|
||||
}),
|
||||
).toBe(90_000);
|
||||
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 150_000 })).toBe(120_000);
|
||||
expect(
|
||||
resolveDiscordGatewayInfoTimeoutMs({
|
||||
configuredTimeoutMs: 1.5,
|
||||
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "0x1000" },
|
||||
}),
|
||||
).toBe(30_000);
|
||||
expect(
|
||||
resolveDiscordGatewayInfoTimeoutMs({
|
||||
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "1e3" },
|
||||
}),
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("falls back on Cloudflare HTML rate limits without logging raw HTML", async () => {
|
||||
const error = await fetchDiscordGatewayInfo({
|
||||
token: "test",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIGatewayBotInfo } from "discord-api-types/v10";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { captureHttpExchange } from "openclaw/plugin-sdk/proxy-capture";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -65,11 +64,12 @@ async function materializeGuardedResponse(response: Response): Promise<Response>
|
||||
}
|
||||
|
||||
function normalizeGatewayInfoTimeoutMs(value: unknown): number | undefined {
|
||||
const numeric = parseStrictPositiveInteger(value);
|
||||
if (numeric === undefined) {
|
||||
const numeric =
|
||||
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(numeric, MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
|
||||
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function resolveDiscordGatewayInfoTimeoutMs(params?: {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Agent as HttpAgent } from "node:http";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import * as httpsProxyAgent from "https-proxy-agent";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import {
|
||||
captureWsEvent,
|
||||
resolveEffectiveDebugProxyUrl,
|
||||
@@ -37,7 +36,9 @@ type DiscordGatewayWebSocketCtor = new (
|
||||
url: string,
|
||||
options?: { agent?: unknown; handshakeTimeout?: number },
|
||||
) => ws.WebSocket;
|
||||
type DiscordGatewayWebSocketAgent = InstanceType<typeof HttpsAgent> | HttpAgent;
|
||||
type DiscordGatewayWebSocketAgent =
|
||||
| InstanceType<typeof HttpsAgent>
|
||||
| InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
|
||||
const registrationPromises = new WeakMap<discordGateway.GatewayPlugin, Promise<void>>();
|
||||
type DiscordGatewayClient = Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
|
||||
type GatewayPluginTestingOptions = {
|
||||
@@ -48,7 +49,7 @@ type GatewayPluginTestingOptions = {
|
||||
webSocketCtor?: DiscordGatewayWebSocketCtor;
|
||||
};
|
||||
type CreateDiscordGatewayPluginTestingOptions = GatewayPluginTestingOptions & {
|
||||
createProxyAgent?: (proxyUrl: string) => HttpAgent;
|
||||
HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent;
|
||||
};
|
||||
type DiscordGatewayRegistrationState = {
|
||||
client?: DiscordGatewayClient;
|
||||
@@ -279,9 +280,9 @@ export function createDiscordGatewayPlugin(params: {
|
||||
if (proxy) {
|
||||
try {
|
||||
validateDiscordProxyUrl(proxy);
|
||||
wsAgent =
|
||||
params.testing?.createProxyAgent?.(proxy) ??
|
||||
createNodeProxyAgent({ mode: "explicit", proxyUrl: proxy, protocol: "https" });
|
||||
const HttpsProxyAgentCtor =
|
||||
params.testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent;
|
||||
wsAgent = new HttpsProxyAgentCtor<string>(proxy);
|
||||
fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled, proxy);
|
||||
params.runtime.log?.("discord: gateway proxy enabled");
|
||||
} catch (err) {
|
||||
|
||||
@@ -233,20 +233,6 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000);
|
||||
});
|
||||
|
||||
it("ignores non-integer gateway READY timeout values", () => {
|
||||
expect(
|
||||
resolveDiscordGatewayReadyTimeoutMs({
|
||||
configuredTimeoutMs: 1.5,
|
||||
env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "0x1000" },
|
||||
}),
|
||||
).toBe(15_000);
|
||||
expect(
|
||||
resolveDiscordGatewayRuntimeReadyTimeoutMs({
|
||||
env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "1e3" },
|
||||
}),
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("cleans up thread bindings when gateway wait fails before READY", async () => {
|
||||
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed"));
|
||||
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createConnectedChannelStatusPatch,
|
||||
createTransportActivityStatusPatch,
|
||||
} from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
@@ -34,11 +33,12 @@ const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000;
|
||||
type GatewayReadyWaitResult = "ready" | "stopped" | "timeout";
|
||||
|
||||
function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined {
|
||||
const numeric = parseStrictPositiveInteger(value);
|
||||
if (numeric === undefined) {
|
||||
const numeric =
|
||||
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(numeric, MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS);
|
||||
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function resolveDiscordGatewayReadyTimeoutMs(params?: {
|
||||
|
||||
@@ -158,6 +158,10 @@ vi.mock("node:https", () => ({
|
||||
Agent: HttpsAgent,
|
||||
}));
|
||||
|
||||
vi.mock("https-proxy-agent", () => ({
|
||||
HttpsProxyAgent,
|
||||
}));
|
||||
|
||||
vi.mock("ws", () => ({
|
||||
default: function MockWebSocket(
|
||||
url: string,
|
||||
@@ -224,8 +228,8 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
|
||||
function createProxyTestingOverrides() {
|
||||
return {
|
||||
createProxyAgent: (proxyUrl: string) =>
|
||||
new HttpsProxyAgent(proxyUrl) as unknown as import("node:http").Agent,
|
||||
HttpsProxyAgentCtor:
|
||||
HttpsProxyAgent as unknown as typeof import("https-proxy-agent").HttpsProxyAgent,
|
||||
webSocketCtor: function WebSocketCtor(
|
||||
url: string,
|
||||
options?: { agent?: unknown; handshakeTimeout?: number },
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
const RETRY_AFTER_HEADER_DELAY_RE = /^\d+$/;
|
||||
const RETRY_AFTER_BODY_SECONDS_RE = /^(?:\d+\.?\d*|\.\d+)$/;
|
||||
const RETRY_AFTER_HTTP_DATE_RE =
|
||||
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
||||
|
||||
export function parseRetryAfterHeaderSeconds(
|
||||
value: string | null | undefined,
|
||||
now = Date.now(),
|
||||
): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (RETRY_AFTER_HEADER_DELAY_RE.test(trimmed)) {
|
||||
const delaySeconds = Number(trimmed);
|
||||
return Number.isFinite(delaySeconds) ? delaySeconds : undefined;
|
||||
}
|
||||
if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const retryAt = Date.parse(trimmed);
|
||||
return Number.isFinite(retryAt) ? Math.max(0, (retryAt - now) / 1000) : undefined;
|
||||
}
|
||||
|
||||
export function parseDiscordRetryAfterBodySeconds(value: unknown): number | undefined {
|
||||
const seconds =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && RETRY_AFTER_BODY_SECONDS_RE.test(value.trim())
|
||||
? Number(value.trim())
|
||||
: undefined;
|
||||
return seconds !== undefined && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user