Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
2afd1c0077 fix(types): unblock changed gate checks 2026-05-28 17:48:14 +02:00
571 changed files with 4328 additions and 21525 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -7,7 +7,7 @@ on:
jobs:
build-and-test:
runs-on: macos-15
runs-on: macos-latest
defaults:
run:
shell: bash

View File

@@ -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;

View File

@@ -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`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>`

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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
{

View File

@@ -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">

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: 15). Depth 2 is recommended for most use cases.
- `maxChildrenPerAgent` caps active children per session (default `5`, range `120`).

View File

@@ -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).

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
) {

View File

@@ -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",
);
});
});

View File

@@ -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();
});
});

View File

@@ -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:*"

View File

@@ -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();

View File

@@ -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);
},
};
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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) => {

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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");

View File

@@ -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,

View File

@@ -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 === "/") {

View File

@@ -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)) {

View File

@@ -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"]);

View File

@@ -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")

View File

@@ -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", () => {

View File

@@ -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(() => {});
},
};
}

View File

@@ -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",
"",

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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"));
});

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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();
}

View File

@@ -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"],
};
}

View File

@@ -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({

View File

@@ -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
);
}

View File

@@ -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();
}
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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?: {

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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?: {

View File

@@ -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 },

View File

@@ -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