diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index bbf39f3d773..ac58b612c8a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -35,6 +35,11 @@ on: - minimum - stable - full + run_release_soak: + description: Run exhaustive live/Docker and upgrade-survivor soak lanes; forced on for release_profile=full + required: false + default: false + type: boolean rerun_group: description: Validation group to run required: false @@ -136,6 +141,7 @@ jobs: EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} run: | @@ -145,6 +151,7 @@ jobs: echo "- Target ref: \`${TARGET_REF}\`" echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Child workflow ref: \`${CHILD_WORKFLOW_REF}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" @@ -206,7 +213,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -246,11 +253,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -299,7 +312,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -339,11 +352,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -388,6 +407,7 @@ jobs: PROVIDER: ${{ inputs.provider }} MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} @@ -398,7 +418,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -438,11 +458,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -465,6 +491,7 @@ jobs: echo "- Provider: \`${PROVIDER}\`" echo "- Cross-OS mode: \`${MODE}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" @@ -485,6 +512,7 @@ jobs: -f provider="$PROVIDER" -f mode="$MODE" -f release_profile="$RELEASE_PROFILE" + -f run_release_soak="$RUN_RELEASE_SOAK" -f rerun_group="$child_rerun_group" ) if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then @@ -640,11 +668,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 95c2a3f2757..d1469c5e8c7 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -39,6 +39,11 @@ on: - minimum - stable - full + run_release_soak: + description: Run exhaustive live/Docker and upgrade-survivor soak lanes; forced on for release_profile=full + required: false + default: false + type: boolean rerun_group: description: Release check group to run required: false @@ -86,6 +91,7 @@ jobs: provider: ${{ steps.inputs.outputs.provider }} mode: ${{ steps.inputs.outputs.mode }} release_profile: ${{ steps.inputs.outputs.release_profile }} + run_release_soak: ${{ steps.inputs.outputs.run_release_soak }} rerun_group: ${{ steps.inputs.outputs.rerun_group }} live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }} qa_live_matrix_enabled: ${{ steps.inputs.outputs.qa_live_matrix_enabled }} @@ -206,14 +212,31 @@ jobs: RELEASE_PROVIDER_INPUT: ${{ inputs.provider }} RELEASE_MODE_INPUT: ${{ inputs.mode }} RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }} + RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }} RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }} + RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }} RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }} run: | set -euo pipefail qa_live_matrix_enabled=true qa_live_telegram_enabled=true - qa_live_slack_enabled=true + qa_live_slack_enabled=false + qa_live_slack_ci_enabled="$(printf '%s' "$RELEASE_QA_SLACK_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')" + if [[ "$qa_live_slack_ci_enabled" != "true" && "$qa_live_slack_ci_enabled" != "1" && "$qa_live_slack_ci_enabled" != "yes" ]]; then + qa_live_slack_ci_enabled=false + else + qa_live_slack_ci_enabled=true + fi + run_release_soak="$(printf '%s' "$RELEASE_RUN_RELEASE_SOAK_INPUT" | tr '[:upper:]' '[:lower:]')" + if [[ "$run_release_soak" != "true" && "$run_release_soak" != "1" && "$run_release_soak" != "yes" ]]; then + run_release_soak=false + else + run_release_soak=true + fi + if [[ "$RELEASE_PROFILE_INPUT" == "full" ]]; then + run_release_soak=true + fi filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')" if [[ -n "${filter// }" ]]; then @@ -233,7 +256,6 @@ jobs: qa_filter_seen=true matrix_selected=true telegram_selected=true - slack_selected=true ;; qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack) qa_filter_seen=true @@ -250,7 +272,7 @@ jobs: ;; qa-live-slack|qa-slack|slack) qa_filter_seen=true - slack_selected=true + slack_selected="$qa_live_slack_ci_enabled" ;; esac done @@ -267,6 +289,7 @@ jobs: printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT" printf 'mode=%s\n' "$RELEASE_MODE_INPUT" printf 'release_profile=%s\n' "$RELEASE_PROFILE_INPUT" + printf 'run_release_soak=%s\n' "$run_release_soak" printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT" printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT" printf 'qa_live_matrix_enabled=%s\n' "$qa_live_matrix_enabled" @@ -283,6 +306,7 @@ jobs: RELEASE_PROVIDER: ${{ inputs.provider }} RELEASE_MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }} RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} @@ -296,6 +320,7 @@ jobs: echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`" echo "- Cross-OS mode: \`${RELEASE_MODE}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`" if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`" @@ -306,7 +331,11 @@ jobs: else echo "- Package Acceptance package spec: prepared release artifact" fi - echo "- This run will execute cross-OS release validation, install smoke, QA Lab parity, Matrix, Telegram, and Slack lanes, and the non-Parallels Docker/live/openwebui coverage from the CI migration plan." + if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then + echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage." + else + echo "- This run will execute blocking release validation. Exhaustive live/Docker soak lanes are skipped unless \`run_release_soak=true\`, \`release_profile=full\`, or \`rerun_group=live-e2e\` is selected." + fi } >> "$GITHUB_STEP_SUMMARY" prepare_release_package: @@ -417,7 +446,7 @@ jobs: live_repo_e2e_release_checks: name: Run repo/live E2E validation needs: [resolve_target] - if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group) + if: needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true') permissions: actions: read contents: read @@ -482,7 +511,7 @@ jobs: docker_e2e_release_checks: name: Run Docker release-path validation needs: [resolve_target, prepare_release_package] - if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.live_suite_filter == '' + if: (needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true')) && needs.resolve_target.outputs.live_suite_filter == '' permissions: actions: read contents: read @@ -517,8 +546,8 @@ jobs: package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }} suite_profile: custom docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update - published_upgrade_survivor_baselines: all-since-2026.4.23 - published_upgrade_survivor_scenarios: reported-issues + published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'all-since-2026.4.23' || '' }} + published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }} telegram_mode: mock-openai telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating secrets: @@ -883,7 +912,7 @@ jobs: qa_live_slack_release_checks: name: Run QA Lab live Slack lane needs: [resolve_target] - if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' + if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 permissions: diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 52c9d017223..06769668731 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -241,7 +241,7 @@ jobs: echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - name: Verify package-local runtime build - run: pnpm release:plugins:npm:runtime:check --package "${{ matrix.plugin.packageDir }}" + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - name: Preview publish command env: diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index f2306dbfdf8..e385423ea99 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -562,6 +562,7 @@ jobs: run_live_slack: name: Run Slack live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] + if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared diff --git a/.gitignore b/.gitignore index 1d1a75a9a1b..ac9d57de4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -219,3 +219,4 @@ extensions/**/.openclaw-runtime-deps-stamp.json # Output dir for scripts/run-opengrep.sh (local opengrep scans) /.opengrep-out/ +/.crabbox-artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0145ebf2415..c5f8e15a02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install ` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys. +- Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. +- Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532) +- Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13. +- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. +- Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. - Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar. - Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev. @@ -27,11 +33,13 @@ Docs: https://docs.openclaw.ai - Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi. - Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup. - Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed. +- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics. - Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc. - QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts. - QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc. - QA/Mantis: return the copied Slack desktop screenshot path even when remote Slack QA fails, so the CLI still prints the failure screenshot artifact. Thanks @vincentkoc. - QA/Mantis: accept Blacksmith Testbox `tbx_...` lease ids from desktop smoke warmup, so provider overrides do not fail before inspect/run. Thanks @vincentkoc. +- QA/Codex harness: add targeted live Docker/Testbox diagnostics, auth preflight checks, cache mount fixes, and app-server protocol checkout discovery so maintainer harness failures are easier to reproduce. Thanks @vincentkoc. - Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc. - Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc. - Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc. @@ -41,16 +49,54 @@ Docs: https://docs.openclaw.ai - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. - Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc. +- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant. +- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred. +- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi. +- Agents/sandbox: store sandbox container and browser registry entries as per-runtime shard files, reducing unrelated session lock contention while `openclaw doctor --fix` migrates legacy monolithic registry files. (#74831) Thanks @luckylhb90. +- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda. - Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight. - Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin. ### Fixes +- Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility. +- Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. +- Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. +- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. +- Discord: prefer IPv4 for Discord REST and gateway WebSocket startup paths so IPv4-only networks no longer stall before Gateway READY and inbound message dispatch. Fixes #77398; refs #77526. Thanks @Beandon13. +- Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. +- Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. +- Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. +- Mattermost: clarify that the model picker only changes the session model and that runtime switches require `/oc_model --runtime `. Thanks @vincentkoc. +- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. +- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. +- CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. +- Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. +- Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. +- Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. +- Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. +- Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive. +- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit ` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3. +- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. +- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. +- Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. +- Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. +- Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC. +- CLI/launcher: forward termination signals to compile-cache respawn children, so killing a wrapper process no longer leaves the security audit worker orphaned. Fixes #77458. Thanks @jaikharbanda. +- fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. +- Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. +- Providers/OpenRouter: keep DeepSeek V4 `reasoning_effort` on OpenRouter-supported values, mapping stale `max` thinking overrides to `xhigh` so `openrouter/deepseek/deepseek-v4-pro` no longer fails with OpenRouter's invalid-effort 400. Fixes #77350. (#77423) Thanks @krllagent, @mushuiyu886, and @sallyom. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. +- Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. +- Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. +- active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. +- Docker/compose: pin container-side `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` on both gateway and CLI services so the host paths written into `.env` by `scripts/docker/setup.sh` (used as Compose bind-mount sources) cannot leak into runtime code via the `env_file` import. Fixes regressions on macOS Docker setups where the first agent reply died with `EACCES: permission denied, mkdir '/Users'` because the host-style workspace path got persisted into `agents.defaults.workspace`. Fixes #77436. Thanks @lonexreb. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. +- Slack: report `unknown error` instead of `undefined` in socket-mode startup retry logs and label the retry reason explicitly. +- Telegram: let explicit forum-topic `requireMention` settings override persisted `/activate` and `/deactivate` state, so per-topic mention gates work consistently. Fixes #49864. Thanks @Panniantong. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. - TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda. - TUI/render: stop the long-token sanitizer from injecting literal spaces inside inline code spans, fenced code blocks, table borders, and bare hyphenated/dotted identifiers, so copied package names, entity IDs, and shell line-continuations stay byte-for-byte intact while narrow-terminal protection still chunks unidentifiable long prose tokens. Fixes #48432, #39505. Thanks @DocOellerson, @xeusoc, @CCcassiusdjs, @akramcodez, @brokemac79, @romneyda. @@ -58,6 +104,7 @@ Docs: https://docs.openclaw.ai - Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc. - Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc. - Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc. +- CLI/update: stage pnpm-detected npm-layout global package updates through a clean npm prefix swap, keep plugin install runtime imports behind a stable alias, and ship legacy install-runtime aliases back to `2026.3.22`, preventing stale overlay chunks from breaking plugin post-update sync. Thanks @vincentkoc. - Plugins/commands: allow the official ClawHub Codex plugin package to keep reserved `/codex` command ownership, matching the existing npm-managed Codex package behavior. Thanks @vincentkoc. - Auth/OpenAI Codex: rewrite invalidated per-agent Codex auth-order and session profile overrides toward a healthy relogin profile, so revoked OAuth accounts do not stay pinned after signing in again. Thanks @BunsDev. - Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc. @@ -66,6 +113,7 @@ Docs: https://docs.openclaw.ai - Plugins/discovery: ignore managed npm plugin packages that only expose TypeScript source entries without compiled runtime output, so stale/broken installs cannot hide a working bundled or reinstallable channel plugin during setup. Thanks @vincentkoc. - CLI/update: treat OpenClaw stable correction versions like `2026.5.3-1` as newer than their base stable release, so package updates no longer ask for downgrade confirmation. Thanks @vincentkoc. - Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package. Thanks @vincentkoc. +- Plugins/commands: suppress dangerous-pattern scanner warnings for trusted catalog npm installs from owner-gated `/plugins install` commands, so chat-driven installs match the CLI install trust path. Thanks @vincentkoc. - Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc. - Plugins/security: ignore inline and block comments when matching source-rule context in plugin install scans, so comment-only `fetch`/`post` references near environment defaults do not block clean plugins. Thanks @vincentkoc. - Doctor/plugins: remove stale managed install records for bundled plugins even when the bundled plugin is not explicitly configured, so doctor cleanup cannot leave orphaned install metadata behind. Thanks @vincentkoc. @@ -76,6 +124,7 @@ Docs: https://docs.openclaw.ai - Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. +- Plugins/providers: make bundled provider discovery honor restrictive `plugins.allow` by default for new configs, while doctor migrates legacy restrictive allowlist configs to `plugins.bundledDiscovery: "compat"` to preserve upgrade behavior. Thanks @dougbtv. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. - Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. @@ -91,6 +140,9 @@ Docs: https://docs.openclaw.ai - Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. - Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda. - Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals. +- Providers/OpenAI Codex: fail closed on malformed `/codex` control commands and diagnostics confirmations before changing bindings, permissions, model overrides, active turns, or feedback uploads. Thanks @vincentkoc. +- Providers/OpenAI Codex: sanitize Codex app-server command readouts, failure replies, approval prompts, elicitation prompts, and `request_user_input` text before posting them back into chat. Thanks @vincentkoc. +- Providers/OpenAI Codex: preserve local bound-turn image paths, reject stale same-thread turn notifications, enforce option-only user input prompts, and return failed dynamic tool results to Codex as unsuccessful tool calls. Thanks @vincentkoc. - Providers/DeepSeek: expose DeepSeek V4 `xhigh` and `max` thinking levels through the lightweight provider-policy surface, so Control UI `/think` pickers keep showing the max reasoning options when the runtime plugin registry is not active. Fixes #77139. Thanks @bittoby. - Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. @@ -183,6 +235,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. - Google Meet: avoid treating repeated participant words as multiple assistant-overlap matches when suppressing realtime echo transcripts. Thanks @vincentkoc. - Google Meet: make `mode: "agent"` the default Chrome talk-back path, using realtime transcription for input and regular OpenClaw TTS for speech output, while keeping direct realtime voice answers available as `mode: "bidi"` and accepting `mode: "realtime"` as an agent-mode compatibility alias. +- Codex harness: keep `codex_app_server.*` telemetry publication owned by the harness instead of republishing the same callback event from core runners. Thanks @vincentkoc. - Slack/Discord: suppress standalone tool-progress chatter when partial preview streaming has `streaming.preview.toolProgress: false`, matching the documented quiet-preview behavior. Thanks @vincentkoc. - Matrix: bind native approval reaction targets before publishing option reactions, so fast approver reactions on threaded prompts are not dropped while the approval handler finishes setup. Thanks @vincentkoc. - Google Meet: make realtime talk-back agent-driven by default with `realtime.strategy: "agent"`, keep the previous direct bidirectional model behavior available as `realtime.strategy: "bidi"`, route the Meet tab speaker output to `BlackHole 2ch` automatically for local Chrome realtime joins, coalesce nearby speech transcript fragments before consulting the agent, and avoid cutting off agent speech from server VAD or stale playback pipe errors. @@ -203,6 +256,12 @@ Docs: https://docs.openclaw.ai - Agents/subagents: detect prefix-only completion announce replies and fall back to the captured child result so requester chats no longer lose most of long sub-agent reports silently. Fixes #76412. Thanks @inxaos and @davemorin. - TUI: replace the stale-response watchdog notice with plain user-facing copy so stalled replies no longer surface backend or streaming internals. (#77120) Thanks @davemorin. - Security/Windows: validate `SystemRoot`/`WINDIR` env values through the Windows install-root validator and add them to the dangerous-host-env policy when resolving `icacls.exe`/`whoami.exe` for `openclaw security audit`, so workspace `.env` overrides and bare command names cannot redirect Windows ACL helpers to attacker-controlled binaries. (#74458) Thanks @mmaps. +- Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. +- QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. +- Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. +- Browser/SSRF: enforce the existing current-tab URL navigation policy before tab-scoped debug, export, and read routes (console, page errors, network requests, trace start/stop, response body, screenshot, snapshot, storage, etc.) collect from an already-selected tab, so blocked tabs return a policy error instead of being read first and redacted only at response time. (#75731) Thanks @eleqtrizit. +- Security/Windows: route the `.cmd`/`.bat` process wrapper through the shared Windows install-root resolver instead of `process.env.ComSpec`, so workspace dotenv-blocked `SystemRoot`/`WINDIR` overrides and unsafe values like UNC paths or path-lists cannot redirect `cmd.exe` selection on Windows. (#77472) Thanks @drobison00. +- Agents/bootstrap: honor `BOOTSTRAP.md` content injected by `agent:bootstrap` hooks when deciding whether bootstrap is pending, so hook-provided required setup instructions are included in the system prompt. (#77501) Thanks @ificator. ## 2026.5.3-1 @@ -319,6 +378,7 @@ Docs: https://docs.openclaw.ai - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. - Plugins/config: report configured plugins that are present but blocked by path-safety checks as blocked instead of stale `plugin not found` entries, and deduplicate repeated blocked-candidate warnings during discovery. Fixes #76144. Thanks @mayank6136. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. +- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. (#76824) Thanks @VACInc. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. @@ -455,6 +515,7 @@ Docs: https://docs.openclaw.ai - Status/update: resolve beta update-channel checks from the installed version when config still says `stable`, and let `status --deep` reuse live gateway channel credential state instead of warning on command-path-only token misses. - Doctor/plugins: preserve unmanaged third-party plugin `node_modules` during `doctor --fix`, while still pruning OpenClaw-managed runtime dependency caches. - Gateway/restart: add `openclaw gateway restart --force` and `--wait `, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. +- Gateway/restart: align `gateway.restart.safe` preflight with scheduled restart deferral by counting only active restart blockers (running non-ended tasks), so queued task records no longer keep "safe" restarts deferred indefinitely. (#76923) Thanks @NikolaFC. - Discord: persist slash-command deploy hashes across process restarts so unchanged command sets skip redeploy and avoid restart-loop 429s. - Providers/LM Studio: normalize binary `off`/`on` reasoning metadata from Gemma 4 and other local models to LM Studio's accepted OpenAI-compatible `reasoning_effort` values. - Plugins/externalization: keep official external install docs, update examples, and live Codex npm checks on default npm tags instead of `@beta`. Thanks @vincentkoc. @@ -462,6 +523,7 @@ Docs: https://docs.openclaw.ai - Plugins/ClawHub: fall back to version metadata when the artifact resolver route is missing and keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, avoiding false version-not-found failures during plugin install validation. Thanks @vincentkoc. - Providers/openai-codex: honor `providerConfig.baseUrl` in the dynamic-model synthesis fallback so codex providers configured with a custom upstream (for example a forwarding proxy) no longer silently bypass the configured URL when the registry has no template row to clone for the requested model id. (#76428) Thanks @arniesaha. - Status/channels: show configured channels in `openclaw status` and config-only `openclaw channels status` output even when the Gateway is unreachable, avoiding empty Channels tables on WSL and other no-Gateway paths. Thanks @vincentkoc. +- Agents/main-session: keep pending final delivery markers until the final reply is actually routed or queued, so restart and heartbeat recovery can retry failed delivery. Refs #65037. (#75280) Thanks @MertBasar0. - Plugins/ClawHub: explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint while ClawHub artifact routing rolls out. Thanks @vincentkoc. - Media: accept home-relative `MEDIA:~/...` attachment paths while preserving existing file-read policy, traversal checks, and media type validation. Fixes #73796. Thanks @fabkury. - Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc. @@ -501,6 +563,7 @@ Docs: https://docs.openclaw.ai - Plugins/update: keep externalized bundled npm bridge updates on the normal plugin security scanner path instead of granting source-linked official trust without artifact provenance. (#76765) Thanks @Lucenx9. - Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus. - Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe. +- Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf. ## 2026.5.2 diff --git a/Dockerfile b/Dockerfile index 60b50869fbc..081e0cfbb1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.7 -# Opt-in extension dependencies at build time (space-separated directory names). -# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . +# Opt-in plugin dependencies at build time (space- or comma-separated directory names). +# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" . # # Multi-stage build produces a minimal runtime image without build tools, # source code, or Bun. Works with Docker, Buildx, and Podman. @@ -32,7 +32,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # Copy package.json for opted-in extensions so pnpm resolves their deps. RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \ mkdir -p /out && \ - for ext in $OPENCLAW_EXTENSIONS; do \ + for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \ if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \ mkdir -p "/out/$ext" && \ cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/$ext/package.json"; \ @@ -118,12 +118,13 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # prune must not rediscover unrelated workspaces from the later full source # copy. RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \ - for ext in $OPENCLAW_EXTENSIONS; do \ + for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \ printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \ done && \ cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \ CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \ node scripts/postinstall-bundled-plugins.mjs && \ + OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \ node scripts/check-package-dist-imports.mjs /app diff --git a/docker-compose.yml b/docker-compose.yml index 8f8193d1f79..5b89f900465 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,14 @@ services: environment: HOME: /home/node TERM: xterm-256color + # Pin container-side workspace and config paths so host values written to + # `.env` (used by Compose for the bind-mount source below) cannot leak + # into runtime code that resolves these env vars inside the container. + # Without this override, a macOS host path like /Users//.openclaw/... + # imported from .env caused first-reply `mkdir '/Users'` EACCES failures + # in Linux Docker (#77436). + OPENCLAW_CONFIG_DIR: /home/node/.openclaw + OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-} OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-} # Empty means auto: Bonjour disables itself in detected containers. @@ -85,6 +93,10 @@ services: environment: HOME: /home/node TERM: xterm-256color + # Pin container-side workspace and config paths so host values written to + # `.env` cannot leak into runtime code via the env_file import (#77436). + OPENCLAW_CONFIG_DIR: /home/node/.openclaw + OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-} OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-} BROWSER: echo diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2a1b748608a..1bc9162c255 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2c78fb7af01e2ee9e919be5ab7b675347b36cae1e347f97fd2640a6f7c72f3ac config-baseline.json -31ec333df9f8b92c7656ac7107cecd5860dd02e08f7e18c7c674dc47a8811baa config-baseline.core.json +2566cb33c48abf3884d44cc605e3fe23ee3dc3e998c29fe86dfe773faf58cb52 config-baseline.json +eab2f8a9af31910e26874209330d10ca46afd910cba88beda8a48fe6b9831159 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 95673ce3b0b..8d14cca3537 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -f8495c07213012748f099b12ddb02847ffd4eaa1b46f2ae9dfa574fa0ef3299a plugin-sdk-api-baseline.json -815ac868dda35d0af88b9c522233d6065c3eeb70775e19c111162b80390733fa plugin-sdk-api-baseline.jsonl +a7116e6c0cae4c7b9ee7cd6dc48f2978812f4b5be647f3e36eee91ec9a81d85e plugin-sdk-api-baseline.json +2b6c9883d701379761724e21946d417399c1247e6a244d6b00c4a982c8ef5968 plugin-sdk-api-baseline.jsonl diff --git a/docs/ci.md b/docs/ci.md index 878d5deeb6e..6c67eb6e1e0 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -152,7 +152,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured ## Full Release Validation -`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, Docker release-path suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and Telegram lanes. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `npm_telegram_package_spec` to rerun the same Telegram package lane against the published npm package. +`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable/default runs keep exhaustive live/E2E and Docker release-path coverage behind `run_release_soak=true`; `release_profile=full` forces that soak coverage on so broad advisory validation remains broad. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `npm_telegram_package_spec` to rerun the same Telegram package lane against the published npm package. See [Full release validation](/reference/full-release-validation) for the stage matrix, exact workflow job names, profile differences, artifacts, and @@ -189,7 +189,9 @@ different SHA. `release_profile` controls live/provider breadth passed into release checks. The manual release workflows default to `stable`; use `full` only when you -intentionally want the broad advisory provider/media matrix. +intentionally want the broad advisory provider/media matrix. `run_release_soak` +controls whether stable/default release checks run the exhaustive live/E2E and +Docker release-path soak; `full` forces soak on. - `minimum` keeps the fastest OpenAI/core release-critical lanes. - `stable` adds the stable provider/backend set. @@ -199,7 +201,7 @@ The umbrella records the dispatched child run ids, and the final `Verify full va For recovery, both `Full Release Validation` and `OpenClaw Release Checks` accept `rerun_group`. Use `all` for a release candidate, `ci` for only the normal full CI child, `plugin-prerelease` for only the plugin prerelease child, `release-checks` for every release child, or a narrower group: `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, or `npm-telegram` on the umbrella. This keeps a failed release box rerun bounded after a focused fix. -`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected ref once into a `release-package-under-test` tarball, then passes that artifact to both the live/E2E release-path Docker workflow and the package acceptance shard. That keeps the package bytes consistent across release boxes and avoids repacking the same candidate in multiple child jobs. +`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected ref once into a `release-package-under-test` tarball, then passes that artifact to cross-OS checks and Package Acceptance, plus the live/E2E release-path Docker workflow when soak coverage runs. That keeps the package bytes consistent across release boxes and avoids repacking the same candidate in multiple child jobs. Duplicate `Full Release Validation` runs for `ref=main` and `rerun_group=all` supersede the older umbrella. The parent monitor cancels any child workflow it @@ -263,7 +265,7 @@ For the dedicated update and plugin testing policy, including local commands, Docker lanes, Package Acceptance inputs, release defaults, and failure triage, see [Testing updates and plugins](/help/testing-updates-plugins). -Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, `published_upgrade_survivor_baselines=all-since-2026.4.23`, `published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, configured-plugin install repair, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Set `package_acceptance_package_spec` on Full Release Validation or OpenClaw Release Checks to run that same matrix against a shipped npm package instead of the SHA-built artifact. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=all-since-2026.4.23` to expand Full Release CI across every stable npm release from `2026.4.23` through `latest`; `release-history` remains available for manual wider sampling with the older pre-date anchor. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, configured OpenClaw plugin installs, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. +Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, configured-plugin install repair, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Set `package_acceptance_package_spec` on Full Release Validation or OpenClaw Release Checks to run that same matrix against a shipped npm package instead of the SHA-built artifact. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run in the blocking release path. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Full Release Validation with `run_release_soak=true` or `release_profile=full` sets `published_upgrade_survivor_baselines=all-since-2026.4.23` and `published_upgrade_survivor_scenarios=reported-issues` to expand across every stable npm release from `2026.4.23` through `latest` and issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, configured OpenClaw plugin installs, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. ### Legacy compatibility windows diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 4581398c4bc..69c5fa9f3fd 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -36,7 +36,7 @@ openclaw daemon uninstall - `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` -- `restart`: `--force`, `--wait `, `--json` +- `restart`: `--safe`, `--force`, `--wait `, `--json` - lifecycle (`uninstall|start|stop`): `--json` Notes: @@ -53,6 +53,7 @@ Notes: - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. - On macOS, `install` keeps LaunchAgent plists owner-only and loads managed service environment values through an owner-only file and wrapper instead of serializing API keys or auth-profile env refs into `EnvironmentVariables`. - If you intentionally run multiple gateways on one host, isolate ports, config/state, and workspaces; see [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host). +- `restart --safe` asks the running Gateway to preflight active work and schedule one coalesced restart after active work drains. Plain `restart` keeps the existing service-manager behavior; `--force` remains the immediate override path. ## Prefer diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index af8a34cf7a8..7f159238833 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -105,6 +105,16 @@ openclaw gateway run Raw stream jsonl path. +## Restart the Gateway + +```bash +openclaw gateway restart +openclaw gateway restart --safe +openclaw gateway restart --force +``` + +`openclaw gateway restart --safe` asks the running Gateway to preflight active OpenClaw work before restarting. If queued operations, reply delivery, embedded runs, or task runs are active, the Gateway reports the blockers, coalesces duplicate safe restart requests, and restarts once the active work drains. Plain `restart` keeps the existing service-manager behavior for compatibility. Use `--force` only when you explicitly want the immediate override path. + Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 9dfdefa1993..58bab6fdbd9 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -16,17 +16,19 @@ until a message is processed. Use `openclaw channels status --probe`, `openclaw status --deep`, or `openclaw health --verbose` when you need live channel connectivity. -Gateway `sessions.list` responses are bounded by default so large long-lived -stores cannot monopolize the Gateway event loop. Pass an explicit positive -`limit` from RPC clients when a different result window is needed; responses -include `totalCount`, `limitApplied`, and `hasMore` when callers need to show -that more rows exist. +`openclaw sessions` and Gateway `sessions.list` responses are bounded by +default so large long-lived stores cannot monopolize the CLI process or Gateway +event loop. The CLI returns the newest 100 sessions by default; pass +`--limit ` for a smaller/larger window or `--limit all` when you intentionally +need the full store. JSON responses include `totalCount`, `limitApplied`, and +`hasMore` when callers need to show that more rows exist. ```bash openclaw sessions openclaw sessions --agent work openclaw sessions --all-agents openclaw sessions --active 120 +openclaw sessions --limit 25 openclaw sessions --verbose openclaw sessions --json ``` @@ -38,6 +40,7 @@ Scope selection: - `--agent `: one configured agent store - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +- `--limit `: max rows to output (default `100`; `all` restores full output) Export a trajectory bundle for a stored session: @@ -69,6 +72,9 @@ JSON examples: ], "allAgents": true, "count": 2, + "totalCount": 2, + "limitApplied": 100, + "hasMore": false, "activeMinutes": null, "sessions": [ { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, diff --git a/docs/cli/update.md b/docs/cli/update.md index 60a1269b1fb..f5cd9467ee7 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -168,8 +168,9 @@ manually. On the beta update channel, tracked npm and ClawHub plugin installs that follow the default/latest line try a plugin `@beta` release first. If the plugin has no -beta release, OpenClaw falls back to the recorded default/latest spec. Exact -versions and explicit tags are not rewritten. +beta release, OpenClaw falls back to the recorded default/latest spec. For npm +plugins, OpenClaw also falls back when the beta package exists but fails install +validation. Exact versions and explicit tags are not rewritten. If an exact pinned npm plugin update resolves to an artifact whose integrity differs from the stored install record, `openclaw update` aborts that plugin artifact update instead of installing it. Reinstall or update the plugin explicitly only after verifying that you trust the new artifact. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 4b14320eeee..62d5bf07a70 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -119,7 +119,8 @@ openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for session overrides. When a user selects a model that isn't in that allowlist, OpenClaw returns: ``` -Model "provider/model" is not allowed. Use /model to list available models. +Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. +Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge ``` @@ -131,6 +132,8 @@ This happens **before** a normal reply is generated, so the message can feel lik +When the rejected command included a runtime override such as `/model openai/gpt-5.5 --runtime codex`, fix the allowlist first, then retry the same `/model ... --runtime ...` command. For native Codex execution, the selected model is still `openai/gpt-5.5`; the `codex` runtime selects the harness and uses Codex auth separately. + For local/GGUF models, store the full provider-prefixed ref in the allowlist, for example `ollama/gemma4:26b`, `lmstudio/Gemma4-26b-a4-it-gguf`, or the exact provider/model shown by `openclaw models list --provider `. diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 48eb1181a58..6cb5cb47b07 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -178,6 +178,12 @@ that agent. To force a different Claude mode, set explicit raw backend args such as `--permission-mode default` or `--permission-mode acceptEdits` under `agents.defaults.cliBackends.claude-cli.args` and matching `resumeArgs`. +The bundled Anthropic `claude-cli` backend also maps OpenClaw `/think` levels +to Claude Code's native `--effort` flag for non-off levels. `minimal` and +`low` map to `low`, `adaptive` and `medium` map to `medium`, and `high`, +`xhigh`, and `max` map directly. Other CLI backends need their owning plugin to +declare an equivalent argv mapper before `/think` can affect the spawned CLI. + Before OpenClaw can use the bundled `claude-cli` backend, Claude Code itself must already be logged in on the same host: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1afaa8d8581..789bc97c3e9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -166,6 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], + bundledDiscovery: "allowlist", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -187,6 +188,10 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. +- `bundledDiscovery`: defaults to `"allowlist"` for new configs, so a non-empty + `plugins.allow` also gates bundled provider plugins, including web-search + runtime providers. Doctor writes `"compat"` for migrated legacy allowlist + configs to preserve existing bundled provider behavior until you opt in. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. diff --git a/docs/gateway/diagnostics.md b/docs/gateway/diagnostics.md index bf3b0c600d4..5877cc9c6e3 100644 --- a/docs/gateway/diagnostics.md +++ b/docs/gateway/diagnostics.md @@ -117,12 +117,19 @@ diagnostics are enabled. It is for operational facts, not content. The same diagnostic heartbeat records liveness samples when the Gateway keeps running but the Node.js event loop or CPU looks saturated. These `diagnostic.liveness.warning` events include event-loop delay, event-loop -utilization, CPU-core ratio, and active/waiting/queued session counts. Idle -samples stay in telemetry at `info` level. Liveness samples become Gateway -warnings only when work is waiting or queued, or when active work overlaps with -sustained event-loop delay. Transient max-delay spikes during otherwise healthy -background work stay in debug logs. They do not restart the Gateway by -themselves. +utilization, CPU-core ratio, active/waiting/queued session counts, the current +startup/runtime phase when known, recent phase spans, and bounded active/queued +work labels. Idle samples stay in telemetry at `info` level. Liveness samples +become Gateway warnings only when work is waiting or queued, or when active work +overlaps with sustained event-loop delay. Transient max-delay spikes during +otherwise healthy background work stay in debug logs. They do not restart the +Gateway by themselves. + +Startup phases also emit `diagnostic.phase.completed` events with wall-clock and +CPU timing. Stalled embedded-run diagnostics mark `terminalProgressStale=true` +when the last bridge progress looked terminal, such as a raw response item or +response completion event, but the Gateway still considers the embedded run +active. Inspect the live recorder: diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 3f4549c2c59..35389f89d9a 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -169,7 +169,9 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor also warns when `plugins.allow` is non-empty and tool policy uses wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools from plugins that actually load; it does not bypass the exclusive plugin - allowlist. + allowlist. Doctor writes `plugins.bundledDiscovery: "compat"` for migrated + legacy allowlist configs to preserve existing bundled provider behavior, and + then points to the stricter `"allowlist"` setting. @@ -189,6 +191,7 @@ That stages grounded durable candidates into the short-term dreaming store while - `routing.groupChat.requireMention` → `channels.whatsapp/telegram/imessage.groups."*".requireMention` - `routing.groupChat.historyLimit` → `messages.groupChat.historyLimit` - `routing.groupChat.mentionPatterns` → `messages.groupChat.mentionPatterns` + - `channels.telegram.requireMention` → `channels.telegram.groups."*".requireMention` - configured-channel configs missing visible reply policy → `messages.groupChat.visibleReplies: "message_tool"` - `routing.queue` → `messages.queue` - `routing.bindings` → top-level `bindings` diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 543623f7d88..c70a5b7252b 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -15,6 +15,17 @@ OpenClaw has two log “surfaces”: - **Console output** (what you see in the terminal / Debug UI). - **File logs** (JSON lines) written by the gateway logger. +At startup, the Gateway logs the resolved default agent model together with the +mode defaults that affect new sessions, for example: + +```text +agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on) +``` + +`thinking` comes from the default agent, model params, or global agent default; +when it is unset, the startup summary shows `medium`. `fast` comes from the +default agent or model `fastMode` params. + ## File-based logger - Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log` diff --git a/docs/help/debugging.md b/docs/help/debugging.md index a88a286d114..1f01cbc819c 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -89,6 +89,17 @@ OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/cli-cpu pnpm openclaw status The source runner adds Node CPU profile flags and writes a `.cpuprofile` for the command. Use this before adding temporary instrumentation to command code. +For startup stalls that look like synchronous filesystem or module-loader work, +add Node's sync I/O trace flag through the source runner: + +```bash +OPENCLAW_TRACE_SYNC_IO=1 pnpm openclaw gateway --force +``` + +`pnpm gateway:watch` enables this flag by default for the watched Gateway child. +Set `OPENCLAW_TRACE_SYNC_IO=0` to suppress Node sync I/O trace output in watch +mode. + ## Gateway watch mode For fast iteration, run the gateway under the file watcher: @@ -146,6 +157,11 @@ Use `--benchmark-dir ` when you want profiles somewhere else. Use `--benchmark-no-force` when you want the benchmarked child to skip the default `--force` port cleanup and fail fast if the Gateway port is already in use. +Benchmark mode suppresses sync-I/O trace spam by default. Set +`OPENCLAW_TRACE_SYNC_IO=1` with `--benchmark` when you explicitly want both CPU +profiles and Node sync-I/O stack traces. In benchmark mode those trace blocks +are written to `gateway-watch-output.log` under the benchmark directory and +filtered from the terminal pane; normal Gateway logs remain visible. The tmux wrapper carries common non-secret runtime selectors such as `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`, diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index c2de0dc49ff..e308029a98f 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -191,11 +191,14 @@ troubleshooting, see the main [FAQ](/help/faq). session overrides. Choosing a model that isn't in that list returns: ``` - Model "provider/model" is not allowed. Use /model to list available models. + Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. + Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge ``` That error is returned **instead of** a normal reply. Fix: add the model to `agents.defaults.models`, remove the allowlist, or pick a model from `/model list`. + If the command also included `--runtime codex`, add the model first and then retry + the same `/model provider/model --runtime codex` command. diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index fcaa7fa028f..45ab2115d8e 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -123,8 +123,8 @@ pnpm test:docker:published-upgrade-survivor ``` Available scenarios are `base`, `feishu-channel`, `bootstrap-persona`, -`plugin-deps-cleanup`, `configured-plugin-installs`, `tilde-log-path`, and -`versioned-runtime-deps`. In aggregate runs, +`plugin-deps-cleanup`, `configured-plugin-installs`, +`stale-source-plugin-shadow`, `tilde-log-path`, and `versioned-runtime-deps`. In aggregate runs, `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues` expands to all reported issue-shaped scenarios, including the configured-plugin install migration. diff --git a/docs/help/testing.md b/docs/help/testing.md index e1e4fec4a6f..daaa48c0163 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -112,7 +112,9 @@ These commands sit beside the main test suites when you need QA-lab realism: CI runs QA Lab in dedicated workflows. Agentic parity is nested under `QA-Lab - All Lanes` and release validation, not a standalone PR workflow. Broad validation should use `Full Release Validation` with -`rerun_group=qa-parity` or the release-checks QA group. `QA-Lab - All Lanes` +`rerun_group=qa-parity` or the release-checks QA group. Stable/default release +checks keep exhaustive live/Docker soak behind `run_release_soak=true`; the +`full` profile forces soak on. `QA-Lab - All Lanes` runs nightly on `main` and from manual dispatch with the mock parity lane, live Matrix lane, Convex-managed live Telegram lane, and Convex-managed live Discord lane as parallel jobs. Scheduled QA and release checks pass Matrix @@ -144,6 +146,14 @@ inside every shard. `aimock` starts a local AIMock-backed provider server for experimental fixture and protocol-mock coverage without replacing the scenario-aware `mock-openai` lane. +- `pnpm test:plugins:kitchen-sink-live` + - Runs the live OpenAI Kitchen Sink plugin gauntlet through QA Lab. It + installs the external Kitchen Sink package, verifies the plugin SDK surface + inventory, probes `/healthz` and `/readyz`, records gateway CPU/RSS + evidence, runs a live OpenAI turn, and checks adversarial diagnostics. + Requires live OpenAI auth such as `OPENAI_API_KEY`. In hydrated Testbox + sessions it automatically sources the Testbox live-auth profile when the + `openclaw-testbox-env` helper is present. - `pnpm test:gateway:cpu-scenarios` - Runs the gateway startup bench plus a small mock QA Lab scenario pack (`channel-chat-baseline`, `memory-failure-fallback`, @@ -633,7 +643,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin dependency state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. -- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; the reported-issues set includes `configured-plugin-installs` for automatic external OpenClaw plugin install repair. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; the reported-issues set includes `configured-plugin-installs` for automatic external OpenClaw plugin install repair. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`; Full Release Validation uses the default latest baseline in the blocking path and expands to all-since/reported-issues only for `run_release_soak=true` or `release_profile=full`. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Override with `OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE=2026.4.22` locally, or with the Install Smoke workflow's `update_baseline_version` input on GitHub. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index dbb1061527b..1384983e2af 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -92,7 +92,9 @@ when it was previously pinned to an exact version or tag. When `openclaw update` runs on the beta channel, default-line npm and ClawHub plugin records try the matching plugin `@beta` release first. If that beta release does not exist, OpenClaw falls back to the recorded default/latest spec. -Exact versions and explicit tags such as `@rc` or `@beta` are preserved. +For npm plugins, OpenClaw also falls back when the beta package exists but fails +install validation. Exact versions and explicit tags such as `@rc` or `@beta` +are preserved. ## Uninstall plugins diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index fa5474ac7fd..b3a253d1b3c 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -257,6 +257,9 @@ AI CLI backend such as `codex-cli`. plugin default before running the CLI. - Use `normalizeConfig` when a backend needs compatibility rewrites after merge (for example normalizing old flag shapes). +- Use `resolveExecutionArgs` for request-scoped argv rewrites that belong to + the CLI dialect, such as mapping OpenClaw thinking levels to a native effort + flag. ### Exclusive slots diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index cf1861f7f0f..f0064bc1c4b 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -211,7 +211,9 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers On verified OpenRouter routes, `openrouter/deepseek/deepseek-v4-flash` and `openrouter/deepseek/deepseek-v4-pro` fill missing `reasoning_content` on replayed assistant turns so thinking/tool conversations keep DeepSeek V4's - required follow-up shape. + required follow-up shape. OpenClaw sends OpenRouter-supported + `reasoning_effort` values for these routes; `xhigh` is the highest advertised + level, and stale `max` overrides are mapped to `xhigh`. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index d5825ab98cd..3e972641ebe 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -114,11 +114,13 @@ the maintainer-only release runbook. - 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 - `OpenClaw Release Checks` for install smoke, package acceptance, Docker - release-path suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and Telegram - lanes. With `release_profile=full` and `rerun_group=all`, it also runs package - Telegram E2E against the `release-package-under-test` artifact from release - checks. Provide `npm_telegram_package_spec` after publishing when the same + `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS + package checks, QA Lab parity, Matrix, and Telegram lanes. Stable/default runs + keep exhaustive live/E2E and Docker release-path soak behind + `run_release_soak=true`; `release_profile=full` forces soak on. With + `release_profile=full` and `rerun_group=all`, it also runs package Telegram + E2E against the `release-package-under-test` artifact from release checks. + Provide `npm_telegram_package_spec` after publishing when the same Telegram E2E should prove the published npm package too. Provide `package_acceptance_package_spec` after publishing when Package Acceptance should run its package/update matrix against the shipped npm package instead @@ -293,8 +295,8 @@ parent `release-package-under-test` artifact for package-facing checks, and dispatches standalone package Telegram E2E when `release_profile=full` with `rerun_group=all` or when `npm_telegram_package_spec` is set. `OpenClaw Release Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker -release-path coverage, Package Acceptance with Telegram package QA, QA Lab -parity, live Matrix, and live Telegram. A full run is only acceptable when the +release-path coverage when soak is enabled, Package Acceptance with Telegram +package QA, QA Lab parity, live Matrix, and live Telegram. A full run is only acceptable when the `Full Release Validation` summary shows `normal_ci` and `release_checks` as successful. In full/all mode, the `npm_telegram` child must also be successful; outside full/all it is skipped @@ -318,10 +320,15 @@ Use `release_profile` to select live/provider breadth: - `stable`: minimum plus stable provider/backend coverage for release approval - `full`: stable plus broad advisory provider/media coverage +Use `run_release_soak=true` with `stable` when the release-blocking lanes are +green and you want the exhaustive live/E2E, Docker release-path, and +all-since-2026.4.23 upgrade-survivor sweep before promotion. `full` implies +`run_release_soak=true`. + `OpenClaw Release Checks` uses the trusted workflow ref to resolve the target -ref once as `release-package-under-test` and reuses that artifact in both -release-path Docker checks and Package Acceptance. This keeps all -package-facing boxes on the same bytes and avoids repeated package builds. +ref once as `release-package-under-test` and reuses that artifact in cross-OS, +Package Acceptance, and release-path Docker checks when soak runs. This keeps +all package-facing boxes on the same bytes and avoids repeated package builds. The cross-OS OpenAI install smoke uses `OPENCLAW_CROSS_OS_OPENAI_MODEL` when the repo/org variable is set, otherwise `openai/gpt-5.4`, because this lane is proving package install, onboarding, gateway startup, and one live agent turn @@ -474,11 +481,12 @@ Supported candidate sources: `OpenClaw Release Checks` runs Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes=doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update`, -`published_upgrade_survivor_baselines=all-since-2026.4.23`, -`published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. Package Acceptance keeps migration, update, stale plugin dependency cleanup, offline plugin fixtures, plugin update, and Telegram -package QA against the same resolved tarball. The upgrade matrix covers every stable npm-published baseline from `2026.4.23` through `latest`; use +package QA against the same resolved tarball. Blocking release checks use the +default latest published package baseline; `run_release_soak=true` or +`release_profile=full` expands to every stable npm-published baseline from +`2026.4.23` through `latest` plus reported-issue fixtures. Use Package Acceptance with `source=npm` for an already shipped candidate, or `source=ref`/`source=artifact` for a SHA-backed local npm tarball before publish. It is the GitHub-native @@ -615,6 +623,9 @@ OpenClaw package must not be published. - `ref`: branch, tag, or full commit SHA to validate. Secret-bearing checks require the resolved commit to be reachable from an OpenClaw branch or release tag. +- `run_release_soak`: opt into exhaustive live/E2E, Docker release-path, and + all-since upgrade-survivor soak on stable/default release checks. It is forced + on by `release_profile=full`. Rules: diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index c44c0ee5cc9..6baeea4b1ee 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -27,6 +27,11 @@ Child workflows use the trusted workflow ref for the harness and the input `ref` for the candidate under test. That keeps new validation logic available when validating an older release branch or tag. +By default, `release_profile=stable` runs the release-blocking lanes and skips +the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the +soak lanes on a stable run. `release_profile=full` always enables soak lanes so +the broad advisory profile never drops coverage silently. + Package Acceptance normally builds the candidate tarball from the resolved `ref`, including full-SHA runs dispatched with `pnpm ci:full-release`. After publish, pass `package_acceptance_package_spec=openclaw@YYYY.M.D` (or @@ -35,15 +40,15 @@ the shipped npm package instead. ## Top-level stages -| Stage | Details | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | -| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | -| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.
**Rerun:** `rerun_group=plugin-prerelease`. | -| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, live/E2E suites, Docker release-path chunks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | -| Package artifact | **Job:** `Prepare release package artifact`
**Child workflow:** none
**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.
**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. | -| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `npm_telegram_package_spec` is set.
**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. | -| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | +| Stage | Details | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | +| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | +| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.
**Rerun:** `rerun_group=plugin-prerelease`. | +| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. With `run_release_soak=true` or `release_profile=full`, also runs exhaustive live/E2E suites and Docker release-path chunks.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | +| Package artifact | **Job:** `Prepare release package artifact`
**Child workflow:** none
**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.
**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. | +| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `npm_telegram_package_spec` is set.
**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. | +| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one. When the parent is cancelled, its monitor cancels any child workflow it already @@ -56,19 +61,19 @@ default. once and prepares a shared `release-package-under-test` artifact when package or Docker-facing stages need it. -| Stage | Details | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | -| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | -| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin install/uninstall E2E.
**Rerun:** `rerun_group=install-smoke`. | -| Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | -| Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | -| Docker release path | **Job:** `Run Docker release-path validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** release-path Docker chunks against the shared package artifact.
**Rerun:** `rerun_group=live-e2e`. | -| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks from every stable npm release at or after `2026.4.23` against the same tarball.
**Rerun:** `rerun_group=package`. | -| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`
**Backing workflow:** direct jobs
**Tests:** candidate and baseline agentic parity packs, then the parity report.
**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. | -| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`
**Backing workflow:** direct job
**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | -| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`
**Backing workflow:** direct job
**Tests:** live Telegram QA with Convex CI credential leases.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | -| Release verifier | **Job:** `Verify release checks`
**Backing workflow:** none
**Tests:** required release-check jobs for the selected rerun group.
**Rerun:** rerun after focused child jobs pass. | +| Stage | Details | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | +| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | +| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin install/uninstall E2E.
**Rerun:** `rerun_group=install-smoke`. | +| Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | +| Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | +| Docker release path | **Job:** `Run Docker release-path validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** release-path Docker chunks against the shared package artifact.
**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.
**Rerun:** `rerun_group=live-e2e`. | +| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.
**Rerun:** `rerun_group=package`. | +| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`
**Backing workflow:** direct jobs
**Tests:** candidate and baseline agentic parity packs, then the parity report.
**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. | +| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`
**Backing workflow:** direct job
**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | +| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`
**Backing workflow:** direct job
**Tests:** live Telegram QA with Convex CI credential leases.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | +| Release verifier | **Job:** `Verify release checks`
**Backing workflow:** none
**Tests:** required release-check jobs for the selected rerun group.
**Rerun:** rerun after focused child jobs pass. | ## Docker release-path chunks @@ -93,10 +98,11 @@ commands with package artifact and image reuse inputs when available. `release_profile` mostly controls live/provider breadth inside release checks. It does not remove normal full CI, Plugin Prerelease, install smoke, package -acceptance, QA Lab, or Docker release-path chunks. `full` also makes the -umbrella run package Telegram E2E against the parent release package artifact when -`rerun_group=all`, so a full pre-publish candidate does not silently skip that -Telegram package lane. +acceptance, or QA Lab. For `stable`, exhaustive repo/live E2E and Docker +release-path chunks are soak coverage and run when `run_release_soak=true`. +`full` forces soak coverage on and also makes the umbrella run package Telegram +E2E against the parent release package artifact when `rerun_group=all`, so a full +pre-publish candidate does not silently skip that Telegram package lane. | Profile | Intended use | Included live/provider coverage | | --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/test.md b/docs/reference/test.md index 13d3cbb68ee..f20d60f73e3 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -44,7 +44,7 @@ title: "Tests" - `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites. - `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits. - `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive. -- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; the reported-issues set includes `configured-plugin-installs` to verify configured external OpenClaw plugins install automatically during upgrade. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; the reported-issues set includes `configured-plugin-installs` to verify configured external OpenClaw plugins install automatically during upgrade and `stale-source-plugin-shadow` to keep source-only plugin shadows from breaking startup. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - `pnpm test:docker:update-migration`: Runs the published-upgrade survivor harness in the cleanup-heavy `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The separate `Update Migration` workflow expands this lane with `baselines=all-since-2026.4.23` so every stable published package from `.23` onward updates to the candidate and proves configured-plugin dependency cleanup outside Full Release CI. - `pnpm test:docker:plugins`: Runs install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 54d924315a0..007bc7cf232 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -260,14 +260,15 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community). } ``` -| Field | Description | -| ---------------- | --------------------------------------------------------- | -| `enabled` | Master toggle (default: `true`) | -| `allow` | Plugin allowlist (optional) | -| `deny` | Plugin denylist (optional; deny wins) | -| `load.paths` | Extra plugin files/directories | -| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | -| `entries.\` | Per-plugin toggles + config | +| Field | Description | +| ------------------ | --------------------------------------------------------- | +| `enabled` | Master toggle (default: `true`) | +| `allow` | Plugin allowlist (optional) | +| `bundledDiscovery` | Bundled plugin discovery mode (`allowlist` by default) | +| `deny` | Plugin denylist (optional; deny wins) | +| `load.paths` | Extra plugin files/directories | +| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | +| `entries.\` | Per-plugin toggles + config | `plugins.allow` is exclusive. When it is non-empty, only listed plugins can load or expose tools, even if `tools.allow` contains `"*"` or a specific plugin-owned @@ -275,6 +276,13 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this shape. +`plugins.bundledDiscovery` defaults to `"allowlist"` for new configs, so a +restrictive `plugins.allow` inventory also blocks omitted bundled provider +plugins, including runtime web-search provider discovery. Doctor stamps older +restrictive allowlist configs with `"compat"` during migration so upgrades keep +legacy bundled provider behavior until the operator opts into the stricter mode. +An empty `plugins.allow` is still treated as unset/open. + Config changes made through `/plugins enable` or `/plugins disable` trigger an in-process Gateway plugin reload. New agent turns rebuild their tool list from the refreshed plugin registry. Source-changing operations such as install, diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 1b546bc2b09..f7e97be51f2 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -26,7 +26,8 @@ title: "Thinking levels" - Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level. - Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting. - Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path. - - DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - Direct DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - OpenRouter-routed DeepSeek V4 models expose `/think xhigh` and send OpenRouter-supported `reasoning_effort` values. Stored `max` overrides fall back to `xhigh`. - Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings. - OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value. - Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers..models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior. @@ -54,6 +55,7 @@ title: "Thinking levels" ## Application by agent - **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. +- **Claude CLI backend**: non-off levels are passed to Claude Code as `--effort` when using `claude-cli`; see [CLI backends](/gateway/cli-backends). ## Fast mode (/fast) diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 7f5fe44bb5e..aef42a1ec98 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -8,8 +8,8 @@ }, "type": "module", "dependencies": { - "@agentclientprotocol/claude-agent-acp": "0.31.4", - "@zed-industries/codex-acp": "0.12.0", + "@agentclientprotocol/claude-agent-acp": "0.32.0", + "@zed-industries/codex-acp": "0.13.0", "acpx": "0.6.1" }, "devDependencies": { diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md index 8ecfcb0d57e..9f8b7b5e304 100644 --- a/extensions/acpx/skills/acp-router/SKILL.md +++ b/extensions/acpx/skills/acp-router/SKILL.md @@ -211,8 +211,8 @@ ${ACPX_CMD} codex sessions close oc-codex- Defaults are: - `openclaw -> openclaw acp` -- `claude -> npx -y @agentclientprotocol/claude-agent-acp@^0.31.0` -- `codex -> bundled @zed-industries/codex-acp@0.12.0 through OpenClaw's isolated CODEX_HOME wrapper` +- `claude -> bundled @agentclientprotocol/claude-agent-acp@0.32.0` +- `codex -> bundled @zed-industries/codex-acp@0.13.0 through OpenClaw's isolated CODEX_HOME wrapper` - `copilot -> copilot --acp --stdio` - `cursor -> cursor-agent acp` - `droid -> droid exec --output-format acp` diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 051c493647b..3dfee4a1401 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -163,7 +163,7 @@ describe("prepareAcpxCodexAuthConfig", () => { }); const wrapper = await fs.readFile(generated.wrapperPath, "utf8"); - expect(wrapper).toContain('"@zed-industries/codex-acp@^0.12.0"'); + expect(wrapper).toContain('"@zed-industries/codex-acp@0.13.0"'); expect(wrapper).toContain('"--", "codex-acp"'); expect(wrapper).not.toContain("@zed-industries/codex-acp@^0.11.1"); }); @@ -184,7 +184,7 @@ describe("prepareAcpxCodexAuthConfig", () => { }); const wrapper = await fs.readFile(generated.wrapperPath, "utf8"); - expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.31.4"'); + expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.32.0"'); expect(wrapper).toContain('"--", "claude-agent-acp"'); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@^0.31.0"); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@0.31.0"); diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 05f5f87699d..30668a16af4 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -4,10 +4,8 @@ import path from "node:path"; import type { ResolvedAcpxPluginConfig } from "./config.js"; const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp"; -const CODEX_ACP_PACKAGE_RANGE = "^0.12.0"; const CODEX_ACP_BIN = "codex-acp"; const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp"; -const CLAUDE_ACP_PACKAGE_VERSION = "0.31.4"; const CLAUDE_ACP_BIN = "claude-agent-acp"; const RUN_CONFIGURED_COMMAND_SENTINEL = "--openclaw-run-configured"; const requireFromHere = createRequire(import.meta.url); @@ -15,8 +13,22 @@ const requireFromHere = createRequire(import.meta.url); type PackageManifest = { name?: unknown; bin?: unknown; + dependencies?: Record; }; +const selfManifest = requireFromHere("../package.json") as PackageManifest; + +function readManifestDependencyVersion(packageName: string): string { + const version = selfManifest.dependencies?.[packageName]; + if (typeof version !== "string" || version.trim() === "") { + throw new Error(`Missing ${packageName} dependency version in @openclaw/acpx manifest`); + } + return version; +} + +const CODEX_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CODEX_ACP_PACKAGE); +const CLAUDE_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CLAUDE_ACP_PACKAGE); + function quoteCommandPart(value: string): string { return JSON.stringify(value); } @@ -205,7 +217,7 @@ child.on("exit", (code, signal) => { function buildCodexAcpWrapperScript(installedBinPath?: string): string { return buildAdapterWrapperScript({ displayName: "Codex", - packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}`, + packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_VERSION}`, binName: CODEX_ACP_BIN, installedBinPath, envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url)); diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts index 8e9e3ee30d9..f43df0315b0 100644 --- a/extensions/acpx/src/manifest.test.ts +++ b/extensions/acpx/src/manifest.test.ts @@ -13,8 +13,8 @@ describe("acpx package manifest", () => { ) as AcpxPackageManifest; expect(packageJson.dependencies?.acpx).toBeDefined(); - expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0"); - expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.31.4"); + expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.13.0"); + expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.32.0"); expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined(); }); }); diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 3d74c9216b3..ca7e1ccda40 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -9,7 +9,7 @@ type TestSessionStore = { const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND = "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"; -const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@^0.12.0"; +const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@0.13.0"; const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`; function makeRuntime( @@ -226,7 +226,7 @@ describe("AcpxRuntime fresh reset wrapper", () => { reasoningEffort: "medium", }), ).toBe( - "npx @zed-industries/codex-acp@^0.12.0 -c model=gpt-5.4 -c model_reasoning_effort=medium", + "npx @zed-industries/codex-acp@0.13.0 -c model=gpt-5.4 -c model_reasoning_effort=medium", ); expect(__testing.isCodexAcpCommand("openclaw acp")).toBe(false); }); diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 9d695573c09..fcb711eb33b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -125,6 +125,23 @@ describe("active-memory plugin", () => { "utf8", ); }; + const makeMemoryToolAllowlistError = ( + reason: string, + sources = "runtime toolsAllow: memory_recall, memory_search, memory_get", + ) => + new Error( + `No callable tools remain after resolving explicit tool allowlist ` + + `(${sources}); ${reason}. ` + + `Fix the allowlist or enable the plugin that registers the requested tool.`, + ); + const hasDebugLine = (needle: string) => + vi + .mocked(api.logger.debug) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); + const hasWarnLine = (needle: string) => + vi + .mocked(api.logger.warn) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); beforeEach(async () => { vi.clearAllMocks(); @@ -1074,9 +1091,12 @@ describe("active-memory plugin", () => { "Your job is to search memory and return only the most relevant memory context for that model.", ); expect(runParams?.prompt).toContain( - "You receive conversation context, including the user's latest message.", + "You receive a bounded search query plus conversation context, including the user's latest message.", ); expect(runParams?.prompt).toContain("Use only the available memory tools."); + expect(runParams?.prompt).toContain( + "Use the bounded search query as the memory_search or memory_recall query.", + ); expect(runParams?.prompt).toContain("Prefer memory_recall when available."); expect(runParams?.prompt).toContain( "If memory_recall is unavailable, use memory_search and memory_get.", @@ -1643,6 +1663,133 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); }); + it("skips the recall subagent when no registered memory tools match", async () => { + const sessionKey = "agent:main:missing-memory-tools"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError("no registered tools matched"); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); + const lines = getActiveMemoryLines(sessionKey); + expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]); + expect(lines.join("\n")).not.toContain("status=unavailable"); + }); + + it("skips missing memory tools when the allowlist error includes inherited sources", async () => { + const sessionKey = "agent:main:missing-memory-tools-with-policy-source"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-with-policy-source", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools with policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }); + + it("keeps memory-tool allowlist errors visible when upstream policy can filter memory tools", async () => { + const sessionKey = "agent:main:memory-tools-filtered-by-policy"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-memory-tools-filtered-by-policy", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? memory tools filtered by policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasWarnLine("No callable tools remain")).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=unavailable"), + ]); + }); + + it.each([ + ["disabled tools", "tools are disabled for this run"], + ["models without tool support", "the selected model does not support tools"], + ])("keeps allowlist errors for %s visible", async (_label, reason) => { + const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; + hoisted.sessionStore[sessionKey] = { + sessionId: `s-${reason.replace(/\W+/g, "-")}`, + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError(reason); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: `what wings should i order? ${reason}`, messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasWarnLine(reason)).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=unavailable"), + ]); + }); + + it("does not skip missing memory-tool allowlist errors after abort", async () => { + const sessionKey = "agent:main:missing-memory-tools-after-abort"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-after-abort", + updatedAt: 0, + }; + runEmbeddedPiAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { + Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { + configurable: true, + value: true, + }); + throw makeMemoryToolAllowlistError("no registered tools matched"); + }); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools after abort", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=timeout"), + ]); + }); + it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => { __testing.setMinimumTimeoutMsForTests(1); __testing.setSetupGraceTimeoutMsForTests(0); @@ -2753,6 +2900,33 @@ describe("active-memory plugin", () => { }); }); + it("skips colon-containing session-store channels for embedded recall (#77396)", async () => { + hoisted.sessionStore["agent:main:qqbot:direct:12345"] = { + sessionId: "session-a", + updatedAt: 25, + channel: "c2c:10D4F7C2", + origin: { + provider: "qqbot", + }, + }; + + await hooks.before_prompt_build( + { prompt: "what wings should i order? scoped stored channel", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:qqbot:direct:12345", + messageProvider: "qqbot", + channelId: "qqbot", + }, + ); + + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({ + messageChannel: "qqbot", + messageProvider: "qqbot", + }); + }); + it("preserves an explicit real channel hint over a stale stored wrapper channel", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", @@ -2867,10 +3041,54 @@ describe("active-memory plugin", () => { ); const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain("Bounded memory search query:\nwhat should i grab on the way?"); expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?"); expect(prompt).not.toContain("Recent conversation tail:"); }); + it("sends a bounded latest-message query instead of channel metadata to memory search", async () => { + api.pluginConfig = { + agents: ["main"], + queryMode: "recent", + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: [ + "Conversation info:", + "Sender: discord:user-123", + "Untrusted Discord message body", + "---", + "do you remember my flight preferences?", + ].join("\n"), + messages: [ + { role: "user", content: "i have a flight tomorrow" }, + { role: "assistant", content: "got it" }, + ], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain( + "Bounded memory search query:\ndo you remember my flight preferences?", + ); + expect(prompt).toContain( + "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", + ); + expect(prompt).toContain("Conversation context:"); + expect(prompt).toContain("Conversation info:"); + expect(prompt).not.toContain("Bounded memory search query:\nConversation info:"); + expect(prompt).not.toContain("Bounded memory search query:\nSender:"); + expect(prompt).not.toContain("Bounded memory search query:\nUntrusted Discord message body"); + }); + it("supports full mode by sending the whole conversation", async () => { api.pluginConfig = { agents: ["main"], @@ -3209,7 +3427,6 @@ describe("active-memory plugin", () => { `^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`, ), ); - expect(rmSpy).not.toHaveBeenCalled(); expect( vi .mocked(api.logger.info) @@ -3217,6 +3434,7 @@ describe("active-memory plugin", () => { String(call[0]).includes(`transcript=${expectedDir}${path.sep}`), ), ).toBe(true); + expect(rmSpy.mock.calls.some(([target]) => String(target).startsWith(expectedDir))).toBe(false); }); it("falls back to the default transcript directory when transcriptDir is unsafe", async () => { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index a7d4e195953..10d891405be 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -41,11 +41,13 @@ const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3; const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000; +const ACTIVE_MEMORY_TOOL_ALLOWLIST = ["memory_recall", "memory_search", "memory_get"] as const; const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024; -const TIMEOUT_PARTIAL_DATA_GRACE_MS = 50; +const TIMEOUT_PARTIAL_DATA_GRACE_MS = 500; +const MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS = 480; const TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS = 25; const NO_RECALL_VALUES = new Set([ @@ -493,6 +495,38 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function isMissingRegisteredMemoryToolsError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.trim(); + const prefix = "No callable tools remain after resolving explicit tool allowlist ("; + const suffix = + "); no registered tools matched. Fix the allowlist or enable the plugin that registers the requested tool."; + if (!message.startsWith(prefix) || !message.endsWith(suffix)) { + return false; + } + const sources = message.slice(prefix.length, -suffix.length); + const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`; + const sourceParts = sources + .split(";") + .map((source) => source.trim()) + .filter(Boolean); + if (!sourceParts.includes(runtimeSource)) { + return false; + } + return sourceParts.every((source) => { + if (source === runtimeSource) { + return true; + } + const entries = source + .slice(source.indexOf(":") + 1) + .split(",") + .map((entry) => entry.trim()); + return entries.includes("*"); + }); +} + function resolveRecallRunChannelContext(params: { api: OpenClawPluginApi; agentId: string; @@ -560,9 +594,17 @@ function resolveRecallRunChannelContext(params: { store, sessionKey: resolvedSessionKey, }).existing; - const strongEntryChannel = + const rawStrongEntryChannel = normalizeOptionalString(sessionEntry?.lastChannel) ?? normalizeOptionalString(sessionEntry?.channel); + // Channel IDs containing ":" are scoped conversation IDs (e.g. QQ c2c + // "c2c:10D4F7C2..."), not runnable channel names. The same guard that + // applies to explicit channelId (#76704) must also apply to channels + // read from the session store (#77396). + const strongEntryChannel = + rawStrongEntryChannel && !rawStrongEntryChannel.includes(":") + ? rawStrongEntryChannel + : undefined; const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider); return resolveReturnValue({ resolvedChannel: strongEntryChannel ?? weakEntryChannel, @@ -932,13 +974,16 @@ function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] { function buildRecallPrompt(params: { config: ResolvedActiveRecallPluginConfig; query: string; + searchQuery: string; }): string { const defaultInstructions = [ "You are a memory search agent.", "Another model is preparing the final user-facing answer.", "Your job is to search memory and return only the most relevant memory context for that model.", - "You receive conversation context, including the user's latest message.", + "You receive a bounded search query plus conversation context, including the user's latest message.", "Use only the available memory tools.", + "Use the bounded search query as the memory_search or memory_recall query.", + "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", "Prefer memory_recall when available.", "If memory_recall is unavailable, use memory_search and memory_get.", "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", @@ -990,7 +1035,11 @@ function buildRecallPrompt(params: { ] .filter((section) => section.length > 0) .join("\n\n"); - return `${instructionBlock}\n\nConversation context:\n${params.query}`; + return [ + instructionBlock, + `Bounded memory search query:\n${params.searchQuery}`, + `Conversation context:\n${params.query}`, + ].join("\n\n"); } function isEnabledForAgent( @@ -2048,6 +2097,83 @@ function buildQuery(params: { ].join("\n"); } +function stripExternalUntrustedBlocks(text: string): string { + return text.replace( + /<<]*>>>[\s\S]*?<<]*>>>/g, + " ", + ); +} + +function stripJsonFences(text: string): string { + return text.replace(/```(?:json)?\s*[\s\S]*?```/gi, " "); +} + +function stripActiveMemoryXmlBlocks(text: string): string { + return text.replace(/[\s\S]*?<\/active_memory_plugin>/gi, " "); +} + +function normalizeSearchQueryText(text: string): string { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => { + if (!line) { + return false; + } + if (/^(conversation info|sender|untrusted context)\b/i.test(line)) { + return false; + } + if (/^(source: external|---|untrusted discord message body)$/i.test(line)) { + return false; + } + if (/^⚠️?\s*Agent couldn't generate a response/i.test(line)) { + return false; + } + if (/^Please try again\.?$/i.test(line)) { + return false; + } + return true; + }) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function clampSearchQuery(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + return normalized.length > MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS + ? normalized.slice(0, MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS).trim() + : normalized; +} + +function buildSearchQuery(params: { + latestUserMessage: string; + recentTurns?: ActiveRecallRecentTurn[]; +}): string { + const latest = clampSearchQuery( + normalizeSearchQueryText( + stripActiveMemoryXmlBlocks( + stripJsonFences(stripExternalUntrustedBlocks(params.latestUserMessage)), + ), + ), + ); + if (latest.length >= 12 || !params.recentTurns?.length) { + return latest || clampSearchQuery(params.latestUserMessage); + } + const previousUser = [...params.recentTurns] + .toReversed() + .find((turn) => turn.role === "user" && turn.text.trim() !== params.latestUserMessage.trim()); + if (!previousUser) { + return latest || clampSearchQuery(params.latestUserMessage); + } + const context = clampSearchQuery( + normalizeSearchQueryText(stripRecalledContextNoise(previousUser.text)), + ) + .slice(0, 120) + .trim(); + return clampSearchQuery(context ? `${context} ${latest}` : latest); +} + function extractTextContent(content: unknown): string { if (typeof content === "string") { return content; @@ -2216,6 +2342,7 @@ async function runRecallSubagent(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; modelRef?: { provider: string; model: string }; @@ -2270,6 +2397,7 @@ async function runRecallSubagent(params: { const prompt = buildRecallPrompt({ config: params.config, query: params.query, + searchQuery: params.searchQuery, }); const { messageChannel, messageProvider } = resolveRecallRunChannelContext({ api: params.api, @@ -2299,7 +2427,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", - toolsAllow: ["memory_recall", "memory_search", "memory_get"], + toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST], disableMessageTool: true, allowGatewaySubagentBinding: true, bootstrapContextMode: "lightweight", @@ -2342,6 +2470,12 @@ async function runRecallSubagent(params: { const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; attachPartialTimeoutData(error, partialReply, searchDebug); } + if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) { + params.api.logger.debug?.( + `active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`, + ); + return { rawReply: "NONE" }; + } throw error; } finally { if (tempDir) { @@ -2359,6 +2493,7 @@ async function maybeResolveActiveRecall(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; }): Promise { @@ -2436,7 +2571,9 @@ async function maybeResolveActiveRecall(params: { if (params.config.logging) { params.api.logger.info?.( - `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(params.query.length)}`, + `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String( + params.query.length, + )} searchQueryChars=${String(params.searchQuery.length)}`, ); } @@ -2805,11 +2942,16 @@ export default definePluginEntry({ }); return undefined; } + const recentTurns = extractRecentTurns(event.messages); const query = buildQuery({ latestUserMessage: event.prompt, - recentTurns: extractRecentTurns(event.messages), + recentTurns, config, }); + const searchQuery = buildSearchQuery({ + latestUserMessage: event.prompt, + recentTurns, + }); const result = await maybeResolveActiveRecall({ api, config, @@ -2819,6 +2961,7 @@ export default definePluginEntry({ messageProvider: ctx.messageProvider, channelId: ctx.channelId, query, + searchQuery, currentModelProviderId: ctx.modelProviderId, currentModelId: ctx.modelId, }); @@ -2855,6 +2998,7 @@ const testing = { buildPromptPrefix, getCachedResult, isCircuitBreakerOpen, + isMissingRegisteredMemoryToolsError, normalizePluginConfig, readActiveMemorySearchDebug, readPartialAssistantText, diff --git a/extensions/amazon-bedrock-mantle/package.json b/extensions/amazon-bedrock-mantle/package.json index 052738fef02..5a21065f8fa 100644 --- a/extensions/amazon-bedrock-mantle/package.json +++ b/extensions/amazon-bedrock-mantle/package.json @@ -5,9 +5,9 @@ "description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin", "type": "module", "dependencies": { - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "@aws/bedrock-token-generator": "^1.1.0", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json index 2cc1469ceea..ab2b0ea2bb2 100644 --- a/extensions/amazon-bedrock/package.json +++ b/extensions/amazon-bedrock/package.json @@ -5,8 +5,8 @@ "description": "OpenClaw Amazon Bedrock provider plugin", "type": "module", "dependencies": { - "@aws-sdk/client-bedrock": "3.1041.0", - "@aws-sdk/client-bedrock-runtime": "3.1041.0", + "@aws-sdk/client-bedrock": "3.1042.0", + "@aws-sdk/client-bedrock-runtime": "3.1042.0", "@aws-sdk/credential-provider-node": "3.972.39" }, "devDependencies": { diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index 63dd29426b4..1e182a6cbf1 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -6,8 +6,8 @@ "type": "module", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.16.0", - "@mariozechner/pi-agent-core": "0.71.1", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index ae7bf5c39b5..6141ac99df2 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -10,6 +10,7 @@ import { CLAUDE_CLI_MODEL_ALIASES, CLAUDE_CLI_SESSION_ID_FIELDS, normalizeClaudeBackendConfig, + resolveClaudeCliExecutionArgs, } from "./cli-shared.js"; export function buildAnthropicCliBackend(): CliBackendPlugin { @@ -76,5 +77,6 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { serialize: true, }, normalizeConfig: normalizeClaudeBackendConfig, + resolveExecutionArgs: resolveClaudeCliExecutionArgs, }; } diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 4c4c577682d..27d5f2589bd 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -6,6 +6,7 @@ import { normalizeClaudePermissionArgs, normalizeClaudeSettingSourcesArgs, resolveClaudePermissionMode, + resolveClaudeCliExecutionArgs, } from "./cli-shared.js"; describe("normalizeClaudePermissionArgs", () => { @@ -75,6 +76,67 @@ describe("normalizeClaudeSettingSourcesArgs", () => { }); }); +describe("resolveClaudeCliExecutionArgs", () => { + it("omits effort args when thinking is off", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + thinkingLevel: "off", + useResume: false, + baseArgs: ["-p", "--output-format", "stream-json"], + }), + ).toEqual(["-p", "--output-format", "stream-json"]); + }); + + it("maps OpenClaw thinking levels to Claude effort args", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "minimal", + useResume: false, + baseArgs: ["-p"], + }), + ).toEqual(["-p", "--effort", "low"]); + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "adaptive", + useResume: false, + baseArgs: ["-p"], + }), + ).toEqual(["-p", "--effort", "medium"]); + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "xhigh", + useResume: true, + baseArgs: ["-p", "--resume", "{sessionId}"], + }), + ).toEqual(["-p", "--resume", "{sessionId}", "--effort", "xhigh"]); + }); + + it("replaces static effort args when a session thinking level is active", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "max", + useResume: false, + baseArgs: ["-p", "--effort", "low", "--effort=high"], + }), + ).toEqual(["-p", "--effort", "max"]); + }); +}); + describe("normalizeClaudeBackendConfig", () => { it("normalizes both args and resumeArgs for custom overrides", () => { const normalized = normalizeClaudeBackendConfig({ @@ -196,6 +258,7 @@ describe("normalizeClaudeBackendConfig", () => { expect(normalized?.resumeArgs).toContain("--permission-mode"); expect(normalized?.resumeArgs).toContain("bypassPermissions"); expect(normalized?.liveSession).toBe("claude-stdio"); + expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs); }); it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => { diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index f73c2ccb142..e981e207eb9 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -1,6 +1,7 @@ import type { CliBackendConfig, CliBackendNormalizeConfigContext, + CliBackendResolveExecutionArgsContext, } from "openclaw/plugin-sdk/cli-backend"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; @@ -60,9 +61,12 @@ export const CLAUDE_CLI_CLEAR_ENV = [ const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions"; const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode"; const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources"; +const CLAUDE_EFFORT_ARG = "--effort"; const CLAUDE_SAFE_SETTING_SOURCES = "user"; const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; +type ClaudeCliEffort = "low" | "medium" | "high" | "xhigh" | "max"; + export function isClaudeCliProvider(providerId: string): boolean { return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID; } @@ -168,6 +172,60 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u return normalized; } +export function mapClaudeCliThinkingLevelToEffort( + thinkingLevel?: string | null, +): ClaudeCliEffort | undefined { + switch (normalizeOptionalLowercaseString(thinkingLevel)) { + case "minimal": + case "low": + return "low"; + case "adaptive": + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + return "xhigh"; + case "max": + return "max"; + default: + return undefined; + } +} + +function stripClaudeEffortArgs(args: readonly string[]): string[] { + const normalized: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === CLAUDE_EFFORT_ARG) { + const maybeValue = args[i + 1]; + if ( + typeof maybeValue === "string" && + maybeValue.trim().length > 0 && + !maybeValue.startsWith("-") + ) { + i += 1; + } + continue; + } + if (arg.startsWith(`${CLAUDE_EFFORT_ARG}=`)) { + continue; + } + normalized.push(arg); + } + return normalized; +} + +export function resolveClaudeCliExecutionArgs( + context: CliBackendResolveExecutionArgsContext, +): string[] { + const effort = mapClaudeCliThinkingLevelToEffort(context.thinkingLevel); + if (!effort) { + return [...context.baseArgs]; + } + return [...stripClaudeEffortArgs(context.baseArgs), CLAUDE_EFFORT_ARG, effort]; +} + export function normalizeClaudeBackendConfig( config: CliBackendConfig, context?: CliBackendNormalizeConfigContext, diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index c311cc15527..9f9b47d6b42 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Anthropic provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/bonjour/manifest.test.ts b/extensions/bonjour/manifest.test.ts index 8ae64c5759c..1e8ba0e246c 100644 --- a/extensions/bonjour/manifest.test.ts +++ b/extensions/bonjour/manifest.test.ts @@ -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.7"); - expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.7"); + expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.8"); + expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.8"); expect(pluginPackageJson.devDependencies?.["@homebridge/ciao"]).toBeUndefined(); }); }); diff --git a/extensions/bonjour/package.json b/extensions/bonjour/package.json index c0eab7e0f0b..8dc12d8fb00 100644 --- a/extensions/bonjour/package.json +++ b/extensions/bonjour/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Bonjour/mDNS gateway discovery", "type": "module", "dependencies": { - "@homebridge/ciao": "^1.3.7" + "@homebridge/ciao": "^1.3.8" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/browser/package.json b/extensions/browser/package.json index e295cee0c13..a46d5450012 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", - "undici": "8.1.0" + "undici": "8.2.0" }, "openclaw": { "extensions": [ diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index ab33f4fb07f..9820ebbb8a8 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -695,6 +695,7 @@ export function registerBrowserAgentActRoutes( res, ctx, targetId, + enforceCurrentUrlAllowed: true, run: async ({ profileCtx, cdpUrl, tab, resolveTabUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody); diff --git a/extensions/browser/src/browser/routes/agent.debug.ts b/extensions/browser/src/browser/routes/agent.debug.ts index 948f1f03be4..81ba1f1b4af 100644 --- a/extensions/browser/src/browser/routes/agent.debug.ts +++ b/extensions/browser/src/browser/routes/agent.debug.ts @@ -29,6 +29,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "console messages", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const messages = await pw.getConsoleMessagesViaPlaywright({ cdpUrl, @@ -54,6 +55,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "page errors", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const result = await pw.getPageErrorsViaPlaywright({ cdpUrl, @@ -80,6 +82,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "network requests", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const result = await pw.getNetworkRequestsViaPlaywright({ cdpUrl, @@ -109,6 +112,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "trace start", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { await pw.traceStartViaPlaywright({ cdpUrl, @@ -137,6 +141,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "trace stop", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const id = crypto.randomUUID(); const tracePath = await resolveWritableOutputPathOrRespond({ diff --git a/extensions/browser/src/browser/routes/agent.shared.test.ts b/extensions/browser/src/browser/routes/agent.shared.test.ts index a6247d35724..1de49298c01 100644 --- a/extensions/browser/src/browser/routes/agent.shared.test.ts +++ b/extensions/browser/src/browser/routes/agent.shared.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import { readBody, resolveSafeRouteTabUrl, resolveTargetIdFromBody, resolveTargetIdFromQuery, + withRouteTabContext, } from "./agent.shared.js"; +import { createBrowserRouteResponse } from "./test-helpers.js"; import type { BrowserRequest } from "./types.js"; function requestWithBody(body: unknown): BrowserRequest { @@ -36,6 +39,31 @@ function profileContext(tabs: Array<{ targetId: string; url: string }>) { }; } +function routeContextForTab(url: string): BrowserRouteContext { + const profileCtx = { + profile: { + cdpUrl: "http://127.0.0.1:9222", + name: "default", + }, + ensureTabAvailable: vi.fn(async () => ({ + targetId: "tab-1", + title: "Tab", + url, + type: "page", + })), + } as unknown as ProfileContext; + + return { + forProfile: () => profileCtx, + state: () => ({ + resolved: { + ssrfPolicy: {}, + }, + }), + mapTabError: () => null, + } as unknown as BrowserRouteContext; +} + describe("browser route shared helpers", () => { describe("readBody", () => { it("returns object bodies", () => { @@ -100,4 +128,44 @@ describe("browser route shared helpers", () => { ).resolves.toBeUndefined(); }); }); + + describe("withRouteTabContext", () => { + it("does not enforce current-tab URL policy unless requested", async () => { + const response = createBrowserRouteResponse(); + const run = vi.fn(async () => { + response.res.json({ ok: true }); + }); + + await withRouteTabContext({ + req: requestWithBody({}), + res: response.res, + ctx: routeContextForTab("http://127.0.0.1:8080/admin"), + run, + }); + + expect(run).toHaveBeenCalledOnce(); + expect(response.body).toEqual({ ok: true }); + }); + + it("blocks guarded routes before running on a disallowed current tab", async () => { + const response = createBrowserRouteResponse(); + const run = vi.fn(async () => { + response.res.json({ ok: true }); + }); + + await withRouteTabContext({ + req: requestWithBody({}), + res: response.res, + ctx: routeContextForTab("http://127.0.0.1:8080/admin"), + enforceCurrentUrlAllowed: true, + run, + }); + + expect(run).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(400); + expect(response.body).toMatchObject({ error: expect.any(String) }); + const body = response.body as { error?: unknown }; + expect(body.error).not.toBe(""); + }); + }); }); diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts index e3c1af2acf4..d95e3f13216 100644 --- a/extensions/browser/src/browser/routes/agent.shared.ts +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -107,6 +107,11 @@ type RouteWithTabParams = { res: BrowserResponse; ctx: BrowserRouteContext; targetId?: string; + /** + * Set for routes that read from or return data scoped to the selected tab. + * Leave false only for routes that navigate, activate, close, or otherwise manage the tab. + */ + enforceCurrentUrlAllowed?: boolean; run: (ctx: RouteTabContext) => Promise; }; @@ -119,6 +124,17 @@ export async function withRouteTabContext( } try { const tab = await profileCtx.ensureTabAvailable(params.targetId); + if (params.enforceCurrentUrlAllowed) { + await assertBrowserNavigationResultAllowed({ + url: tab.url, + ...withBrowserNavigationPolicy(params.ctx.state().resolved.ssrfPolicy, { + browserProxyMode: resolveBrowserNavigationProxyMode({ + resolved: params.ctx.state().resolved, + profile: profileCtx.profile, + }), + }), + }); + } return await params.run({ profileCtx, tab, @@ -137,6 +153,10 @@ export async function withRouteTabContext( } } +/** + * Response-only URL redaction. This swallows policy failures and must not be used as + * an execution gate; use enforceCurrentUrlAllowed on the route helper instead. + */ export async function resolveSafeRouteTabUrl(params: { ctx: BrowserRouteContext; profileCtx: ProfileContext; @@ -171,6 +191,11 @@ type RouteWithPwParams = { ctx: BrowserRouteContext; targetId?: string; feature: string; + /** + * Set for routes that read from or return data scoped to the selected tab. + * Leave false only for routes that navigate, activate, close, or otherwise manage the tab. + */ + enforceCurrentUrlAllowed?: boolean; run: (ctx: RouteTabPwContext) => Promise; }; @@ -182,6 +207,7 @@ export async function withPlaywrightRouteContext( res: params.res, ctx: params.ctx, targetId: params.targetId, + enforceCurrentUrlAllowed: params.enforceCurrentUrlAllowed, run: async ({ profileCtx, tab, cdpUrl, resolveTabUrl }) => { const pw = await requirePwAi(params.res, params.feature); if (!pw) { diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 36d6d3039ee..5c8a576bcc8 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -318,6 +318,7 @@ export function registerBrowserAgentSnapshotRoutes( ctx, targetId, feature: "pdf", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const pdf = await pw.pdfViaPlaywright({ cdpUrl, @@ -361,18 +362,19 @@ export function registerBrowserAgentSnapshotRoutes( res, ctx, targetId, + enforceCurrentUrlAllowed: true, run: async ({ profileCtx, tab, cdpUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); - if (element) { - return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement); - } if (ssrfPolicyOpts.ssrfPolicy) { await assertBrowserNavigationResultAllowed({ url: tab.url, ...ssrfPolicyOpts, }); } + if (element) { + return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement); + } if (labels) { const snapshot = await takeChromeMcpSnapshot({ profileName: profileCtx.profile.name, diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts index 0ec039f14b6..cc78dbd1f03 100644 --- a/extensions/browser/src/browser/routes/agent.storage.ts +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -85,6 +85,7 @@ export function registerBrowserAgentStorageRoutes( ctx, targetId, feature: "cookies", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const result = await pw.cookiesGetViaPlaywright({ cdpUrl, @@ -109,6 +110,7 @@ export function registerBrowserAgentStorageRoutes( return jsonError(res, 400, "cookie is required"); } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -148,6 +150,7 @@ export function registerBrowserAgentStorageRoutes( const body = readBody(req); const targetId = resolveTargetIdFromBody(body); + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -181,6 +184,7 @@ export function registerBrowserAgentStorageRoutes( ctx, targetId, feature: "storage get", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const result = await pw.storageGetViaPlaywright({ cdpUrl, @@ -207,6 +211,7 @@ export function registerBrowserAgentStorageRoutes( } const value = typeof mutation.body.value === "string" ? mutation.body.value : ""; + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -235,6 +240,7 @@ export function registerBrowserAgentStorageRoutes( return; } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -263,6 +269,7 @@ export function registerBrowserAgentStorageRoutes( return jsonError(res, 400, "offline is required"); } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -301,6 +308,7 @@ export function registerBrowserAgentStorageRoutes( } } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, diff --git a/extensions/browser/src/browser/routes/existing-session.test-support.ts b/extensions/browser/src/browser/routes/existing-session.test-support.ts index 4d1313fbc19..83049920409 100644 --- a/extensions/browser/src/browser/routes/existing-session.test-support.ts +++ b/extensions/browser/src/browser/routes/existing-session.test-support.ts @@ -1,4 +1,9 @@ import { vi } from "vitest"; +import { + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "../navigation-guard.js"; +import type { BrowserRouteContext } from "../server-context.js"; import type { BrowserRequest } from "./types.js"; export const existingSessionRouteState = { @@ -37,14 +42,33 @@ export function createExistingSessionAgentSharedModule() { typeof body.targetId === "string" ? body.targetId : undefined, ), withPlaywrightRouteContext: vi.fn(), - withRouteTabContext: vi.fn(async ({ run }: { run: (args: unknown) => Promise }) => { - await run({ - profileCtx: existingSessionRouteState.profileCtx, - cdpUrl: "http://127.0.0.1:18800", - tab: existingSessionRouteState.tab, - resolveTabUrl: vi.fn(async (fallbackUrl?: string) => fallbackUrl ?? routeStateUrl()), - }); - }), + withRouteTabContext: vi.fn( + async ({ + ctx, + enforceCurrentUrlAllowed, + run, + }: { + ctx: BrowserRouteContext; + enforceCurrentUrlAllowed?: boolean; + run: (args: unknown) => Promise; + }) => { + if (enforceCurrentUrlAllowed) { + const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + if (ssrfPolicyOpts.ssrfPolicy) { + await assertBrowserNavigationResultAllowed({ + url: existingSessionRouteState.tab.url, + ...ssrfPolicyOpts, + }); + } + } + await run({ + profileCtx: existingSessionRouteState.profileCtx, + cdpUrl: "http://127.0.0.1:18800", + tab: existingSessionRouteState.tab, + resolveTabUrl: vi.fn(async (fallbackUrl?: string) => fallbackUrl ?? routeStateUrl()), + }); + }, + ), }; } diff --git a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 97a958cbbe2..dc5947309d8 100644 --- a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -11,6 +11,8 @@ import { import { getBrowserControlServerTestState, getPwMocks, + setBrowserControlServerSsrFPolicy, + setBrowserControlServerTabUrl, } from "./server.control-server.test-harness.js"; import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js"; @@ -18,6 +20,81 @@ const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); const realFetch: BrowserTestFetch = (input, init) => getBrowserTestFetch()(input, init); +type GuardedCurrentTabRouteCase = { + method: "GET" | "POST"; + path: string; + body?: Record; + mockName: + | "cookiesGetViaPlaywright" + | "pdfViaPlaywright" + | "getConsoleMessagesViaPlaywright" + | "getPageErrorsViaPlaywright" + | "getNetworkRequestsViaPlaywright" + | "responseBodyViaPlaywright" + | "storageGetViaPlaywright" + | "takeScreenshotViaPlaywright" + | "traceStartViaPlaywright" + | "traceStopViaPlaywright"; +}; + +const guardedCurrentTabRouteCases: readonly GuardedCurrentTabRouteCase[] = [ + { + method: "GET", + path: "/console?targetId=abcd1234", + mockName: "getConsoleMessagesViaPlaywright", + }, + { + method: "GET", + path: "/errors?targetId=abcd1234", + mockName: "getPageErrorsViaPlaywright", + }, + { + method: "GET", + path: "/requests?targetId=abcd1234", + mockName: "getNetworkRequestsViaPlaywright", + }, + { + method: "POST", + path: "/pdf", + body: { targetId: "abcd1234" }, + mockName: "pdfViaPlaywright", + }, + { + method: "POST", + path: "/screenshot", + body: { targetId: "abcd1234" }, + mockName: "takeScreenshotViaPlaywright", + }, + { + method: "POST", + path: "/response/body", + body: { targetId: "abcd1234", url: "**/api/data" }, + mockName: "responseBodyViaPlaywright", + }, + { + method: "GET", + path: "/cookies?targetId=abcd1234", + mockName: "cookiesGetViaPlaywright", + }, + { + method: "GET", + path: "/storage/local?targetId=abcd1234", + mockName: "storageGetViaPlaywright", + }, + { + method: "POST", + path: "/trace/start", + body: { targetId: "abcd1234" }, + mockName: "traceStartViaPlaywright", + }, + { + method: "POST", + path: "/trace/stop", + body: { targetId: "abcd1234" }, + mockName: "traceStopViaPlaywright", + }, +] as const; + async function withSymlinkPathEscape(params: { rootDir: string; run: (relativePath: string) => Promise; @@ -439,6 +516,25 @@ describe("browser control server", () => { ); }); + it.each(guardedCurrentTabRouteCases)( + "blocks $method $path on disallowed current tab URLs", + async (routeCase) => { + setBrowserControlServerSsrFPolicy({ allowPrivateNetwork: false }); + setBrowserControlServerTabUrl("http://127.0.0.1:8080/admin"); + const base = await startServerAndBase(); + + const res = await realFetch(`${base}${routeCase.path}`, { + method: routeCase.method, + headers: routeCase.body ? { "Content-Type": "application/json" } : undefined, + body: routeCase.body ? JSON.stringify(routeCase.body) : undefined, + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: unknown }; + expect(body.error).toEqual(expect.stringMatching(/(blocked|denied|not allowed|policy)/i)); + expect(pwMocks[routeCase.mockName]).not.toHaveBeenCalled(); + }, + ); + it("wait/download rejects traversal path outside downloads dir", async () => { const base = await startServerAndBase(); const waitRes = await postJson<{ error?: string }>(`${base}/wait/download`, { diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index fe7a525aee4..bc5d16eb00c 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, vi } from "vitest"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; import { getFreePort } from "./test-port.js"; @@ -10,6 +11,7 @@ type HarnessState = { reachable: boolean; cfgAttachOnly: boolean; cfgEvaluateEnabled: boolean; + cfgSsrfPolicy: SsrFPolicy | undefined; cfgDefaultProfile: string; cfgProfiles: Record< string, @@ -21,6 +23,7 @@ type HarnessState = { attachOnly?: boolean; } >; + tabUrl: string; prevGatewayPort: string | undefined; prevGatewayToken: string | undefined; prevGatewayPassword: string | undefined; @@ -32,8 +35,10 @@ const state: HarnessState = { reachable: false, cfgAttachOnly: false, cfgEvaluateEnabled: true, + cfgSsrfPolicy: undefined, cfgDefaultProfile: "openclaw", cfgProfiles: {}, + tabUrl: "https://example.com", prevGatewayPort: undefined, prevGatewayToken: undefined, prevGatewayPassword: undefined, @@ -59,10 +64,18 @@ export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void { state.cfgEvaluateEnabled = enabled; } +export function setBrowserControlServerSsrFPolicy(policy: SsrFPolicy | undefined): void { + state.cfgSsrfPolicy = policy; +} + export function setBrowserControlServerReachable(reachable: boolean): void { state.reachable = reachable; } +export function setBrowserControlServerTabUrl(url: string): void { + state.tabUrl = url; +} + export function setBrowserControlServerProfiles( profiles: HarnessState["cfgProfiles"], defaultProfile = Object.keys(profiles)[0] ?? "openclaw", @@ -152,6 +165,7 @@ const pwMocks = vi.hoisted(() => ({ clickViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePageViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePlaywrightBrowserConnection: vi.fn(async () => {}), + cookiesGetViaPlaywright: vi.fn(async () => ({ cookies: [] })), downloadViaPlaywright: vi.fn(async () => ({ url: "https://example.com/report.pdf", suggestedFilename: "report.pdf", @@ -161,6 +175,8 @@ const pwMocks = vi.hoisted(() => ({ evaluateViaPlaywright: vi.fn(async (_opts?: unknown) => "ok"), fillFormViaPlaywright: vi.fn(async (_opts?: unknown) => {}), getConsoleMessagesViaPlaywright: vi.fn(async () => []), + getNetworkRequestsViaPlaywright: vi.fn(async () => ({ requests: [] })), + getPageErrorsViaPlaywright: vi.fn(async () => ({ errors: [] })), hoverViaPlaywright: vi.fn(async (_opts?: unknown) => {}), scrollIntoViewViaPlaywright: vi.fn(async (_opts?: unknown) => {}), navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), @@ -181,7 +197,9 @@ const pwMocks = vi.hoisted(() => ({ refs: { e1: { role: "button", name: "Role" } }, stats: { lines: 1, chars: 24, refs: 1, interactive: 1 }, })), + storageGetViaPlaywright: vi.fn(async () => ({ values: {} })), storeAriaSnapshotRefsViaPlaywright: vi.fn(async () => {}), + traceStartViaPlaywright: vi.fn(async () => {}), traceStopViaPlaywright: vi.fn(async () => {}), takeScreenshotViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("png"), @@ -393,13 +411,13 @@ vi.mock("../config/config.js", async () => { evaluateEnabled: state.cfgEvaluateEnabled, color: "#FF4500", attachOnly: state.cfgAttachOnly, + ssrfPolicy: state.cfgSsrfPolicy ?? { dangerouslyAllowPrivateNetwork: true }, headless: true, defaultProfile: state.cfgDefaultProfile, profiles: Object.keys(state.cfgProfiles).length > 0 ? state.cfgProfiles : defaultProfilesForState(state.testPort), - ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, }, }; }; @@ -513,8 +531,10 @@ export async function resetBrowserControlServerTestContext(): Promise { state.reachable = false; state.cfgAttachOnly = false; state.cfgEvaluateEnabled = true; + state.cfgSsrfPolicy = undefined; state.cfgDefaultProfile = "openclaw"; state.cfgProfiles = defaultProfilesForState(state.testPort); + state.tabUrl = "https://example.com"; mockClearAll(pwMocks); mockClearAll(cdpMocks); @@ -580,7 +600,7 @@ export function installBrowserControlServerHooks() { { id: "abcd1234", title: "Tab", - url: "https://example.com", + url: state.tabUrl, webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234", type: "page", }, diff --git a/extensions/codex/package.json b/extensions/codex/package.json index e17fe4f3d7b..7653fc282fd 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -8,11 +8,11 @@ }, "type": "module", "dependencies": { - "@mariozechner/pi-coding-agent": "0.71.1", + "@mariozechner/pi-coding-agent": "0.73.0", "@openai/codex": "0.128.0", "ajv": "^8.20.0", "ws": "^8.20.0", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index 1ef725f04bb..3276244b19d 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -118,6 +118,83 @@ describe("Codex app-server approval bridge", () => { ); }); + it("describes command approval permission and policy amendments", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-command-permissions", status: "accepted" }) + .mockResolvedValueOnce({ + id: "plugin:approval-command-permissions", + decision: "allow-always", + }); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-permissions", + command: "npm install", + additionalPermissions: { + network: { enabled: true }, + fileSystem: { + write: ["/"], + }, + }, + proposedExecpolicyAmendment: ["npm install"], + proposedNetworkPolicyAmendments: [{ host: "registry.npmjs.org", action: "allow" }], + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(result).toEqual({ decision: "acceptForSession" }); + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("Command: npm install"); + expect(description).toContain("Additional permissions: network, fileSystem"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + expect(description).toContain("Network enabled: true"); + expect(description).toContain("File system write: /"); + expect(description).toContain("Proposed exec policy: npm install"); + expect(description).toContain("Proposed network policy: allow registry.npmjs.org"); + }); + + it("keeps command approval permission details visible after long command previews", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-long-command-permissions", status: "accepted" }) + .mockResolvedValueOnce({ + id: "plugin:approval-long-command-permissions", + decision: "allow-always", + }); + + await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-long-permissions", + command: `${"npm install ".repeat(500)} --unsafe-perm`, + additionalPermissions: { + network: { enabled: true }, + fileSystem: { + write: ["/"], + }, + }, + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("[preview truncated or unsafe content omitted]"); + expect(description).toContain("Additional permissions: network, fileSystem"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + }); + it("sanitizes command previews before forwarding approval text and events", async () => { const params = createParams(); mockCallGatewayTool @@ -155,6 +232,44 @@ describe("Codex app-server approval bridge", () => { ); }); + it("escapes command approval previews before forwarding approval text and events", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-escaped-command", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-escaped-command", decision: "allow-once" }); + + await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-escaped", + command: "printf '<@U123> [trusted](https://evil) @here'", + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain( + "printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'", + ); + expect(description).not.toContain("<@U123>"); + expect(description).not.toContain("[trusted](https://evil)"); + expect(description).not.toContain("@here"); + expect(params.onAgentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + stream: "approval", + data: expect.objectContaining({ + command: + "printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'", + }), + }), + ); + }); + it("preserves visible OSC-8 link labels in command previews", async () => { const params = createParams(); mockCallGatewayTool @@ -615,6 +730,59 @@ describe("Codex app-server approval bridge", () => { expect(description).toContain("readPaths: ~/.ssh/id_rsa, /etc/hosts"); }); + it("describes current protocol network and filesystem permission grants", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-current-permissions", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-current-permissions", decision: "allow-once" }); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/permissions/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "perm-current", + permissions: { + network: { enabled: true }, + fileSystem: { + read: ["/Users/simone/.ssh/id_rsa"], + write: ["/"], + entries: [ + { path: "/workspace/project", access: "read" }, + { path: "/tmp/output", access: "write" }, + { path: "/ignored", access: "none" }, + ], + }, + }, + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(result).toEqual({ + permissions: { + network: { enabled: true }, + fileSystem: { + read: ["/Users/simone/.ssh/id_rsa"], + write: ["/"], + entries: [ + { path: "/workspace/project", access: "read" }, + { path: "/tmp/output", access: "write" }, + { path: "/ignored", access: "none" }, + ], + }, + }, + scope: "turn", + }); + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("Network enabled: true"); + expect(description).toContain("File system read: ~/.ssh/id_rsa; write: /"); + expect(description).toContain("entries: read /workspace/project, write /tmp/output (+1 more)"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + }); + it("compacts Windows home paths in permission descriptions", async () => { const params = createParams(); mockCallGatewayTool diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 7f9b278c10b..7c8fd2f4e9c 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -3,6 +3,7 @@ import { formatApprovalDisplayPath, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { approvalRequestExplicitlyUnavailable, mapExecDecisionToOutcome, @@ -15,6 +16,7 @@ import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; const PERMISSION_DESCRIPTION_MAX_LENGTH = 700; const PERMISSION_SAMPLE_LIMIT = 2; const PERMISSION_VALUE_MAX_LENGTH = 48; +const COMMAND_PREVIEW_WITH_DETAILS_MAX_LENGTH = 80; const APPROVAL_PREVIEW_SCAN_MAX_LENGTH = 4096; const APPROVAL_PREVIEW_OMITTED = "[preview truncated or unsafe content omitted]"; const ANSI_OSC_SEQUENCE_RE = new RegExp( @@ -136,7 +138,9 @@ export async function handleCodexAppServerApprovalRequest(params: { ...approvalEventScope(params.method, cancelled ? "cancelled" : "denied"), message: cancelled ? "Codex app-server approval cancelled because the run stopped." - : `Codex app-server approval route failed: ${formatErrorMessage(error)}`, + : `Codex app-server approval route failed: ${formatCodexDisplayText( + formatErrorMessage(error), + )}`, }); return buildApprovalResponse( params.method, @@ -192,9 +196,13 @@ function buildApprovalContext(params: { readString(params.requestParams, "itemId") ?? readString(params.requestParams, "callId") ?? readString(params.requestParams, "approvalId"); + const commandDetailLines = + params.method === "item/commandExecution/requestApproval" + ? describeCommandApprovalDetails(params.requestParams) + : []; const commandPreview = sanitizeApprovalPreview( readDisplayCommandPreview(params.requestParams), - 180, + commandDetailLines.length > 0 ? COMMAND_PREVIEW_WITH_DETAILS_MAX_LENGTH : 180, ); const reasonPreview = sanitizeApprovalPreview( readStringPreview(params.requestParams, "reason"), @@ -229,7 +237,11 @@ function buildApprovalContext(params: { const description = permissionLines.length > 0 ? joinDescriptionLinesWithinLimit(permissionLines, PERMISSION_DESCRIPTION_MAX_LENGTH) - : [subject, params.paramsForRun.sessionKey && `Session: ${params.paramsForRun.sessionKey}`] + : [ + subject, + ...commandDetailLines, + params.paramsForRun.sessionKey && `Session: ${params.paramsForRun.sessionKey}`, + ] .filter(Boolean) .join("\n"); return { @@ -310,6 +322,35 @@ function unsupportedApprovalResponse(): JsonValue { function describeRequestedPermissions(requestParams: JsonObject | undefined): string[] { const permissions = requestedPermissions(requestParams); + return describePermissionProfile(permissions, "Permissions"); +} + +function describeCommandApprovalDetails(requestParams: JsonObject | undefined): string[] { + const lines: string[] = []; + const additionalPermissions = isJsonObject(requestParams?.additionalPermissions) + ? requestParams.additionalPermissions + : undefined; + if (additionalPermissions) { + lines.push(...describePermissionProfile(additionalPermissions, "Additional permissions")); + } + const execpolicySummary = summarizeStringArray( + requestParams?.proposedExecpolicyAmendment, + "Proposed exec policy", + sanitizePermissionScalar, + ); + if (execpolicySummary) { + lines.push(execpolicySummary); + } + const networkAmendmentSummary = summarizeNetworkPolicyAmendments( + requestParams?.proposedNetworkPolicyAmendments, + ); + if (networkAmendmentSummary) { + lines.push(networkAmendmentSummary); + } + return lines; +} + +function describePermissionProfile(permissions: JsonObject, label: string): string[] { const lines: string[] = []; const kinds: string[] = []; const risks = new Set(); @@ -320,41 +361,61 @@ function describeRequestedPermissions(requestParams: JsonObject | undefined): st kinds.push("fileSystem"); } if (kinds.length > 0) { - lines.push(`Permissions: ${kinds.join(", ")}`); + lines.push(`${label}: ${kinds.join(", ")}`); } let networkSummary: string | undefined; if (isJsonObject(permissions.network)) { - networkSummary = summarizePermissionRecord(permissions.network, risks, [ - { - key: "allowHosts", - label: "allowHosts", - sanitize: sanitizePermissionHostValue, - risksFor: permissionHostRisks, - }, - ]); + const summaries = [ + summarizeNetworkEnabledPermission(permissions.network, risks), + summarizePermissionRecord(permissions.network, risks, [ + { + key: "allowHosts", + label: "allowHosts", + sanitize: sanitizePermissionHostValue, + risksFor: permissionHostRisks, + }, + ]), + ].filter((summary): summary is string => Boolean(summary)); + networkSummary = summaries.length > 0 ? summaries.join("; ") : undefined; } let fileSystemSummary: string | undefined; if (isJsonObject(permissions.fileSystem)) { - fileSystemSummary = summarizePermissionRecord(permissions.fileSystem, risks, [ - { - key: "roots", - label: "roots", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - { - key: "readPaths", - label: "readPaths", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - { - key: "writePaths", - label: "writePaths", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - ]); + const summaries = [ + summarizePermissionRecord(permissions.fileSystem, risks, [ + { + key: "read", + label: "read", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "write", + label: "write", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "roots", + label: "roots", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "readPaths", + label: "readPaths", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "writePaths", + label: "writePaths", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + ]), + summarizeFileSystemEntries(permissions.fileSystem, risks), + ].filter((summary): summary is string => Boolean(summary)); + fileSystemSummary = summaries.length > 0 ? summaries.join("; ") : undefined; } if (risks.size > 0) { lines.push(`High-risk targets: ${[...risks].join(", ")}`); @@ -375,6 +436,55 @@ type PermissionArrayDescriptor = { risksFor: (value: string) => readonly string[]; }; +function summarizeNetworkEnabledPermission( + permission: JsonObject, + risks: Set, +): string | undefined { + const enabled = permission.enabled; + if (typeof enabled !== "boolean") { + return undefined; + } + if (enabled) { + risks.add("network access"); + } + return `enabled: ${enabled}`; +} + +function summarizeFileSystemEntries( + permission: JsonObject, + risks: Set, +): string | undefined { + const entries = permission.entries; + if (!Array.isArray(entries)) { + return undefined; + } + const samples: string[] = []; + let count = 0; + for (const entry of entries) { + const item = isJsonObject(entry) ? entry : undefined; + const path = typeof item?.path === "string" ? item.path.trim() : ""; + const access = typeof item?.access === "string" ? item.access.trim() : ""; + if (!path || !access) { + continue; + } + count += 1; + if (access !== "none") { + for (const risk of permissionPathRisks(path)) { + risks.add(risk); + } + } + if (samples.length < PERMISSION_SAMPLE_LIMIT) { + samples.push(`${sanitizePermissionScalar(access)} ${sanitizePermissionPathValue(path)}`); + } + } + if (count === 0) { + return undefined; + } + const remaining = count - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `entries: ${samples.join(", ")}${remainderSuffix}`; +} + function summarizePermissionRecord( permission: JsonObject, risks: Set, @@ -416,6 +526,53 @@ function summarizePermissionArray( return `${descriptor.label}: ${sampleValues.join(", ")}${remainderSuffix}`; } +function summarizeStringArray( + value: JsonValue | undefined, + label: string, + sanitize: (value: string) => string, +): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const values = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => sanitize(entry)) + .filter(Boolean); + if (values.length === 0) { + return undefined; + } + const samples = values.slice(0, PERMISSION_SAMPLE_LIMIT); + const remaining = values.length - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `${label}: ${samples.join(", ")}${remainderSuffix}`; +} + +function summarizeNetworkPolicyAmendments(value: JsonValue | undefined): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const samples: string[] = []; + let count = 0; + for (const entry of value) { + const amendment = isJsonObject(entry) ? entry : undefined; + const host = typeof amendment?.host === "string" ? amendment.host : ""; + const action = typeof amendment?.action === "string" ? amendment.action : ""; + if (!host || !action) { + continue; + } + count += 1; + if (samples.length < PERMISSION_SAMPLE_LIMIT) { + samples.push(`${sanitizePermissionScalar(action)} ${sanitizePermissionHostValue(host)}`); + } + } + if (count === 0) { + return undefined; + } + const remaining = count - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `Proposed network policy: ${samples.join(", ")}${remainderSuffix}`; +} + function readStringArray(record: JsonObject, key: string): string[] { const value = record[key]; return Array.isArray(value) @@ -693,7 +850,7 @@ function sanitizeApprovalPreview( if (!sanitized) { return { omitted: true }; } - return { text: truncate(sanitized, maxLength), omitted: source.clipped }; + return { text: formatCodexDisplayText(truncate(sanitized, maxLength)), omitted: source.clipped }; } function sanitizeVisibleScalar(value: string): string { diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 14460326d06..9176c7f864f 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -314,7 +314,7 @@ describe("createCodexDynamicToolBridge", () => { details: { status: "failed", exitCode: 1 }, }); - await bridge.handleToolCall({ + const result = await bridge.handleToolCall({ threadId: "thread-1", turnId: "turn-1", callId: "call-1", @@ -323,6 +323,10 @@ describe("createCodexDynamicToolBridge", () => { arguments: { command: "false" }, }); + expect(result).toEqual({ + success: false, + contentItems: [{ type: "inputText", text: "failed output" }], + }); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ isError: true }), expect.objectContaining({ runtime: "codex" }), @@ -641,7 +645,7 @@ describe("createCodexDynamicToolBridge", () => { }); expect(result).toEqual({ - success: true, + success: false, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 285fe2979e2..dfb02bd5e2d 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -119,13 +119,14 @@ export function createCodexDynamicToolBridge(params: { args, result: middlewareResult, }); + const resultIsError = rawIsError || isToolResultError(result); collectToolTelemetry({ toolName: tool.name, args, result, mediaTrustResult: rawResult, telemetry, - isError: rawIsError || isToolResultError(result), + isError: resultIsError, }); void runAgentHarnessAfterToolCallHook({ toolName: tool.name, @@ -140,7 +141,7 @@ export function createCodexDynamicToolBridge(params: { }); return { contentItems: result.content.flatMap(convertToolContent), - success: true, + success: !resultIsError, }; } catch (error) { collectToolTelemetry({ diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index d21f581d8e2..1139f2514b1 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -243,6 +243,67 @@ describe("Codex app-server elicitation bridge", () => { expect(approvalRequest.description).not.toContain("\u202e"); }); + it("escapes approval display text before forwarding approval prompts", async () => { + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-escaped", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-escaped", decision: "allow-once" }); + + await handleCodexAppServerElicitationRequest({ + requestParams: { + ...buildCurrentCodexApprovalElicitation(), + message: "Approve <@U123>", + serverName: "server @here", + _meta: { + codex_approval_kind: "mcp_tool_call", + connector_name: "GitHub [trusted](https://evil)", + tool_title: "Create <@U123>", + tool_description: "Use @here", + tool_params_display: [ + { + name: "repo", + display_name: "Repository [trusted](https://evil)", + value: "<@U123>", + }, + ], + }, + requestedSchema: { + type: "object", + properties: { + approve: { + type: "boolean", + title: "Approve <@U123>", + description: "Confirm @here", + }, + }, + required: ["approve"], + }, + }, + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + }); + + const approvalRequest = mockCallGatewayTool.mock.calls[0]?.[2] as { + title: string; + description: string; + }; + expect(approvalRequest.title).toBe("Approve <\uff20U123>"); + expect(approvalRequest.description).toContain( + "GitHub \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(approvalRequest.description).toContain("Tool: Create <\uff20U123>"); + expect(approvalRequest.description).toContain("MCP server: server \uff20here"); + expect(approvalRequest.description).toContain( + "Repository \uff3btrusted\uff3d\uff08https://evil\uff09: <\uff20U123>", + ); + expect(approvalRequest.description).toContain( + "- Approve <\uff20U123>: Confirm \uff20here", + ); + expect(approvalRequest.description).not.toContain("<@U123>"); + expect(approvalRequest.description).not.toContain("[trusted](https://evil)"); + expect(approvalRequest.description).not.toContain("@here"); + }); + it("falls back to stable names when display labels sanitize to empty", async () => { mockCallGatewayTool .mockResolvedValueOnce({ id: "plugin:approval-label-fallback", status: "accepted" }) diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index 017c3d7c4d9..a91aa8a4305 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -2,6 +2,7 @@ import { embeddedAgentLog, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { approvalRequestExplicitlyUnavailable, mapExecDecisionToOutcome, @@ -283,7 +284,8 @@ function sanitizeDisplayText(value: string): string { .replace(CONTROL_CHARACTER_RE, " ") .replace(/\s+/g, " ") .trim(); - return clipped ? `${sanitized}...` : sanitized; + const escaped = sanitized ? formatCodexDisplayText(sanitized) : ""; + return clipped && escaped ? `${escaped}...` : escaped; } function truncateDisplayText(value: string, maxLength: number): string { diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index bae62e0ed02..d0f79c6a55f 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -28,6 +28,7 @@ import { type JsonValue, } from "./protocol.js"; import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; +import { attachCodexMirrorIdentity } from "./transcript-mirror.js"; export type CodexAppServerToolTelemetry = { didSendViaMessagingTool: boolean; @@ -185,23 +186,47 @@ export class CodexAppServerEventProjector { assistantTexts.length > 0 ? this.createAssistantMessage(assistantTexts.join("\n\n")) : undefined; + // Each snapshot entry is tagged with a stable mirror identity of the + // shape `${turnId}:${kind}`. The mirror's idempotency key is derived + // from this identity rather than from snapshot position or content + // hash, so: + // - Re-mirror of the same turn (retry) → same identity → no-op. + // - Re-emit of a prior turn's entry into a later turn's snapshot + // (the cross-turn drift mode named in #77012) → original identity + // is preserved → on-disk key still matches → also a no-op. + // - Two distinct turns where the user repeats verbatim content → + // distinct turnIds → distinct identities → both kept. + const turnId = this.turnId; const messagesSnapshot: AgentMessage[] = [ - { - role: "user", - content: this.params.prompt, - timestamp: Date.now(), - }, + attachCodexMirrorIdentity( + { + role: "user", + content: this.params.prompt, + timestamp: Date.now(), + }, + `${turnId}:prompt`, + ), ]; // Codex owns the canonical thread. These mirror records keep enough local // context for OpenClaw history, search, and future harness switching. if (reasoningText) { - messagesSnapshot.push(this.createAssistantMirrorMessage("Codex reasoning", reasoningText)); + messagesSnapshot.push( + attachCodexMirrorIdentity( + this.createAssistantMirrorMessage("Codex reasoning", reasoningText), + `${turnId}:reasoning`, + ), + ); } if (planText) { - messagesSnapshot.push(this.createAssistantMirrorMessage("Codex plan", planText)); + messagesSnapshot.push( + attachCodexMirrorIdentity( + this.createAssistantMirrorMessage("Codex plan", planText), + `${turnId}:plan`, + ), + ); } if (lastAssistant) { - messagesSnapshot.push(lastAssistant); + messagesSnapshot.push(attachCodexMirrorIdentity(lastAssistant, `${turnId}:assistant`)); } const turnFailed = this.completedTurn?.status === "failed"; const turnInterrupted = this.completedTurn?.status === "interrupted"; diff --git a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts index ad9bcbc6379..1961573d915 100644 --- a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts @@ -188,7 +188,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () }); expect(result).toEqual({ - success: true, + success: false, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 36b3d026f15..bf33227e364 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -146,6 +146,14 @@ function assistantMessage(text: string, timestamp: number) { }; } +function userMessage(text: string, timestamp: number) { + return { + role: "user" as const, + content: [{ type: "text" as const, text }], + timestamp, + }; +} + function createAppServerHarness( requestImpl: (method: string, params: unknown) => Promise, options: { @@ -752,6 +760,34 @@ describe("runCodexAppServerAttempt", () => { ); }); + 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"); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now())); + sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1)); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.prompt = "make the default webpage openclaw"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const inputText = + (turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ?? + ""; + + expect(inputText).toContain("OpenClaw assembled context for this turn:"); + expect(inputText).toContain("we are fixing the Opik default project"); + expect(inputText).toContain("Opik default project context"); + expect(inputText).toContain("Current user request:"); + expect(inputText).toContain("make the default webpage openclaw"); + }); + it("passes OpenClaw bootstrap files through Codex config instructions", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -2048,6 +2084,90 @@ describe("runCodexAppServerAttempt", () => { expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); }); + it("resumes a bound Codex thread when dynamic tools are reordered", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-existing"); + } + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("wiki_status"), createNamedDynamicTool("diffs")], + appServer, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("diffs"), createNamedDynamicTool("wiki_status")], + appServer, + }); + + expect(binding.threadId).toBe("thread-existing"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); + }); + + it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + let nextThread = 1; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(`thread-${nextThread++}`); + } + if (method === "thread/resume") { + return threadStartResult("thread-1"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint; + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + }); + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + dynamicToolsFingerprint: fingerprint, + threadId: "thread-1", + }); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/start", + "thread/start", + "thread/resume", + ]); + }); + it("preserves the binding when the app-server closes during thread resume", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 6f195eaf368..3850b072220 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -88,8 +88,10 @@ import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./s import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; import { + areCodexDynamicToolFingerprintsCompatible, buildDeveloperInstructions, buildTurnStartParams, + codexDynamicToolsFingerprint, startOrResumeThread, } from "./thread-lifecycle.js"; import { @@ -500,6 +502,20 @@ export async function runCodexAppServerAttempt( error: formatErrorMessage(assembleErr), }); } + } else if ( + shouldProjectMirroredHistoryForCodexStart({ + startupBinding, + dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs), + historyMessages, + }) + ) { + const projection = projectContextEngineAssemblyForCodex({ + assembledMessages: historyMessages, + originalHistoryMessages: historyMessages, + prompt: params.prompt, + }); + promptText = projection.promptText; + prePromptMessageCount = projection.prePromptMessageCount; } const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ prompt: promptText, @@ -1546,6 +1562,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { }); } +function shouldProjectMirroredHistoryForCodexStart(params: { + startupBinding: CodexAppServerThreadBinding | undefined; + dynamicToolsFingerprint: string; + historyMessages: AgentMessage[]; +}): boolean { + if (!params.historyMessages.some((message) => message.role === "user")) { + return false; + } + if (!params.startupBinding?.threadId) { + return true; + } + return !areCodexDynamicToolFingerprintsCompatible({ + previous: params.startupBinding.dynamicToolsFingerprint, + next: params.dynamicToolsFingerprint, + }); +} + async function withCodexStartupTimeout(params: { timeoutMs: number; timeoutFloorMs?: number; @@ -1789,7 +1822,14 @@ async function mirrorTranscriptBestEffort(params: { agentId: params.agentId, sessionKey: params.sessionKey, messages: params.result.messagesSnapshot, - idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`, + // Scope is thread-stable. Each entry in `messagesSnapshot` is tagged + // with a per-turn `attachCodexMirrorIdentity` value carrying its own + // turnId, so distinct turns produce distinct dedupe keys via the + // identity (not via the scope). Dropping `turnId` from the scope + // here is what lets a re-emitted prior-turn entry — which still + // carries its original `${turnId}:${kind}` identity — collide with + // its existing on-disk key and be a true no-op. + idempotencyScope: `codex-app-server:${params.threadId}`, config: params.params.config, }); } catch (error) { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 023eb85544b..a43f3c3f279 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -47,20 +47,37 @@ export async function startOrResumeThread(params: { agentDir: params.params.agentDir, config: params.params.config, }); + let preserveExistingBinding = false; if (binding?.threadId) { // `/codex resume ` writes a binding before the next turn can know // the dynamic tool catalog, so only invalidate fingerprints we actually have. if ( binding.dynamicToolsFingerprint && - binding.dynamicToolsFingerprint !== dynamicToolsFingerprint + !areDynamicToolFingerprintsCompatible( + binding.dynamicToolsFingerprint, + dynamicToolsFingerprint, + ) ) { - embeddedAgentLog.debug( - "codex app-server dynamic tool catalog changed; starting a new thread", - { - threadId: binding.threadId, - }, - ); - await clearCodexAppServerBinding(params.params.sessionFile); + preserveExistingBinding = shouldStartTransientNoToolThread({ + previous: binding.dynamicToolsFingerprint, + next: dynamicToolsFingerprint, + }); + if (preserveExistingBinding) { + embeddedAgentLog.debug( + "codex app-server dynamic tools unavailable for turn; starting transient thread", + { + threadId: binding.threadId, + }, + ); + } else { + embeddedAgentLog.debug( + "codex app-server dynamic tool catalog changed; starting a new thread", + { + threadId: binding.threadId, + }, + ); + await clearCodexAppServerBinding(params.params.sessionFile); + } } else { try { const authProfileId = params.params.authProfileId ?? binding.authProfileId; @@ -142,23 +159,25 @@ export async function startOrResumeThread(params: { config: params.params.config, }); const createdAt = new Date().toISOString(); - await writeCodexAppServerBinding( - params.params.sessionFile, - { - threadId: response.thread.id, - cwd: params.cwd, - authProfileId: params.params.authProfileId, - model: response.model ?? params.params.modelId, - modelProvider: response.modelProvider ?? modelProvider, - dynamicToolsFingerprint, - createdAt, - }, - { - authProfileStore: params.params.authProfileStore, - agentDir: params.params.agentDir, - config: params.params.config, - }, - ); + if (!preserveExistingBinding) { + await writeCodexAppServerBinding( + params.params.sessionFile, + { + threadId: response.thread.id, + cwd: params.cwd, + authProfileId: params.params.authProfileId, + model: response.model ?? params.params.modelId, + modelProvider: response.modelProvider ?? modelProvider, + dynamicToolsFingerprint, + createdAt, + }, + { + authProfileStore: params.params.authProfileStore, + agentDir: params.params.agentDir, + config: params.params.config, + }, + ); + } return { schemaVersion: 1, threadId: response.thread.id, @@ -284,8 +303,21 @@ function buildHeartbeatCollaborationInstructions(): string { ].join("\n\n"); } +export function codexDynamicToolsFingerprint(dynamicTools: CodexDynamicToolSpec[]): string { + return fingerprintDynamicTools(dynamicTools); +} + +export function areCodexDynamicToolFingerprintsCompatible(params: { + previous?: string; + next: string; +}): boolean { + return areDynamicToolFingerprintsCompatible(params.previous, params.next); +} + function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string { - return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec)); + return JSON.stringify( + dynamicTools.map(fingerprintDynamicToolSpec).toSorted(compareJsonFingerprint), + ); } function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue { @@ -320,6 +352,27 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { return stable; } +const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = JSON.stringify([]); + +function areDynamicToolFingerprintsCompatible(previous: string | undefined, next: string): boolean { + return !previous || previous === next; +} + +function shouldStartTransientNoToolThread(params: { + previous: string | undefined; + next: string; +}): boolean { + return Boolean( + params.previous && + params.previous !== EMPTY_DYNAMIC_TOOLS_FINGERPRINT && + params.next === EMPTY_DYNAMIC_TOOLS_FINGERPRINT, + ); +} + +function compareJsonFingerprint(left: JsonValue, right: JsonValue): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} + export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { const promptOverlay = renderCodexRuntimePromptOverlay(params); const sections = [ diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 9415891fa69..db2cd35268c 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import { initializeGlobalHookRunner, resetGlobalHookRunner, @@ -12,7 +14,16 @@ import { makeAgentUserMessage, } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; -import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; +import { attachCodexMirrorIdentity, mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; + +type MirroredAgentMessage = Extract; + +// Mirrors transcript-mirror.ts's fallback fingerprint exactly so test +// expectations stay in sync without exposing the helper publicly. +function expectedFingerprint(message: MirroredAgentMessage): string { + const payload = JSON.stringify({ role: message.role, content: message.content }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} const tempDirs: string[] = []; @@ -38,20 +49,19 @@ async function makeRoot(prefix: string): Promise { describe("mirrorCodexAppServerTranscript", () => { it("mirrors user and assistant messages into the Pi transcript", async () => { const sessionFile = await createTempSessionFile(); + const userMessage = makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); + const assistantMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentUserMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hi there" }], - timestamp: Date.now() + 1, - }), - ], + messages: [userMessage, assistantMessage], idempotencyScope: "scope-1", }); @@ -60,8 +70,10 @@ describe("mirrorCodexAppServerTranscript", () => { expect(raw).toContain('"content":[{"type":"text","text":"hello"}]'); expect(raw).toContain('"role":"assistant"'); expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]'); - expect(raw).toContain('"idempotencyKey":"scope-1:user:0"'); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:1"'); + expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`, + ); }); it("creates the transcript directory on first mirror", async () => { @@ -134,22 +146,25 @@ describe("mirrorCodexAppServerTranscript", () => { ]), ); const sessionFile = await createTempSessionFile(); + const sourceMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - ], + messages: [sourceMessage], idempotencyScope: "scope-1", }); const raw = await fs.readFile(sessionFile, "utf8"); expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]'); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + // The idempotency fingerprint is derived from the pre-hook message so a + // hook rewrite cannot bypass dedupe by reshaping content on every retry. + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`, + ); }); it("preserves the computed idempotency key when hooks rewrite message keys", async () => { @@ -167,21 +182,22 @@ describe("mirrorCodexAppServerTranscript", () => { ]), ); const sessionFile = await createTempSessionFile(); + const sourceMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - ], + messages: [sourceMessage], idempotencyScope: "scope-1", }); const raw = await fs.readFile(sessionFile, "utf8"); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`, + ); expect(raw).not.toContain("hook-rewritten-key"); }); @@ -262,4 +278,226 @@ describe("mirrorCodexAppServerTranscript", () => { expect(records[0]).toMatchObject({ id: "legacy-user", parentId: null }); expect(records[1]).toMatchObject({ parentId: "legacy-user" }); }); + + // Helpers for the identity-based regression tests below. + // + // The mirror dedupe key is now `${idempotencyScope}:${identity}`, where + // `identity` is either an explicit `attachCodexMirrorIdentity` tag (the + // production path; event-projector emits `${turnId}:${kind}`) or the + // role/content fingerprint fallback (legacy callers). + type FileMessage = { + type?: string; + message?: { role?: string; content?: Array<{ text?: string }> }; + }; + function readFileMessages(raw: string): Array<{ role?: string; text?: string }> { + return raw + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as FileMessage) + .filter((record) => record.type === "message") + .map((record) => ({ + role: record.message?.role, + text: record.message?.content?.[0]?.text, + })); + } + + // Regression for #77012 (within-turn snapshot reordering). When mirror is + // invoked twice under the same scope/turn but the second snapshot inserts + // a reasoning record between the user prompt and the assistant reply, + // every assistant-role record after the inserted slot shifts. With the + // previous `:role:index` key, the second call's reasoning record collided + // with the first call's assistant key (both `:assistant:1`) — the + // legitimately-new reasoning entry was silently dropped, and the + // assistant content was re-appended under `:assistant:2`, producing a + // duplicate assistant entry. The identity-based key (event-projector + // tags `${turnId}:reasoning` and `${turnId}:assistant`) makes each kind + // its own dedupe slot. + it("dedupes mirrored messages despite snapshot positional shifts", async () => { + const sessionFile = await createTempSessionFile(); + const userMessage = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantMessage = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, assistantMessage], + idempotencyScope: "codex-app-server:thread-X", + }); + const reasoningMessage = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "[Codex reasoning] thinking" }], + timestamp: Date.now() + 2, + }), + "turn-1:reasoning", + ); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, reasoningMessage, assistantMessage], + idempotencyScope: "codex-app-server:thread-X", + }); + + const messageTexts = readFileMessages(await fs.readFile(sessionFile, "utf8")).map( + (m) => m.text, + ); + expect(messageTexts).toEqual(["hello", "hi there", "[Codex reasoning] thinking"]); + }); + + // Two distinct turns where the user types the same thing must not collapse: + // each entry carries its own `${turnId}:${kind}` identity so the dedupe + // key differs even when role+content match. (Prior content-fingerprint-only + // designs would have collapsed the second user turn here.) + it("keeps repeated same-content turns distinct", async () => { + const sessionFile = await createTempSessionFile(); + const userTurn1 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "yes" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantTurn1 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "ok 1" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + const userTurn2 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "yes" }], + timestamp: Date.now() + 2, + }), + "turn-2:prompt", + ); + const assistantTurn2 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "ok 2" }], + timestamp: Date.now() + 3, + }), + "turn-2:assistant", + ); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1], + idempotencyScope: "codex-app-server:thread-X", + }); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn2, assistantTurn2], + idempotencyScope: "codex-app-server:thread-X", + }); + + expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([ + { role: "user", text: "yes" }, + { role: "assistant", text: "ok 1" }, + { role: "user", text: "yes" }, + { role: "assistant", text: "ok 2" }, + ]); + }); + + // Cross-turn re-emit: an entry first written under turn 1 may be re-emitted + // as part of a later turn's snapshot (e.g. a context-engine flow that + // bundles prior history). Because every entry carries its own original + // `${turnId}:${kind}` identity, the re-emitted entries collide with their + // existing on-disk keys and become true no-ops — instead of being + // appended again on a sibling branch (the on-disk symptom in #77012). + it("dedupes prior-turn entries re-emitted into a later turn's snapshot", async () => { + const sessionFile = await createTempSessionFile(); + const userTurn1 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "msg1" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantTurn1 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "reply1" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1], + idempotencyScope: "codex-app-server:thread-X", + }); + + const userTurn2 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "msg2" }], + timestamp: Date.now() + 2, + }), + "turn-2:prompt", + ); + const assistantTurn2 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "reply2" }], + timestamp: Date.now() + 3, + }), + "turn-2:assistant", + ); + // Buggy upstream: snapshot for turn 2 also includes the just-completed + // turn 1's entries (with their original identities preserved). + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2], + idempotencyScope: "codex-app-server:thread-X", + }); + + expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([ + { role: "user", text: "msg1" }, + { role: "assistant", text: "reply1" }, + { role: "user", text: "msg2" }, + { role: "assistant", text: "reply2" }, + ]); + }); + + // Backward-compat: callers that do not tag messages with a mirror identity + // (e.g. third-party harnesses or tests routed through the legacy path) + // still get the role/content fingerprint key. Distinct turns are then + // distinguished by the caller's idempotency scope. + it("falls back to the role+content fingerprint when no identity is attached", async () => { + const sessionFile = await createTempSessionFile(); + const userMessage = makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); + const assistantMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, assistantMessage], + idempotencyScope: "scope-1", + }); + + const raw = await fs.readFile(sessionFile, "utf8"); + expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`, + ); + }); }); diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 4b445cda106..a96cd0c3782 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import { acquireSessionWriteLock, @@ -9,6 +10,61 @@ import { type SessionWriteLockAcquireTimeoutConfig, } from "openclaw/plugin-sdk/agent-harness-runtime"; +type MirroredAgentMessage = Extract; + +const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; + +/** + * Tag a message with a stable logical identity for mirror dedupe. Callers + * should use a value that is invariant for the same logical message across + * re-emits (e.g. `${turnId}:prompt`, `${turnId}:assistant`) but distinct + * for genuinely-distinct messages (different turns, different kinds). When + * present this identity replaces the role/content fingerprint in the + * idempotency key, so the dedupe survives caller-scope rotation without + * collapsing distinct same-content turns. + */ +export function attachCodexMirrorIdentity(message: T, identity: string): T { + const record = message as unknown as Record; + const existing = record.__openclaw; + const baseMeta = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + return { + ...record, + __openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity }, + } as unknown as T; +} + +function readMirrorIdentity(message: MirroredAgentMessage): string | undefined { + const record = message as unknown as { __openclaw?: unknown }; + const meta = record.__openclaw; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return undefined; + } + const id = (meta as Record)[MIRROR_IDENTITY_META_KEY]; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + +// Fallback content fingerprint for callers that did not tag the message +// with a stable mirror identity. Only role and content participate; volatile +// metadata (timestamps, usage, etc.) is intentionally excluded so the +// fingerprint survives snapshot reordering inside a fixed scope. Distinct +// same-content turns are still distinguished by the caller's idempotency +// scope when callers route through this fallback. +function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string { + const payload = JSON.stringify({ role: message.role, content: message.content }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} + +function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string { + const explicit = readMirrorIdentity(message); + if (explicit) { + return explicit; + } + return `${message.role}:${fingerprintMirrorMessageContent(message)}`; +} + export async function mirrorCodexAppServerTranscript(params: { sessionFile: string; sessionKey?: string; @@ -18,7 +74,8 @@ export async function mirrorCodexAppServerTranscript(params: { config?: SessionWriteLockAcquireTimeoutConfig; }): Promise { const messages = params.messages.filter( - (message) => message.role === "user" || message.role === "assistant", + (message): message is MirroredAgentMessage => + message.role === "user" || message.role === "assistant", ); if (messages.length === 0) { return; @@ -30,9 +87,10 @@ export async function mirrorCodexAppServerTranscript(params: { }); try { const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile); - for (const [index, message] of messages.entries()) { + for (const message of messages) { + const dedupeIdentity = buildMirrorDedupeIdentity(message); const idempotencyKey = params.idempotencyScope - ? `${params.idempotencyScope}:${message.role}:${index}` + ? `${params.idempotencyScope}:${dedupeIdentity}` : undefined; if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) { continue; diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index ec39264cbba..7ef88c1cc93 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -98,6 +98,87 @@ describe("Codex app-server user input bridge", () => { }); }); + it("rejects free-form option replies when Other is disabled", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const response = bridge.handleRequest({ + id: "input-options", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [ + { + id: "mode", + header: "Mode", + question: "Pick a mode", + isOther: false, + isSecret: false, + options: [{ label: "Fast", description: "Use less reasoning" }], + }, + ], + }, + }); + + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); + expect(bridge.handleQueuedMessage("banana")).toBe(true); + + await expect(response).resolves.toEqual({ + answers: { mode: { answers: [] } }, + }); + }); + + it("escapes prompt question and option text before chat display", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const response = bridge.handleRequest({ + id: "input-escaped", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [ + { + id: "mode", + header: "Mode <@U123>", + question: "Pick [trusted](https://evil) @here", + isOther: false, + isSecret: false, + options: [{ label: "Fast <@U123>", description: "Use [less](https://evil)" }], + }, + ], + }, + }); + + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); + const payload = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ text: expect.any(String) })); + const text = payload?.text ?? ""; + expect(text).toContain("Mode <\uff20U123>"); + expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here"); + expect(text).toContain( + "Fast <\uff20U123> - Use \uff3bless\uff3d\uff08https://evil\uff09", + ); + expect(text).not.toContain("<@U123>"); + expect(text).not.toContain("[trusted](https://evil)"); + expect(text).not.toContain("@here"); + + expect(bridge.handleQueuedMessage("1")).toBe(true); + await expect(response).resolves.toEqual({ + answers: { mode: { answers: ["Fast <@U123>"] } }, + }); + }); + it("clears pending prompts when Codex resolves the server request itself", async () => { const params = createParams(); const bridge = createCodexUserInputBridge({ @@ -134,4 +215,27 @@ describe("Codex app-server user input bridge", () => { await expect(response).resolves.toEqual({ answers: {} }); expect(bridge.handleQueuedMessage("too late")).toBe(false); }); + + it("resolves malformed empty question prompts without waiting for chat input", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + await expect( + bridge.handleRequest({ + id: "input-empty", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [], + }, + }), + ).resolves.toEqual({ answers: {} }); + expect(params.onBlockReply).not.toHaveBeenCalled(); + expect(bridge.handleQueuedMessage("late answer")).toBe(false); + }); }); diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index 360c46651ee..880308ab6ba 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -2,6 +2,7 @@ import { embeddedAgentLog, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { isJsonObject, type CodexServerNotification, @@ -70,6 +71,9 @@ export function createCodexUserInputBridge(params: { if (requestParams.threadId !== params.threadId || requestParams.turnId !== params.turnId) { return undefined; } + if (requestParams.questions.length === 0) { + return emptyUserInputResponse(); + } resolvePending(emptyUserInputResponse()); @@ -205,16 +209,26 @@ function formatUserInputPrompt(questions: UserInputQuestion[]): string { const lines = ["Codex needs input:"]; questions.forEach((question, index) => { if (questions.length > 1) { - lines.push("", `${index + 1}. ${question.header}`, question.question); + lines.push( + "", + `${index + 1}. ${formatCodexDisplayText(question.header)}`, + formatCodexDisplayText(question.question), + ); } else { - lines.push("", question.header, question.question); + lines.push( + "", + formatCodexDisplayText(question.header), + formatCodexDisplayText(question.question), + ); } if (question.isSecret) { lines.push("This channel may show your reply to other participants."); } question.options?.forEach((option, optionIndex) => { lines.push( - `${optionIndex + 1}. ${option.label}${option.description ? ` - ${option.description}` : ""}`, + `${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${ + option.description ? ` - ${formatCodexDisplayText(option.description)}` : "" + }`, ); }); if (question.isOther) { @@ -229,7 +243,8 @@ function buildUserInputResponse(questions: UserInputQuestion[], inputText: strin if (questions.length === 1) { const question = questions[0]; if (question) { - answers[question.id] = { answers: [normalizeAnswer(inputText, question)] }; + const answer = normalizeAnswer(inputText, question); + answers[question.id] = { answers: answer ? [answer] : [] }; } return { answers }; } @@ -246,12 +261,13 @@ function buildUserInputResponse(questions: UserInputQuestion[], inputText: strin keyed.get(question.question.toLowerCase()) ?? keyed.get(String(index + 1)); const answer = key ?? fallbackLines[index] ?? ""; - answers[question.id] = { answers: answer ? [normalizeAnswer(answer, question)] : [] }; + const normalized = answer ? normalizeAnswer(answer, question) : undefined; + answers[question.id] = { answers: normalized ? [normalized] : [] }; }); return { answers }; } -function normalizeAnswer(answer: string, question: UserInputQuestion): string { +function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined { const trimmed = answer.trim(); const options = question.options ?? []; const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1; @@ -260,7 +276,13 @@ function normalizeAnswer(answer: string, question: UserInputQuestion): string { return indexed.label; } const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase()); - return exact?.label ?? trimmed; + if (exact) { + return exact.label; + } + if (options.length > 0 && !question.isOther) { + return undefined; + } + return trimmed || undefined; } function parseKeyedAnswers(inputText: string): Map { diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index a6f935dd429..bf8855e5598 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -19,25 +19,41 @@ export function formatCodexStatus(probes: CodexStatusProbes): string { lines.push( `Models: ${ probes.models.value.models - .map((model) => model.id) + .map((model) => formatCodexDisplayText(model.id)) .slice(0, 8) .join(", ") || "none" }`, ); } else { - lines.push(`Models: ${probes.models.error}`); + lines.push(`Models: ${formatCodexDisplayText(probes.models.error)}`); } lines.push( - `Account: ${probes.account.ok ? summarizeAccount(probes.account.value) : probes.account.error}`, + `Account: ${ + probes.account.ok + ? formatCodexAccountSummary(probes.account.value) + : formatCodexDisplayText(probes.account.error) + }`, ); lines.push( - `Rate limits: ${probes.limits.ok ? summarizeArrayLike(probes.limits.value) : probes.limits.error}`, + `Rate limits: ${ + probes.limits.ok + ? summarizeRateLimits(probes.limits.value) + : formatCodexDisplayText(probes.limits.error) + }`, ); lines.push( - `MCP servers: ${probes.mcps.ok ? summarizeArrayLike(probes.mcps.value) : probes.mcps.error}`, + `MCP servers: ${ + probes.mcps.ok + ? summarizeArrayLike(probes.mcps.value) + : formatCodexDisplayText(probes.mcps.error) + }`, ); lines.push( - `Skills: ${probes.skills.ok ? summarizeArrayLike(probes.skills.value) : probes.skills.error}`, + `Skills: ${ + probes.skills.ok + ? summarizeArrayLike(probes.skills.value) + : formatCodexDisplayText(probes.skills.error) + }`, ); return lines.join("\n"); } @@ -48,7 +64,9 @@ export function formatModels(result: CodexAppServerModelListResult): string { } const lines = [ "Codex models:", - ...result.models.map((model) => `- ${model.id}${model.isDefault ? " (default)" : ""}`), + ...result.models.map( + (model) => `- ${formatCodexDisplayText(model.id)}${model.isDefault ? " (default)" : ""}`, + ), ]; if (result.truncated) { lines.push("- More models available; output truncated."); @@ -72,10 +90,10 @@ export function formatThreads(response: JsonValue | undefined): string { readString(record, "model"), readString(record, "cwd"), readString(record, "updatedAt") ?? readString(record, "lastUpdatedAt"), - ].filter(Boolean); - return `- ${id}${title ? ` - ${title}` : ""}${ - details.length > 0 ? ` (${details.join(", ")})` : "" - }\n Resume: /codex resume ${id}`; + ].filter((value): value is string => Boolean(value)); + return `- ${formatCodexDisplayText(id)}${title ? ` - ${formatCodexDisplayText(title)}` : ""}${ + details.length > 0 ? ` (${details.map(formatCodexDisplayText).join(", ")})` : "" + }\n Resume: ${formatCodexResumeHint(id)}`; }), ].join("\n"); } @@ -85,8 +103,8 @@ export function formatAccount( limits: SafeValue, ): string { return [ - `Account: ${account.ok ? summarizeAccount(account.value) : account.error}`, - `Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`, + `Account: ${account.ok ? formatCodexAccountSummary(account.value) : formatCodexDisplayText(account.error)}`, + `Rate limits: ${limits.ok ? summarizeRateLimits(limits.value) : formatCodexDisplayText(limits.error)}`, ].join("\n"); } @@ -94,19 +112,21 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string const lines = [ `Computer Use: ${status.ready ? "ready" : status.enabled ? "not ready" : "disabled"}`, ]; - lines.push(`Plugin: ${status.pluginName} (${computerUsePluginState(status)})`); lines.push( - `MCP server: ${status.mcpServerName}${ + `Plugin: ${formatCodexDisplayText(status.pluginName)} (${computerUsePluginState(status)})`, + ); + lines.push( + `MCP server: ${formatCodexDisplayText(status.mcpServerName)}${ status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)" }`, ); if (status.marketplaceName) { - lines.push(`Marketplace: ${status.marketplaceName}`); + lines.push(`Marketplace: ${formatCodexDisplayText(status.marketplaceName)}`); } if (status.tools.length > 0) { - lines.push(`Tools: ${status.tools.slice(0, 8).join(", ")}`); + lines.push(`Tools: ${status.tools.slice(0, 8).map(formatCodexDisplayText).join(", ")}`); } - lines.push(status.message); + lines.push(formatCodexDisplayText(status.message)); return lines.join("\n"); } @@ -126,11 +146,85 @@ export function formatList(response: JsonValue | undefined, label: string): stri `${label}:`, ...entries.slice(0, 25).map((entry) => { const record = isJsonObject(entry) ? entry : {}; - return `- ${readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry)}`; + return `- ${formatCodexDisplayText( + readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry), + )}`; }), ].join("\n"); } +const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +function formatCodexResumeHint(threadId: string): string { + const safe = formatCodexTextForDisplay(threadId); + if (!CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safe)) { + return "copy the thread id above and run /codex resume "; + } + return `/codex resume ${safe}`; +} + +export function formatCodexDisplayText(value: string): string { + return escapeCodexChatText(formatCodexTextForDisplay(value)); +} + +function formatCodexAccountSummary(value: JsonValue | undefined): string { + const safe = formatCodexTextForDisplay(summarizeAccount(value)); + return isLikelyEmailAddress(safe) + ? escapeCodexChatTextPreservingAt(safe) + : escapeCodexChatText(safe); +} + +function formatCodexTextForDisplay(value: string): string { + let safe = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character; + } + safe = safe.trim(); + return safe || ""; +} + +function escapeCodexChatText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("@", "\uff20") + .replaceAll("`", "\uff40") + .replaceAll("[", "\uff3b") + .replaceAll("]", "\uff3d") + .replaceAll("(", "\uff08") + .replaceAll(")", "\uff09") + .replaceAll("*", "\u2217") + .replaceAll("_", "\uff3f") + .replaceAll("~", "\uff5e") + .replaceAll("|", "\uff5c"); +} + +function escapeCodexChatTextPreservingAt(value: string): string { + return escapeCodexChatText(value).replaceAll("\uff20", "@"); +} + +function isLikelyEmailAddress(value: string): boolean { + return /^[^\s@<>()[\]`]+@[^\s@<>()[\]`]+\.[^\s@<>()[\]`]+$/.test(value); +} + +function isUnsafeDisplayCodePoint(codePoint: number): boolean { + return ( + codePoint <= 0x001f || + (codePoint >= 0x007f && codePoint <= 0x009f) || + codePoint === 0x00ad || + codePoint === 0x061c || + codePoint === 0x180e || + (codePoint >= 0x200b && codePoint <= 0x200f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2060 && codePoint <= 0x206f) || + codePoint === 0xfeff || + (codePoint >= 0xfff9 && codePoint <= 0xfffb) || + (codePoint >= 0xe0000 && codePoint <= 0xe007f) + ); +} + export function buildHelp(): string { return [ "Codex commands:", @@ -182,6 +276,28 @@ function summarizeArrayLike(value: JsonValue | undefined): string { return `${entries.length}`; } +function summarizeRateLimits(value: JsonValue | undefined): string { + const entries = extractArray(value); + if (entries.length > 0) { + return `${entries.length}`; + } + if (!isJsonObject(value)) { + return "none returned"; + } + const keyed = value.rateLimitsByLimitId; + if (isJsonObject(keyed)) { + const count = Object.values(keyed).filter(isMeaningfulRateLimitSnapshot).length; + if (count > 0) { + return `${count}`; + } + } + return isMeaningfulRateLimitSnapshot(value.rateLimits) ? "1" : "none returned"; +} + +function isMeaningfulRateLimitSnapshot(value: JsonValue | undefined): boolean { + return isJsonObject(value) && Object.values(value).some((entry) => entry != null); +} + function extractArray(value: JsonValue | undefined): JsonValue[] { if (Array.isArray(value)) { return value; diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 761b6ac1813..b6d141859c2 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -18,6 +18,7 @@ import { buildHelp, formatAccount, formatComputerUseStatus, + formatCodexDisplayText, formatCodexStatus, formatList, formatModels, @@ -120,7 +121,8 @@ type ParsedComputerUseArgs = { type ParsedDiagnosticsArgs = | { action: "request"; note: string } | { action: "confirm"; token: string } - | { action: "cancel"; token: string }; + | { action: "cancel"; token: string } + | { action: "usage" }; type CodexDiagnosticsTarget = { threadId: string; @@ -185,11 +187,17 @@ export async function handleCodexSubcommand( return { text: buildHelp() }; } if (normalized === "status") { + if (rest.length > 0) { + return { text: "Usage: /codex status" }; + } return { text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig, ctx.config)), }; } if (normalized === "models") { + if (rest.length > 0) { + return { text: "Usage: /codex models" }; + } return { text: formatModels( await deps.listCodexAppServerModels( @@ -202,31 +210,40 @@ export async function handleCodexSubcommand( return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) }; } if (normalized === "resume") { - return { text: await resumeThread(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await resumeThread(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "bind") { return await bindConversation(deps, ctx, options.pluginConfig, rest); } if (normalized === "detach" || normalized === "unbind") { + if (rest.length > 0) { + return { text: "Usage: /codex detach" }; + } return { text: await detachConversation(deps, ctx) }; } if (normalized === "binding") { + if (rest.length > 0) { + return { text: "Usage: /codex binding" }; + } return { text: await describeConversationBinding(deps, ctx) }; } if (normalized === "stop") { + if (rest.length > 0) { + return { text: "Usage: /codex stop" }; + } return { text: await stopConversationTurn(deps, ctx, options.pluginConfig) }; } if (normalized === "steer") { return { text: await steerConversationTurn(deps, ctx, options.pluginConfig, rest.join(" ")) }; } if (normalized === "model") { - return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest.join(" ")) }; + return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "fast") { - return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "permissions") { - return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "compact") { return { @@ -236,6 +253,7 @@ export async function handleCodexSubcommand( options.pluginConfig, CODEX_CONTROL_METHODS.compact, "compaction", + rest, ), }; } @@ -247,6 +265,7 @@ export async function handleCodexSubcommand( options.pluginConfig, CODEX_CONTROL_METHODS.review, "review", + rest, ), }; } @@ -265,6 +284,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "mcp") { + if (rest.length > 0) { + return { text: "Usage: /codex mcp" }; + } return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { @@ -275,6 +297,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "skills") { + if (rest.length > 0) { + return { text: "Usage: /codex skills" }; + } return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}), @@ -283,6 +308,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "account") { + if (rest.length > 0) { + return { text: "Usage: /codex account" }; + } const [account, limits] = await Promise.all([ deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.account, { refreshToken: false, @@ -295,7 +323,7 @@ export async function handleCodexSubcommand( ]); return { text: formatAccount(account, limits) }; } - return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` }; + return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` }; } async function handleComputerUseCommand( @@ -327,17 +355,17 @@ async function bindConversation( pluginConfig: unknown, args: string[], ): Promise { - if (!ctx.sessionFile) { - return { - text: "Cannot bind Codex because this command did not include an OpenClaw session file.", - }; - } const parsed = parseBindArgs(args); if (parsed.help) { return { text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", }; } + if (!ctx.sessionFile) { + return { + text: "Cannot bind Codex because this command did not include an OpenClaw session file.", + }; + } const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig); const existingBinding = await deps.readCodexAppServerBinding(ctx.sessionFile); const authProfileId = existingBinding?.authProfileId; @@ -356,7 +384,7 @@ async function bindConversation( const data = await deps.startCodexConversationThread(startParams); const binding = await deps.readCodexAppServerBinding(ctx.sessionFile); const threadId = binding?.threadId ?? parsed.threadId ?? "new thread"; - const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`; + const summary = `Codex app-server thread ${formatCodexDisplayText(threadId)} in ${formatCodexDisplayText(workspaceDir)}`; let request: Awaited>; try { request = await ctx.requestConversationBinding({ @@ -369,13 +397,17 @@ async function bindConversation( throw error; } if (request.status === "bound") { - return { text: `Bound this conversation to Codex thread ${threadId} in ${workspaceDir}.` }; + return { + text: `Bound this conversation to Codex thread ${formatCodexDisplayText( + threadId, + )} in ${formatCodexDisplayText(workspaceDir)}.`, + }; } if (request.status === "pending") { return request.reply; } await deps.clearCodexAppServerBinding(ctx.sessionFile); - return { text: request.message }; + return { text: formatCodexDisplayText(request.message) }; } async function detachConversation( @@ -408,13 +440,13 @@ async function describeConversationBinding( const active = deps.readCodexConversationActiveTurn(data.sessionFile); return [ "Codex conversation binding:", - `- Thread: ${threadBinding?.threadId ?? "unknown"}`, - `- Workspace: ${data.workspaceDir}`, - `- Model: ${threadBinding?.model ?? "default"}`, + `- Thread: ${formatCodexDisplayText(threadBinding?.threadId ?? "unknown")}`, + `- Workspace: ${formatCodexDisplayText(data.workspaceDir)}`, + `- Model: ${formatCodexDisplayText(threadBinding?.model ?? "default")}`, `- Fast: ${threadBinding?.serviceTier === "fast" ? "on" : "off"}`, `- Permissions: ${threadBinding ? formatPermissionsMode(threadBinding) : "default"}`, - `- Active run: ${active ? active.turnId : "none"}`, - `- Session: ${data.sessionFile}`, + `- Active run: ${formatCodexDisplayText(active ? active.turnId : "none")}`, + `- Session: ${formatCodexDisplayText(data.sessionFile)}`, ].join("\n"); } @@ -434,10 +466,11 @@ async function resumeThread( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - threadId: string | undefined, + args: string[], ): Promise { + const [threadId] = args; const normalizedThreadId = threadId?.trim(); - if (!normalizedThreadId) { + if (!normalizedThreadId || args.length !== 1) { return "Usage: /codex resume "; } if (!ctx.sessionFile) { @@ -459,7 +492,9 @@ async function resumeThread( model: isJsonObject(response) ? readString(response, "model") : undefined, modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined, }); - return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`; + return `Attached this OpenClaw session to Codex thread ${formatCodexDisplayText( + effectiveThreadId, + )}.`; } async function stopConversationTurn( @@ -497,16 +532,22 @@ async function setConversationModel( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - model: string, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex model "; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex model because this command did not include an OpenClaw session file."; } + const [model = ""] = args; const normalized = model.trim(); if (!normalized) { const binding = await deps.readCodexAppServerBinding(sessionFile); - return binding?.model ? `Codex model: ${binding.model}` : "Usage: /codex model "; + return binding?.model + ? `Codex model: ${formatCodexDisplayText(binding.model)}` + : "Usage: /codex model "; } return await deps.setCodexConversationModel({ sessionFile, @@ -519,12 +560,16 @@ async function setConversationFastMode( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - value: string | undefined, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex fast [on|off|status]"; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex fast mode because this command did not include an OpenClaw session file."; } + const value = args[0]; const parsed = parseCodexFastModeArg(value); if (value && parsed == null && value.trim().toLowerCase() !== "status") { return "Usage: /codex fast [on|off|status]"; @@ -540,12 +585,16 @@ async function setConversationPermissions( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - value: string | undefined, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex permissions [default|yolo|status]"; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex permissions because this command did not include an OpenClaw session file."; } + const value = args[0]; const parsed = parseCodexPermissionsModeArg(value); if (value && !parsed && value.trim().toLowerCase() !== "status") { return "Usage: /codex permissions [default|yolo|status]"; @@ -573,6 +622,9 @@ async function handleCodexDiagnosticsFeedback( return { text: "Only an owner can send Codex diagnostics." }; } const parsed = parseDiagnosticsArgs(args); + if (parsed.action === "usage") { + return { text: formatDiagnosticsUsage(commandPrefix) }; + } if (parsed.action === "confirm") { return { text: await confirmCodexDiagnosticsFeedback(deps, ctx, pluginConfig, parsed.token), @@ -998,17 +1050,41 @@ function normalizeDiagnosticsReason(note: string): string | undefined { } function parseDiagnosticsArgs(args: string): ParsedDiagnosticsArgs { - const [action, token] = splitArgs(args); + const [action, token, ...extra] = splitArgs(args); const normalizedAction = action?.toLowerCase(); - if ((normalizedAction === "confirm" || normalizedAction === "--confirm") && token) { + if ( + (normalizedAction === "confirm" || normalizedAction === "--confirm") && + token && + extra.length === 0 + ) { return { action: "confirm", token }; } - if ((normalizedAction === "cancel" || normalizedAction === "--cancel") && token) { + if ( + (normalizedAction === "cancel" || normalizedAction === "--cancel") && + token && + extra.length === 0 + ) { return { action: "cancel", token }; } + if ( + normalizedAction === "confirm" || + normalizedAction === "--confirm" || + normalizedAction === "cancel" || + normalizedAction === "--cancel" + ) { + return { action: "usage" }; + } return { action: "request", note: args }; } +function formatDiagnosticsUsage(commandPrefix: string): string { + return [ + `Usage: ${commandPrefix} [note]`, + `Usage: ${commandPrefix} confirm `, + `Usage: ${commandPrefix} cancel `, + ].join("\n"); +} + function createCodexDiagnosticsConfirmation(params: { targets: CodexDiagnosticsTarget[]; note?: string; @@ -1396,7 +1472,11 @@ async function startThreadAction( pluginConfig: unknown, method: typeof CODEX_CONTROL_METHODS.compact | typeof CODEX_CONTROL_METHODS.review, label: string, + args: string[], ): Promise { + if (args.length > 0) { + return `Usage: /codex ${label === "compaction" ? "compact" : label}`; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`; @@ -1413,11 +1493,60 @@ async function startThreadAction( } else { await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId }); } - return `Started Codex ${label} for thread ${binding.threadId}.`; + return `Started Codex ${label} for thread ${formatCodexDisplayText(binding.threadId)}.`; } function splitArgs(value: string | undefined): string[] { - return (value ?? "").trim().split(/\s+/).filter(Boolean); + const input = value ?? ""; + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + let escaping = false; + let tokenStarted = false; + for (const char of input) { + if (escaping) { + current += char; + escaping = false; + tokenStarted = true; + continue; + } + if (char === "\\" && quote !== "'") { + escaping = true; + tokenStarted = true; + continue; + } + if (quote) { + if (char === quote) { + quote = undefined; + } else { + current += char; + } + tokenStarted = true; + continue; + } + if (char === '"' || char === "'") { + quote = char; + tokenStarted = true; + continue; + } + if (/\s/.test(char)) { + if (tokenStarted) { + args.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + current += char; + tokenStarted = true; + } + if (escaping) { + current += "\\"; + } + if (tokenStarted) { + args.push(current); + } + return args; } function parseBindArgs(args: string[]): ParsedBindArgs { @@ -1429,17 +1558,32 @@ function parseBindArgs(args: string[]): ParsedBindArgs { continue; } if (arg === "--cwd") { - parsed.cwd = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.cwd !== undefined) { + parsed.help = true; + continue; + } + parsed.cwd = value; index += 1; continue; } if (arg === "--model") { - parsed.model = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.model !== undefined) { + parsed.help = true; + continue; + } + parsed.model = value; index += 1; continue; } if (arg === "--provider" || arg === "--model-provider") { - parsed.provider = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.provider !== undefined) { + parsed.help = true; + continue; + } + parsed.provider = value; index += 1; continue; } @@ -1462,6 +1606,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { overrides: {}, hasOverrides: false, }; + let sawAction = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--help" || arg === "-h") { @@ -1469,12 +1614,17 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { continue; } if (arg === "status" || arg === "install") { + if (sawAction) { + parsed.help = true; + continue; + } + sawAction = true; parsed.action = arg; continue; } if (arg === "--source" || arg === "--marketplace-source") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplaceSource !== undefined) { parsed.help = true; continue; } @@ -1484,7 +1634,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--marketplace-path" || arg === "--path") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplacePath !== undefined) { parsed.help = true; continue; } @@ -1494,7 +1644,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--marketplace") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplaceName !== undefined) { parsed.help = true; continue; } @@ -1504,7 +1654,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--plugin") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.pluginName !== undefined) { parsed.help = true; continue; } @@ -1514,7 +1664,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--server" || arg === "--mcp-server") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.mcpServerName !== undefined) { parsed.help = true; continue; } @@ -1531,7 +1681,8 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { function readRequiredOptionValue(args: string[], index: number): string | undefined { const value = args[index + 1]; - if (!value || value.startsWith("-")) { + const normalized = value?.trim(); + if (!normalized || normalized.startsWith("-")) { return undefined; } return value; diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index ab09902df6b..e669ae7ed1b 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -105,6 +105,13 @@ describe("codex command", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); + it("escapes unknown subcommands before chat display", async () => { + const result = await handleCodexCommand(createContext("<@U123> [trusted](https://evil) @here")); + + expect(result.text).toContain("Unknown Codex command: <\uff20U123>"); + expect(result.text).not.toContain("<@U123>"); + }); + it("attaches the current session to an existing Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: unknown }> = []; @@ -138,6 +145,42 @@ describe("codex command", () => { ); }); + it("rejects malformed resume commands before attaching a Codex thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const codexControlRequest = vi.fn(); + const writeCodexAppServerBinding = vi.fn(); + + await expect( + handleCodexCommand(createContext("resume thread-123 extra", sessionFile), { + deps: createDeps({ codexControlRequest, writeCodexAppServerBinding }), + }), + ).resolves.toEqual({ + text: "Usage: /codex resume ", + }); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(writeCodexAppServerBinding).not.toHaveBeenCalled(); + }); + + it("escapes resumed Codex thread ids before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const unsafe = "thread-123 <@U123> [trusted](https://evil)"; + const deps = createDeps({ + codexControlRequest: vi.fn(async () => ({ + thread: { id: unsafe, cwd: "/repo" }, + })), + }); + + const result = await handleCodexCommand(createContext("resume thread-123", sessionFile), { + deps, + }); + + expect(result.text).toContain( + "thread-123 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + it("shows model ids from Codex app-server", async () => { const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } }; const deps = createDeps({ @@ -183,6 +226,49 @@ describe("codex command", () => { }); }); + it("escapes Codex app-server model ids before chat display", async () => { + const deps = createDeps({ + listCodexAppServerModels: vi.fn(async () => ({ + models: [ + { + id: "gpt-5.4 <@U123> [trusted](https://evil)", + model: "gpt-5.4", + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + })), + }); + + const result = await handleCodexCommand(createContext("models"), { deps }); + + expect(result.text).toContain( + "gpt-5.4 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + + it("escapes markdown underscores in Codex app-server readouts", async () => { + const deps = createDeps({ + listCodexAppServerModels: vi.fn(async () => ({ + models: [ + { + id: "unsafe_model_name", + model: "unsafe_model_name", + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + })), + }); + + const result = await handleCodexCommand(createContext("models"), { deps }); + + expect(result.text).toContain("unsafe\uff3fmodel\uff3fname"); + expect(result.text).not.toContain("unsafe_model_name"); + }); + it("reports status unavailable when every Codex probe fails", async () => { const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } }; const offline = { ok: false as const, error: "offline" }; @@ -211,6 +297,184 @@ describe("codex command", () => { expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config); }); + it("escapes Codex status probe errors before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const offline = { ok: false as const, error: unsafe }; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: offline, + account: offline, + limits: offline, + mcps: offline, + skills: offline, + })), + }); + + const result = await handleCodexCommand(createContext("status"), { deps }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("escapes successful Codex status model ids and account summaries", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: { + ok: true as const, + value: { + models: [ + { + id: unsafe, + model: unsafe, + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + }, + }, + account: { + ok: true as const, + value: { + account: { + type: "chatgpt" as const, + email: unsafe, + planType: "plus" as const, + }, + requiresOpenaiAuth: false, + }, + }, + limits: { + ok: true as const, + value: { + rateLimits: { + limitId: null, + limitName: null, + primary: null, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, + }, + }, + mcps: { ok: true as const, value: { data: [], nextCursor: null } }, + skills: { ok: true as const, value: { data: [] } }, + })), + }); + + const result = await handleCodexCommand(createContext("status"), { deps }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("summarizes generated Codex rate-limit payloads", async () => { + const limits = { + ok: true as const, + value: { + rateLimits: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: { + codex: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + }, + }, + }; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: { ok: false as const, error: "offline" }, + account: { ok: false as const, error: "offline" }, + limits, + mcps: { ok: true as const, value: { data: [], nextCursor: null } }, + skills: { ok: true as const, value: { data: [] } }, + })), + safeCodexControlRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true as const, + value: { account: { email: "codex@example.com" } }, + }) + .mockResolvedValueOnce(limits), + }); + + await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toMatchObject({ + text: expect.stringContaining("Rate limits: 1"), + }); + await expect(handleCodexCommand(createContext("account"), { deps })).resolves.toMatchObject({ + text: expect.stringContaining("Rate limits: 1"), + }); + }); + + it("rejects extra operands for read-only Codex commands", async () => { + const readCodexStatusProbes = vi.fn(); + const listCodexAppServerModels = vi.fn(); + const safeCodexControlRequest = vi.fn(); + const codexControlRequest = vi.fn(); + const getCurrentConversationBinding = vi.fn(); + const deps = createDeps({ + codexControlRequest, + listCodexAppServerModels, + readCodexStatusProbes, + safeCodexControlRequest, + }); + + await expect(handleCodexCommand(createContext("status now"), { deps })).resolves.toEqual({ + text: "Usage: /codex status", + }); + await expect(handleCodexCommand(createContext("models all"), { deps })).resolves.toEqual({ + text: "Usage: /codex models", + }); + await expect(handleCodexCommand(createContext("account refresh"), { deps })).resolves.toEqual({ + text: "Usage: /codex account", + }); + await expect(handleCodexCommand(createContext("mcp list"), { deps })).resolves.toEqual({ + text: "Usage: /codex mcp", + }); + await expect(handleCodexCommand(createContext("skills list"), { deps })).resolves.toEqual({ + text: "Usage: /codex skills", + }); + await expect( + handleCodexCommand( + createContext("binding current", undefined, { + getCurrentConversationBinding, + }), + { deps }, + ), + ).resolves.toEqual({ + text: "Usage: /codex binding", + }); + + expect(readCodexStatusProbes).not.toHaveBeenCalled(); + expect(listCodexAppServerModels).not.toHaveBeenCalled(); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(getCurrentConversationBinding).not.toHaveBeenCalled(); + }); + it("formats generated account/read responses", async () => { const safeCodexControlRequest = vi .fn() @@ -235,6 +499,44 @@ describe("codex command", () => { }); }); + it("escapes Codex account probe errors before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ ok: false as const, error: unsafe }) + .mockResolvedValueOnce({ ok: false as const, error: unsafe }); + + const result = await handleCodexCommand(createContext("account"), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("escapes successful Codex account fallback summaries before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ ok: true as const, value: { account: { id: unsafe } } }) + .mockResolvedValueOnce({ ok: true as const, value: [] }); + + const result = await handleCodexCommand(createContext("account"), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + it("formats generated Amazon Bedrock account responses", async () => { const safeCodexControlRequest = vi .fn() @@ -295,6 +597,43 @@ describe("codex command", () => { }); }); + it("rejects malformed compact and review commands before starting thread actions", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const codexControlRequest = vi.fn(); + + await expect( + handleCodexCommand(createContext("compact now", sessionFile), { + deps: createDeps({ codexControlRequest }), + }), + ).resolves.toEqual({ + text: "Usage: /codex compact", + }); + await expect( + handleCodexCommand(createContext("review staged", sessionFile), { + deps: createDeps({ codexControlRequest }), + }), + ).resolves.toEqual({ + text: "Usage: /codex review", + }); + expect(codexControlRequest).not.toHaveBeenCalled(); + }); + + it("escapes started thread-action ids before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-123 <@U123>", cwd: "/repo" }), + ); + const codexControlRequest = vi.fn(async () => ({})); + + const result = await handleCodexCommand(createContext("compact", sessionFile), { + deps: createDeps({ codexControlRequest }), + }); + + expect(result.text).toContain("thread-123 <\uff20U123>"); + expect(result.text).not.toContain("<@U123>"); + }); + it("checks Codex Computer Use setup", async () => { const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); @@ -308,7 +647,7 @@ describe("codex command", () => { "Plugin: computer-use (installed)", "MCP server: computer-use (1 tools)", "Marketplace: desktop-tools", - "Tools: list_apps", + "Tools: list\uff3fapps", "Computer Use is ready.", ].join("\n"), }); @@ -318,6 +657,34 @@ describe("codex command", () => { }); }); + it("escapes Codex Computer Use status fields before chat display", async () => { + const readCodexComputerUseStatus = vi.fn(async () => ({ + ...computerUseReadyStatus(), + pluginName: "<@U123>", + mcpServerName: "computer-use [server](https://evil)", + marketplaceName: "desktop_tools", + tools: ["list_apps", "[click](https://evil)"], + message: "Computer Use is ready @here.", + })); + + const result = await handleCodexCommand(createContext("computer-use status"), { + deps: createDeps({ readCodexComputerUseStatus }), + }); + + expect(result.text).toContain("Plugin: <\uff20U123> (installed)"); + expect(result.text).toContain( + "MCP server: computer-use \uff3bserver\uff3d\uff08https://evil\uff09 (2 tools)", + ); + expect(result.text).toContain("Marketplace: desktop\uff3ftools"); + expect(result.text).toContain( + "Tools: list\uff3fapps, \uff3bclick\uff3d\uff08https://evil\uff09", + ); + expect(result.text).toContain("Computer Use is ready \uff20here."); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[click](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + it("formats disabled installed Codex Computer Use plugins", async () => { const readCodexComputerUseStatus = vi.fn(async () => ({ ...computerUseReadyStatus(), @@ -377,6 +744,21 @@ describe("codex command", () => { expect(installCodexComputerUse).not.toHaveBeenCalled(); }); + it("rejects ambiguous Computer Use actions before setup checks", async () => { + const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); + const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); + + await expect( + handleCodexCommand(createContext("computer-use status install"), { + deps: createDeps({ readCodexComputerUseStatus, installCodexComputerUse }), + }), + ).resolves.toEqual({ + text: expect.stringContaining("Usage: /codex computer-use"), + }); + expect(readCodexComputerUseStatus).not.toHaveBeenCalled(); + expect(installCodexComputerUse).not.toHaveBeenCalled(); + }); + it("explains compaction when no Codex thread is attached", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); @@ -481,6 +863,53 @@ describe("codex command", () => { ); }); + it("rejects malformed diagnostics confirmation commands without consuming the token", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-confirm-args", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-confirm-args" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps }); + const token = readDiagnosticsConfirmationToken(request); + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token} extra`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Usage: /codex diagnostics [note]", + "Usage: /codex diagnostics confirm ", + "Usage: /codex diagnostics cancel ", + ].join("\n"), + }); + await expect( + handleCodexCommand(createContext(`diagnostics cancel ${token} extra`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Usage: /codex diagnostics [note]", + "Usage: /codex diagnostics confirm ", + "Usage: /codex diagnostics cancel ", + ].join("\n"), + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + }); + it("previews exec-approved diagnostics upload without exposing Codex ids", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( @@ -1386,6 +1815,86 @@ describe("codex command", () => { }); }); + it("escapes Codex thread fields and avoids unsafe resume commands", async () => { + const codexControlRequest = vi.fn(async () => ({ + data: [ + { + id: "thread-123\n`bad`", + title: "<@U123> [trusted](https://evil) @here", + model: "gpt_5", + cwd: "/repo_(x)", + }, + ], + })); + const deps = createDeps({ codexControlRequest }); + + const result = await handleCodexCommand(createContext("threads"), { deps }); + + expect(result.text).toContain("thread-123?\uff40bad\uff40"); + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).toContain("(gpt\uff3f5, /repo\uff3f\uff08x\uff09)"); + expect(result.text).toContain( + "Resume: copy the thread id above and run /codex resume ", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("Resume: /codex resume thread-123"); + }); + + it("escapes Codex MCP and skill list entries before chat display", async () => { + const codexControlRequest = vi + .fn() + .mockResolvedValueOnce({ data: [{ name: "<@U123> [mcp](https://evil)" }] }) + .mockResolvedValueOnce({ data: [{ id: "skill_1 @here" }] }); + const deps = createDeps({ codexControlRequest }); + + const mcp = await handleCodexCommand(createContext("mcp"), { deps }); + const skills = await handleCodexCommand(createContext("skills"), { deps }); + + expect(mcp.text).toContain("<\uff20U123> \uff3bmcp\uff3d\uff08https://evil\uff09"); + expect(skills.text).toContain("skill\uff3f1 \uff20here"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("<@U123>"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("[mcp](https://evil)"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("@here"); + }); + + it("returns sanitized command failures instead of leaking app-server errors", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), + ); + const failure = () => { + throw new Error("app-server failed <@U123> [trusted](https://evil) @here"); + }; + const expectSanitizedFailure = (result: PluginCommandResult) => { + expect(result.text).toContain( + "Codex command failed: app-server failed <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }; + + for (const [args, deps] of [ + ["models", createDeps({ listCodexAppServerModels: vi.fn(failure) })], + ["threads", createDeps({ codexControlRequest: vi.fn(failure) })], + ["mcp", createDeps({ codexControlRequest: vi.fn(failure) })], + ["skills", createDeps({ codexControlRequest: vi.fn(failure) })], + ["resume thread-123", createDeps({ codexControlRequest: vi.fn(failure) })], + ["compact", createDeps({ codexControlRequest: vi.fn(failure) })], + ["review", createDeps({ codexControlRequest: vi.fn(failure) })], + ["bind", createDeps({ startCodexConversationThread: vi.fn(failure) })], + ["stop", createDeps({ stopCodexConversationTurn: vi.fn(failure) })], + ["steer keep going", createDeps({ steerCodexConversationTurn: vi.fn(failure) })], + ["model gpt-5.4", createDeps({ setCodexConversationModel: vi.fn(failure) })], + ] as const) { + expectSanitizedFailure(await handleCodexCommand(createContext(args, sessionFile), { deps })); + } + }); + it("binds the current conversation to a Codex app-server thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( @@ -1458,6 +1967,170 @@ describe("codex command", () => { }); }); + it("binds quoted workspace paths that contain spaces", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const startCodexConversationThread = vi.fn(async () => ({ + kind: "codex-app-server-session" as const, + version: 1 as const, + sessionFile, + workspaceDir: "/repo with space", + })); + const requestConversationBinding = vi.fn(async () => ({ + status: "bound" as const, + binding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + }, + })); + + await expect( + handleCodexCommand( + createContext('bind thread-123 --cwd "/repo with space"', sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Bound this conversation to Codex thread thread-123 in /repo with space.", + }); + expect(startCodexConversationThread).toHaveBeenCalledWith({ + pluginConfig: undefined, + config: {}, + sessionFile, + workspaceDir: "/repo with space", + threadId: "thread-123", + model: undefined, + modelProvider: undefined, + }); + }); + + it("escapes bound Codex thread ids and workspace paths before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const unsafeThread = "thread-123 <@U123>"; + const unsafeWorkspace = "/repo [trusted](https://evil)"; + const startCodexConversationThread = vi.fn(async () => ({ + kind: "codex-app-server-session" as const, + version: 1 as const, + sessionFile, + workspaceDir: unsafeWorkspace, + })); + const requestConversationBinding = vi.fn(async () => ({ + status: "bound" as const, + binding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + }, + })); + + const result = await handleCodexCommand( + createContext(`bind "${unsafeThread}" --cwd "${unsafeWorkspace}"`, sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ); + + expect(result.text).toContain("thread-123 <\uff20U123>"); + expect(result.text).toContain("/repo \uff3btrusted\uff3d\uff08https://evil\uff09"); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(requestConversationBinding).toHaveBeenCalledWith( + expect.objectContaining({ + summary: + "Codex app-server thread thread-123 <\uff20U123> in /repo \uff3btrusted\uff3d\uff08https://evil\uff09", + }), + ); + }); + + it("rejects bind options with missing, blank, or repeated values before starting Codex", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const startCodexConversationThread = vi.fn(); + const requestConversationBinding = vi.fn(); + + await expect( + handleCodexCommand( + createContext("bind thread-123 --cwd --model gpt-5.4", sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + await expect( + handleCodexCommand( + createContext('bind thread-123 --cwd ""', sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + await expect( + handleCodexCommand( + createContext("bind thread-123 --cwd /repo --cwd /other", sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + expect(startCodexConversationThread).not.toHaveBeenCalled(); + expect(requestConversationBinding).not.toHaveBeenCalled(); + }); + + it("rejects malformed bind arguments before requiring a session file", async () => { + const startCodexConversationThread = vi.fn(); + + await expect( + handleCodexCommand(createContext("bind thread-123 --cwd", undefined), { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + expect(startCodexConversationThread).not.toHaveBeenCalled(); + }); + it("returns the binding approval reply when conversation bind needs approval", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const reply = { text: "Approve this?" }; @@ -1494,7 +2167,7 @@ describe("codex command", () => { createContext("bind", sessionFile, { requestConversationBinding: async () => ({ status: "error", - message: "binding unsupported", + message: "binding unsupported <@U123> [trusted](https://evil)", }), }), { @@ -1510,7 +2183,9 @@ describe("codex command", () => { }), }, ), - ).resolves.toEqual({ text: "binding unsupported" }); + ).resolves.toEqual({ + text: "binding unsupported <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + }); expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); @@ -1548,6 +2223,25 @@ describe("codex command", () => { expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); + it("rejects malformed detach commands before clearing bindings", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const clearCodexAppServerBinding = vi.fn(); + const detachConversationBinding = vi.fn(); + + await expect( + handleCodexCommand( + createContext("detach now", sessionFile, { + detachConversationBinding, + }), + { deps: createDeps({ clearCodexAppServerBinding }) }, + ), + ).resolves.toEqual({ + text: "Usage: /codex detach", + }); + expect(detachConversationBinding).not.toHaveBeenCalled(); + expect(clearCodexAppServerBinding).not.toHaveBeenCalled(); + }); + it("stops the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const stopCodexConversationTurn = vi.fn(async () => ({ @@ -1566,6 +2260,18 @@ describe("codex command", () => { }); }); + it("rejects malformed stop commands before interrupting Codex", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const stopCodexConversationTurn = vi.fn(); + + await expect( + handleCodexCommand(createContext("stop now", sessionFile), { + deps: createDeps({ stopCodexConversationTurn }), + }), + ).resolves.toEqual({ text: "Usage: /codex stop" }); + expect(stopCodexConversationTurn).not.toHaveBeenCalled(); + }); + it("steers the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const steerCodexConversationTurn = vi.fn(async () => ({ @@ -1625,6 +2331,86 @@ describe("codex command", () => { }); }); + it("escapes current bound model status before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-model", + cwd: "/repo", + model: "model_<@U123>_[trusted](https://evil)", + }), + ); + + const result = await handleCodexCommand(createContext("model", sessionFile), { + deps: createDeps(), + }); + + expect(result.text).toContain( + "model\uff3f<\uff20U123>\uff3f\uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + + it("rejects malformed model commands before persisting the model", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const setCodexConversationModel = vi.fn(); + + await expect( + handleCodexCommand(createContext("model gpt-5.4 extra", sessionFile), { + deps: createDeps({ setCodexConversationModel }), + }), + ).resolves.toEqual({ text: "Usage: /codex model " }); + expect(setCodexConversationModel).not.toHaveBeenCalled(); + }); + + it("rejects extra fast and permissions arguments", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const setCodexConversationFastMode = vi.fn(); + const setCodexConversationPermissions = vi.fn(); + const deps = createDeps({ + setCodexConversationFastMode, + setCodexConversationPermissions, + }); + + await expect( + handleCodexCommand(createContext("fast on now", sessionFile), { deps }), + ).resolves.toEqual({ text: "Usage: /codex fast [on|off|status]" }); + await expect( + handleCodexCommand(createContext("permissions yolo now", sessionFile), { deps }), + ).resolves.toEqual({ text: "Usage: /codex permissions [default|yolo|status]" }); + + expect(setCodexConversationFastMode).not.toHaveBeenCalled(); + expect(setCodexConversationPermissions).not.toHaveBeenCalled(); + }); + + it("rejects malformed control arguments before requiring a session file", async () => { + const deps = createDeps({ + setCodexConversationModel: vi.fn(), + setCodexConversationFastMode: vi.fn(), + setCodexConversationPermissions: vi.fn(), + }); + + await expect( + handleCodexCommand(createContext("model gpt-5.4 extra"), { deps }), + ).resolves.toEqual({ + text: "Usage: /codex model ", + }); + await expect(handleCodexCommand(createContext("fast on now"), { deps })).resolves.toEqual({ + text: "Usage: /codex fast [on|off|status]", + }); + await expect( + handleCodexCommand(createContext("permissions yolo now"), { deps }), + ).resolves.toEqual({ + text: "Usage: /codex permissions [default|yolo|status]", + }); + expect(deps.setCodexConversationModel).not.toHaveBeenCalled(); + expect(deps.setCodexConversationFastMode).not.toHaveBeenCalled(); + expect(deps.setCodexConversationPermissions).not.toHaveBeenCalled(); + }); + it("uses current plugin binding data for follow-up control commands", async () => { const hostSessionFile = path.join(tempDir, "host-session.jsonl"); const pluginSessionFile = path.join(tempDir, "plugin-session.jsonl"); @@ -1717,10 +2503,50 @@ describe("codex command", () => { "- Fast: on", "- Permissions: full access", "- Active run: turn-1", - `- Session: ${sessionFile}`, + `- Session: ${sessionFile.replaceAll("_", "\uff3f")}`, ].join("\n"), }); }); + + it("escapes active binding fields before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123 <@U123>", + cwd: "/repo", + model: "gpt [trusted](https://evil)", + }), + ); + + const result = await handleCodexCommand( + createContext("binding", sessionFile, { + getCurrentConversationBinding: async () => ({ + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile, + workspaceDir: "/repo <@U123>", + }, + }), + }), + { deps: createDeps() }, + ); + + expect(result.text).toContain("Thread: thread-123 <\uff20U123>"); + expect(result.text).toContain("Workspace: /repo <\uff20U123>"); + expect(result.text).toContain("Model: gpt \uff3btrusted\uff3d\uff08https://evil\uff09"); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); }); function computerUseReadyStatus(): CodexComputerUseStatus { diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts index e5dc83023a3..8fb715bdd3f 100644 --- a/extensions/codex/src/commands.ts +++ b/extensions/codex/src/commands.ts @@ -3,6 +3,8 @@ import type { PluginCommandContext, PluginCommandResult, } from "openclaw/plugin-sdk/plugin-entry"; +import { describeControlFailure } from "./app-server/capabilities.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; import type { CodexCommandDeps } from "./command-handlers.js"; export function createCodexCommand(options: { @@ -28,5 +30,11 @@ export async function handleCodexCommand( options: { pluginConfig?: unknown; deps?: Partial } = {}, ): Promise { const { handleCodexSubcommand } = await import("./command-handlers.js"); - return await handleCodexSubcommand(ctx, options); + try { + return await handleCodexSubcommand(ctx, options); + } catch (error) { + return { + text: `Codex command failed: ${formatCodexDisplayText(describeControlFailure(error))}`, + }; + } } diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index 5339145b8f7..675d3463050 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -240,7 +240,7 @@ describe("codex conversation binding", () => { request: vi.fn(async (method: string) => { if (method === "turn/start") { throw new Error( - "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + "unexpected status 401 Unauthorized: Missing bearer <@U123> [trusted](https://evil) @here", ); } throw new Error(`unexpected method: ${method}`); @@ -283,12 +283,91 @@ describe("codex conversation binding", () => { expect(result).toEqual({ handled: true, reply: { - text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", }, }); + const replyText = result?.reply?.text ?? ""; + expect(replyText).not.toContain("<@U123>"); + expect(replyText).not.toContain("[trusted](https://evil)"); + expect(replyText).not.toContain("@here"); expect(unhandledRejections).toEqual([]); } finally { process.off("unhandledRejection", onUnhandledRejection); } }); + + it("falls back to content when the channel body for agent is blank", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-1", + cwd: tempDir, + }), + ); + let notificationHandler: ((notification: unknown) => void) | undefined; + const turnStartParams: Record[] = []; + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async (method: string, requestParams: Record) => { + if (method === "turn/start") { + turnStartParams.push(requestParams); + setImmediate(() => + notificationHandler?.({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + items: [{ type: "agentMessage", id: "item-1", text: "done" }], + }, + }, + }), + ); + return { turn: { id: "turn-1" } }; + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => { + notificationHandler = handler; + return () => undefined; + }), + addRequestHandler: vi.fn(() => () => undefined), + }); + + const result = await handleCodexConversationInboundClaim( + { + content: "use the fallback prompt", + bodyForAgent: "", + channel: "telegram", + isGroup: false, + commandAuthorized: true, + }, + { + channelId: "telegram", + pluginBinding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: tempDir, + channel: "telegram", + accountId: "default", + conversationId: "5185575566", + boundAt: Date.now(), + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile, + workspaceDir: tempDir, + }, + }, + }, + { timeoutMs: 50 }, + ); + + expect(result).toEqual({ handled: true, reply: { text: "done" } }); + expect(turnStartParams[0]?.input).toMatchObject([ + { type: "text", text: "use the fallback prompt" }, + ]); + }); }); diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index a0e0a17b197..c8919e8f1b0 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -26,6 +26,7 @@ import { type CodexAppServerAuthProfileLookup, } from "./app-server/session-binding.js"; import { getSharedCodexAppServerClient } from "./app-server/shared-client.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; import { createCodexConversationBindingData, readCodexConversationBindingData, @@ -130,7 +131,7 @@ export async function handleCodexConversationInboundClaim( if (event.commandAuthorized !== true) { return { handled: true }; } - const prompt = (event.bodyForAgent ?? event.content ?? "").trim(); + const prompt = event.bodyForAgent?.trim() || event.content?.trim() || ""; if (!prompt) { return { handled: true }; } @@ -149,7 +150,7 @@ export async function handleCodexConversationInboundClaim( return { handled: true, reply: { - text: `Codex app-server turn failed: ${formatErrorMessage(error)}`, + text: `Codex app-server turn failed: ${formatCodexDisplayText(formatErrorMessage(error))}`, }, }; } diff --git a/extensions/codex/src/conversation-control.test.ts b/extensions/codex/src/conversation-control.test.ts index 0b33fefa35f..e5e9a7fced7 100644 --- a/extensions/codex/src/conversation-control.test.ts +++ b/extensions/codex/src/conversation-control.test.ts @@ -102,4 +102,25 @@ describe("codex conversation controls", () => { }); expect(binding?.modelProvider).toBeUndefined(); }); + + it("escapes model names returned from Codex before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-1", + cwd: tempDir, + model: "gpt-5.4", + modelProvider: "openai", + }); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async () => ({ + thread: { id: "thread-1", cwd: tempDir }, + model: "gpt-5.5 <@U123> [trusted](https://evil)", + modelProvider: "openai", + })), + }); + + await expect(setCodexConversationModel({ sessionFile, model: "gpt-5.5" })).resolves.toBe( + "Codex model set to gpt-5.5 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09.", + ); + }); }); diff --git a/extensions/codex/src/conversation-control.ts b/extensions/codex/src/conversation-control.ts index 04e0b7f76ea..8fedfb295d0 100644 --- a/extensions/codex/src/conversation-control.ts +++ b/extensions/codex/src/conversation-control.ts @@ -10,6 +10,7 @@ import { writeCodexAppServerBinding, } from "./app-server/session-binding.js"; import { getSharedCodexAppServerClient } from "./app-server/shared-client.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; type ActiveTurn = { sessionFile: string; @@ -128,7 +129,7 @@ export async function setCodexConversationModel(params: { sandbox: binding.sandbox, serviceTier: binding.serviceTier ?? runtime.serviceTier, }); - return `Codex model set to ${response.model ?? model}.`; + return `Codex model set to ${formatCodexDisplayText(response.model ?? model)}.`; } export async function setCodexConversationFastMode(params: { diff --git a/extensions/codex/src/conversation-turn-collector.test.ts b/extensions/codex/src/conversation-turn-collector.test.ts index 49b61f652ab..47f4018dea0 100644 --- a/extensions/codex/src/conversation-turn-collector.test.ts +++ b/extensions/codex/src/conversation-turn-collector.test.ts @@ -23,6 +23,43 @@ describe("codex conversation turn collector", () => { await expect(completion).resolves.toEqual({ replyText: "hello world" }); }); + it("buffers pre-start notifications and replays only the selected turn", async () => { + const collector = createCodexConversationTurnCollector("thread-1"); + + collector.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-stale", + status: "completed", + items: [{ type: "agentMessage", id: "wrong", text: "stale answer" }], + }, + }, + }); + collector.handleNotification({ + method: "item/agentMessage/delta", + params: { threadId: "thread-1", turnId: "turn-1", itemId: "right", delta: "fresh " }, + }); + collector.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + items: [{ type: "agentMessage", id: "right", text: "fresh answer" }], + }, + }, + }); + + collector.setTurnId("turn-1"); + + await expect(collector.wait({ timeoutMs: 1_000 })).resolves.toEqual({ + replyText: "fresh answer", + }); + }); + it("uses completed agent message items when deltas are absent", async () => { const collector = createCodexConversationTurnCollector("thread-1"); collector.setTurnId("turn-1"); diff --git a/extensions/codex/src/conversation-turn-collector.ts b/extensions/codex/src/conversation-turn-collector.ts index b9cc4e7a548..ba82956b1ca 100644 --- a/extensions/codex/src/conversation-turn-collector.ts +++ b/extensions/codex/src/conversation-turn-collector.ts @@ -4,6 +4,8 @@ import { type JsonObject, } from "./app-server/protocol.js"; +const MAX_PENDING_NOTIFICATIONS_PER_TURN = 100; + export function createCodexConversationTurnCollector(threadId: string) { let turnId: string | undefined; let completed = false; @@ -11,6 +13,7 @@ export function createCodexConversationTurnCollector(threadId: string) { let timeout: ReturnType | undefined; const assistantTextByItem = new Map(); const assistantOrder: string[] = []; + const pendingNotificationsByTurnId = new Map(); let resolveCompletion: ((value: { replyText: string }) => void) | undefined; let rejectCompletion: ((error: Error) => void) | undefined; @@ -46,59 +49,80 @@ export function createCodexConversationTurnCollector(threadId: string) { clearWaitState(); }; + const handleNotification = (notification: CodexServerNotification) => { + const params = isJsonObject(notification.params) ? notification.params : undefined; + if (!params || readString(params, "threadId") !== threadId) { + return; + } + if (!turnId) { + const pendingTurnId = readNotificationTurnId(params); + if (pendingTurnId) { + const pending = pendingNotificationsByTurnId.get(pendingTurnId) ?? []; + if (pending.length < MAX_PENDING_NOTIFICATIONS_PER_TURN) { + pending.push(notification); + pendingNotificationsByTurnId.set(pendingTurnId, pending); + } + } + return; + } + if (!isNotificationForTurn(params, threadId, turnId)) { + return; + } + if (notification.method === "item/agentMessage/delta") { + const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant"; + const delta = readTextString(params, "delta"); + if (!delta) { + return; + } + rememberItem(itemId); + assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`); + return; + } + if (notification.method === "item/completed") { + const item = isJsonObject(params.item) ? params.item : undefined; + if (item?.type === "agentMessage") { + const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; + const text = readTextString(item, "text"); + if (text) { + rememberItem(itemId); + assistantTextByItem.set(itemId, text); + } + } + return; + } + if (notification.method === "turn/completed") { + const turn = isJsonObject(params.turn) ? params.turn : undefined; + const status = readString(turn, "status"); + if (status === "failed") { + failedError = + readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; + } + const items = Array.isArray(turn?.items) ? turn.items : []; + for (const item of items) { + if (!isJsonObject(item) || item.type !== "agentMessage") { + continue; + } + const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`; + const text = readTextString(item, "text"); + if (text) { + rememberItem(itemId); + assistantTextByItem.set(itemId, text); + } + } + finish(); + } + }; + return { setTurnId(nextTurnId: string) { turnId = nextTurnId; - }, - handleNotification(notification: CodexServerNotification) { - const params = isJsonObject(notification.params) ? notification.params : undefined; - if (!params || !isNotificationForTurn(params, threadId, turnId)) { - return; - } - if (notification.method === "item/agentMessage/delta") { - const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant"; - const delta = readTextString(params, "delta"); - if (!delta) { - return; - } - rememberItem(itemId); - assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`); - return; - } - if (notification.method === "item/completed") { - const item = isJsonObject(params.item) ? params.item : undefined; - if (item?.type === "agentMessage") { - const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; - const text = readTextString(item, "text"); - if (text) { - rememberItem(itemId); - assistantTextByItem.set(itemId, text); - } - } - return; - } - if (notification.method === "turn/completed") { - const turn = isJsonObject(params.turn) ? params.turn : undefined; - const status = readString(turn, "status"); - if (status === "failed") { - failedError = - readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; - } - const items = Array.isArray(turn?.items) ? turn.items : []; - for (const item of items) { - if (!isJsonObject(item) || item.type !== "agentMessage") { - continue; - } - const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`; - const text = readTextString(item, "text"); - if (text) { - rememberItem(itemId); - assistantTextByItem.set(itemId, text); - } - } - finish(); + const pending = pendingNotificationsByTurnId.get(nextTurnId) ?? []; + pendingNotificationsByTurnId.clear(); + for (const notification of pending) { + handleNotification(notification); } }, + handleNotification, wait(params: { timeoutMs: number }): Promise<{ replyText: string }> { if (completed) { return failedError @@ -141,6 +165,10 @@ function isNotificationForTurn( return readString(turn, "id") === turnId; } +function readNotificationTurnId(params: JsonObject): string | undefined { + return readString(params, "turnId") ?? readString(readRecord(params.turn), "id"); +} + function readRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/extensions/codex/src/conversation-turn-input.test.ts b/extensions/codex/src/conversation-turn-input.test.ts index f5e86691ba9..917a4ca8434 100644 --- a/extensions/codex/src/conversation-turn-input.test.ts +++ b/extensions/codex/src/conversation-turn-input.test.ts @@ -41,4 +41,101 @@ describe("codex conversation turn input", () => { { type: "image", url: "https://example.test/photo.webp?sig=1" }, ]); }); + + it("keeps protocol-relative image urls remote", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrl: "//cdn.example.test/photo.webp", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "image", url: "//cdn.example.test/photo.webp" }, + ]); + }); + + it("decodes local file URLs for Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaPath: "file:///tmp/OpenClaw%20QA/photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "/tmp/OpenClaw QA/photo.png" }, + ]); + }); + + it("drops malformed local file URLs instead of throwing", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaPath: "file:///tmp/%zz/photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([{ type: "text", text: "look", text_elements: [] }]); + }); + + it("treats local media URLs as Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrls: ["/tmp/staged-photo.png", "file:///tmp/OpenClaw%20QA/second.jpg"], + mediaTypes: ["image/png", "image/jpeg"], + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "/tmp/staged-photo.png" }, + { type: "localImage", path: "/tmp/OpenClaw QA/second.jpg" }, + ]); + }); + + it("treats Windows media paths as Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrl: "C:\\OpenClaw QA\\photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "C:\\OpenClaw QA\\photo.png" }, + ]); + }); }); diff --git a/extensions/codex/src/conversation-turn-input.ts b/extensions/codex/src/conversation-turn-input.ts index aaff58453e1..0f30bafcee1 100644 --- a/extensions/codex/src/conversation-turn-input.ts +++ b/extensions/codex/src/conversation-turn-input.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginHookInboundClaimEvent } from "openclaw/plugin-sdk/plugin-entry"; import type { CodexUserInput } from "./app-server/protocol.js"; @@ -48,8 +49,10 @@ function toCodexImageInput(media: InboundMedia): CodexUserInput | undefined { if (!isImageMedia(media)) { return undefined; } - if (media.path) { - return { type: "localImage", path: normalizeFileUrl(media.path) }; + const localPath = media.path ?? readLocalMediaPath(media.url); + if (localPath) { + const normalized = normalizeFileUrl(localPath); + return normalized ? { type: "localImage", path: normalized } : undefined; } return media.url ? { type: "image", url: media.url } : undefined; } @@ -65,8 +68,31 @@ function isImageMedia(media: InboundMedia): boolean { return IMAGE_EXTENSIONS.has(path.extname(candidate.split(/[?#]/, 1)[0] ?? "").toLowerCase()); } -function normalizeFileUrl(value: string): string { - return value.startsWith("file://") ? new URL(value).pathname : value; +function normalizeFileUrl(value: string): string | undefined { + if (!value.startsWith("file://")) { + return value; + } + try { + return fileURLToPath(value); + } catch { + return undefined; + } +} + +function readLocalMediaPath(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (value.startsWith("file://")) { + return value; + } + if (value.startsWith("//")) { + return undefined; + } + if (path.isAbsolute(value) || path.win32.isAbsolute(value)) { + return value; + } + return /^[a-z][a-z0-9+.-]*:/i.test(value) ? undefined : value; } function readStringArray(value: unknown): string[] { diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 8c3f607fd66..3ada2aefacc 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -2222,6 +2222,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "diagnostic.liveness.warning": recordLivenessWarning(evt); return; + case "diagnostic.phase.completed": + return; case "run.started": recordRunStarted(evt, metadata); return; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 8add267074c..8df832be851 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -13,7 +13,7 @@ "https-proxy-agent": "^9.0.0", "opusscript": "^0.1.1", "typebox": "1.1.37", - "undici": "8.1.0", + "undici": "8.2.0", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 4b450db733d..65db426e7d0 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { Agent as HttpsAgent } from "node:https"; import * as httpsProxyAgent from "https-proxy-agent"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types"; import { @@ -10,6 +11,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as ws from "ws"; import * as discordGateway from "../internal/gateway.js"; +import { createDiscordDnsLookup } from "../network-config.js"; import { validateDiscordProxyUrl } from "../proxy-fetch.js"; import { resolveDiscordVoiceEnabled } from "../voice/config.js"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; @@ -28,11 +30,15 @@ export { } from "./gateway-metadata.js"; const DISCORD_GATEWAY_HANDSHAKE_TIMEOUT_MS = 30_000; +const discordDnsLookup = createDiscordDnsLookup(); type DiscordGatewayWebSocketCtor = new ( url: string, options?: { agent?: unknown; handshakeTimeout?: number }, ) => ws.WebSocket; +type DiscordGatewayWebSocketAgent = + | InstanceType + | InstanceType>; const registrationPromises = new WeakMap>(); type DiscordGatewayClient = Parameters[0]; type GatewayPluginTestingOptions = { @@ -100,7 +106,7 @@ function createGatewayPlugin(params: { gatewayInfoTimeoutMs: number; fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; - wsAgent?: InstanceType>; + wsAgent?: DiscordGatewayWebSocketAgent; runtime?: RuntimeEnv; testing?: GatewayPluginTestingOptions; }): discordGateway.GatewayPlugin { @@ -263,7 +269,9 @@ export function createDiscordGatewayPlugin(params: { env: process.env, }); let fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled); - let wsAgent: InstanceType> | undefined; + let wsAgent: DiscordGatewayWebSocketAgent = new HttpsAgent({ + lookup: discordDnsLookup, + }); if (proxy) { try { diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 9bacc3a38e3..3bbf2f3c1f0 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -333,7 +333,7 @@ describe("runDiscordGatewayLifecycle", () => { expect(statusSink).toHaveBeenCalledTimes(callCountAfterCleanup); }); - it("restarts the gateway once when startup never reaches READY, then recovers", async () => { + it("reconnects with backoff when startup never reaches READY, then recovers", async () => { vi.useFakeTimers(); try { const { emitter, gateway } = createGatewayHarness(); @@ -347,10 +347,13 @@ describe("runDiscordGatewayLifecycle", () => { const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway }); const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); - await vi.advanceTimersByTimeAsync(16_500); + await vi.advanceTimersByTimeAsync(18_500); await expect(lifecyclePromise).resolves.toBeUndefined(); expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("gateway READY wait timed out after 15000ms"), + ); + expect(runtimeError).not.toHaveBeenCalledWith( expect.stringContaining("gateway was not ready after 15000ms; restarting gateway"), ); expect(gateway.disconnect).toHaveBeenCalledTimes(1); @@ -396,14 +399,14 @@ describe("runDiscordGatewayLifecycle", () => { expect(gateway.connect).toHaveBeenCalledTimes(1); expect(gateway.connect).toHaveBeenCalledWith(false); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(3_000); await expect(lifecyclePromise).resolves.toBeUndefined(); } finally { vi.useRealTimers(); } }); - it("fails when startup still is not ready after a restart", async () => { + it("keeps retrying when startup still is not ready after a reconnect", async () => { vi.useFakeTimers(); try { const { emitter, gateway } = createGatewayHarness(); @@ -414,19 +417,17 @@ describe("runDiscordGatewayLifecycle", () => { const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); lifecyclePromise.catch(() => {}); - await vi.advanceTimersByTimeAsync(31_000); + await vi.advanceTimersByTimeAsync(34_000); - await expect(lifecyclePromise).rejects.toThrow( - "discord gateway did not reach READY within 15000ms after restart", - ); - expect(gateway.disconnect).toHaveBeenCalledTimes(1); - expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.disconnect).toHaveBeenCalledTimes(2); + expect(gateway.connect).toHaveBeenCalledTimes(2); expect(gateway.connect).toHaveBeenCalledWith(false); - expectLifecycleCleanup({ - threadStop, - waitCalls: 0, - gatewaySupervisor, - }); + expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled(); + + gateway.isConnected = true; + await vi.advanceTimersByTimeAsync(2_500); + await expect(lifecyclePromise).resolves.toBeUndefined(); + expectLifecycleCleanup({ threadStop, waitCalls: 1, gatewaySupervisor }); } finally { vi.useRealTimers(); } diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index af7223c4da0..9bad4270702 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -25,6 +25,7 @@ const MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS = 120_000; const DISCORD_GATEWAY_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_READY_TIMEOUT_MS"; const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS"; const DISCORD_GATEWAY_READY_POLL_MS = 250; +const DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS = 2_000; const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000; const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000; const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000; @@ -355,41 +356,50 @@ async function waitForGatewayReady(params: { return "stopped"; }; - const firstAttempt = await waitUntilReady(); - if (firstAttempt !== "timeout") { - return; - } if (!params.gateway) { - throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`); - } - - const restartAt = Date.now(); - params.runtime.error?.( - danger(`discord: gateway was not ready after ${params.readyTimeoutMs}ms; restarting gateway`), - ); - params.pushStatus?.({ - connected: false, - lastEventAt: restartAt, - lastDisconnect: { - at: restartAt, - error: "startup-not-ready", - }, - lastError: "startup-not-ready", - }); - if (params.abortSignal?.aborted) { + const attempt = await waitUntilReady(); + if (attempt === "timeout") { + throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`); + } return; } - await params.beforeRestart?.(); - await restartGatewayAfterReadyTimeout({ - gateway: params.gateway, - abortSignal: params.abortSignal, - runtime: params.runtime, - }); - if ((await waitUntilReady()) === "timeout") { - throw new Error( - `discord gateway did not reach READY within ${params.readyTimeoutMs}ms after restart`, + let attempt = 0; + while (!params.abortSignal?.aborted) { + const result = await waitUntilReady(); + if (result !== "timeout") { + return; + } + + attempt += 1; + const restartAt = Date.now(); + params.runtime.error?.( + danger( + `discord: gateway READY wait timed out after ${params.readyTimeoutMs}ms; reconnecting with backoff (attempt ${attempt})`, + ), ); + params.pushStatus?.({ + connected: false, + lastEventAt: restartAt, + lastDisconnect: { + at: restartAt, + error: "startup-not-ready", + }, + lastError: "startup-not-ready", + }); + await params.beforeRestart?.(); + await restartGatewayAfterReadyTimeout({ + gateway: params.gateway, + abortSignal: params.abortSignal, + runtime: params.runtime, + }); + if (params.abortSignal?.aborted) { + return; + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS); + timeout.unref?.(); + }); } } diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 5f6750c7982..399181be082 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -33,14 +33,18 @@ const { captureWsEventSpy, GatewayPlugin, globalFetchMock, + HttpsAgent, HttpsProxyAgent, getLastAgent, + getLastProxyAgent, resolveDebugProxySettingsMock, resetLastAgent, webSocketSpy, + httpsAgentSpy, wsProxyAgentSpy, } = vi.hoisted(() => { const wsProxyAgentSpy = vi.fn(); + const httpsAgentSpy = vi.fn(); const globalFetchMock = vi.fn(); const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); @@ -78,6 +82,16 @@ const { } } + class HttpsAgent { + static lastCreated: HttpsAgent | undefined; + options: unknown; + constructor(options?: unknown) { + this.options = options; + HttpsAgent.lastCreated = this; + httpsAgentSpy(options); + } + } + class HttpsProxyAgent { static lastCreated: HttpsProxyAgent | undefined; proxyUrl: string; @@ -96,12 +110,16 @@ const { GatewayIntents, GatewayPlugin, globalFetchMock, + HttpsAgent, HttpsProxyAgent, - getLastAgent: () => HttpsProxyAgent.lastCreated, + getLastAgent: () => HttpsAgent.lastCreated, + getLastProxyAgent: () => HttpsProxyAgent.lastCreated, captureHttpExchangeSpy, captureWsEventSpy, + httpsAgentSpy, resolveDebugProxySettingsMock, resetLastAgent: () => { + HttpsAgent.lastCreated = undefined; HttpsProxyAgent.lastCreated = undefined; }, webSocketSpy, @@ -120,6 +138,10 @@ vi.mock("../internal/gateway.js", () => ({ GatewayPlugin, })); +vi.mock("node:https", () => ({ + Agent: HttpsAgent, +})); + vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); @@ -279,6 +301,7 @@ describe("createDiscordGatewayPlugin", () => { vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); + httpsAgentSpy.mockClear(); wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); captureHttpExchangeSpy.mockClear(); @@ -321,9 +344,16 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", { - handshakeTimeout: 30_000, - }); + expect(httpsAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ lookup: expect.any(Function) }), + ); + expect(webSocketSpy).toHaveBeenCalledWith( + "wss://gateway.discord.gg", + expect.objectContaining({ + agent: getLastAgent(), + handshakeTimeout: 30_000, + }), + ); expect(wsProxyAgentSpy).not.toHaveBeenCalled(); }); @@ -437,7 +467,7 @@ describe("createDiscordGatewayPlugin", () => { expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", - expect.objectContaining({ agent: getLastAgent(), handshakeTimeout: 30_000 }), + expect.objectContaining({ agent: getLastProxyAgent(), handshakeTimeout: 30_000 }), ); expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled"); expect(runtime.error).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index e12d031b578..ad3ee1748db 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -1,22 +1,34 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({ +const { undiciFetchMock, agentSpy, proxyAgentSpy } = vi.hoisted(() => ({ undiciFetchMock: vi.fn(), + agentSpy: vi.fn(), proxyAgentSpy: vi.fn(), })); vi.mock("undici", () => { + class Agent { + options: unknown; + constructor(options?: unknown) { + this.options = options; + agentSpy(options); + } + } class ProxyAgent { - proxyUrl: string; - constructor(proxyUrl: string) { - if (proxyUrl === "bad-proxy") { + options: unknown; + uri: string; + constructor(options: string | { uri: string; allowH2?: boolean }) { + const resolved = typeof options === "string" ? { uri: options } : options; + if (resolved.uri === "bad-proxy") { throw new Error("bad proxy"); } - this.proxyUrl = proxyUrl; - proxyAgentSpy(proxyUrl); + this.options = resolved; + this.uri = resolved.uri; + proxyAgentSpy(resolved); } } return { + Agent, ProxyAgent, fetch: undiciFetchMock, }; @@ -32,6 +44,7 @@ describe("resolveDiscordRestFetch", () => { beforeEach(() => { vi.unstubAllEnvs(); undiciFetchMock.mockReset(); + agentSpy.mockReset(); proxyAgentSpy.mockReset(); }); @@ -47,11 +60,19 @@ describe("resolveDiscordRestFetch", () => { await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://127.0.0.1:8080", + allowH2: false, + }), + ); expect(undiciFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/oauth2/applications/@me", expect.objectContaining({ - dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }), + dispatcher: expect.objectContaining({ + uri: "http://127.0.0.1:8080", + options: expect.objectContaining({ allowH2: false }), + }), }), ); expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); @@ -98,10 +119,46 @@ describe("resolveDiscordRestFetch", () => { await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://[::1]:8080", + allowH2: false, + }), + ); expect(runtime.error).not.toHaveBeenCalled(); }); + it("uses undici Agent with IPv4-first lookup when no discord proxy URL is configured", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as const; + undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 })); + + const fetcher = resolveDiscordRestFetch(undefined, runtime); + await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); + + expect(agentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + allowH2: false, + connect: expect.objectContaining({ lookup: expect.any(Function) }), + }), + ); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/oauth2/applications/@me", + expect.objectContaining({ + dispatcher: expect.objectContaining({ + options: expect.objectContaining({ + allowH2: false, + connect: expect.objectContaining({ lookup: expect.any(Function) }), + }), + }), + }), + ); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("uses debug proxy env when no discord proxy URL is configured", async () => { vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "1"); vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "http://127.0.0.1:7777"); @@ -115,7 +172,12 @@ describe("resolveDiscordRestFetch", () => { const fetcher = resolveDiscordRestFetch(undefined, runtime); await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:7777"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://127.0.0.1:7777", + allowH2: false, + }), + ); expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); }); }); diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index 13742286909..0cc52a4ffb2 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -92,7 +92,7 @@ vi.mock("./presence.js", () => ({ resolveDiscordPresenceUpdate: vi.fn(() => undefined), })); -import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; +import { DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; import { registerDiscordListener } from "./listeners.js"; import { createDiscordMonitorClient, @@ -104,7 +104,6 @@ describe("createDiscordMonitorClient", () => { beforeEach(() => { registerVoiceClientSpy.mockReset(); waitForDiscordGatewayPluginRegistrationMock.mockReset().mockReturnValue(undefined); - vi.mocked(createDiscordRequestClient).mockClear(); vi.mocked(registerDiscordListener).mockClear(); }); @@ -253,33 +252,40 @@ describe("createDiscordMonitorClient", () => { ); }); - it("passes REST timeout options to proxied Discord fetch", async () => { - const proxyFetch = vi.fn(); + it("passes REST timeout options and fetch to internal Discord REST", async () => { + const restFetch = vi.fn(); + const createClient = vi.fn(createClientWithPlugins); await createDiscordMonitorClient({ accountId: "default", applicationId: "app-1", token: "token-1", - proxyFetch, + restFetch, commands: [], components: [], modals: [], voiceEnabled: false, discordConfig: {}, runtime: createRuntime(), - createClient: createClientWithPlugins, + createClient, createGatewayPlugin: () => ({ id: "gateway" }) as never, createGatewaySupervisor: () => ({ shutdown: vi.fn(), handleError: vi.fn() }) as never, createAutoPresenceController: () => createAutoPresenceController() as never, isDisallowedIntentsError: () => false, }); - expect(createDiscordRequestClient).toHaveBeenCalledWith("token-1", { - fetch: proxyFetch, - timeout: DISCORD_REST_TIMEOUT_MS, - runtimeProfile: "persistent", - maxQueueSize: 1000, - }); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + requestOptions: { + timeout: DISCORD_REST_TIMEOUT_MS, + runtimeProfile: "persistent", + maxQueueSize: 1000, + fetch: restFetch, + }, + }), + expect.any(Object), + expect.any(Array), + ); }); it("propagates gateway registration failures before supervisor startup", async () => { diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 18dadd3e54c..7c584bed6ce 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -16,7 +16,7 @@ import { import type { GatewayPlugin } from "../internal/gateway.js"; import { VoicePlugin } from "../internal/voice.js"; import { parseApplicationIdFromToken } from "../probe.js"; -import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; +import { DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; import type { DiscordGuildEntryResolved } from "./allow-list.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; import type { DiscordDmPolicy } from "./dm-command-auth.js"; @@ -88,7 +88,7 @@ export async function createDiscordMonitorClient(params: { accountId: string; applicationId: string; token: string; - proxyFetch?: typeof fetch; + restFetch?: typeof fetch; commands: BaseCommand[]; components: BaseMessageInteractiveComponent[]; modals: Modal[]; @@ -147,6 +147,7 @@ export async function createDiscordMonitorClient(params: { timeout: DISCORD_REST_TIMEOUT_MS, runtimeProfile: "persistent", maxQueueSize: 1000, + ...(params.restFetch ? { fetch: params.restFetch } : {}), }, eventQueue: eventQueueOpts, }, @@ -161,14 +162,6 @@ export async function createDiscordMonitorClient(params: { if (voicePlugin) { registerLatePlugin(client, voicePlugin); } - if (params.proxyFetch) { - client.rest = createDiscordRequestClient(params.token, { - fetch: params.proxyFetch, - timeout: DISCORD_REST_TIMEOUT_MS, - runtimeProfile: "persistent", - maxQueueSize: 1000, - }); - } const gateway = client.getPlugin("gateway") as MutableDiscordGateway | undefined; await waitForDiscordGatewayPluginRegistration(gateway); const gatewaySupervisor = params.createGatewaySupervisor({ diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 862e126f18f..285def4c0e2 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -29,7 +29,6 @@ import { import { Client } from "../internal/discord.js"; import { GatewayCloseCodes } from "../internal/gateway.js"; import { fetchDiscordApplicationId, parseApplicationIdFromToken } from "../probe.js"; -import { resolveDiscordProxyFetchForAccount } from "../proxy-fetch.js"; import { normalizeDiscordToken } from "../token.js"; import { resolveDiscordVoiceEnabled } from "../voice/config.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; @@ -203,7 +202,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); - const discordProxyFetch = resolveDiscordProxyFetchForAccount(account, cfg, runtime); const dmConfig = rawDiscordCfg.dm; const configuredDmAllowFrom = resolveDiscordAccountAllowFrom({ cfg, @@ -429,7 +427,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, applicationId, token, - proxyFetch: discordProxyFetch, + restFetch: discordRestFetch, commands, components, modals, diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 1fb35a1c4cf..100d5c46b8a 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -105,6 +105,175 @@ describe("deliverDiscordReply", () => { ); }); + it("strips internal execution trace lines at the final Discord send boundary", async () => { + await deliverDiscordReply({ + replies: [ + { + text: [ + "📊 Session Status: current", + "🛠️ Exec: run git status", + "📖 Read: lines 1-40 from secret.md", + "Visible reply.", + ].join("\n"), + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text: "Visible reply." }], + }), + ); + }); + + it("drops pure internal trace text while preserving media-only delivery", async () => { + await deliverDiscordReply({ + replies: [ + { + text: "commentary: calling tool\nanalysis: inspect private state", + mediaUrl: "https://example.com/result.png", + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ mediaUrl: "https://example.com/result.png", text: undefined }], + }), + ); + }); + + it("preserves component-only channelData payloads when text scrubs empty", async () => { + const channelData = { + discord: { + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 1, + label: "Open", + custom_id: "open", + }, + ], + }, + ], + }, + }; + + await deliverDiscordReply({ + replies: [ + { + text: "analysis: internal only", + channelData, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ channelData, text: undefined }], + }), + ); + }); + + it("preserves presentation-only payloads when text scrubs empty", async () => { + const presentation = { + title: "Action required", + blocks: [ + { + type: "buttons" as const, + buttons: [{ label: "Approve", value: "approve", style: "primary" as const }], + }, + ], + }; + + await deliverDiscordReply({ + replies: [ + { + text: "commentary: hidden", + presentation, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ presentation, text: undefined }], + }), + ); + }); + + it("does not strip ordinary code-fenced examples of tool-call labels", async () => { + const text = ["Example:", "```", "🛠️ Exec: run ls", "```"].join("\n"); + + await deliverDiscordReply({ + replies: [{ text }], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text }], + }), + ); + }); + + it("does not strip ordinary visible labeled lines", async () => { + const text = [ + "Command: restart the gateway", + "Search: check recent Discord logs", + "Open: the channel status page", + "Find: the failing account", + ].join("\n"); + + await deliverDiscordReply({ + replies: [{ text }], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text }], + }), + ); + }); + it("passes resolved Discord formatting options as explicit delivery options", async () => { const baseCfg = { channels: { diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 6703e9e44f2..c1b34574c91 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -18,6 +18,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { RequestClient } from "../internal/discord.js"; import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js"; +import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js"; export type DiscordThreadBindingLookupRecord = { accountId: string; @@ -175,13 +176,17 @@ export async function deliverDiscordReply(params: { void params.runtime; const delivery = resolveDiscordDeliveryOptions(params); + const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies); + if (payloads.length === 0) { + return; + } await deliverOutboundPayloads({ cfg: params.cfg, channel: "discord", to: delivery.to, accountId: params.accountId, - payloads: params.replies, + payloads, replyToId: normalizeOptionalString(params.replyToId), replyToMode: delivery.replyToMode, formatting: delivery.formatting, diff --git a/extensions/discord/src/monitor/reply-safety.ts b/extensions/discord/src/monitor/reply-safety.ts new file mode 100644 index 00000000000..c4fbd389f8e --- /dev/null +++ b/extensions/discord/src/monitor/reply-safety.ts @@ -0,0 +1,89 @@ +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-runtime"; + +const DISCORD_INTERNAL_TRACE_LINE_RE = + /^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i; +const DISCORD_INTERNAL_CHANNEL_LINE_RE = + /^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i; + +function hasNonEmptyRecord(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +function hasInteractiveOrPresentationBlocks(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + if (typeof record.title === "string" && record.title.trim().length > 0) { + return true; + } + return Array.isArray(record.blocks) && record.blocks.length > 0; +} + +function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean { + return ( + payload.audioAsVoice === true || + hasNonEmptyRecord(payload.channelData) || + hasInteractiveOrPresentationBlocks(payload.interactive) || + hasInteractiveOrPresentationBlocks(payload.presentation) + ); +} + +function stripDiscordInternalTraceLines(text: string): string { + let inFence = false; + const kept: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + kept.push(line); + continue; + } + if (!inFence) { + const trimmed = line.trim(); + if ( + DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) || + DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed) + ) { + continue; + } + } + kept.push(line); + } + return kept.join("\n"); +} + +function collapseExcessBlankLines(text: string): string { + return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); +} + +export function sanitizeDiscordFrontChannelText(text: string): string { + const withoutAssistantScaffolding = sanitizeAssistantVisibleText(text); + const withoutTraceLines = stripDiscordInternalTraceLines(withoutAssistantScaffolding); + return collapseExcessBlankLines(withoutTraceLines).trim(); +} + +export function sanitizeDiscordFrontChannelReplyPayloads( + payloads: readonly ReplyPayload[], +): ReplyPayload[] { + const safePayloads: ReplyPayload[] = []; + for (const payload of payloads) { + const safeText = + typeof payload.text === "string" + ? sanitizeDiscordFrontChannelText(payload.text) + : payload.text; + const nextPayload = + safeText === payload.text + ? payload + : ({ ...payload, text: safeText || undefined } as ReplyPayload); + const nextParts = resolveSendableOutboundReplyParts(nextPayload); + if (!nextParts.hasContent && !hasNonTextReplyPayloadContent(nextPayload)) { + continue; + } + safePayloads.push(nextPayload); + } + return safePayloads; +} diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index 9e20fc821b8..1711ddcd47d 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -6,38 +6,61 @@ import { } from "openclaw/plugin-sdk/proxy-capture"; import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { ProxyAgent, fetch as undiciFetch } from "undici"; +import { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; +import { createDiscordDnsLookup } from "../network-config.js"; import { withValidatedDiscordProxy } from "../proxy-fetch.js"; +const discordDnsLookup = createDiscordDnsLookup(); + +type DiscordRestDispatcher = InstanceType | InstanceType; + +function createDiscordRestFetchWithDispatcher(dispatcher: DiscordRestDispatcher): typeof fetch { + return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) => + ( + undiciFetch(input as string | URL, { + ...(init as Record), + dispatcher, + }) as unknown as Promise + ).then((response) => { + captureHttpExchange({ + url: resolveRequestUrl(input), + method: init?.method ?? "GET", + requestHeaders: init?.headers as Headers | Record | undefined, + requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, + response, + flowId: randomUUID(), + meta: { subsystem: "discord-rest" }, + }); + return response; + })) as typeof fetch); +} + export function resolveDiscordRestFetch( proxyUrl: string | undefined, runtime: RuntimeEnv, ): typeof fetch { const effectiveProxyUrl = resolveEffectiveDebugProxyUrl(proxyUrl); - const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) => { - const agent = new ProxyAgent(proxy); - return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) => - ( - undiciFetch(input as string | URL, { - ...(init as Record), - dispatcher: agent, - }) as unknown as Promise - ).then((response) => { - captureHttpExchange({ - url: resolveRequestUrl(input), - method: init?.method ?? "GET", - requestHeaders: init?.headers as Headers | Record | undefined, - requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, - response, - flowId: randomUUID(), - meta: { subsystem: "discord-rest" }, - }); - return response; - })) as typeof fetch); - }); - if (!fetcher) { - return fetch; + if (effectiveProxyUrl) { + const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) => + createDiscordRestFetchWithDispatcher( + new ProxyAgent({ + uri: proxy, + allowH2: false, + }), + ), + ); + if (!fetcher) { + return fetch; + } + runtime.log?.("discord: rest proxy enabled"); + return fetcher; } - runtime.log?.("discord: rest proxy enabled"); + + const fetcher = createDiscordRestFetchWithDispatcher( + new Agent({ + allowH2: false, + connect: { lookup: discordDnsLookup }, + }), + ); return fetcher; } diff --git a/extensions/discord/src/network-config.test.ts b/extensions/discord/src/network-config.test.ts new file mode 100644 index 00000000000..5355935e521 --- /dev/null +++ b/extensions/discord/src/network-config.test.ts @@ -0,0 +1,92 @@ +import type * as dns from "node:dns"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const dnsMocks = vi.hoisted(() => ({ + lookup: vi.fn(), +})); + +vi.mock("node:dns", async () => { + const actual = await vi.importActual("node:dns"); + return { + ...actual, + lookup: dnsMocks.lookup, + }; +}); + +import { createDiscordDnsLookup } from "./network-config.js"; + +describe("createDiscordDnsLookup", () => { + afterEach(() => { + dnsMocks.lookup.mockReset(); + }); + + it("returns reordered address arrays when the caller requests all addresses", async () => { + dnsMocks.lookup.mockImplementation((_hostname: string, options: unknown, callback: unknown) => { + expect(options).toMatchObject({ all: true }); + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + [ + { address: "2606:4700::6810:1234", family: 6 }, + { address: "162.159.135.232", family: 4 }, + ], + ); + }); + + const lookup = createDiscordDnsLookup(); + const addresses = await new Promise((resolve, reject) => { + lookup("discord.com", { all: true }, (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result as dns.LookupAddress[]); + }); + }); + + expect(addresses).toEqual([ + { address: "162.159.135.232", family: 4 }, + { address: "2606:4700::6810:1234", family: 6 }, + ]); + }); + + it("returns the first reordered IPv4 address for scalar lookups", async () => { + dnsMocks.lookup.mockImplementation( + (_hostname: string, _options: unknown, callback: unknown) => { + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + [ + { address: "2606:4700::6810:1234", family: 6 }, + { address: "162.159.135.232", family: 4 }, + ], + ); + }, + ); + + const lookup = createDiscordDnsLookup(); + const result = await new Promise<{ address: string; family: number }>((resolve, reject) => { + lookup("gateway.discord.gg", {}, (err, address, family) => { + if (err) { + reject(err); + return; + } + if (typeof address !== "string" || typeof family !== "number") { + reject(new Error("Expected scalar lookup result")); + return; + } + resolve({ address, family }); + }); + }); + + expect(result).toEqual({ address: "162.159.135.232", family: 4 }); + }); + + it("delegates non-Discord hostnames unchanged", () => { + const callback = vi.fn(); + const options = { all: true }; + const lookup = createDiscordDnsLookup(); + + lookup("example.com", options, callback); + + expect(dnsMocks.lookup).toHaveBeenCalledWith("example.com", options, callback); + }); +}); diff --git a/extensions/discord/src/network-config.ts b/extensions/discord/src/network-config.ts new file mode 100644 index 00000000000..3f5c34faf37 --- /dev/null +++ b/extensions/discord/src/network-config.ts @@ -0,0 +1,79 @@ +import * as dns from "node:dns"; +import type { LookupFunction } from "node:net"; + +const DISCORD_DNS_HOSTS = ["discord.com", "discord.gg", "gateway.discord.gg"]; + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase(); +} + +function isDiscordTransportHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + if (!normalized) { + return false; + } + return DISCORD_DNS_HOSTS.some( + (target) => normalized === target || normalized.endsWith(`.${target}`), + ); +} + +function reorderLookupAddresses(addresses: dns.LookupAddress[]): dns.LookupAddress[] { + if (!Array.isArray(addresses) || addresses.length < 2) { + return addresses; + } + const ipv4 = addresses.filter((entry) => entry.family === 4); + const ipv6 = addresses.filter((entry) => entry.family === 6); + if (ipv4.length === 0) { + return ipv6; + } + if (ipv6.length === 0) { + return ipv4; + } + return [...ipv4, ...ipv6]; +} + +export function createDiscordDnsLookup(): LookupFunction { + return (hostname, options, callback) => { + if (!isDiscordTransportHostname(hostname)) { + return dns.lookup(hostname, options, callback); + } + + const lookupOptions: dns.LookupOptions = + typeof options === "number" + ? { family: options } + : options === undefined + ? {} + : ({ ...options } as dns.LookupOptions); + + if (lookupOptions.family === 4 || lookupOptions.family === 6) { + return dns.lookup(hostname, lookupOptions, callback as never); + } + + dns.lookup(hostname, { ...lookupOptions, all: true }, (err, addresses) => { + if (err) { + callback(err, "", 4); + return; + } + if (!Array.isArray(addresses)) { + callback(new Error("Expected all lookup addresses to be an array"), "", 4); + return; + } + + const reordered = reorderLookupAddresses(addresses); + if (lookupOptions.all === true) { + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + reordered, + ); + return; + } + + const first = reordered[0]; + if (!first) { + callback(new Error("No Discord DNS addresses resolved"), "", 4); + return; + } + callback(null, first.address, first.family); + }); + }; +} diff --git a/extensions/fireworks/package.json b/extensions/fireworks/package.json index 233e2738ff0..d31dc7ed2ea 100644 --- a/extensions/fireworks/package.json +++ b/extensions/fireworks/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Fireworks provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json index f5abdf96f46..08193f611a1 100644 --- a/extensions/github-copilot/package.json +++ b/extensions/github-copilot/package.json @@ -8,7 +8,7 @@ "@clack/prompts": "^1.3.0" }, "devDependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { diff --git a/extensions/google/package.json b/extensions/google/package.json index 2b8da51f2dc..39f90c9207f 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "@google/genai": "^1.51.0", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 05c3bef7153..ea2fac4063a 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -10,7 +10,7 @@ "dependencies": { "gaxios": "7.1.4", "google-auth-library": "10.6.2", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index 79ee8572373..d9d114107ec 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Kimi provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/lmstudio/package.json b/extensions/lmstudio/package.json index dae65f824a8..1570a48a2ee 100644 --- a/extensions/lmstudio/package.json +++ b/extensions/lmstudio/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw LM Studio provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "openclaw": { "extensions": [ diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 753c9218aeb..a3c8d02c949 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -57,6 +57,8 @@ describe("Mattermost model picker", () => { expect(view.text).toContain("Current: openai/gpt-5"); expect(view.text).toContain("Tap below to browse models"); expect(view.text).toContain("/oc_model to switch"); + expect(view.text).toContain("Browse keeps the current runtime"); + expect(view.text).toContain("/oc_model --runtime "); expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); }); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index d5f1a7c6d2b..20d2206f564 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -273,7 +273,7 @@ export function renderMattermostModelSummaryView(params: { "", "Tap below to browse models, or use:", "/oc_model to switch", - "/oc_model --runtime for runtime", + "Browse keeps the current runtime; use /oc_model --runtime to switch runtime too", "/oc_model status for details", ].join("\n"), buttons: [ diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 18776511d3c..91d16cdce98 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -10,7 +10,7 @@ "dependencies": { "@lancedb/lancedb": "^0.27.2", "apache-arrow": "18.1.0", - "openai": "^6.35.0", + "openai": "^6.36.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/memory-wiki/package.json b/extensions/memory-wiki/package.json index aaf48c0ec07..adf0eafeb72 100644 --- a/extensions/memory-wiki/package.json +++ b/extensions/memory-wiki/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "typebox": "1.1.37", - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/migrate-hermes/package.json b/extensions/migrate-hermes/package.json index 8545f784842..30e5e6b5f3a 100644 --- a/extensions/migrate-hermes/package.json +++ b/extensions/migrate-hermes/package.json @@ -5,7 +5,7 @@ "description": "Hermes to OpenClaw migration provider", "type": "module", "dependencies": { - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index de1cded5b29..f7982b43033 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 4af0a36a2c5..b21602a182d 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -9,7 +9,7 @@ "type": "module", "dependencies": { "nostr-tools": "^2.23.3", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index e316c09a8d6..029c19ce174 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Ollama provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 3cb2872f5f0..24b96524566 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -508,9 +508,9 @@ describe("buildOpenAIProvider", () => { }); expect(extraParams).toMatchObject({ - transport: "auto", - openaiWsWarmup: true, + transport: "sse", }); + expect(extraParams?.openaiWsWarmup).toBeUndefined(); expect(result.payload.store).toBe(true); expect(result.payload.context_management).toEqual([ { type: "compaction", compact_threshold: 140_000 }, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 969b04ed5dc..76503bb293d 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -227,7 +227,7 @@ export function buildOpenAIProvider(): ProviderPlugin { shouldUseOpenAIResponsesTransport({ provider, api, baseUrl }) ? { api: "openai-responses", baseUrl } : undefined, - ...buildOpenAIResponsesProviderHooks({ openaiWsWarmup: true }), + ...buildOpenAIResponsesProviderHooks({ transport: "sse" }), matchesContextOverflowError: ({ errorMessage }) => /content_filter.*(?:prompt|input).*(?:too long|exceed)/i.test(errorMessage), resolveReasoningOutputMode: () => "native", diff --git a/extensions/openai/openclaw.plugin.test.ts b/extensions/openai/openclaw.plugin.test.ts index 4580250244d..936d6d4c8fb 100644 --- a/extensions/openai/openclaw.plugin.test.ts +++ b/extensions/openai/openclaw.plugin.test.ts @@ -60,7 +60,7 @@ function providerWizardByKey() { describe("OpenAI plugin manifest", () => { it("keeps runtime dependencies in the package manifest", () => { - expect(packageJson.dependencies?.["@mariozechner/pi-ai"]).toBe("0.71.1"); + expect(packageJson.dependencies?.["@mariozechner/pi-ai"]).toBe("0.73.0"); expect(packageJson.dependencies?.ws).toBe("^8.20.0"); }); diff --git a/extensions/openai/package.json b/extensions/openai/package.json index d01eddd8ad0..efc27b9df50 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw OpenAI provider plugins", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index c2b43cbe9be..143737f3d9d 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -50,10 +50,11 @@ function hasSupportedOpenAIResponsesTransport( function defaultOpenAIResponsesExtraParams( extraParams: Record | undefined, - options?: { openaiWsWarmup?: boolean }, + options?: { openaiWsWarmup?: boolean; transport?: "auto" | "sse" | "websocket" }, ): Record | undefined { const hasSupportedTransport = hasSupportedOpenAIResponsesTransport(extraParams?.transport); const hasExplicitWarmup = typeof extraParams?.openaiWsWarmup === "boolean"; + const defaultTransport = options?.transport ?? "auto"; const shouldDefaultWarmup = options?.openaiWsWarmup === true; if (hasSupportedTransport && (!shouldDefaultWarmup || hasExplicitWarmup)) { return extraParams; @@ -61,7 +62,7 @@ function defaultOpenAIResponsesExtraParams( return { ...extraParams, - ...(hasSupportedTransport ? {} : { transport: "auto" }), + ...(hasSupportedTransport ? {} : { transport: defaultTransport }), ...(shouldDefaultWarmup && !hasExplicitWarmup ? { openaiWsWarmup: true } : {}), }; } @@ -93,6 +94,7 @@ const wrapOpenAIResponsesProviderStreamFn: NonNullable< export function buildOpenAIResponsesProviderHooks(options?: { openaiWsWarmup?: boolean; + transport?: "auto" | "sse" | "websocket"; }): OpenAIResponsesProviderHooks { return { buildReplayPolicy: buildOpenAIReplayPolicy, diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index 3201d1278b0..cb66a3694e3 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -97,8 +97,8 @@ describe("opencode-go provider plugin", () => { maxTokens: 65_536, }); expect(models.get("minimax-m2.7")).toMatchObject({ - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen/go", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, contextWindow: 204_800, maxTokens: 131_072, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 777abb8f55b..08925d7015f 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -73,7 +73,7 @@ describe("openrouter provider hooks", () => { it("advertises xhigh thinking for OpenRouter-routed DeepSeek V4 models", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); - const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh"]; expect( provider @@ -309,7 +309,7 @@ describe("openrouter provider hooks", () => { expect(capturedPayload).toMatchObject({ thinking: { type: "enabled" }, - reasoning_effort: "max", + reasoning_effort: "xhigh", messages: [ { role: "user", content: "read file" }, { @@ -324,6 +324,50 @@ describe("openrouter provider hooks", () => { expect(baseStreamFn).toHaveBeenCalledOnce(); }); + it("keeps OpenRouter DeepSeek V4 reasoning_effort within OpenRouter values", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const payloads: Array> = []; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + const payload = { messages: [] }; + void args[2]?.onPayload?.(payload, args[0]); + payloads.push(payload); + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + for (const thinkingLevel of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { + const wrapped = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "openrouter/deepseek/deepseek-v4-pro", + streamFn: baseStreamFn, + thinkingLevel, + } as never); + void wrapped?.( + { + provider: "openrouter", + api: "openai-completions", + id: "openrouter/deepseek/deepseek-v4-pro", + baseUrl: "https://openrouter.ai/api/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + } + + expect(payloads.map((payload) => payload.reasoning_effort)).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "xhigh", + ]); + }); + it("recognizes full OpenRouter DeepSeek V4 refs but skips custom proxy routes", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); const payloads: Array> = []; diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 9f2e733d3a8..83d68c8bbf7 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -3,6 +3,8 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-ent import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, + type DeepSeekV4ReasoningEffort, + type DeepSeekV4ThinkingLevel, createPayloadPatchStreamWrapper, stripTrailingAssistantPrefillMessages, } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -55,6 +57,27 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): ); } +function resolveOpenRouterDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { + switch (thinkingLevel) { + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return thinkingLevel; + case "max": + return "xhigh"; + case "adaptive": + return "medium"; + case "off": + case undefined: + return "high"; + } + return "high"; +} + function isEnabledReasoningValue(value: unknown): boolean { if (value === undefined || value === null || value === false) { return false; @@ -125,6 +148,7 @@ function createOpenRouterDeepSeekV4ThinkingWrapper( baseStreamFn, thinkingLevel, shouldPatchModel: shouldPatchDeepSeekV4OpenRouterPayload, + resolveReasoningEffort: resolveOpenRouterDeepSeekV4ReasoningEffort, }); } @@ -156,12 +180,3 @@ export function wrapOpenRouterProviderStream( createOpenRouterDeepSeekV4ThinkingWrapper(wrappedStreamFn, ctx.thinkingLevel), ); } - -export const __testing = { - isOpenRouterDeepSeekV4ModelId, - isOpenRouterAnthropicModelId, - isOpenRouterReasoningPayloadEnabled, - isVerifiedOpenRouterRoute, - shouldPatchDeepSeekV4OpenRouterPayload, - shouldPatchAnthropicOpenRouterPayload, -}; diff --git a/extensions/openrouter/thinking-policy.ts b/extensions/openrouter/thinking-policy.ts index 18695fa7428..6c16c77b726 100644 --- a/extensions/openrouter/thinking-policy.ts +++ b/extensions/openrouter/thinking-policy.ts @@ -8,7 +8,6 @@ const OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS = [ "medium", "high", "xhigh", - "max", ] as const; function buildOpenRouterDeepSeekV4ThinkingLevel( diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index eb3d35f4ea4..0e9a1133e36 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -5,11 +5,11 @@ "description": "OpenClaw QA lab plugin with private debugger UI and scenario runner", "type": "module", "dependencies": { - "@copilotkit/aimock": "1.16.4", + "@copilotkit/aimock": "1.17.0", "@modelcontextprotocol/sdk": "1.29.0", "playwright-core": "1.59.1", - "yaml": "^2.8.3", - "zod": "^4.4.1" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/discord": "workspace:*", diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 3261dde31f7..944bbb670b8 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -407,6 +407,44 @@ describe("buildQaRuntimeEnv", () => { }); }); + it("stages live env API-key profiles for isolated QA workers", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-live-api-key-state-")); + cleanups.push(async () => { + await rm(stateDir, { recursive: true, force: true }); + }); + + const cfg = await __testing.stageQaLiveApiKeyProfiles({ + cfg: {}, + stateDir, + providerIds: ["openai"], + env: { + OPENAI_API_KEY: "qa-live-not-a-real-key", + }, + }); + + expect(cfg.auth?.profiles?.["qa-live-openai-env"]).toMatchObject({ + provider: "openai", + mode: "api_key", + displayName: "QA live openai env credential", + }); + + for (const agentId of ["main", "qa"]) { + const storeRaw = await readFile( + path.join(stateDir, "agents", agentId, "agent", "auth-profiles.json"), + "utf8", + ); + expect(JSON.parse(storeRaw)).toMatchObject({ + profiles: { + "qa-live-openai-env": { + type: "api_key", + provider: "openai", + key: "qa-live-not-a-real-key", + }, + }, + }); + } + }); + it("stages placeholder mock auth profiles per agent dir so mock-openai runs can resolve credentials", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-mock-auth-")); cleanups.push(async () => { diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 62fdae18243..ba706677cb1 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -34,6 +34,7 @@ import { DEFAULT_QA_PROVIDER_MODE, getQaProvider } from "./providers/index.js"; import { QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV, QA_LIVE_SETUP_TOKEN_VALUE_ENV, + stageQaLiveApiKeyProfiles, stageQaLiveAnthropicSetupToken, } from "./providers/live-frontier/auth.js"; import { stageQaMockAuthProfiles } from "./providers/shared/mock-auth.js"; @@ -314,6 +315,7 @@ export const __testing = { redactQaGatewayDebugText, readQaLiveProviderConfigOverrides, resolveQaGatewayChildProviderMode, + stageQaLiveApiKeyProfiles, stageQaLiveAnthropicSetupToken, stageQaMockAuthProfiles, resolveQaLiveCliAuthEnv, @@ -573,6 +575,11 @@ export async function startQaGatewayChild(params: { }); const buildStagedGatewayConfig = async (gatewayPort: number) => { let cfg = buildGatewayConfig(gatewayPort); + cfg = await stageQaLiveApiKeyProfiles({ + cfg, + stateDir, + providerIds: liveProviderIds, + }); cfg = await stageQaLiveAnthropicSetupToken({ cfg, stateDir, diff --git a/extensions/qa-lab/src/providers/live-frontier/auth.ts b/extensions/qa-lab/src/providers/live-frontier/auth.ts index 797582b942f..bfd73bd37cf 100644 --- a/extensions/qa-lab/src/providers/live-frontier/auth.ts +++ b/extensions/qa-lab/src/providers/live-frontier/auth.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { applyAuthProfileConfig, + resolveEnvApiKey, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "../shared/auth-store.js"; @@ -9,6 +10,11 @@ export const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SET export const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ID = "anthropic:qa-setup-token"; +const QA_LIVE_API_KEY_AGENT_IDS = Object.freeze(["main", "qa"] as const); + +function buildQaLiveApiKeyProfileId(provider: string): string { + return `qa-live-${provider.replaceAll(/[^a-z0-9_-]/giu, "-")}-env`; +} function resolveQaLiveAnthropicSetupToken(env: NodeJS.ProcessEnv = process.env) { const token = ( @@ -55,3 +61,59 @@ export async function stageQaLiveAnthropicSetupToken(params: { displayName: "QA setup-token", }); } + +export async function stageQaLiveApiKeyProfiles(params: { + cfg: OpenClawConfig; + stateDir: string; + providerIds: readonly string[]; + env?: NodeJS.ProcessEnv; + agentIds?: readonly string[]; +}): Promise { + const env = params.env ?? process.env; + const providerIds = [...new Set(params.providerIds.map((providerId) => providerId.trim()))] + .filter((providerId) => providerId.length > 0) + .toSorted(); + const profiles: Record< + string, + { + type: "api_key"; + provider: string; + key: string; + displayName: string; + } + > = {}; + let next = params.cfg; + for (const providerId of providerIds) { + const resolved = resolveEnvApiKey(providerId, env, { config: next }); + if (!resolved?.apiKey) { + continue; + } + const profileId = buildQaLiveApiKeyProfileId(providerId); + const displayName = `QA live ${providerId} env credential`; + profiles[profileId] = { + type: "api_key", + provider: providerId, + key: resolved.apiKey, + displayName, + }; + next = applyAuthProfileConfig(next, { + profileId, + provider: providerId, + mode: "api_key", + displayName, + }); + } + if (Object.keys(profiles).length === 0) { + return next; + } + const agentIds = [...new Set(params.agentIds ?? QA_LIVE_API_KEY_AGENT_IDS)]; + await Promise.all( + agentIds.map((agentId) => + writeQaAuthProfiles({ + agentDir: resolveQaAgentAuthDir({ stateDir: params.stateDir, agentId }), + profiles, + }), + ), + ); + return next; +} diff --git a/extensions/qa-lab/src/providers/shared/auth-store.ts b/extensions/qa-lab/src/providers/shared/auth-store.ts index d18f7420031..29195750873 100644 --- a/extensions/qa-lab/src/providers/shared/auth-store.ts +++ b/extensions/qa-lab/src/providers/shared/auth-store.ts @@ -22,10 +22,15 @@ export async function writeQaAuthProfiles(params: { agentDir: string; profiles: Record; }): Promise { + const authPath = path.join(params.agentDir, "auth-profiles.json"); + const existing = await fs + .readFile(authPath, "utf8") + .then((raw) => JSON.parse(raw) as { profiles?: Record }) + .catch(() => ({ profiles: {} })); await fs.mkdir(params.agentDir, { recursive: true }); await fs.writeFile( - path.join(params.agentDir, "auth-profiles.json"), - `${JSON.stringify({ version: 1, profiles: params.profiles }, null, 2)}\n`, + authPath, + `${JSON.stringify({ version: 1, profiles: { ...existing.profiles, ...params.profiles } }, null, 2)}\n`, "utf8", ); } diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 8481275998c..a4c64f631f9 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -187,6 +187,7 @@ describe("qa scenario catalog", () => { pluginId?: string; pluginPersonality?: string; adversarialPersonality?: string; + expectedSurfaceIds?: Record; expectedAdversarialDiagnostics?: string[]; } | undefined; @@ -198,9 +199,22 @@ describe("qa scenario catalog", () => { expect(config?.pluginId).toBe("openclaw-kitchen-sink-fixture"); expect(config?.pluginPersonality).toBe("conformance"); expect(config?.adversarialPersonality).toBe("adversarial"); + expect(config?.expectedSurfaceIds?.webSearchProviderIds).toContain( + "kitchen-sink-web-search-provider", + ); + expect(config?.expectedSurfaceIds?.realtimeVoiceProviderIds).toContain( + "kitchen-sink-realtime-voice-provider", + ); expect(config?.expectedAdversarialDiagnostics).toContain( "only bundled plugins can register agent tool result middleware", ); + expect(config?.expectedAdversarialDiagnostics).toContain( + "control UI descriptor registration requires id, surface, label, and valid optional fields", + ); + expect( + config?.expectedAdversarialDiagnostics?.every((entry) => typeof entry === "string"), + ).toBe(true); + expect(JSON.stringify(scenario.execution.flow)).toContain("--runtime"); expect(scenario.execution.flow?.steps.map((step) => step.name)).toEqual([ "installs and inspects the Kitchen Sink plugin", "restarts gateway with Kitchen Sink configured", diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts index efb969ce383..74c727d1e73 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts @@ -51,6 +51,8 @@ import { import { createTempDirHarness } from "./temp-dir.test-helper.js"; const { cleanup, makeTempDir } = createTempDirHarness(); +const repoRoot = "/repo/openclaw"; +const gatewayTempRoot = "/tmp/openclaw-qa-runtime"; afterEach(cleanup); @@ -111,12 +113,14 @@ describe("qa suite runtime agent tools helpers", () => { callPluginToolsMcp({ env: { gateway: { + tempRoot: gatewayTempRoot, runtimeEnv: { PATH: "/usr/bin", OPENCLAW_KEY: "1", EMPTY: undefined, }, }, + repoRoot, } as never, toolName: "plugin.echo", args: { text: "hello" }, @@ -127,8 +131,13 @@ describe("qa suite runtime agent tools helpers", () => { expect(stdioTransportMock).toHaveBeenCalledWith({ command: "/usr/bin/node", - args: ["--import", "tsx", "src/mcp/plugin-tools-serve.ts"], + args: [ + "--import", + expect.stringContaining(path.join("node_modules", "tsx")), + path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts"), + ], stderr: "pipe", + cwd: gatewayTempRoot, env: { PATH: "/usr/bin", OPENCLAW_KEY: "1", @@ -140,4 +149,31 @@ describe("qa suite runtime agent tools helpers", () => { }); expect(closeMock).toHaveBeenCalled(); }); + + it("reports available plugin-tools MCP names when the requested tool is missing", async () => { + listToolsMock.mockResolvedValueOnce({ + tools: [{ name: "plugin.beta" }, { name: "plugin.alpha" }] as never[], + }); + + await expect( + callPluginToolsMcp({ + env: { + gateway: { + tempRoot: gatewayTempRoot, + runtimeEnv: { + PATH: "/usr/bin", + }, + }, + repoRoot, + } as never, + toolName: "plugin.missing", + args: {}, + }), + ).rejects.toThrow( + "MCP tool missing: plugin.missing; available tools: plugin.alpha, plugin.beta", + ); + + expect(callToolMock).not.toHaveBeenCalled(); + expect(closeMock).toHaveBeenCalled(); + }); }); diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.ts index c57fdf1a193..977bb26c243 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -11,6 +12,8 @@ import type { QaTransportActionName, } from "./suite-runtime-types.js"; +const requireFromHere = createRequire(import.meta.url); + function findSkill(skills: QaSkillStatusEntry[], name: string) { return skills.find((skill) => skill.name === name); } @@ -28,7 +31,7 @@ async function writeWorkspaceSkill(params: { } async function callPluginToolsMcp(params: { - env: Pick; + env: Pick; toolName: string; args: Record; }) { @@ -40,8 +43,13 @@ async function callPluginToolsMcp(params: { const nodeExecPath = await resolveQaNodeExecPath(); const transport = new StdioClientTransport({ command: nodeExecPath, - args: ["--import", "tsx", "src/mcp/plugin-tools-serve.ts"], + args: [ + "--import", + requireFromHere.resolve("tsx"), + path.join(params.env.repoRoot, "src/mcp/plugin-tools-serve.ts"), + ], stderr: "pipe", + cwd: params.env.gateway.tempRoot, env: transportEnv, }); const client = new Client({ name: "openclaw-qa-suite", version: "0.0.0" }, {}); @@ -50,7 +58,13 @@ async function callPluginToolsMcp(params: { const listed = await client.listTools(); const tool = listed.tools.find((entry) => entry.name === params.toolName); if (!tool) { - throw new Error(`MCP tool missing: ${params.toolName}`); + const availableTools = listed.tools + .map((entry) => entry.name) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .toSorted(); + throw new Error( + `MCP tool missing: ${params.toolName}; available tools: ${availableTools.join(", ") || ""}`, + ); } return await client.callTool({ name: params.toolName, diff --git a/extensions/qa-matrix/package.json b/extensions/qa-matrix/package.json index 2626a063e08..ffe54943230 100644 --- a/extensions/qa-matrix/package.json +++ b/extensions/qa-matrix/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Matrix QA runner plugin", "type": "module", "dependencies": { - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/matrix": "workspace:*", diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index f5e9f770eaa..fb645b248f2 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -13,7 +13,7 @@ "mpg123-decoder": "^1.0.3", "silk-wasm": "^3.7.1", "ws": "^8.20.0", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts new file mode 100644 index 00000000000..f362d4c9fd8 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts @@ -0,0 +1,55 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it } from "vitest"; +import { buildFrameworkSlashContext } from "./framework-context-adapter.js"; + +function createCommandContext(isAuthorizedSender: boolean): PluginCommandContext { + return { + senderId: "SENDER_OPENID", + channel: "qqbot", + isAuthorizedSender, + args: "on", + commandBody: "/bot-streaming on", + config: {} as OpenClawConfig, + from: "qqbot:c2c:SENDER_OPENID", + requestConversationBinding: async () => undefined, + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + } as unknown as PluginCommandContext; +} + +describe("buildFrameworkSlashContext", () => { + it("preserves the framework authorization decision in the slash context", () => { + const authorized = buildFrameworkSlashContext({ + ctx: createCommandContext(true), + account: { + accountId: "default", + enabled: true, + appId: "app", + clientSecret: "secret", + secretSource: "config", + markdownSupport: true, + config: {}, + }, + from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, + commandName: "bot-streaming", + }); + const unauthorized = buildFrameworkSlashContext({ + ctx: createCommandContext(false), + account: { + accountId: "default", + enabled: true, + appId: "app", + clientSecret: "secret", + secretSource: "config", + markdownSupport: true, + config: {}, + }, + from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, + commandName: "bot-streaming", + }); + + expect(authorized.commandAuthorized).toBe(true); + expect(unauthorized.commandAuthorized).toBe(false); + }); +}); diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts index 437c72cc3f4..fb6e7ccad0e 100644 --- a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts @@ -54,7 +54,7 @@ export function buildFrameworkSlashContext({ accountId: account.accountId, appId: account.appId, accountConfig: account.config as unknown as Record, - commandAuthorized: true, + commandAuthorized: ctx.isAuthorizedSender, queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT }, }; } diff --git a/extensions/slack/package.json b/extensions/slack/package.json index c0554804967..926e5cd142f 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -6,8 +6,8 @@ "type": "module", "dependencies": { "@slack/bolt": "^4.7.2", - "@slack/types": "^2.20.1", - "@slack/web-api": "^7.15.1", + "@slack/types": "^2.21.0", + "@slack/web-api": "^7.15.2", "https-proxy-agent": "^9.0.0" }, "devDependencies": { diff --git a/extensions/slack/src/monitor/provider.reconnect.test.ts b/extensions/slack/src/monitor/provider.reconnect.test.ts index 3cdaaefc65c..2ca36bb952e 100644 --- a/extensions/slack/src/monitor/provider.reconnect.test.ts +++ b/extensions/slack/src/monitor/provider.reconnect.test.ts @@ -5,8 +5,11 @@ import { publishSlackDisconnectedStatus, startSlackSocketAndWaitForDisconnect, } from "./provider-support.js"; -import { formatSlackSocketReconnectMessage } from "./provider.js"; -import { waitForSlackSocketDisconnect } from "./reconnect-policy.js"; +import { + formatSlackSocketReconnectMessage, + formatSlackSocketStartRetryMessage, +} from "./provider.js"; +import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-policy.js"; class FakeEmitter { private listeners = new Map void>>(); @@ -97,6 +100,28 @@ describe("slack socket reconnect helpers", () => { ).toBe("slack socket disconnected (disconnect); reconnecting in 2s (attempt 1/12)"); }); + it("formats missing and unserializable socket errors without leaking undefined", () => { + const circular: Record = {}; + circular.self = circular; + + expect(formatUnknownError(undefined)).toBe("unknown error"); + expect(formatUnknownError(null)).toBe("unknown error"); + expect(formatUnknownError("")).toBe("unknown error"); + expect(formatUnknownError(new Error(""))).toBe("Error"); + expect(formatUnknownError(circular)).toBe("unknown error"); + }); + + it("formats socket start retries with an explicit reason field", () => { + expect( + formatSlackSocketStartRetryMessage({ + attempt: 1, + maxAttempts: 12, + delayMs: 2_340, + error: undefined, + }), + ).toBe('slack socket mode failed to start; retry 1/12 in 2s reason="unknown error"'); + }); + it("resolves disconnect waiter on socket disconnect event", async () => { const client = new FakeEmitter(); const app = { receiver: { client } }; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 46fb13459a5..986368d367f 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -97,6 +97,16 @@ export function formatSlackSocketReconnectMessage(params: { return `slack socket disconnected (${params.event}); reconnecting in ${Math.round(params.delayMs / 1000)}s (attempt ${params.attempt}/${maxAttempts})${suffix}`; } +export function formatSlackSocketStartRetryMessage(params: { + attempt: number; + maxAttempts: number; + delayMs: number; + error: unknown; +}) { + const maxAttempts = params.maxAttempts > 0 ? String(params.maxAttempts) : "∞"; + return `slack socket mode failed to start; retry ${params.attempt}/${maxAttempts} in ${Math.round(params.delayMs / 1000)}s reason="${formatUnknownError(params.error)}"`; +} + function parseApiAppIdFromAppToken(raw?: string) { const token = raw?.trim(); if (!token) { @@ -534,7 +544,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } const delayMs = computeBackoff(SLACK_SOCKET_RECONNECT_POLICY, reconnectAttempts); runtime.error?.( - `slack socket mode failed to start. retry ${reconnectAttempts}/${SLACK_SOCKET_RECONNECT_POLICY.maxAttempts || "∞"} in ${Math.round(delayMs / 1000)}s (${formatUnknownError(err)})`, + formatSlackSocketStartRetryMessage({ + attempt: reconnectAttempts, + maxAttempts: SLACK_SOCKET_RECONNECT_POLICY.maxAttempts, + delayMs, + error: err, + }), ); try { await sleepWithAbort(delayMs, opts.abortSignal); diff --git a/extensions/slack/src/monitor/reconnect-policy.ts b/extensions/slack/src/monitor/reconnect-policy.ts index 2c1d7bde9d9..319fd561b47 100644 --- a/extensions/slack/src/monitor/reconnect-policy.ts +++ b/extensions/slack/src/monitor/reconnect-policy.ts @@ -94,14 +94,17 @@ export function isNonRecoverableSlackAuthError(error: unknown): boolean { } export function formatUnknownError(error: unknown): string { + if (error === undefined || error === null) { + return "unknown error"; + } if (error instanceof Error) { - return error.message; + return error.message || error.name || "unknown error"; } if (typeof error === "string") { - return error; + return error || "unknown error"; } try { - return JSON.stringify(error); + return JSON.stringify(error) ?? "unknown error"; } catch { return "unknown error"; } diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index d6e3dab5db2..82894b63927 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index b5c6b5e1e71..b70a5dd6fa7 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -9,7 +9,7 @@ "@grammyjs/transformer-throttler": "^1.2.1", "grammy": "^1.42.0", "typebox": "1.1.37", - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 00be09bef1b..84d7a61c45d 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1764,8 +1764,8 @@ export const registerTelegramHandlers = ({ ? "reset to default" : `changed to ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}`; const scopeText = isDefaultSelection - ? "Session selection cleared. New replies use the agent's configured default." - : "Session-only selection. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default."; + ? "Session selection cleared. Runtime unchanged. New replies use the agent's configured default." + : `Session-only model selection. Runtime unchanged. Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime <runtime> to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`; await editMessageWithButtons( `✅ Model ${actionText}\n\n${scopeText}`, [], // Empty buttons = remove inline keyboard diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts new file mode 100644 index 00000000000..deed88a9901 --- /dev/null +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -0,0 +1,112 @@ +import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { defaultRouteConfig } = vi.hoisted(() => ({ + defaultRouteConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + }, +})); + +vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", async () => { + const actual = await vi.importActual< + typeof import("openclaw/plugin-sdk/runtime-config-snapshot") + >("openclaw/plugin-sdk/runtime-config-snapshot"); + return { + ...actual, + getRuntimeConfig: vi.fn(() => defaultRouteConfig), + }; +}); + +const { buildTelegramMessageContextForTest } = + await import("./bot-message-context.test-harness.js"); + +describe("buildTelegramMessageContext requireMention precedence", () => { + function buildForumMessage(threadId = 99) { + return { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup" as const, + title: "Forum", + is_forum: true, + }, + date: 1_700_000_000, + text: "hello everyone", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }; + } + + beforeEach(() => { + vi.mocked(getRuntimeConfig).mockReturnValue(defaultRouteConfig as never); + }); + + it("lets explicit topic requireMention=false override group requireMention=true", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { requireMention: false }, + }), + }); + + expect(ctx).not.toBeNull(); + }); + + it("lets explicit topic requireMention=false override mention activation", async () => { + const resolveGroupActivation = vi.fn(() => true); + + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { requireMention: false }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(resolveGroupActivation).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: -1001234567890, + messageThreadId: 99, + sessionKey: "agent:main:telegram:group:-1001234567890:topic:99", + }), + ); + }); + + it("lets explicit topic requireMention=true override always activation", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { requireMention: true }, + }), + }); + + expect(ctx).toBeNull(); + }); + + it("keeps activation fallback when no topic requireMention is configured", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { agentId: "main" }, + }), + }); + + expect(ctx).not.toBeNull(); + }); +}); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index d7c44e39587..e969c668161 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -411,8 +411,8 @@ export const buildTelegramMessageContext = async ({ }); const baseRequireMention = resolveGroupRequireMention(chatId); const requireMention = firstDefined( - activationOverride, topicConfig?.requireMention, + activationOverride, telegramGroupConfig?.requireMention, baseRequireMention, ); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 2c8546e5088..1d6c747f93f 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3768,7 +3768,7 @@ describe("createTelegramBot", () => { expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(String(editMessageTextSpy.mock.calls.at(-1)?.[2] ?? "")).toContain( - "Session-only selection. The agent default in openclaw.json is unchanged", + "Session-only model selection. Runtime unchanged.", ); expect( editMessageTextSpy.mock.calls.some((call) => diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 63ed0cd1edc..6666167b17d 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1062,7 +1062,7 @@ describe("createTelegramBot", () => { `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain( - "Session selection cleared. New replies use the agent's configured default.", + "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; @@ -1209,7 +1209,7 @@ describe("createTelegramBot", () => { `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain( - "Session selection cleared. New replies use the agent's configured default.", + "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; @@ -1281,7 +1281,7 @@ describe("createTelegramBot", () => { expect(editMessageTextSpy).toHaveBeenCalledWith( 1234, 17, - `${CHECK_MARK_EMOJI} Model changed to openai/gpt-5.4\n\nSession-only selection. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`, + `${CHECK_MARK_EMOJI} Model changed to openai/gpt-5.4\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime <runtime> to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`, expect.objectContaining({ parse_mode: "HTML" }), ); diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c5277de87f6..6ce6feec34b 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -8,8 +8,8 @@ }, "type": "module", "dependencies": { - "@aws-sdk/client-s3": "3.1041.0", - "@aws-sdk/s3-request-presigner": "3.1041.0", + "@aws-sdk/client-s3": "3.1042.0", + "@aws-sdk/s3-request-presigner": "3.1042.0", "@tloncorp/tlon-skill": "0.3.5", "@urbit/aura": "^3.0.0" }, diff --git a/extensions/webhooks/package.json b/extensions/webhooks/package.json index c6e23fbdbff..f22a6ba6ffd 100644 --- a/extensions/webhooks/package.json +++ b/extensions/webhooks/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw webhook bridge plugin", "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 9ced33efe05..1e0eec5a716 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -12,7 +12,7 @@ "https-proxy-agent": "^9.0.0", "jimp": "^1.6.1", "typebox": "1.1.37", - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index 593bd62e02f..f7902601fc6 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -142,7 +142,9 @@ describe("loginWeb coverage", () => { restartOpts?.onQr?.("restart-qr"); await flushTasks(); - expect(runtime.log).toHaveBeenCalledWith("Scan this QR in WhatsApp (Linked Devices):"); + expect(runtime.log).toHaveBeenCalledWith( + "Open the WhatsApp app, go to Linked Devices, then scan this QR:", + ); expect(runtime.log).toHaveBeenCalledWith("terminal:initial-qr"); expect(runtime.log).toHaveBeenCalledWith("terminal:restart-qr"); expect(renderQrTerminalMock).toHaveBeenCalledWith("initial-qr", { small: true }); diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 014ec77d263..0019b10e746 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -21,7 +21,7 @@ export async function loginWeb( const socketTiming = resolveWhatsAppSocketTiming(cfg); const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); const onQr = (qr: string) => { - runtime.log("Scan this QR in WhatsApp (Linked Devices):"); + runtime.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:"); void renderQrTerminal(qr, { small: true }) .then((output) => { runtime.log(output.endsWith("\n") ? output.slice(0, -1) : output); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index e7a569ba0c3..6eb31a6a994 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -192,7 +192,7 @@ export async function createWaSocket( if (qr) { opts.onQr?.(qr); if (printQr) { - console.log("Scan this QR in WhatsApp (Linked Devices):"); + console.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:"); void printTerminalQr(qr).catch((err) => { sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR"); }); diff --git a/extensions/xai/package.json b/extensions/xai/package.json index 9010e145bd4..91870caa2cc 100644 --- a/extensions/xai/package.json +++ b/extensions/xai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw xAI plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 81f12da0dd3..b28ad2d1ca1 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/openclaw.mjs b/openclaw.mjs index aae262e5b57..7a35f199b2a 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; @@ -84,6 +84,102 @@ const resolvePackagedCompileCacheDirectory = () => { ); }; +const respawnSignals = + process.platform === "win32" + ? ["SIGTERM", "SIGINT", "SIGBREAK"] + : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; +const respawnSignalExitGraceMs = 1_000; +const respawnSignalForceKillGraceMs = 1_000; + +const runRespawnedChild = (command, args, env) => { + const child = spawn(command, args, { + stdio: "inherit", + env, + }); + const listeners = new Map(); + // This intentionally overlaps with src/entry.compile-cache.ts; keep the + // respawn supervision behavior in sync until the launcher can share TS code. + // Give the child a moment to honor forwarded signals, then exit the wrapper so + // a child that ignores SIGTERM cannot keep the launcher alive indefinitely. + let signalExitTimer = null; + let signalForceKillTimer = null; + const detach = () => { + for (const [signal, listener] of listeners) { + process.off(signal, listener); + } + listeners.clear(); + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = null; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = null; + } + }; + const forceKillChild = () => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = () => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + process.exit(1); + }, respawnSignalForceKillGraceMs); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = () => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, respawnSignalExitGraceMs); + signalExitTimer.unref?.(); + }; + for (const signal of respawnSignals) { + const listener = () => { + try { + child.kill(signal); + } catch { + // Best-effort signal forwarding. + } + scheduleParentExit(); + }; + try { + process.on(signal, listener); + listeners.set(signal, listener); + } catch { + // Unsupported signal on this platform. + } + } + child.once("exit", (code, signal) => { + detach(); + if (signal) { + process.exit(1); + } + process.exit(code ?? 1); + }); + child.once("error", (error) => { + detach(); + process.stderr.write( + `[openclaw] Failed to respawn launcher: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + process.exit(1); + }); + return true; +}; + const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { return false; @@ -100,22 +196,13 @@ const respawnWithoutCompileCacheIfNeeded = () => { OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", }; delete env.NODE_COMPILE_CACHE; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithoutCompileCacheIfNeeded(); - const respawnWithPackagedCompileCacheIfNeeded = () => { if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) { return false; @@ -136,24 +223,23 @@ const respawnWithPackagedCompileCacheIfNeeded = () => { NODE_COMPILE_CACHE: desiredDirectory, OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1", }; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithPackagedCompileCacheIfNeeded(); +const waitingForCompileCacheRespawn = + respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded(); // https://nodejs.org/api/module.html#module-compile-cache -if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) { +if ( + !waitingForCompileCacheRespawn && + module.enableCompileCache && + !isNodeCompileCacheDisabled() && + !isSourceCheckoutLauncher() +) { try { module.enableCompileCache(resolvePackagedCompileCacheDirectory()); } catch { @@ -297,17 +383,19 @@ const tryOutputBrowserHelp = () => { return true; }; -if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { - // OK -} else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { - // OK -} else { - await installProcessWarningFilter(); - if (await tryImport("./dist/entry.js")) { +if (!waitingForCompileCacheRespawn) { + if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { // OK - } else if (await tryImport("./dist/entry.mjs")) { + } else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { // OK } else { - throw new Error(await buildMissingEntryErrorMessage()); + await installProcessWarningFilter(); + if (await tryImport("./dist/entry.js")) { + // OK + } else if (await tryImport("./dist/entry.mjs")) { + // OK + } else { + throw new Error(await buildMissingEntryErrorMessage()); + } } } diff --git a/package.json b/package.json index 5551e9b955b..2b6916311e1 100644 --- a/package.json +++ b/package.json @@ -1562,7 +1562,7 @@ "test:docker:plugin-lifecycle-matrix": "bash scripts/e2e/plugin-lifecycle-matrix-docker.sh", "test:docker:plugin-update": "bash scripts/e2e/plugin-update-unchanged-docker.sh", "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", - "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} bash scripts/e2e/upgrade-survivor-docker.sh", + "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} bash scripts/e2e/upgrade-survivor-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:rerun": "node scripts/docker-e2e-rerun.mjs", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", @@ -1614,6 +1614,7 @@ "test:perf:profile:main": "node scripts/run-vitest-profile.mjs main", "test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner", "test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs", + "test:plugins:kitchen-sink-live": "bash -lc 'if [ -x \"$HOME/.local/bin/openclaw-testbox-env\" ]; then exec \"$HOME/.local/bin/openclaw-testbox-env\" pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai; fi; exec pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai'", "test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs", "test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts", @@ -1662,27 +1663,27 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.21.0", - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/vertex-sdk": "^0.16.0", - "@aws-sdk/client-bedrock": "3.1041.0", - "@aws-sdk/client-bedrock-runtime": "3.1041.0", + "@aws-sdk/client-bedrock": "3.1042.0", + "@aws-sdk/client-bedrock-runtime": "3.1042.0", "@aws-sdk/credential-provider-node": "3.972.39", "@aws/bedrock-token-generator": "^1.1.0", "@clack/prompts": "^1.3.0", "@google/genai": "^1.51.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", - "@homebridge/ciao": "^1.3.7", + "@homebridge/ciao": "^1.3.8", "@lydell/node-pty": "1.2.0-beta.12", - "@mariozechner/pi-agent-core": "0.71.1", - "@mariozechner/pi-ai": "0.71.1", - "@mariozechner/pi-coding-agent": "0.71.1", - "@mariozechner/pi-tui": "0.71.1", + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-coding-agent": "0.73.0", + "@mariozechner/pi-tui": "0.73.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@slack/bolt": "^4.7.2", - "@slack/types": "^2.20.1", - "@slack/web-api": "^7.15.1", + "@slack/types": "^2.21.0", + "@slack/web-api": "^7.15.2", "ajv": "^8.20.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -1694,7 +1695,7 @@ "global-agent": "^4.1.3", "grammy": "^1.42.0", "https-proxy-agent": "^9.0.0", - "ipaddr.js": "^2.3.0", + "ipaddr.js": "^2.4.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", @@ -1702,7 +1703,7 @@ "markdown-it": "14.1.1", "minimatch": "10.2.5", "node-edge-tts": "^1.2.10", - "openai": "^6.35.0", + "openai": "^6.36.0", "openshell": "0.1.0", "pdfjs-dist": "^5.7.284", "playwright-core": "1.59.1", @@ -1713,15 +1714,15 @@ "tree-sitter-bash": "^0.25.1", "tslog": "^4.10.2", "typebox": "1.1.37", - "undici": "8.1.0", + "undici": "8.2.0", "web-push": "^3.6.7", "web-tree-sitter": "^0.26.8", "ws": "^8.20.0", - "yaml": "^2.8.3", - "zod": "^4.4.1" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { - "@copilotkit/aimock": "1.16.4", + "@copilotkit/aimock": "1.17.0", "@grammyjs/types": "^3.26.0", "@lit-labs/signals": "^0.2.0", "@lit/context": "^1.1.6", @@ -1730,7 +1731,7 @@ "@types/markdown-it": "^14.1.2", "@types/node": "25.6.0", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260501.1", + "@typescript/native-preview": "7.0.0-dev.20260504.1", "@vitest/coverage-v8": "^4.1.5", "jscpd": "4.0.9", "jsdom": "^29.1.1", @@ -1760,7 +1761,7 @@ "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", "pnpm": { "overrides": { - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "hono": "4.12.14", "@hono/node-server": "1.19.14", "@aws-sdk/client-bedrock-runtime": "3.1024.0", @@ -1817,7 +1818,7 @@ }, "patchedDependencies": { "@whiskeysockets/baileys@7.0.0-rc.9": "patches/@whiskeysockets__baileys@7.0.0-rc.9.patch", - "@agentclientprotocol/claude-agent-acp@0.31.4": "patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch" + "@agentclientprotocol/claude-agent-acp@0.32.0": "patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch" } } } diff --git a/patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch b/patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch similarity index 82% rename from patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch rename to patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch index a765f36823f..3e228a51a5c 100644 --- a/patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch +++ b/patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch @@ -1,8 +1,8 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js -index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b5acb79ca 100644 +index e1d9aa9f0815f57ea2fd299a7f2b8ef0917ca191..875fdfb25fbfa905ca80728355d25a17e6d89148 100644 --- a/dist/acp-agent.js +++ b/dist/acp-agent.js -@@ -421,6 +421,7 @@ export class ClaudeAcpAgent { +@@ -436,6 +436,7 @@ export class ClaudeAcpAgent { session.promptRunning = true; let handedOff = false; let stopReason = "end_turn"; @@ -10,7 +10,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b try { while (true) { const { value: message, done } = await session.query.next(); -@@ -428,6 +429,9 @@ export class ClaudeAcpAgent { +@@ -443,6 +444,9 @@ export class ClaudeAcpAgent { if (session.cancelled) { return { stopReason: "cancelled" }; } @@ -20,7 +20,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b break; } if (session.emitRawSDKMessages && -@@ -496,7 +500,7 @@ export class ClaudeAcpAgent { +@@ -499,7 +503,7 @@ export class ClaudeAcpAgent { break; } case "session_state_changed": { @@ -29,7 +29,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b return { stopReason, usage: sessionUsage(session) }; } break; -@@ -601,6 +605,7 @@ export class ClaudeAcpAgent { +@@ -621,6 +625,7 @@ export class ClaudeAcpAgent { unreachable(message, this.logger); break; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b40ba19b7e..dc35dae2bd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@anthropic-ai/sdk': 0.92.0 + '@anthropic-ai/sdk': 0.93.0 hono: 4.12.14 '@hono/node-server': 1.19.14 '@aws-sdk/client-bedrock-runtime': 3.1024.0 @@ -32,9 +32,9 @@ overrides: packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8= patchedDependencies: - '@agentclientprotocol/claude-agent-acp@0.31.4': - hash: e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615 - path: patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch + '@agentclientprotocol/claude-agent-acp@0.32.0': + hash: 1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98 + path: patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch '@whiskeysockets/baileys@7.0.0-rc.9': hash: 23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201 path: patches/@whiskeysockets__baileys@7.0.0-rc.9.patch @@ -45,16 +45,16 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: 0.21.0 - version: 0.21.0(zod@4.4.1) + version: 0.21.0(zod@4.4.3) '@anthropic-ai/sdk': - specifier: 0.92.0 - version: 0.92.0(zod@4.4.1) + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) '@anthropic-ai/vertex-sdk': specifier: ^0.16.0 - version: 0.16.0(zod@4.4.1) + version: 0.16.0(zod@4.4.3) '@aws-sdk/client-bedrock': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/client-bedrock-runtime': specifier: 3.1024.0 version: 3.1024.0 @@ -69,7 +69,7 @@ importers: version: 1.3.0 '@google/genai': specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.42.0) @@ -77,26 +77,26 @@ importers: specifier: ^1.2.1 version: 1.2.1(grammy@1.42.0) '@homebridge/ciao': - specifier: ^1.3.7 - version: 1.3.7 + specifier: ^1.3.8 + version: 1.3.8 '@lydell/node-pty': specifier: 1.2.0-beta.12 version: 1.2.0-beta.12 '@mariozechner/pi-agent-core': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-coding-agent': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-tui': - specifier: 0.71.1 - version: 0.71.1 + specifier: 0.73.0 + version: 0.73.0 '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -104,11 +104,11 @@ importers: specifier: ^4.7.2 version: 4.7.2(@types/express@5.0.6) '@slack/types': - specifier: ^2.20.1 - version: 2.20.1 + specifier: ^2.21.0 + version: 2.21.0 '@slack/web-api': - specifier: ^7.15.1 - version: 7.15.1 + specifier: ^7.15.2 + version: 7.15.2 ajv: specifier: ^8.20.0 version: 8.20.0 @@ -143,8 +143,8 @@ importers: specifier: ^9.0.0 version: 9.0.0 ipaddr.js: - specifier: ^2.3.0 - version: 2.3.0 + specifier: ^2.4.0 + version: 2.4.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -167,8 +167,8 @@ importers: specifier: ^1.2.10 version: 1.2.10 openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.1) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) openshell: specifier: 0.1.0 version: 0.1.0 @@ -200,8 +200,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 web-push: specifier: ^3.6.7 version: 3.6.7 @@ -212,19 +212,15 @@ importers: specifier: ^8.20.0 version: 8.20.0 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 zod: - specifier: ^4.4.1 - version: 4.4.1 - optionalDependencies: - sqlite-vec: - specifier: 0.1.9 - version: 0.1.9 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@copilotkit/aimock': - specifier: 1.16.4 - version: 1.16.4(vitest@4.1.5) + specifier: 1.17.0 + version: 1.17.0(vitest@4.1.5) '@grammyjs/types': specifier: ^3.26.0 version: 3.26.0 @@ -250,8 +246,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260501.1 - version: 7.0.0-dev.20260501.1 + specifier: 7.0.0-dev.20260504.1 + version: 7.0.0-dev.20260504.1 '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) @@ -278,7 +274,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: 0.21.10 - version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260501.1)(typescript@6.0.3) + version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260504.1)(typescript@6.0.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -287,16 +283,20 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + optionalDependencies: + sqlite-vec: + specifier: 0.1.9 + version: 0.1.9 extensions/acpx: dependencies: '@agentclientprotocol/claude-agent-acp': - specifier: 0.31.4 - version: 0.31.4(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615) + specifier: 0.32.0 + version: 0.32.0(patch_hash=1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98) '@zed-industries/codex-acp': - specifier: 0.12.0 - version: 0.12.0 + specifier: 0.13.0 + version: 0.13.0 acpx: specifier: 0.6.1 version: 0.6.1 @@ -314,8 +314,8 @@ importers: extensions/amazon-bedrock: dependencies: '@aws-sdk/client-bedrock': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/client-bedrock-runtime': specifier: 3.1024.0 version: 3.1024.0 @@ -330,14 +330,14 @@ importers: extensions/amazon-bedrock-mantle: dependencies: '@anthropic-ai/sdk': - specifier: 0.92.0 - version: 0.92.0(zod@4.4.1) + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) '@aws/bedrock-token-generator': specifier: ^1.1.0 version: 1.1.0 '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -346,8 +346,8 @@ importers: extensions/anthropic: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -357,13 +357,13 @@ importers: dependencies: '@anthropic-ai/vertex-sdk': specifier: ^0.16.0 - version: 0.16.0(zod@4.4.1) + version: 0.16.0(zod@4.4.3) '@mariozechner/pi-agent-core': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -393,8 +393,8 @@ importers: extensions/bonjour: dependencies: '@homebridge/ciao': - specifier: ^1.3.7 - version: 1.3.7 + specifier: ^1.3.8 + version: 1.3.8 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -410,7 +410,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) commander: specifier: ^14.0.3 version: 14.0.3 @@ -431,8 +431,8 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 extensions/byteplus: devDependencies: @@ -461,8 +461,8 @@ importers: extensions/codex: dependencies: '@mariozechner/pi-coding-agent': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openai/codex': specifier: 0.128.0 version: 0.128.0 @@ -473,8 +473,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -593,8 +593,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 ws: specifier: ^8.20.0 version: 8.20.0 @@ -685,8 +685,8 @@ importers: extensions/fireworks: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -699,8 +699,8 @@ importers: version: 1.3.0 devDependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -709,10 +709,10 @@ importers: dependencies: '@google/genai': specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -743,8 +743,8 @@ importers: specifier: 10.6.2 version: 10.6.2 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -804,8 +804,8 @@ importers: extensions/kimi-coding: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -846,8 +846,8 @@ importers: extensions/lmstudio: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) extensions/lobster: dependencies: @@ -944,8 +944,8 @@ importers: specifier: 18.1.0 version: 18.1.0 openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.1) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -960,8 +960,8 @@ importers: specifier: 1.1.37 version: 1.1.37 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -998,8 +998,8 @@ importers: extensions/migrate-hermes: dependencies: yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1063,8 +1063,8 @@ importers: extensions/nextcloud-talk: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1079,8 +1079,8 @@ importers: specifier: ^2.23.3 version: 2.23.3(typescript@6.0.3) zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1098,8 +1098,8 @@ importers: extensions/ollama: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1117,8 +1117,8 @@ importers: extensions/openai: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) ws: specifier: ^8.20.0 version: 8.20.0 @@ -1177,20 +1177,20 @@ importers: extensions/qa-lab: dependencies: '@copilotkit/aimock': - specifier: 1.16.4 - version: 1.16.4(vitest@4.1.5) + specifier: 1.17.0 + version: 1.17.0(vitest@4.1.5) '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) playwright-core: specifier: 1.59.1 version: 1.59.1 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/discord': specifier: workspace:* @@ -1208,8 +1208,8 @@ importers: extensions/qa-matrix: dependencies: undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/matrix': specifier: workspace:* @@ -1242,8 +1242,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1307,11 +1307,11 @@ importers: specifier: ^4.7.2 version: 4.7.2(@types/express@5.0.6) '@slack/types': - specifier: ^2.20.1 - version: 2.20.1 + specifier: ^2.21.0 + version: 2.21.0 '@slack/web-api': - specifier: ^7.15.1 - version: 7.15.1 + specifier: ^7.15.2 + version: 7.15.2 https-proxy-agent: specifier: ^9.0.0 version: 9.0.0 @@ -1335,8 +1335,8 @@ importers: extensions/synology-chat: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1373,8 +1373,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1389,11 +1389,11 @@ importers: extensions/tlon: dependencies: '@aws-sdk/client-s3': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/s3-request-presigner': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@tloncorp/tlon-skill': specifier: 0.3.5 version: 0.3.5 @@ -1523,8 +1523,8 @@ importers: extensions/webhooks: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1545,8 +1545,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1558,8 +1558,8 @@ importers: extensions/xai: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1586,8 +1586,8 @@ importers: extensions/zalo: dependencies: undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1652,7 +1652,7 @@ importers: version: 14.1.2 '@vitest/browser-playwright': specifier: 4.1.5 - version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -1661,15 +1661,15 @@ importers: version: 1.59.1 vite: specifier: 8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) vitest: specifier: 4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) packages: - '@agentclientprotocol/claude-agent-acp@0.31.4': - resolution: {integrity: sha512-Ge2qzNN7vXQje0H+xoPhcRToubgdkgpY/YoqNSeJGpx8S90V/uposdsE+OSgIA+4nHcUEbgV9OmCiIqpyEsA9g==} + '@agentclientprotocol/claude-agent-acp@0.32.0': + resolution: {integrity: sha512-3WIaD1bTmIciqHdeU97oeNajOG9H+ctloXnQ+R/T563C2CM8u1K7QsNqqgqR2F+Cn8NVBkXdHRvAMtUHglLzAw==} hasBin: true '@agentclientprotocol/sdk@0.20.0': @@ -1682,58 +1682,58 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121': - resolution: {integrity: sha512-zVHcXvx6Hl/glDcOCH+EyNx4KPE9cMGLk42eEBSZe014tAN5W8bwM/By08iM6dxijnpH0NQRNNEAW+BryWzuDg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126': + resolution: {integrity: sha512-JFlJBbeAlx7Ic5s4lGUN9SppobryXk/lIqPCvhp6KrJTQIerh3MIBzxsVIJ0MaDut7jVni/oYgsvDni7NIyqHA==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': - resolution: {integrity: sha512-lIXdqKj+bpfDxCk/eU1F1TXNqsIsLTRrkUG/wx19WIGZ8gLUmmVSveUKGlNegTs7S6evMvuezprJzDJT4TcvPA==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.126': + resolution: {integrity: sha512-J8BpMj16NK9FUaG3HnHSivyp4Xww9DKWHiC8QSHT9oiT8pH5IG7nl0jxyjIq/lY79evlTY+ubgDVWlMUhUAN/g==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': - resolution: {integrity: sha512-4XaGK+dRBYy7krln7BrDG0WsdE6ejUSgHjWHlUGXoubFfZUvls4GSahLcYjJBArLi4dLnxKw8zEuiQguPAIbrw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.126': + resolution: {integrity: sha512-GO0BnIUw3LQ3XAy+nipAabkN0GwQGPhHB6ITI4XLoR99fLHB3TA6WfyvTf0fnpxd25A+c/+UsAoxz4zBQaHlhA==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': - resolution: {integrity: sha512-AQSnJzaiFvQpUPfO1tWLvsHgb6KNar4QYEQ/5/sk1itfgr3Fx9gxTreq43wX7AXSvkBX1QlDaP1aR1sfM/g/lQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.126': + resolution: {integrity: sha512-LM+mnfQsgI+1i5mYZwIPDDf14NGBu5wbhzm5U8P11dCa2p8sXmKoWpkbO16BFM2NxeW44I/RXCxE5qFsbz4zcg==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': - resolution: {integrity: sha512-sQoGIgzLlBRrwizxsCV/lbaEuxXom/cfOwlDtQ2HnS1IzDDSjSf5d5pugpWItkOyXBWcHzMUu731WTTutvd/BQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.126': + resolution: {integrity: sha512-ByJGO0+mu7EplxSFSCIHd7QWsXdrF3qgtzQ177o/j+oSppLoqR1ot5ktf8aw5oR3CC5lFHg4tqd6TnneQpEoIg==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': - resolution: {integrity: sha512-DJUgpm7au086WaQV/S7BGOt2M8D90spGZRizT3twYsacf1BxzK1qsXqB/Pw1lUjPy6pI107pml/TaPzWuS/Vzg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.126': + resolution: {integrity: sha512-yaOTDcYCdscxC0LKg9w8IwSa5g+993WggFZJBTZpqvflA2+WMQeTarDnKlsFTCw9XUZkL8XZeBALYJGx0HutuA==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': - resolution: {integrity: sha512-6n/NHkHxs0/lCJX3XPADjo1EFzXBf0IwYz/nyzJGBCDJjGKmgTe0i8eYBr/hviwt1/OPeK7dmVzVSVl6EL9Azg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.126': + resolution: {integrity: sha512-gv3MOsOBkCx3LajOOIjD7AKsOtz/qNHsS2oshGt2GVoy7JA3XbCDeCetDjM6SorV4SE+7F/IH0UJdXe5ejI/Zg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': - resolution: {integrity: sha512-v2/R918/t94cCwc6rmbxk+UYeQPtF2oBLtQAk+cT0M60hvqmCZO2noyZx5uTp8TQncOlG4MkINIeNY2yfmWSoQ==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.126': + resolution: {integrity: sha512-oRV75HwyoOd1/t5+kipAM2g62CaElpKGvSBx3Ys4lCwCiFUyOnmet/O+hRXENsY6ShDeQZEcJL2UWljr2d5NQw==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.2.121': - resolution: {integrity: sha512-hwZNYTkGLKVixd/V/OCJwfH/SdfxZXGV0m6wvy5EBq6qfB+lvJTRz/MSOSa7dHqo4/F7zJY68crEEca68Wrxpw==} + '@anthropic-ai/claude-agent-sdk@0.2.126': + resolution: {integrity: sha512-4ZrVu0XUEwNG6wxvsLgppRAmSfAf3oeEMEUPhgazb0AXUUe/7W8MxwZKJWOffqSLWaNYzOt3ZCIL7NJY6toqWw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.92.0': - resolution: {integrity: sha512-l653JFC83wCglH8H83t1xpgDurCyPyslYW1maPRdCsfuNuGbLvQjQ81sWd3Go3LWRm0jNspzAhuqAYV8r9joSw==} + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1786,16 +1786,16 @@ packages: resolution: {integrity: sha512-nIhsn0/eYrL2fTh4kMO7Hpfmhv+AkkXl0KGNpD6+fdmotGvRBWcDv9/PmP/+sT6gvrKTYyzH3vu4efpTPzzP0Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1041.0': - resolution: {integrity: sha512-xUpJ9iRgpj89d9QzjqYUlCnHYNQ/mblICGWhLdpZwvJpege4c36/W40fiYsvs3c3ql58JHQAnGdbNU6cNV1zew==} + '@aws-sdk/client-bedrock@3.1042.0': + resolution: {integrity: sha512-oEVjGU8wgW+eTF7ApdRU4jTs/iMVl4OdfpLmiNLuB082UVxxN/fQ5GIX2Ktbyt+x0mPlI3fug36XnOyf7oCo+Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.1041.0': - resolution: {integrity: sha512-h8DxvCsv95RSHTZPyEwGCqOyiQYVWQ4tFe5im4d0qFvFc9xRmseTu3ZsQ9nd+uOzU9rkCoDHClyqUxXU7nm90Q==} + '@aws-sdk/client-cognito-identity@3.1042.0': + resolution: {integrity: sha512-Cdan/gdzcNEwnvaxzCDQUDJnAAROfvnxjlv5/kvc78E1yQYdgqMN9vEzaqePDNjj8V0rel/iGx2tTlf3KY+V5g==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1041.0': - resolution: {integrity: sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ==} + '@aws-sdk/client-s3@3.1042.0': + resolution: {integrity: sha512-z3Ibstr7ckDT10dz/nkk4+93LitrrO49Oq563/JoFHt30ZNodPBCfSxysKcelLyi/lNVF1MZrhZZfikUAG3iNQ==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.8': @@ -1842,8 +1842,8 @@ packages: resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.1041.0': - resolution: {integrity: sha512-Ps7dcWV1JbXKoFy8QpWhTpWkX0x2tiZFmDdgojK98/rqyybPdwEtGB8xY/N2uJjE0MZkrV9X7T3Xrnk/rGFoNw==} + '@aws-sdk/credential-providers@3.1042.0': + resolution: {integrity: sha512-TKOJXoBKTtfWkRrMITPBq4CFfiXydKc7bcvTONLhXgs1BuyHPcVp2KL0Rl1//xmlw48lYgnT/jn99SanLhB8EQ==} engines: {node: '>=20.0.0'} '@aws-sdk/eventstream-handler-node@3.972.14': @@ -1906,8 +1906,8 @@ packages: resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1041.0': - resolution: {integrity: sha512-DlKsPQ8Z75wgeDSHbjUPNDQCYUF0OLBkqllZqFei61KIoQDqEeKUCwuCf6RhNLjaP4b8oSpBA9+FmUS+zm3xUg==} + '@aws-sdk/s3-request-presigner@3.1042.0': + resolution: {integrity: sha512-yWgXWDg4W0Vk1xlY4M7puM07ce6PPBS4tBytNOpu57k+wY0puXgxkGN0+k/dUAA4sR4Th6+wDps50gBBLj48Ew==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.25': @@ -1922,6 +1922,10 @@ packages: resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1042.0': + resolution: {integrity: sha512-rOEGTVOrceb/1CfIWK0zl1v2WS70f/i5bDirLl5xdFAbVQ5znub6Ezf2ugmJEg+rionO0IkwbKX3Dh3T/oZjbA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -2101,8 +2105,8 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@copilotkit/aimock@1.16.4': - resolution: {integrity: sha512-DA9WjJWpi2Yh36ltsnfMycj+BbifSS9G0pyHw0JjQZQPm41+FziGIdl2gusBtwYebStypQ4v9Jj2rjqjJqqtvQ==} + '@copilotkit/aimock@1.17.0': + resolution: {integrity: sha512-yhU5LahwobRC71Y3JNSqSjvuUU0KtFbaXywgIT6boQwt0XuXmsi91yGlI+tMRb7yNutF2cwXV/qolUYwuqXwJQ==} engines: {node: '>=24.0.0'} hasBin: true peerDependencies: @@ -2423,8 +2427,8 @@ packages: '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - '@homebridge/ciao@1.3.7': - resolution: {integrity: sha512-ncvcXQe4vrqBLNqnVjQjke5NpNin6SO9bStfBZ4jgZk/xIjD9GMcH8vp8XKd7hw5akIzwITMiDMysIKvE5rHBw==} + '@homebridge/ciao@1.3.8': + resolution: {integrity: sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==} hasBin: true '@hono/node-server@1.19.14': @@ -2920,22 +2924,22 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.71.1': - resolution: {integrity: sha512-LMXcKoPmjD06EHwnl7IGMkJs/l3Qdl9z1xKsQGqqyd60ZgdxaATtR40Yyzcku1ogu16NhCHrUg6PJ9XeRcT+qQ==} + '@mariozechner/pi-agent-core@0.73.0': + resolution: {integrity: sha512-ugcpvq0X9fr9fTSK29/3S4+KU/eeVMrBb7ZU3HqiF3xD7I1GlgumLj4FYmDrYSEA6+rzgNWlJUKwjKh9o0Z6AA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.71.1': - resolution: {integrity: sha512-xksl4Y20qnjGbF3/eo0rX+TXEiZkkgRCEO8n/q7tMeVKhQ41migVG+msF+xTJoC3HkrTWfak3Y2Z6UjTUbjeTg==} + '@mariozechner/pi-ai@0.73.0': + resolution: {integrity: sha512-phKOpcde/ssz6UYszkmaGJ9LF9mgt/AP8LrtSwsfap+kMSeFfSQ2/mCSBT1mLJ2BqVuff9uXs1/+op1aQeaafQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.71.1': - resolution: {integrity: sha512-pP7ymz+MmZrcN5aUldm1q1cVbG3u04yZR/XsHEfidku5W3PP1uxsA0A4g4NOhXnkK5EZ+Qg6H12BAbVvl7Qq2Q==} + '@mariozechner/pi-coding-agent@0.73.0': + resolution: {integrity: sha512-Fs2dRIgtjDT8X5VDGNGzxj251B0FvkRsgX03YJv1FK4wg5Maj+jkf8/5A6tbPnPcXsCgs41xxJRf3tF5vJRccA==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.71.1': - resolution: {integrity: sha512-jNMN9EmGiH8EIKG62fceOTonoJ9k0cohTdjQCDrOk77vnxPVK+3be/+S1xk4hxviltwxlRH0d7mGQXs+CuEL8g==} + '@mariozechner/pi-tui@0.73.0': + resolution: {integrity: sha512-St1W+tMPKHatfK+lblsKfL+SsFyFVMK2tW6xHpBfCiMuevbOCRo/CMatso7mu1642UO04ncmfCrrpUK5L9aoog==} engines: {node: '>=20.0.0'} '@matrix-org/matrix-sdk-crypto-nodejs@0.5.1': @@ -3867,12 +3871,12 @@ packages: resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@slack/types@2.20.1': - resolution: {integrity: sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==} + '@slack/types@2.21.0': + resolution: {integrity: sha512-ZLMsKnD5KLRPmhFEoGoBQUD5Pc2bH3xFc5ygHlioEc0WmLGyZGoGCtMff4rpejrFnptrhfxcKpWxW4r3g39R0A==} engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} - '@slack/web-api@7.15.1': - resolution: {integrity: sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==} + '@slack/web-api@7.15.2': + resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==} engines: {node: '>= 18', npm: '>= 8.6.0'} '@smithy/chunked-blob-reader-native@4.2.3': @@ -4059,8 +4063,8 @@ packages: resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.3.6': - resolution: {integrity: sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==} + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} engines: {node: '>=18.0.0'} '@smithy/util-stream@4.5.25': @@ -4256,8 +4260,8 @@ packages: '@twurple/common@8.1.3': resolution: {integrity: sha512-B2BT42fJAEYqSPGjTd6qyZoUv6kgFzIvUJuTIrOUcBiJxcvZh8tD+WLRd5xfMKhtLbUFgesYlHxdPhmdar8/zw==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -4373,50 +4377,50 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-OIYsqKouI2U7W5Q6VgUz7+t9FpIXNFk30xSUG7gGlN1bdDniWfW7t5n6mzEtiHUVTxRgJQBjXGAlhVa6A9h+pg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-+Qs1Q7Qxfp11n/hU3pweFU+EQ37FnDsdWOOxb7/vCy8QGBysrLUUYRhQ+GSW3s663oMtN6+9Kf82hk3ZT+kXlg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-hQ5UsEyOz3ErQE3sKKHMCfJJGQenD0DSCi2ob+ywElXirG2NyFNA8cmx1g+MIm1lpQeEQslWZhe9EGwo9DJAbg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-Wr3GWTRiMgibmhe88cjQ612ZyY7sbgsPYEaWKGPUxBaXtMHFIzgTBIoJMuaQqQx4GEJs6AUDyhnIHG1gx4rJjg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-fbaFKE1UvtsQ6i1eJjBiNbglR9ywXrW/CH1sqYPEtr0WgTUpixbE6inQOXjB0jlEA9RzQq+QMzDyaCDmU82Dkw==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-y1Qai5l55Sl+/3B0hyQtvynq//C22BKFH3CfU35fbLYUo4P/ISUycyAbcA+PAPazpDFO3E56I96QUQrbJL2VVA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-agkTW/t85XSJKWGcXdUV9ZmSi3Akh3POK+HhWehigEJR3W/jebiO9njifETfoUF6cpoYkFn+CZvfAJ00IWGZfA==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-s8QkhZe0M4QD2xhK1Xiy2JUQv1AOl8kUg5DLx1G8ws0f1BK/oKyqDNbxhZMGINYLFvkjpr9lOxt7qehSnpJMYQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-Sd8D+S88P7K0IH1U+a8pK20ZD+GM54t48/GLw9ebSklfCdt0iKdHgprjKIcl54C3SocGCcvEBPr1thwtTO9Vtg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-ngN3Ie3Vin6pFtqeNywxm86RTxgI0Fo0GZyJ1PxokLES8J3xfMPtMYfv85c/+5uz5+7T+m4LRLyY5IoLY4gtuw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-07sJNDnU7KHfo/trv/cBXpgFBELDYJAsTx5kNvBckSQUxbX+p/b9oQ3eFbtK3zDP4EEKdeiD9EelIy22atBnzA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-/GZDJN/CsLbqIe7EdWDkXhNX9C41VjemBeUN6+9ckvEFLH8XyKTmXPYikNOn0N819M1KSeNZltplyUslfROOdw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-8rzd/eQZyBuR+IRiPnIQrCwSuXIGBFiL8LsUMFqQt2WAUlQ0gGWBlLJHUVU4YNlju9QROjNHUGpJ52XGZbFv0Q==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-EYQBdVZq4xIzhTtKxw6wvee9238hEb7XrPG413AEZBD3kcR3qqvPULXsPzQyEpneCReATSaihscP/LfhMQYUmA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-skD0ig8IzPwSY1L8VmNgfaxkfT8ImBwKeIypfZyJA+zHzWvroRKbRbT2GryOSREl22ZqLOuDfcq+7BdA0rjF2Q==} + '@typescript/native-preview@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-bHFGxyIU83qjj6ywn3817A+Ug2ZID0GiBA5WFdbc/T7EjcrKnUUylexq0fU81N/mTbfw3FyP6ZCEdO2Ntcl/VQ==} engines: {node: '>=16.20.0'} hasBin: true @@ -4512,44 +4516,44 @@ packages: resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} version: 2.0.1 - '@zed-industries/codex-acp-darwin-arm64@0.12.0': - resolution: {integrity: sha512-RvTXH21sLpswEo8xLeQXcA/uWZauyNP1y+WI6b355+/o7sQ5wrvBkxt+NyhaJXJIQvbfdpl04LND4cmM+DTcNg==} + '@zed-industries/codex-acp-darwin-arm64@0.13.0': + resolution: {integrity: sha512-SNJbpxOD1b98pK1Qw2pZjFJbfYBICheRs3mYvLMgHABehdypaeYKnEmEGp3Bu/gUT6JFAtOPRtaU+sfxKzgCvw==} cpu: [arm64] os: [darwin] hasBin: true - '@zed-industries/codex-acp-darwin-x64@0.12.0': - resolution: {integrity: sha512-N7EhrUTioix3L21qnm6kZzAESc+B7Mac+/uW3khn/UQe7fJJ7u1ojbgMPDdGo/8Xm6HBBXgak2NOj7mJ+NNXSw==} + '@zed-industries/codex-acp-darwin-x64@0.13.0': + resolution: {integrity: sha512-R5CQi2mmi9Nk2P6t48T5JoOQx0jWnP9DzLf5jcTnCLqk1tsg9XtASpLBtsedll9MesBax6aflDvz+0dyWW+3Mw==} cpu: [x64] os: [darwin] hasBin: true - '@zed-industries/codex-acp-linux-arm64@0.12.0': - resolution: {integrity: sha512-Kq35FclgZiSMBKyf80PnCvvJ3xfMjZIkPJXpci35U/VqXVQelhHCwYWwA3waTxvW07tNHxsehv1eQICz7wZdVQ==} + '@zed-industries/codex-acp-linux-arm64@0.13.0': + resolution: {integrity: sha512-Z3f2D94SOgy+BVFEIWxoR64IQB+d4/zgjHB1oeBS5yAYKaX7Wv3W6x+XGktDx+KnfD7c9vSSdFdknI6cZ8hO7g==} cpu: [arm64] os: [linux] hasBin: true - '@zed-industries/codex-acp-linux-x64@0.12.0': - resolution: {integrity: sha512-twmX9noSqfgWgVkGG1dd9u20Pxp8vNRXggvJ61RQSrNYITGuqHil2F3ViYICZoXyr9w1gok28bWG5DU2d9adPg==} + '@zed-industries/codex-acp-linux-x64@0.13.0': + resolution: {integrity: sha512-sWNfyeuwEHPo6DSbcjklnBr7M8+MWd2b9oVbIqgwxryTPpm0ZPF3U28PWR3/vGxS5UmhGiZIShe9tqx8FsvvBg==} cpu: [x64] os: [linux] hasBin: true - '@zed-industries/codex-acp-win32-arm64@0.12.0': - resolution: {integrity: sha512-VoFsTIrQopO917x2EpxYXm3jTIoSknCbzP76FwX9uOThlRms+M+fHWJ4kJttOPpeofz1ulAS3vPVMQ3WNlvnhw==} + '@zed-industries/codex-acp-win32-arm64@0.13.0': + resolution: {integrity: sha512-oxd6IF5dVHsa7zLnK1VAClzGADqn4N9TVSPb+3X4CqnOs4y4M9JPHSEEPiRYF44ibDJTWR+9EZ673djRYEGraw==} cpu: [arm64] os: [win32] hasBin: true - '@zed-industries/codex-acp-win32-x64@0.12.0': - resolution: {integrity: sha512-HImgXGIYgW6Wxr3rylrHS7Dzs35zvcQQB7eqAEWZ2Lj+3AxP/7TViW9KkjS+PTPnVWqpTkz0hYDQhk63Ruw3JA==} + '@zed-industries/codex-acp-win32-x64@0.13.0': + resolution: {integrity: sha512-675+tZlhzDMBJUrgiTnbcCMB15MQ8B0Ih/GmzB9MqW/FDFJqOFjXe4P+M7joePzQqa7QYwf36le50sDokXDrew==} cpu: [x64] os: [win32] hasBin: true - '@zed-industries/codex-acp@0.12.0': - resolution: {integrity: sha512-0d7gRzOiYTgDmIyh783mCcq50h3mdOg/TtKdLfBIghOLushpQRwhuLjKK8Q9hxZfNlPL0Ua56DoPjnsW8amf8g==} + '@zed-industries/codex-acp@0.13.0': + resolution: {integrity: sha512-Ep3gINMVB8qQL3kozJxEzG4YP7NmWUb5s+8yu8tQ7YSPfaIPXBIQQmO5sQk2Uu2av+gIC2EchbwaSSG3Mo17YQ==} hasBin: true abbrev@1.1.1: @@ -5377,14 +5381,14 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.1: + resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.1.7: + resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} fast-xml-parser@5.7.0: resolution: {integrity: sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==} @@ -5738,8 +5742,8 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} ircv3@0.33.1: @@ -6098,8 +6102,8 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} lru-cache@6.0.0: @@ -6395,10 +6399,6 @@ packages: resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} engines: {node: ^18 || ^20 || >= 21} - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-downloader-helper@2.1.11: resolution: {integrity: sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==} engines: {node: '>=14.18'} @@ -6421,6 +6421,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -6518,8 +6522,8 @@ packages: zod: optional: true - openai@6.35.0: - resolution: {integrity: sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==} + openai@6.36.0: + resolution: {integrity: sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -6746,8 +6750,8 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} prism-media@1.3.5: @@ -7519,8 +7523,8 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} - undici@8.1.0: - resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} engines: {node: '>=22.19.0'} unhomoglyph@1.0.6: @@ -7800,8 +7804,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -7849,82 +7853,82 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.1: - resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@agentclientprotocol/claude-agent-acp@0.31.4(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615)': + '@agentclientprotocol/claude-agent-acp@0.32.0(patch_hash=1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98)': dependencies: - '@agentclientprotocol/sdk': 0.21.0(zod@4.4.1) - '@anthropic-ai/claude-agent-sdk': 0.2.121(zod@4.4.1) - zod: 4.4.1 + '@agentclientprotocol/sdk': 0.21.0(zod@4.4.3) + '@anthropic-ai/claude-agent-sdk': 0.2.126(zod@4.4.3) + zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color - '@agentclientprotocol/sdk@0.20.0(zod@4.4.1)': + '@agentclientprotocol/sdk@0.20.0(zod@4.4.3)': dependencies: - zod: 4.4.1 + zod: 4.4.3 - '@agentclientprotocol/sdk@0.21.0(zod@4.4.1)': + '@agentclientprotocol/sdk@0.21.0(zod@4.4.3)': dependencies: - zod: 4.4.1 + zod: 4.4.3 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk@0.2.121(zod@4.4.1)': + '@anthropic-ai/claude-agent-sdk@0.2.126(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.1) - zod: 4.4.1 + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.121 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.121 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.126 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.126 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/sdk@0.92.0(zod@4.4.1)': + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.4.1 + zod: 4.4.3 - '@anthropic-ai/vertex-sdk@0.16.0(zod@4.4.1)': + '@anthropic-ai/vertex-sdk@0.16.0(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) google-auth-library: 9.15.1 transitivePeerDependencies: - encoding @@ -8043,14 +8047,14 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1041.0': + '@aws-sdk/client-bedrock@3.1042.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 @@ -8061,7 +8065,7 @@ snapshots: '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/token-providers': 3.1042.0 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 @@ -8089,13 +8093,13 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-cognito-identity@3.1041.0': + '@aws-sdk/client-cognito-identity@3.1042.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 @@ -8133,13 +8137,13 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-s3@3.1041.0': + '@aws-sdk/client-s3@3.1042.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -8191,7 +8195,7 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 '@smithy/util-waiter': 4.3.0 @@ -8212,7 +8216,7 @@ snapshots: '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -8335,9 +8339,9 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-providers@3.1041.0': + '@aws-sdk/credential-providers@3.1042.0': dependencies: - '@aws-sdk/client-cognito-identity': 3.1041.0 + '@aws-sdk/client-cognito-identity': 3.1042.0 '@aws-sdk/core': 3.974.8 '@aws-sdk/credential-provider-cognito-identity': 3.972.31 '@aws-sdk/credential-provider-env': 3.972.34 @@ -8466,7 +8470,7 @@ snapshots: '@smithy/core': 3.23.17 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 tslib: 2.8.1 '@aws-sdk/middleware-websocket@3.972.16': @@ -8522,7 +8526,7 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -8536,7 +8540,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1041.0': + '@aws-sdk/s3-request-presigner@3.1042.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.996.25 '@aws-sdk/types': 3.973.8 @@ -8580,6 +8584,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1042.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -8633,7 +8649,7 @@ snapshots: '@aws/bedrock-token-generator@1.1.0': dependencies: - '@aws-sdk/credential-providers': 3.1041.0 + '@aws-sdk/credential-providers': 3.1042.0 '@aws-sdk/util-format-url': 3.972.10 '@smithy/config-resolver': 4.4.17 '@smithy/hash-node': 4.2.14 @@ -8817,14 +8833,14 @@ snapshots: '@clawdbot/lobster@2026.4.6': dependencies: ajv: 8.20.0 - yaml: 2.8.3 + yaml: 2.8.4 '@colors/colors@1.5.0': optional: true - '@copilotkit/aimock@1.16.4(vitest@4.1.5)': + '@copilotkit/aimock@1.17.0(vitest@4.1.5)': optionalDependencies: - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@create-markdown/preview@2.0.3(shiki@3.23.0)': optionalDependencies: @@ -9046,14 +9062,14 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 - '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))': + '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.5 ws: 8.20.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.1) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9089,7 +9105,7 @@ snapshots: '@hapi/hoek@9.3.0': {} - '@homebridge/ciao@1.3.7': + '@homebridge/ciao@1.3.8': dependencies: debug: 4.4.3 fast-deep-equal: 3.1.3 @@ -9627,9 +9643,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-agent-core@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - '@mariozechner/pi-ai': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: 1.1.37 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -9640,19 +9656,19 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-ai@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1024.0 - '@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + '@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.4.1) + openai: 6.26.0(ws@8.20.0)(zod@4.4.3) partial-json: 0.1.7 proxy-agent: 6.5.0 typebox: 1.1.37 undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -9662,12 +9678,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-coding-agent@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) - '@mariozechner/pi-ai': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) - '@mariozechner/pi-tui': 0.71.1 + '@mariozechner/pi-agent-core': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-tui': 0.73.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -9684,7 +9700,7 @@ snapshots: typebox: 1.1.37 undici: 7.25.0 uuid: 14.0.0 - yaml: 2.8.3 + yaml: 2.8.4 optionalDependencies: '@mariozechner/clipboard': 0.3.5 transitivePeerDependencies: @@ -9696,7 +9712,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.71.1': + '@mariozechner/pi-tui@0.73.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -9788,13 +9804,13 @@ snapshots: '@mistralai/mistralai@2.2.1': dependencies: ws: 8.20.0 - zod: 4.4.1 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.20.0 @@ -9811,8 +9827,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.4.1 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -9917,7 +9933,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@noble/ciphers@2.1.1': {} @@ -9983,7 +9999,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - yaml: 2.8.3 + yaml: 2.8.4 '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)': dependencies: @@ -10500,8 +10516,8 @@ snapshots: '@slack/logger': 4.0.1 '@slack/oauth': 3.0.5 '@slack/socket-mode': 2.0.7 - '@slack/types': 2.20.1 - '@slack/web-api': 7.15.1 + '@slack/types': 2.21.0 + '@slack/web-api': 7.15.2 '@types/express': 5.0.6 axios: 1.15.0 express: 5.2.1 @@ -10521,7 +10537,7 @@ snapshots: '@slack/oauth@3.0.5': dependencies: '@slack/logger': 4.0.1 - '@slack/web-api': 7.15.1 + '@slack/web-api': 7.15.2 '@types/jsonwebtoken': 9.0.10 '@types/node': 25.6.0 jsonwebtoken: 9.0.3 @@ -10531,7 +10547,7 @@ snapshots: '@slack/socket-mode@2.0.7': dependencies: '@slack/logger': 4.0.1 - '@slack/web-api': 7.15.1 + '@slack/web-api': 7.15.2 '@types/node': 25.6.0 '@types/ws': 8.18.1 eventemitter3: 5.0.4 @@ -10541,12 +10557,12 @@ snapshots: - debug - utf-8-validate - '@slack/types@2.20.1': {} + '@slack/types@2.21.0': {} - '@slack/web-api@7.15.1': + '@slack/web-api@7.15.2': dependencies: '@slack/logger': 4.0.1 - '@slack/types': 2.20.1 + '@slack/types': 2.21.0 '@types/node': 25.6.0 '@types/retry': 0.12.0 axios: 1.15.0 @@ -10702,7 +10718,7 @@ snapshots: '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/uuid': 1.1.2 tslib: 2.8.1 @@ -10853,7 +10869,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-retry@4.3.6': + '@smithy/util-retry@4.3.8': dependencies: '@smithy/service-error-classification': 4.3.1 '@smithy/types': 4.14.1 @@ -11061,7 +11077,7 @@ snapshots: klona: 2.0.6 tslib: 2.8.1 - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true @@ -11189,36 +11205,36 @@ snapshots: '@types/node': 25.6.0 optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260501.1': + '@typescript/native-preview@7.0.0-dev.20260504.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260504.1 '@typespec/ts-http-runtime@0.3.5': dependencies: @@ -11232,29 +11248,29 @@ snapshots: '@urbit/aura@3.0.0': {} - '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: - '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) playwright: 1.59.1 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/utils': 4.1.5 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -11274,9 +11290,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: - '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/expect@4.1.5': dependencies: @@ -11287,13 +11303,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -11347,7 +11363,7 @@ snapshots: '@hapi/boom': 9.1.4 async-mutex: 0.5.0 libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' - lru-cache: 11.3.5 + lru-cache: 11.3.6 music-metadata: 11.12.3 p-queue: 9.2.0 pino: 9.14.0 @@ -11367,32 +11383,32 @@ snapshots: curve25519-js: 0.0.4 protobufjs: 7.5.5 - '@zed-industries/codex-acp-darwin-arm64@0.12.0': + '@zed-industries/codex-acp-darwin-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-darwin-x64@0.12.0': + '@zed-industries/codex-acp-darwin-x64@0.13.0': optional: true - '@zed-industries/codex-acp-linux-arm64@0.12.0': + '@zed-industries/codex-acp-linux-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-linux-x64@0.12.0': + '@zed-industries/codex-acp-linux-x64@0.13.0': optional: true - '@zed-industries/codex-acp-win32-arm64@0.12.0': + '@zed-industries/codex-acp-win32-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-win32-x64@0.12.0': + '@zed-industries/codex-acp-win32-x64@0.13.0': optional: true - '@zed-industries/codex-acp@0.12.0': + '@zed-industries/codex-acp@0.13.0': optionalDependencies: - '@zed-industries/codex-acp-darwin-arm64': 0.12.0 - '@zed-industries/codex-acp-darwin-x64': 0.12.0 - '@zed-industries/codex-acp-linux-arm64': 0.12.0 - '@zed-industries/codex-acp-linux-x64': 0.12.0 - '@zed-industries/codex-acp-win32-arm64': 0.12.0 - '@zed-industries/codex-acp-win32-x64': 0.12.0 + '@zed-industries/codex-acp-darwin-arm64': 0.13.0 + '@zed-industries/codex-acp-darwin-x64': 0.13.0 + '@zed-industries/codex-acp-linux-arm64': 0.13.0 + '@zed-industries/codex-acp-linux-x64': 0.13.0 + '@zed-industries/codex-acp-win32-arm64': 0.13.0 + '@zed-industries/codex-acp-win32-x64': 0.13.0 abbrev@1.1.1: optional: true @@ -11420,11 +11436,11 @@ snapshots: acpx@0.6.1: dependencies: - '@agentclientprotocol/sdk': 0.20.0(zod@4.4.1) + '@agentclientprotocol/sdk': 0.20.0(zod@4.4.3) commander: 14.0.3 skillflag: 0.1.4 tsx: 4.21.0 - zod: 4.4.1 + zod: 4.4.3 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -11448,7 +11464,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -12229,20 +12245,20 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.1: {} fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 - fast-xml-builder@1.1.5: + fast-xml-builder@1.1.7: dependencies: path-expression-matcher: 1.5.0 fast-xml-parser@5.7.0: dependencies: '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 + fast-xml-builder: 1.1.7 path-expression-matcher: 1.5.0 strnum: 2.2.3 @@ -12607,7 +12623,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: @@ -12715,7 +12731,7 @@ snapshots: ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} + ipaddr.js@2.4.0: {} ircv3@0.33.1: dependencies: @@ -12882,7 +12898,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.3.5 + lru-cache: 11.3.6 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -13105,7 +13121,7 @@ snapshots: longest-streak@3.1.0: {} - lru-cache@11.3.5: {} + lru-cache@11.3.6: {} lru-cache@6.0.0: dependencies: @@ -13121,7 +13137,7 @@ snapshots: lru-memoizer@3.0.0: dependencies: lodash.clonedeep: 4.5.0 - lru-cache: 11.3.5 + lru-cache: 11.3.6 lru_map@0.4.1: {} @@ -13588,8 +13604,6 @@ snapshots: node-addon-api@8.7.0: {} - node-gyp-build@4.8.4: {} - node-downloader-helper@2.1.11: {} node-edge-tts@1.2.10: @@ -13612,6 +13626,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} + node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -13706,15 +13722,15 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@6.26.0(ws@8.20.0)(zod@4.4.1): + openai@6.26.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.1 + zod: 4.4.3 - openai@6.35.0(ws@8.20.0)(zod@4.4.1): + openai@6.36.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.1 + zod: 4.4.3 openshell@0.1.0: dependencies: @@ -13915,7 +13931,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 minipass: 7.1.3 path-to-regexp@8.4.0: {} @@ -13974,7 +13990,7 @@ snapshots: pngjs@7.0.0: {} - postcss@8.5.13: + postcss@8.5.14: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -14311,7 +14327,7 @@ snapshots: glob: 7.2.3 optional: true - rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260501.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260504.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -14325,7 +14341,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.17 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260501.1 + '@typescript/native-preview': 7.0.0-dev.20260504.1 typescript: 6.0.3 transitivePeerDependencies: - oxc-resolver @@ -14606,6 +14622,7 @@ snapshots: sqlite-vec-linux-arm64: 0.1.9 sqlite-vec-linux-x64: 0.1.9 sqlite-vec-windows-x64: 0.1.9 + optional: true stackback@0.0.2: {} @@ -14798,7 +14815,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260501.1)(typescript@6.0.3): + tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260504.1)(typescript@6.0.3): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -14809,7 +14826,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.17 - rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260501.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260504.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) semver: 7.7.4 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -14873,7 +14890,7 @@ snapshots: undici@7.25.0: {} - undici@8.1.0: {} + undici@8.2.0: {} unhomoglyph@1.0.6: {} @@ -14949,11 +14966,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.13 + postcss: 8.5.14 rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: @@ -14962,12 +14979,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.8.4 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -14984,12 +15001,12 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: @@ -15102,7 +15119,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.3: {} + yaml@2.8.4: {} yargs-parser@18.1.3: dependencies: @@ -15168,12 +15185,12 @@ snapshots: - bufferutil - utf-8-validate - zod-to-json-schema@3.25.2(zod@4.4.1): + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: - zod: 4.4.1 + zod: 4.4.3 zod@3.25.76: {} - zod@4.4.1: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/qa/scenarios/plugins/kitchen-sink-live-openai.md b/qa/scenarios/plugins/kitchen-sink-live-openai.md index ecf62e53ccb..01de4d97914 100644 --- a/qa/scenarios/plugins/kitchen-sink-live-openai.md +++ b/qa/scenarios/plugins/kitchen-sink-live-openai.md @@ -49,12 +49,44 @@ execution: - kitchen_sink_text - kitchen_sink_search - kitchen_sink_image_job + expectedSurfaceIds: + speechProviderIds: + - kitchen-sink-speech + - kitchen-sink-speech-provider + realtimeTranscriptionProviderIds: + - kitchen-sink-realtime-transcription + - kitchen-sink-realtime-transcription-provider + realtimeVoiceProviderIds: + - kitchen-sink-realtime-voice + - kitchen-sink-realtime-voice-provider + mediaUnderstandingProviderIds: + - kitchen-sink-media + - kitchen-sink-media-understanding-provider + imageGenerationProviderIds: + - kitchen-sink-image + - kitchen-sink-image-generation-provider + videoGenerationProviderIds: + - kitchen-sink-video + - kitchen-sink-video-generation-provider + musicGenerationProviderIds: + - kitchen-sink-music + - kitchen-sink-music-generation-provider + webFetchProviderIds: + - kitchen-sink-fetch + - kitchen-sink-web-fetch-provider + webSearchProviderIds: + - kitchen-sink-search + - kitchen-sink-web-search-provider + migrationProviderIds: + - kitchen-sink-migration-providers + - kitchen-sink-migration-provider maxGatewayCpuCoreRatio: 1.5 maxGatewayRssMiB: 2048 agentTurnTimeoutMs: 120000 outboundTimeoutMs: 60000 livePrompt: "Kitchen Sink OpenAI marker. Reply exactly: KITCHEN-SINK-OPENAI-OK" expectedAdversarialDiagnostics: + - agent event subscription registration requires id and handle - only bundled plugins can register agent tool result middleware - agent harness "kitchen-sink-agent-harness" registration missing required runtime methods - channel "kitchen-sink-channel-probe" registration missing required config helpers @@ -62,9 +94,16 @@ execution: - only bundled plugins can register Codex app-server extension factories - compaction provider "kitchen-sink-compaction-provider" registration missing summarize - context engine registration missing id - - http route registration missing or invalid auth: /kitchen-sink/http-route + - control UI descriptor registration requires id, surface, label, and valid optional fields + - "http route registration missing or invalid auth: /kitchen-sink/http-route" - "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider" - memory prompt supplement registration missing builder + - node invoke policy registration missing commands + - session extension registration requires namespace and description + - session scheduler job registration requires unique id, sessionKey, and kind + - "plugin must declare contracts.tools for: kitchen-sink-tool" + - tool metadata registration missing toolName + - only bundled plugins can register trusted tool policies ``` ```yaml qa-flow @@ -110,6 +149,10 @@ steps: ...(cfg.channels || {}), [config.channelId]: { enabled: true, token: "kitchen-sink-qa" }, }; + cfg.tools = { + ...(cfg.tools || {}), + alsoAllow: [...new Set([...(cfg.tools?.alsoAllow || []), ...config.expectedToolAny])], + }; await fs.writeFile(env.gateway.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); return env.gateway.configPath; })() @@ -129,6 +172,7 @@ steps: - - plugins - inspect - expr: config.pluginId + - --runtime - --json - json: true timeoutMs: 60000 @@ -148,9 +192,22 @@ steps: channels: [...new Set([...(plugin.channelIds ?? []), ...(plugin.channels ?? [])])], providers: [...new Set([...(plugin.providerIds ?? []), ...(plugin.providers ?? [])])], tools: [...new Set([...namesFromTools, ...(contracts.tools ?? [])])], + commands: inspect.commands ?? [], + services: inspect.services ?? [], + typedHookCount: Array.isArray(inspect.typedHooks) ? inspect.typedHooks.length : 0, + hookCount: plugin.hookCount ?? 0, + surfaceIds: Object.fromEntries( + Object.keys(config.expectedSurfaceIds ?? {}) + .map((field) => [field, Array.isArray(plugin[field]) ? plugin[field] : []]) + ), + agentHarnessIds: plugin.agentHarnessIds ?? [], diagnostics: [...(pluginList.diagnostics ?? []), ...(inspect.diagnostics ?? [])] .filter((entry) => entry?.level === "error") .map((entry) => String(entry.message ?? "")), + unexpectedDiagnostics: [...new Set([...(pluginList.diagnostics ?? []), ...(inspect.diagnostics ?? [])] + .filter((entry) => entry?.level === "error") + .map((entry) => String(entry.message ?? "")) + .filter((message) => !config.expectedAdversarialDiagnostics.includes(message)))], }; })() - assert: @@ -170,9 +227,25 @@ steps: message: expr: "`Kitchen Sink tools missing from inspect output: ${JSON.stringify(inspectFacts.tools)}`" - assert: - expr: "inspectFacts.diagnostics.length === 0" + expr: "Object.entries(config.expectedSurfaceIds).every(([field, expected]) => expected.some((id) => (inspectFacts.surfaceIds[field] ?? []).includes(id)))" message: - expr: "`Kitchen Sink conformance personality emitted diagnostics: ${JSON.stringify(inspectFacts.diagnostics)}`" + expr: "`Kitchen Sink SDK provider surface missing from inspect output: ${JSON.stringify(inspectFacts.surfaceIds)}`" + - assert: + expr: "inspectFacts.commands.includes('kitchen') && inspectFacts.services.includes('kitchen-sink-service')" + message: + expr: "`Kitchen Sink command/service surfaces missing: ${JSON.stringify({ commands: inspectFacts.commands, services: inspectFacts.services })}`" + - assert: + expr: "inspectFacts.hookCount >= 30 && inspectFacts.typedHookCount >= 30" + message: + expr: "`Kitchen Sink hook surfaces missing: ${JSON.stringify({ hookCount: inspectFacts.hookCount, typedHookCount: inspectFacts.typedHookCount })}`" + - assert: + expr: "!inspectFacts.agentHarnessIds.includes('kitchen-sink-agent-harness')" + message: + expr: "`External Kitchen Sink plugin unexpectedly registered bundled-only agent harness: ${JSON.stringify(inspectFacts.agentHarnessIds)}`" + - assert: + expr: "inspectFacts.unexpectedDiagnostics.length === 0" + message: + expr: "`Kitchen Sink conformance personality emitted unexpected diagnostics: ${JSON.stringify(inspectFacts.unexpectedDiagnostics)}`" detailsExpr: inspectFacts - name: restarts gateway with Kitchen Sink configured @@ -208,12 +281,32 @@ steps: ...(cfg.channels || {}), [config.channelId]: { enabled: true, token: "kitchen-sink-qa" }, }; + cfg.tools = { + ...(cfg.tools || {}), + alsoAllow: [...new Set([...(cfg.tools?.alsoAllow || []), ...config.expectedToolAny])], + }; await fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); })() - call: waitForGatewayHealthy args: - ref: env - 120000 + - call: fetchJson + saveAs: healthz + args: + - expr: "`${env.gateway.baseUrl}/healthz`" + - call: fetchJson + saveAs: readyz + args: + - expr: "`${env.gateway.baseUrl}/readyz`" + - assert: + expr: "healthz?.ok === true && healthz?.status === 'live'" + message: + expr: "`/healthz did not report live: ${JSON.stringify(healthz)}`" + - assert: + expr: "readyz?.ready === true" + message: + expr: "`/readyz did not report ready: ${JSON.stringify(readyz)}`" - call: waitForQaChannelReady args: - ref: env @@ -241,7 +334,7 @@ steps: expr: "kitchenChannelAccount?.running === true && kitchenChannelAccount?.configured === true" message: expr: "`Kitchen Sink channel did not report running+configured: ${JSON.stringify(kitchenChannelAccount)}`" - detailsExpr: kitchenChannelAccount + detailsExpr: "{ healthz, readyz, kitchenChannelAccount }" - name: exercises command inventory and MCP tool surfaces actions: @@ -390,6 +483,7 @@ steps: - - plugins - inspect - expr: config.pluginId + - --runtime - --json - json: true timeoutMs: 60000 diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 144a2aaa72d..54f89c7bab9 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -33,6 +33,52 @@ export function createChangedCheckChildEnv(baseEnv = process.env) { }; } +function isTruthyEnvFlag(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no"; +} + +export function shouldDelegateChangedCheckToTestbox(argv = [], env = process.env) { + if (!isTruthyEnvFlag(env.OPENCLAW_TESTBOX)) { + return false; + } + if (isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN)) { + return false; + } + if (isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS)) { + return false; + } + if (argv.includes("--dry-run")) { + return false; + } + return true; +} + +export function buildChangedCheckTestboxArgs(argv = []) { + return [ + "testbox:run", + "--", + "OPENCLAW_TESTBOX=1", + "OPENCLAW_TESTBOX_REMOTE_RUN=1", + "pnpm", + "check:changed", + ...argv, + ]; +} + +export async function runChangedCheckViaTestbox(argv = [], env = process.env) { + console.error( + "[check:changed] OPENCLAW_TESTBOX=1 set; delegating to Blacksmith Testbox via `pnpm testbox:run`.", + ); + return await runManagedCommand({ + bin: "pnpm", + args: buildChangedCheckTestboxArgs(argv), + env, + }); +} + export function createChangedCheckPlan(result, options = {}) { const commands = []; const baseEnv = createChangedCheckChildEnv(options.env ?? process.env); @@ -283,21 +329,26 @@ function isDirectRun() { } if (isDirectRun()) { - const args = parseArgs(process.argv.slice(2)); - const paths = - args.paths.length > 0 - ? args.paths - : args.staged - ? listStagedChangedPaths() - : listChangedPathsFromGit({ base: args.base, head: args.head }); - const result = detectChangedLanesForPaths({ - paths, - base: args.base, - head: args.head, - staged: args.staged, - }); - process.exitCode = await runChangedCheck(result, { - ...args, - explicitPaths: args.paths.length > 0, - }); + const argv = process.argv.slice(2); + if (shouldDelegateChangedCheckToTestbox(argv, process.env)) { + process.exitCode = await runChangedCheckViaTestbox(argv, process.env); + } else { + const args = parseArgs(argv); + const paths = + args.paths.length > 0 + ? args.paths + : args.staged + ? listStagedChangedPaths() + : listChangedPathsFromGit({ base: args.base, head: args.head }); + const result = detectChangedLanesForPaths({ + paths, + base: args.base, + head: args.head, + staged: args.staged, + }); + process.exitCode = await runChangedCheck(result, { + ...args, + explicitPaths: args.paths.length > 0, + }); + } } diff --git a/scripts/check-codex-app-server-protocol.ts b/scripts/check-codex-app-server-protocol.ts index e543e5a281e..65431874ef7 100644 --- a/scripts/check-codex-app-server-protocol.ts +++ b/scripts/check-codex-app-server-protocol.ts @@ -1,11 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveCodexAppServerProtocolSource } from "./lib/codex-app-server-protocol-source.js"; -const codexRepo = process.env.OPENCLAW_CODEX_REPO - ? path.resolve(process.env.OPENCLAW_CODEX_REPO) - : path.resolve(process.cwd(), "../codex"); -const schemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript"); -const sourceSchemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema"); +const { sourceRoot: sourceSchemaRoot } = await resolveCodexAppServerProtocolSource(process.cwd()); +const schemaRoot = path.join(sourceSchemaRoot, "typescript"); const generatedRoot = path.resolve( process.cwd(), "extensions/codex/src/app-server/protocol-generated", @@ -104,12 +102,14 @@ if (failures.length > 0) { for (const failure of failures) { console.error(`- ${failure}`); } - console.error("Run `pnpm codex-app-server:protocol:sync` after refreshing ../codex."); + console.error( + `Run \`pnpm codex-app-server:protocol:sync\` after refreshing the Codex checkout at ${path.resolve(sourceSchemaRoot, "../../..")}.`, + ); process.exit(1); } console.log( - `Codex app-server generated protocol matches OpenClaw bridge assumptions: ${schemaRoot}`, + `Codex app-server generated protocol matches OpenClaw bridge assumptions: ${sourceSchemaRoot}`, ); async function compareGeneratedProtocolMirror(): Promise { @@ -130,14 +130,12 @@ async function compareGeneratedProtocolMirror(): Promise { ); const target = await fs.readFile(path.join(targetTsRoot, file), "utf8"); if (source !== target) { - failures.push( - `protocol-generated/typescript/${file}: differs from normalized ../codex schema`, - ); + failures.push(`protocol-generated/typescript/${file}: differs from normalized source schema`); } } for (const file of targetFiles) { if (!sourceSet.has(file)) { - failures.push(`protocol-generated/typescript/${file}: no longer present in ../codex schema`); + failures.push(`protocol-generated/typescript/${file}: no longer present in source schema`); } } @@ -161,7 +159,7 @@ async function compareGeneratedProtocolMirror(): Promise { continue; } if (source !== target) { - failures.push(`protocol-generated/json/${schema}: differs from ../codex schema`); + failures.push(`protocol-generated/json/${schema}: differs from source schema`); } } } diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index fd008e1cf72..2e2c1d7fa3d 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -8,6 +8,7 @@ const SCENARIOS = new Set([ "bootstrap-persona", "plugin-deps-cleanup", "configured-plugin-installs", + "stale-source-plugin-shadow", "tilde-log-path", "versioned-runtime-deps", ]); @@ -355,6 +356,13 @@ function assertStateSurvived() { assert(actual === contents, `${fileName} was changed during update/doctor`); } } + if (scenario === "stale-source-plugin-shadow") { + const staleRoot = path.join(stateDir, "extensions", "opik-openclaw"); + assert( + fs.existsSync(path.join(staleRoot, "src", "index.ts")), + "source-only plugin shadow fixture missing", + ); + } if (scenario === "versioned-runtime-deps") { if (stage === "baseline") { return; diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 61a1734f0fc..3f64479651f 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -286,6 +286,47 @@ configured_plugin_installs_enabled() { [ "$SCENARIO" = "configured-plugin-installs" ] } +source_only_plugin_shadow_enabled() { + [ "$SCENARIO" = "stale-source-plugin-shadow" ] +} + +seed_source_only_plugin_shadow() { + source_only_plugin_shadow_enabled || return 0 + + local shadow_root="$OPENCLAW_STATE_DIR/extensions/opik-openclaw" + mkdir -p "$shadow_root/src" + cat >"$shadow_root/package.json" <<'JSON' +{ + "name": "@opik/opik-openclaw", + "version": "0.0.0-upgrade-survivor", + "openclaw": { + "extensions": ["./src/index.ts"] + } +} +JSON + cat >"$shadow_root/openclaw.plugin.json" <<'JSON' +{ + "id": "opik-openclaw", + "activation": { + "onStartup": false + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} +JSON + cat >"$shadow_root/src/index.ts" <<'TS' +export default { + id: "opik-openclaw", + name: "Source-only Opik shadow", + register() {}, +}; +TS + echo "Seeded source-only plugin shadow: $shadow_root" +} + configure_configured_plugin_install_fixture_registry() { configured_plugin_installs_enabled || return 0 @@ -785,6 +826,7 @@ phase validate-baseline-config validate_baseline_config phase install-baseline-plugin-dependencies install_baseline_plugin_dependencies phase seed-legacy-plugin-dependency-debris seed_legacy_plugin_dependency_debris phase assert-legacy-plugin-dependency-debris assert_legacy_plugin_dependency_debris_present +phase seed-source-only-plugin-shadow seed_source_only_plugin_shadow phase assert-baseline assert_baseline_state phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs index 76a44a1ad53..24d63ba1b57 100644 --- a/scripts/gateway-watch-tmux.mjs +++ b/scripts/gateway-watch-tmux.mjs @@ -9,6 +9,8 @@ const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]); const DEFAULT_PROFILE_NAME = "main"; const DEFAULT_BENCHMARK_PROFILE_DIR = ".artifacts/gateway-watch-profiles"; const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR"; +const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG"; +const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR"; const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs"; const TMUX_CWD_ENV_KEY = "OPENCLAW_GATEWAY_WATCH_CWD"; const TMUX_CWD_OPTION_KEY = "@openclaw.gateway_watch.cwd"; @@ -19,8 +21,11 @@ const TMUX_CHILD_ENV_KEYS = [ "OPENCLAW_HOME", "OPENCLAW_PROFILE", RUN_NODE_CPU_PROF_DIR_ENV, + RUN_NODE_FILTER_SYNC_IO_STDERR_ENV, + RUN_NODE_OUTPUT_LOG_ENV, "OPENCLAW_SKIP_CHANNELS", "OPENCLAW_STATE_DIR", + "OPENCLAW_TRACE_SYNC_IO", ]; const sanitizeSessionPart = (value) => { @@ -49,6 +54,11 @@ const readArgValue = (args, flag) => { return null; }; +const joinArtifactPath = (dir, basename) => { + const normalizedDir = String(dir || DEFAULT_BENCHMARK_PROFILE_DIR).replace(/[\\/]+$/g, ""); + return `${normalizedDir || "."}/${basename}`; +}; + const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) => { const passthroughArgs = []; let benchmarkDir = null; @@ -96,6 +106,14 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) if (benchmarkFlagSeen) { nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] = benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR; + nextEnv.OPENCLAW_TRACE_SYNC_IO ??= "0"; + if (nextEnv.OPENCLAW_TRACE_SYNC_IO === "1") { + nextEnv[RUN_NODE_OUTPUT_LOG_ENV] ??= joinArtifactPath( + nextEnv[RUN_NODE_CPU_PROF_DIR_ENV], + "gateway-watch-output.log", + ); + nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] ??= "1"; + } } return { args: benchmarkNoForceSeen @@ -103,6 +121,10 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) : passthroughArgs, benchmarkNoForce: benchmarkNoForceSeen, benchmarkProfileDir: nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || null, + benchmarkTraceOutputLog: + nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1" + ? nextEnv[RUN_NODE_OUTPUT_LOG_ENV] || null + : null, env: nextEnv, }; }; @@ -248,6 +270,12 @@ export const runGatewayWatchTmuxMain = (params = {}) => { if (resolvedArgs.benchmarkProfileDir) { log(deps.stderr, `gateway:watch benchmark CPU profiles: ${resolvedArgs.benchmarkProfileDir}`); } + if (resolvedArgs.benchmarkTraceOutputLog) { + log( + deps.stderr, + `gateway:watch benchmark trace output: ${resolvedArgs.benchmarkTraceOutputLog}`, + ); + } if (resolvedArgs.benchmarkNoForce) { log(deps.stderr, "gateway:watch benchmark running without --force"); } diff --git a/scripts/lib/codex-app-server-protocol-source.ts b/scripts/lib/codex-app-server-protocol-source.ts new file mode 100644 index 00000000000..1c13c50e161 --- /dev/null +++ b/scripts/lib/codex-app-server-protocol-source.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const PROTOCOL_SCHEMA_RELATIVE_PATH = "codex-rs/app-server-protocol/schema"; + +export async function resolveCodexAppServerProtocolSource(repoRoot: string): Promise<{ + codexRepo: string; + sourceRoot: string; +}> { + const candidates = await collectCodexRepoCandidates(repoRoot); + const checked: string[] = []; + + for (const candidate of candidates) { + const codexRepo = path.resolve(candidate); + if (checked.includes(codexRepo)) { + continue; + } + checked.push(codexRepo); + const sourceRoot = path.join(codexRepo, PROTOCOL_SCHEMA_RELATIVE_PATH); + if (await isDirectory(path.join(sourceRoot, "typescript"))) { + return { codexRepo, sourceRoot }; + } + } + + throw new Error( + [ + "Codex app-server protocol schema not found.", + "Set OPENCLAW_CODEX_REPO to a checkout of openai/codex, or keep a sibling `codex` checkout next to the primary OpenClaw checkout.", + `Checked: ${checked.join(", ") || ""}`, + ].join("\n"), + ); +} + +async function collectCodexRepoCandidates(repoRoot: string): Promise { + const candidates = [ + process.env.OPENCLAW_CODEX_REPO, + path.resolve(repoRoot, "../codex"), + await resolvePrimaryWorktreeSiblingCodex(repoRoot), + ]; + return candidates.filter((candidate): candidate is string => Boolean(candidate)); +} + +async function resolvePrimaryWorktreeSiblingCodex(repoRoot: string): Promise { + const gitFilePath = path.join(repoRoot, ".git"); + let gitFile: string; + try { + gitFile = await fs.readFile(gitFilePath, "utf8"); + } catch { + return undefined; + } + + const match = /^gitdir:\s*(.+)$/m.exec(gitFile); + if (!match) { + return undefined; + } + + const gitDir = path.resolve(repoRoot, match[1].trim()); + const worktreeMarker = `${path.sep}.git${path.sep}worktrees${path.sep}`; + const markerIndex = gitDir.indexOf(worktreeMarker); + if (markerIndex < 0) { + return undefined; + } + + const primaryWorktreeRoot = gitDir.slice(0, markerIndex); + return path.join(path.dirname(primaryWorktreeRoot), "codex"); +} + +async function isDirectory(candidate: string): Promise { + try { + return (await fs.stat(candidate)).isDirectory(); + } catch { + return false; + } +} diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index b8de69eb664..7d5db1dbf70 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -75,6 +75,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [ "bootstrap-persona", "plugin-deps-cleanup", "configured-plugin-installs", + "stale-source-plugin-shadow", "tilde-log-path", "versioned-runtime-deps", ]; diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 4b6fe265fee..866b656f890 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -765,24 +765,36 @@ async function runUpgradeLane(params) { logLanePhase(lane, "update"); const updateEnv = buildRealUpdateEnv(env); - const updateArgs = [ - "update", - "--tag", - params.candidateUrl, - "--yes", - "--json", - "--timeout", - String(updateStepTimeoutSeconds()), - ]; - const updateResult = await runOpenClaw({ - lane, - env: updateEnv, - args: updateArgs, - logPath: join(params.logsDir, "upgrade-update.log"), - timeoutMs: updateTimeoutMs(), - check: false, - }); + const updateArgs = buildPackagedUpgradeUpdateArgs(params.candidateUrl); + const updateLogPath = join(params.logsDir, "upgrade-update.log"); + let updateResult; + let usedWindowsPackagedUpgradeTimeoutFallback = false; + try { + updateResult = await runOpenClaw({ + lane, + env: updateEnv, + args: updateArgs, + logPath: updateLogPath, + timeoutMs: updateTimeoutMs(), + check: false, + }); + } catch (error) { + if (!isRecoverableWindowsPackagedUpgradeTimeoutError(error, process.platform)) { + throw error; + } + usedWindowsPackagedUpgradeTimeoutFallback = true; + appendFileSync( + updateLogPath, + `\n[release-checks] Windows baseline updater timed out after fetching candidate; falling back to direct candidate install: ${formatError(error)}\n`, + ); + updateResult = { + exitCode: 124, + stdout: "", + stderr: formatError(error), + }; + } const usedWindowsPackagedUpgradeFallback = + usedWindowsPackagedUpgradeTimeoutFallback || isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform); if (usedWindowsPackagedUpgradeFallback) { logLanePhase(lane, "update-fallback-install"); @@ -1345,6 +1357,19 @@ export function verifyPackagedUpgradeUpdateResult(result, _options) { ); } +export function buildPackagedUpgradeUpdateArgs(candidateUrl) { + return [ + "update", + "--tag", + candidateUrl, + "--yes", + "--json", + "--no-restart", + "--timeout", + String(updateStepTimeoutSeconds()), + ]; +} + export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( result, platform = process.platform, @@ -1362,6 +1387,22 @@ export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( ); } +export function isRecoverableWindowsPackagedUpgradeTimeoutError( + error, + platform = process.platform, +) { + if (platform !== "win32") { + return false; + } + const message = error instanceof Error ? error.message : String(error); + return ( + /\bCommand timed out:/u.test(message) && + /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json(?: --no-restart)? --timeout \d+/u.test( + message, + ) + ); +} + export function shouldRunPackagedUpgradeStatusProbe({ platform = process.platform, usedWindowsPackagedUpgradeFallback, diff --git a/scripts/prune-docker-plugin-dist.d.mts b/scripts/prune-docker-plugin-dist.d.mts new file mode 100644 index 00000000000..cdc7d9163bd --- /dev/null +++ b/scripts/prune-docker-plugin-dist.d.mts @@ -0,0 +1,6 @@ +export function parseDockerPluginKeepList(value: unknown): Set; +export function pruneDockerPluginDist(params?: { + cwd?: string; + repoRoot?: string; + env?: NodeJS.ProcessEnv; +}): string[]; diff --git a/scripts/prune-docker-plugin-dist.mjs b/scripts/prune-docker-plugin-dist.mjs new file mode 100644 index 00000000000..cd01d591bc6 --- /dev/null +++ b/scripts/prune-docker-plugin-dist.mjs @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs"; +import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; + +function parsePluginList(value) { + if (typeof value !== "string") { + return new Set(); + } + return new Set( + value + .split(/[\s,]+/u) + .map((entry) => entry.trim()) + .filter(Boolean), + ); +} + +export function parseDockerPluginKeepList(value) { + return parsePluginList(value); +} + +export function pruneDockerPluginDist(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const env = params.env ?? process.env; + const keepPluginIds = parseDockerPluginKeepList(env.OPENCLAW_EXTENSIONS); + const excludedPluginIds = collectRootPackageExcludedExtensionDirs({ cwd: repoRoot }); + const removed = []; + + for (const pluginId of [...excludedPluginIds].toSorted((left, right) => + left.localeCompare(right), + )) { + if (keepPluginIds.has(pluginId)) { + continue; + } + + for (const root of ["dist", "dist-runtime"]) { + const pluginDistDir = path.join(repoRoot, root, "extensions", pluginId); + if (!fs.existsSync(pluginDistDir)) { + continue; + } + removePathIfExists(pluginDistDir); + removed.push(path.relative(repoRoot, pluginDistDir).replaceAll("\\", "/")); + } + } + + return removed; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + pruneDockerPluginDist(); +} diff --git a/scripts/qa-coverage-report.ts b/scripts/qa-coverage-report.ts new file mode 100644 index 00000000000..dfe9bcba67e --- /dev/null +++ b/scripts/qa-coverage-report.ts @@ -0,0 +1,56 @@ +import { runQaCoverageReportCommand } from "../extensions/qa-lab/src/cli.runtime.ts"; + +type Options = { + json?: boolean; + output?: string; + repoRoot?: string; +}; + +function takeValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +function parseArgs(args: string[]): Options { + const opts: Options = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + switch (arg) { + case "--help": + case "-h": + process.stdout.write(`Usage: openclaw qa coverage [options] + +Options: + --json Print machine-readable JSON + --output Write the report to a file + --repo-root Repository root to target + -h, --help Display help +`); + process.exit(0); + case "--json": + opts.json = true; + break; + case "--output": + opts.output = takeValue(args, index, arg); + index += 1; + break; + case "--repo-root": + opts.repoRoot = takeValue(args, index, arg); + index += 1; + break; + default: + throw new Error(`Unknown qa coverage option: ${arg}`); + } + } + return opts; +} + +const opts = parseArgs(process.argv.slice(2)); +await runQaCoverageReportCommand({ + ...(opts.json ? { json: true } : {}), + ...(opts.output ? { output: opts.output } : {}), + ...(opts.repoRoot ? { repoRoot: opts.repoRoot } : {}), +}); diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 7b485805527..9b2e8744b1f 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -386,6 +386,7 @@ const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[s const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG"; const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR"; +const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR"; const RUN_NODE_BUILD_LOCK_TIMEOUT_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_TIMEOUT_MS"; const RUN_NODE_BUILD_LOCK_POLL_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS"; const RUN_NODE_BUILD_LOCK_STALE_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_STALE_MS"; @@ -487,6 +488,15 @@ const resolveRunNodeCpuProfileArgs = (deps) => { return ["--cpu-prof", `--cpu-prof-dir=${absoluteProfileDir}`, `--cpu-prof-name=${profileName}`]; }; +const resolveRunNodeDiagnosticArgs = (deps) => { + const args = [...resolveRunNodeCpuProfileArgs(deps)]; + if (deps.env.OPENCLAW_TRACE_SYNC_IO === "1") { + logRunner("Enabling Node --trace-sync-io for startup I/O diagnostics.", deps); + args.push("--trace-sync-io"); + } + return args; +}; + const waitForSpawnedProcess = async (childProcess, deps) => { let forwardedSignal = null; let onSigInt; @@ -557,8 +567,8 @@ const getInterruptedSpawnExitCode = (res) => { }; const runOpenClaw = async (deps) => { - const cpuProfileArgs = resolveRunNodeCpuProfileArgs(deps); - const nodeProcess = deps.spawn(deps.execPath, [...cpuProfileArgs, "openclaw.mjs", ...deps.args], { + const diagnosticArgs = resolveRunNodeDiagnosticArgs(deps); + const nodeProcess = deps.spawn(deps.execPath, [...diagnosticArgs, "openclaw.mjs", ...deps.args], { cwd: deps.cwd, env: deps.env, stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", @@ -576,14 +586,78 @@ const pipeSpawnedOutput = (childProcess, deps) => { if (!deps.outputTee) { return; } + const stderrFilter = + deps.env[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1" + ? createSyncIoTraceStderrFilter(deps) + : null; childProcess.stdout?.on("data", (chunk) => { deps.stdout.write(chunk); deps.outputTee.write(chunk); }); childProcess.stderr?.on("data", (chunk) => { - deps.stderr.write(chunk); + if (stderrFilter) { + stderrFilter.write(chunk); + } else { + deps.stderr.write(chunk); + } deps.outputTee.write(chunk); }); + childProcess.stderr?.on("end", () => { + stderrFilter?.flush(); + }); +}; + +const createSyncIoTraceStderrFilter = (deps) => { + let buffer = ""; + let inSyncIoTrace = false; + + const shouldSuppressLine = (line) => { + const text = line.replace(/\r?\n$/, ""); + if (/^\(node:\d+\) WARNING: Detected use of sync API/.test(text)) { + inSyncIoTrace = true; + return true; + } + if (!inSyncIoTrace) { + return false; + } + if (text.trim() === "") { + inSyncIoTrace = false; + return true; + } + if (/^\s+at\b/.test(text)) { + return true; + } + inSyncIoTrace = false; + return false; + }; + + const writeLine = (line) => { + if (!shouldSuppressLine(line)) { + deps.stderr.write(line); + } + }; + + return { + write(chunk) { + buffer += String(chunk); + while (true) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) { + break; + } + const line = buffer.slice(0, newlineIndex + 1); + buffer = buffer.slice(newlineIndex + 1); + writeLine(line); + } + }, + flush() { + if (!buffer) { + return; + } + writeLine(buffer); + buffer = ""; + }, + }; }; const closeRunNodeOutputTee = async (deps, exitCode) => { @@ -796,6 +870,7 @@ const shouldUseExistingDistForGatewayClient = (deps, buildRequirement) => statMtime(deps.distEntry, deps.fs) != null; const isQaParityReportCommand = (args) => args[0] === "qa" && args[1] === "parity-report"; +const isQaCoverageReportCommand = (args) => args[0] === "qa" && args[1] === "coverage"; const shouldRunQaParityReportFromSource = (deps, buildRequirement) => buildRequirement.reason === "missing_private_qa_dist" && @@ -803,6 +878,12 @@ const shouldRunQaParityReportFromSource = (deps, buildRequirement) => deps.env.OPENCLAW_FORCE_BUILD !== "1" && statMtime(path.join(deps.cwd, "extensions", "qa-lab", "src", "cli.runtime.ts"), deps.fs) != null; +const shouldRunQaCoverageReportFromSource = (deps, buildRequirement) => + buildRequirement.reason === "missing_private_qa_dist" && + isQaCoverageReportCommand(deps.args) && + deps.env.OPENCLAW_FORCE_BUILD !== "1" && + statMtime(path.join(deps.cwd, "extensions", "qa-lab", "src", "cli.runtime.ts"), deps.fs) != null; + const runQaParityReportFromSource = async (deps) => { const sourceEntrypoint = path.join(deps.cwd, "scripts", "qa-parity-report.ts"); const nodeProcess = deps.spawn( @@ -823,6 +904,26 @@ const runQaParityReportFromSource = async (deps) => { return res.exitCode ?? 1; }; +const runQaCoverageReportFromSource = async (deps) => { + const sourceEntrypoint = path.join(deps.cwd, "scripts", "qa-coverage-report.ts"); + const nodeProcess = deps.spawn( + deps.execPath, + ["--import", "tsx", sourceEntrypoint, ...deps.args.slice(2)], + { + cwd: deps.cwd, + env: deps.env, + stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", + }, + ); + pipeSpawnedOutput(nodeProcess, deps); + const res = await waitForSpawnedProcess(nodeProcess, deps); + const interruptedExitCode = getInterruptedSpawnExitCode(res); + if (interruptedExitCode !== null) { + return interruptedExitCode; + } + return res.exitCode ?? 1; +}; + export async function runNodeMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -862,6 +963,7 @@ export async function runNodeMain(params = {}) { buildRequirement, ); const useQaParityReportSource = shouldRunQaParityReportFromSource(deps, buildRequirement); + const useQaCoverageReportSource = shouldRunQaCoverageReportFromSource(deps, buildRequirement); if (useExistingGatewayClientDist) { buildRequirement = { shouldBuild: false, reason: "gateway_client_existing_dist" }; } @@ -870,6 +972,11 @@ export async function runNodeMain(params = {}) { exitCode = await runQaParityReportFromSource(deps); return await closeRunNodeOutputTee(deps, exitCode); } + if (useQaCoverageReportSource) { + logRunner("Running QA coverage report from source without rebuilding private QA dist.", deps); + exitCode = await runQaCoverageReportFromSource(deps); + return await closeRunNodeOutputTee(deps, exitCode); + } if (!buildRequirement.shouldBuild) { if (!useExistingGatewayClientDist) { const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); diff --git a/scripts/run-oxlint.mjs b/scripts/run-oxlint.mjs index e89b87768d2..924fb02ebdb 100644 --- a/scripts/run-oxlint.mjs +++ b/scripts/run-oxlint.mjs @@ -52,16 +52,24 @@ export function filterSparseMissingOxlintTargets( } = {}, ) { if (!isSparseCheckoutEnabled({ cwd })) { - return { args, hadExplicitTargets: false, remainingExplicitTargets: 0, skippedTargets: [] }; + return { + args, + hadExplicitTargets: false, + remainingExplicitTargets: 0, + skippedTargets: [], + skippedConfigs: [], + }; } const filteredArgs = []; const skippedTargets = []; + const skippedConfigs = []; let hadExplicitTargets = false; let remainingExplicitTargets = 0; let consumeNextValue = false; - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; if (consumeNextValue) { filteredArgs.push(arg); consumeNextValue = false; @@ -74,6 +82,29 @@ export function filterSparseMissingOxlintTargets( } if (arg.startsWith("--")) { + if (arg === "--tsconfig") { + const value = args[index + 1]; + if (value !== undefined) { + index += 1; + if (!fileExists(path.resolve(cwd, value)) && isTrackedPath({ cwd, target: value })) { + skippedConfigs.push(value); + continue; + } + filteredArgs.push(arg, value); + continue; + } + } + if (arg.startsWith("--tsconfig=")) { + const value = arg.slice("--tsconfig=".length); + if ( + value && + !fileExists(path.resolve(cwd, value)) && + isTrackedPath({ cwd, target: value }) + ) { + skippedConfigs.push(value); + continue; + } + } filteredArgs.push(arg); if (!arg.includes("=") && OXLINT_VALUE_FLAGS.has(arg)) { consumeNextValue = true; @@ -97,7 +128,13 @@ export function filterSparseMissingOxlintTargets( filteredArgs.push(arg); } - return { args: filteredArgs, hadExplicitTargets, remainingExplicitTargets, skippedTargets }; + return { + args: filteredArgs, + hadExplicitTargets, + remainingExplicitTargets, + skippedTargets, + skippedConfigs, + }; } function getSparseCheckoutEnabled({ cwd }) { @@ -159,6 +196,12 @@ export async function main(argv = process.argv.slice(2), runtimeEnv = process.en `[oxlint] sparse checkout is missing tracked target(s); skipping ${sparseTargets.skippedTargets.join(", ")}`, ); } + if (sparseTargets.skippedConfigs.length > 0) { + console.error( + `[oxlint] sparse checkout is missing tracked config(s); skipping oxlint: ${sparseTargets.skippedConfigs.join(", ")}`, + ); + return; + } if (sparseTargets.hadExplicitTargets && sparseTargets.remainingExplicitTargets === 0) { console.error("[oxlint] no present sparse-checkout targets remain; skipping oxlint."); return; diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 0d1d1e40437..fae1cf9b920 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -19,6 +19,7 @@ const ROOT_RUNTIME_ALIAS_PATTERN = /^(?.+\.(?:runtime|contract))-[A-Za-z0- const ROOT_STABLE_RUNTIME_ALIAS_PATTERN = /^.+\.(?:runtime|contract)\.js$/u; const ROOT_RUNTIME_IMPORT_SPECIFIER_PATTERN = /(["'])\.\/([^"']+\.(?:runtime|contract)-[A-Za-z0-9_-]+\.js)\1/gu; +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ // v2026.4.29 dispatch lazy chunks. Package updates used to replace the // dist tree before the live gateway had restarted, so an already-loaded old @@ -45,6 +46,77 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ ["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"], ["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.runtime.js"], ]; +const LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS = [ + "scanPackageInstallSource", + "scanFileInstallSource", + "scanInstalledPackageDependencyTree", + "scanBundleInstallSource", +]; +const PLUGIN_INSTALL_RUNTIME_ALIAS = { + aliasFileName: "install.runtime.js", + sourceIncludes: LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS, +}; +const LEGACY_PLUGIN_INSTALL_RUNTIME_COMPAT_ALIASES = [ + // Published releases from v2026.3.22 onward. Older updaters could + // overlay package dist instead of swapping it, leaving old install chunks + // that still import these hashed plugin install runtime files. + "install.runtime-D7SL02B2.js", + "install.runtime-Deq6Beal.js", + "install.runtime-Eoq8y3HE.js", + "install.runtime-DDmlaKdG.js", + "install.runtime-ADTafpVD.js", + "install.runtime-v8X-j3Tm.js", + "install.runtime-BLcZ-44g.js", + "install.runtime-vS4aFJvO.js", + "install.runtime-Dm_c092A.js", + "install.runtime-D_7OUvuY.js", + "install.runtime-BLEE0OIk.js", + "install.runtime-3LpjZbr8.js", + "install.runtime-BrsB9OnV.js", + "install.runtime-BEOb-kNW.js", + "install.runtime-Cx_xphd1.js", + "install.runtime-B-MtEMSR.js", + "install.runtime-C-Y4HAqX.js", + "install.runtime-j1SedTZh.js", + "install.runtime-4zsL_8wt.js", + "install.runtime-BhCKlLSJ.js", + "install.runtime-tGJ0KhMF.js", + "install.runtime-DtmATpak.js", + "install.runtime-BzZ38ePb.js", + "install.runtime-DwQr7nEE.js", + "install.runtime-CEIURnUz.js", + "install.runtime-D3EPlM0r.js", + "install.runtime-DIlN5H3O.js", + "install.runtime-DjcOwVH_.js", + "install.runtime-B13jZink.js", + "install.runtime-O8MXNrwm.js", + "install.runtime-Bkf_VMnk.js", + "install.runtime-QOfEzAcZ.js", + "install.runtime-BRVACueI.js", + "install.runtime-DX8jy7tN.js", + "install.runtime-BdfsTamp.js", + "install.runtime-B6OA2_P8.js", + "install.runtime-D9cTH-C0.js", + "install.runtime-OCJULXQo.js", + "install.runtime-9ZXBhZSk.js", + "install.runtime-DlL3C3t_.js", + "install.runtime-TU-jP-TN.js", + "install.runtime-a2FlfOSp.js", + "install.runtime-BwuRABU1.js", + "install.runtime-B3mZL_R2.js", + "install.runtime-CWUzypNQ.js", + "install.runtime-D6FSd9v2.js", + "install.runtime-DQ-ui3nL.js", + "install.runtime-CNHwKOIb.js", + "install.runtime-Dzuj9tSw.js", + "install.runtime-BuF-YAfQ.js", + "install.runtime-Xom5hOHq.js", + "install.runtime-tnhNR9WW.js", +].map((legacyFileName) => ({ + legacyFileName, + aliasFileName: PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName, + sourceIncludes: LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS, +})); const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [ { dest: "dist/memory-state-CcqRgDZU.js", @@ -82,10 +154,18 @@ export function writeStableRootRuntimeAliases(params = {}) { candidatesByAlias.set(aliasFileName, candidates); } - const resolveAliasCandidate = (candidates) => { + const resolveAliasCandidate = (aliasFileName, candidates) => { if (candidates.length === 1) { return candidates[0]; } + if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) { + return resolveRootRuntimeCandidateByMarkers({ + distDir, + fsImpl, + aliasFileName, + sourceIncludes: PLUGIN_INSTALL_RUNTIME_ALIAS.sourceIncludes, + }); + } const candidateSet = new Set(candidates); const wrappers = candidates.filter((candidate) => { const filePath = path.join(distDir, candidate); @@ -108,7 +188,7 @@ export function writeStableRootRuntimeAliases(params = {}) { for (const [aliasFileName, candidates] of candidatesByAlias) { const aliasPath = path.join(distDir, aliasFileName); - const candidate = resolveAliasCandidate(candidates); + const candidate = resolveAliasCandidate(aliasFileName, candidates); if (!candidate) { fsImpl.rmSync?.(aliasPath, { force: true }); continue; @@ -143,10 +223,21 @@ export function rewriteRootRuntimeImportsToStableAliases(params = {}) { } const runtimeAliasFiles = new Map(); for (const [aliasFileName, candidates] of candidatesByAlias) { - if (candidates.length !== 1) { + if (candidates.length === 1) { + runtimeAliasFiles.set(candidates[0], aliasFileName); continue; } - runtimeAliasFiles.set(candidates[0], aliasFileName); + if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) { + const candidate = resolveRootRuntimeCandidateByMarkers({ + distDir, + fsImpl, + aliasFileName, + sourceIncludes: PLUGIN_INSTALL_RUNTIME_ALIAS.sourceIncludes, + }); + if (candidate) { + runtimeAliasFiles.set(candidate, aliasFileName); + } + } } if (runtimeAliasFiles.size === 0) { return; @@ -179,19 +270,87 @@ export function rewriteRootRuntimeImportsToStableAliases(params = {}) { } } +function resolveRootRuntimeCandidateByMarkers(params) { + if (!params.sourceIncludes?.length) { + return null; + } + const match = params.aliasFileName.match(ROOT_STABLE_RUNTIME_ALIAS_PATTERN); + if (!match) { + return null; + } + const aliasBaseFileName = params.aliasFileName.replace(/\.js$/u, ""); + const hashedPattern = new RegExp(`^${escapeRegExp(aliasBaseFileName)}-[A-Za-z0-9_-]+\\.js$`, "u"); + let entries = []; + try { + entries = params.fsImpl.readdirSync(params.distDir, { withFileTypes: true }); + } catch { + return null; + } + const candidates = []; + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isFile() || !hashedPattern.test(entry.name)) { + continue; + } + const candidatePath = path.join(params.distDir, entry.name); + let source; + try { + source = params.fsImpl.readFileSync(candidatePath, "utf8"); + } catch { + continue; + } + if (params.sourceIncludes.every((marker) => source.includes(marker))) { + candidates.push(entry.name); + } + } + return candidates.length === 1 ? candidates[0] : null; +} + +function resolveLegacyRootRuntimeCompatTarget(params) { + if ( + params.aliasFileName && + params.fsImpl.existsSync(path.join(params.distDir, params.aliasFileName)) + ) { + return params.aliasFileName; + } + const match = params.legacyFileName.match(ROOT_RUNTIME_ALIAS_PATTERN); + if (!match?.groups?.base) { + return null; + } + return resolveRootRuntimeCandidateByMarkers({ + distDir: params.distDir, + fsImpl: params.fsImpl, + aliasFileName: `${match.groups.base}.js`, + sourceIncludes: params.sourceIncludes, + }); +} + export function writeLegacyRootRuntimeCompatAliases(params = {}) { const rootDir = params.rootDir ?? ROOT; const distDir = path.join(rootDir, "dist"); const fsImpl = params.fs ?? fs; - for (const [legacyFileName, aliasFileName] of LEGACY_ROOT_RUNTIME_COMPAT_ALIASES) { + for (const entry of [ + ...LEGACY_ROOT_RUNTIME_COMPAT_ALIASES.map(([legacyFileName, aliasFileName]) => ({ + legacyFileName, + aliasFileName, + })), + ...LEGACY_PLUGIN_INSTALL_RUNTIME_COMPAT_ALIASES, + ]) { + const { legacyFileName } = entry; const legacyPath = path.join(distDir, legacyFileName); if (fsImpl.existsSync(legacyPath)) { continue; } - if (!fsImpl.existsSync(path.join(distDir, aliasFileName))) { + const targetFileName = resolveLegacyRootRuntimeCompatTarget({ + distDir, + fsImpl, + legacyFileName, + aliasFileName: entry.aliasFileName, + sourceIncludes: entry.sourceIncludes, + }); + if (!targetFileName) { continue; } - writeTextFileIfChanged(legacyPath, `export * from "./${aliasFileName}";\n`); + writeTextFileIfChanged(legacyPath, `export * from "./${targetFileName}";\n`); } } diff --git a/scripts/sync-codex-app-server-protocol.ts b/scripts/sync-codex-app-server-protocol.ts index 526bc3b7bde..379d150d7bc 100644 --- a/scripts/sync-codex-app-server-protocol.ts +++ b/scripts/sync-codex-app-server-protocol.ts @@ -1,11 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveCodexAppServerProtocolSource } from "./lib/codex-app-server-protocol-source.js"; -const codexRepo = process.env.OPENCLAW_CODEX_REPO - ? path.resolve(process.env.OPENCLAW_CODEX_REPO) - : path.resolve(process.cwd(), "../codex"); - -const sourceRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema"); +const { sourceRoot } = await resolveCodexAppServerProtocolSource(process.cwd()); const targetRoot = path.resolve( process.cwd(), "extensions/codex/src/app-server/protocol-generated", diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index b7cc1406672..7d322c1fe33 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -22,9 +22,15 @@ DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" DOCKER_HOME_MOUNT=() DOCKER_TRUSTED_HARNESS_MOUNT=() DOCKER_TRUSTED_HARNESS_CONTAINER_DIR="" +DOCKER_CACHE_CONTAINER_DIR="/tmp/openclaw-cache" +DOCKER_CLI_TOOLS_CONTAINER_DIR="/tmp/openclaw-npm-global" DOCKER_EXTRA_ENV_FILES=() DOCKER_AUTH_PRESTAGED=0 +openclaw_live_codex_harness_is_ci() { + [[ -n "${CI:-}" && "${CI:-}" != "false" ]] || [[ -n "${GITHUB_ACTIONS:-}" && "${GITHUB_ACTIONS:-}" != "false" ]] +} + openclaw_live_codex_harness_append_build_extension() { local extension="${1:?extension required}" local current="${OPENCLAW_DOCKER_BUILD_EXTENSIONS:-${OPENCLAW_EXTENSIONS:-}}" @@ -50,6 +56,13 @@ if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" && -z "${OPENAI_API_KEY:-}" ]]; th echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key requires OPENAI_API_KEY." >&2 exit 1 fi +if [[ "$CODEX_HARNESS_AUTH_MODE" != "api-key" && ! -s "$HOME/.codex/auth.json" ]]; then + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=codex-auth requires ~/.codex/auth.json before building the live Docker image." >&2 + if [[ -n "${OPENAI_API_KEY:-}" ]]; then + echo "If this is a Testbox/API-key run, set OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key and run through openclaw-testbox-env." >&2 + fi + exit 1 +fi cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then @@ -60,7 +73,7 @@ trap cleanup_temp_dirs EXIT if [[ -n "${OPENCLAW_DOCKER_CLI_TOOLS_DIR:-}" ]]; then CLI_TOOLS_DIR="${OPENCLAW_DOCKER_CLI_TOOLS_DIR}" -elif [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +elif openclaw_live_codex_harness_is_ci; then CLI_TOOLS_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-cli-tools.XXXXXX")" TEMP_DIRS+=("$CLI_TOOLS_DIR") else @@ -68,7 +81,7 @@ else fi if [[ -n "${OPENCLAW_DOCKER_CACHE_HOME_DIR:-}" ]]; then CACHE_HOME_DIR="${OPENCLAW_DOCKER_CACHE_HOME_DIR}" -elif [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +elif openclaw_live_codex_harness_is_ci; then CACHE_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-cache.XXXXXX")" TEMP_DIRS+=("$CACHE_HOME_DIR") else @@ -77,7 +90,10 @@ fi mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" -if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +if openclaw_live_codex_harness_is_ci; then + chmod 0777 "$CLI_TOOLS_DIR" "$CACHE_HOME_DIR" || true +fi +if openclaw_live_codex_harness_is_ci; then DOCKER_USER="$(id -u):$(id -g)" DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" TEMP_DIRS+=("$DOCKER_HOME_DIR") @@ -146,6 +162,11 @@ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/node/corepack}" export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" = "1" ]; then + id + mount | grep -E 'openclaw-cache|openclaw-npm|/home/node' || true + ls -ld "$HOME" "$XDG_CACHE_HOME" "$NPM_CONFIG_PREFIX" 2>/dev/null || true +fi # Force the Codex harness to use the staged `~/.codex` auth files. This lane # is not meant to exercise raw OpenAI API-key routing unless the lane # explicitly opts into API-key auth for CI. @@ -254,6 +275,12 @@ DOCKER_RUN_ARGS=(docker run --rm -t \ --entrypoint bash \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ + -e NPM_CONFIG_PREFIX="$DOCKER_CLI_TOOLS_CONTAINER_DIR" \ + -e npm_config_prefix="$DOCKER_CLI_TOOLS_CONTAINER_DIR" \ + -e XDG_CACHE_HOME="$DOCKER_CACHE_CONTAINER_DIR" \ + -e COREPACK_HOME="$DOCKER_CACHE_CONTAINER_DIR/node/corepack" \ + -e NPM_CONFIG_CACHE="$DOCKER_CACHE_CONTAINER_DIR/npm" \ + -e npm_config_cache="$DOCKER_CACHE_CONTAINER_DIR/npm" \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_AGENT_HARNESS_FALLBACK=none \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ @@ -287,14 +314,22 @@ openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_EXTRA_ENV_FILES openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_HOME_MOUNT openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_TRUSTED_HARNESS_MOUNT DOCKER_RUN_ARGS+=(\ - -v "$CACHE_HOME_DIR":/home/node/.cache \ + -v "$CACHE_HOME_DIR":"$DOCKER_CACHE_CONTAINER_DIR" \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ - -v "$CLI_TOOLS_DIR":/home/node/.npm-global) + -v "$CLI_TOOLS_DIR":"$DOCKER_CLI_TOOLS_CONTAINER_DIR") openclaw_live_append_array DOCKER_RUN_ARGS EXTERNAL_AUTH_MOUNTS openclaw_live_append_array DOCKER_RUN_ARGS PROFILE_MOUNT DOCKER_RUN_ARGS+=(\ "$LIVE_IMAGE_NAME" \ -lc "$LIVE_TEST_CMD") +if [[ "${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" == "1" ]]; then + echo "==> Docker debug: host ids and mounted dirs" + id + ls -ld "$CACHE_HOME_DIR" "$CLI_TOOLS_DIR" "${DOCKER_HOME_DIR:-$HOME}" 2>/dev/null || true + printf '==> Docker debug args:' + printf ' %q' "${DOCKER_RUN_ARGS[@]}" + printf '\n' +fi "${DOCKER_RUN_ARGS[@]}" diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 6cb65d6dbc9..da64b7cff0a 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -277,6 +277,9 @@ export async function runWatchMain(params = {}) { // The watcher owns process restarts; keep SIGUSR1/config reloads in-process // so inherited launchd/systemd markers do not make the child exit and stall. childEnv.OPENCLAW_NO_RESPAWN = "1"; + if (isGatewayWatchCommand(deps.args) && childEnv.OPENCLAW_TRACE_SYNC_IO === undefined) { + childEnv.OPENCLAW_TRACE_SYNC_IO = "1"; + } if (deps.args.length > 0) { childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); } diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index dfde018cd13..d2b0ae4f488 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -17,8 +17,11 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; @@ -1028,14 +1031,6 @@ async function agentCommandInternal( currentTurnUserMessagePersisted = true; }, onAgentEvent: (evt) => { - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data ?? {}, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } if ( evt.stream === "lifecycle" && typeof evt.data?.phase === "string" && @@ -1246,8 +1241,43 @@ async function agentCommandInternal( } const payloads = result.payloads ?? []; + + // Phase 2: Persist pending final delivery for main sessions before attempting delivery. + // This ensures that if the process restarts during delivery, the payload is durable. + if ( + opts.deliver === true && + sessionStore && + sessionKey && + payloads.length > 0 && + !isSubagentSessionKey(sessionKey) + ) { + const now = Date.now(); + const combinedPayload = payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n"); + + if (combinedPayload) { + const entry = sessionStore[sessionKey] ?? sessionEntry; + const next: SessionEntry = { + ...entry, + pendingFinalDelivery: true, + pendingFinalDeliveryText: combinedPayload, + pendingFinalDeliveryCreatedAt: now, + updatedAt: now, + }; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, + }); + sessionEntry = next; + } + } + const { deliverAgentCommandResult } = await loadDeliveryRuntime(); - return await deliverAgentCommandResult({ + const deliveryResult = await deliverAgentCommandResult({ cfg, deps: resolvedDeps, runtime, @@ -1257,6 +1287,32 @@ async function agentCommandInternal( result, payloads, }); + + // Phase 2: Clear pending delivery payload after successful delivery. + if ( + deliveryResult?.deliverySucceeded === true && + sessionStore && + sessionKey && + !isSubagentSessionKey(sessionKey) + ) { + const entry = sessionStore[sessionKey] ?? sessionEntry; + const next: SessionEntry = { + ...entry, + pendingFinalDelivery: undefined, + pendingFinalDeliveryText: undefined, + pendingFinalDeliveryCreatedAt: undefined, + updatedAt: Date.now(), + }; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, + }); + sessionEntry = next; + } + + return deliveryResult; } finally { clearAgentRunContext(runId); } diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index c1353d01e66..281e1593373 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -279,12 +279,23 @@ export async function resolveBootstrapContextForRun(params: { contextFiles: EmbeddedContextFile[]; }> { const bootstrapFiles = await resolveBootstrapFilesForRun(params); + const contextFiles = buildBootstrapContextForFiles(bootstrapFiles, params); + return { bootstrapFiles, contextFiles }; +} + +export function buildBootstrapContextForFiles( + bootstrapFiles: WorkspaceBootstrapFile[], + params: { + config?: OpenClawConfig; + warn?: (message: string) => void; + }, +): EmbeddedContextFile[] { const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { maxChars: resolveBootstrapMaxChars(params.config), totalMaxChars: resolveBootstrapTotalMaxChars(params.config), warn: params.warn, }); - return { bootstrapFiles, contextFiles }; + return contextFiles; } export { isWorkspaceBootstrapPending }; diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index e83b3f179d3..54feec553e3 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -417,6 +417,13 @@ describe("runBtwSideQuestion", () => { const result = await runSideQuestion(); expect(result).toEqual({ text: "Final answer." }); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith( + expect.any(Object), + DEFAULT_AGENT_DIR, + { + workspaceDir: "/tmp/workspace", + }, + ); }); it("applies provider runtime auth before streaming github-copilot BTW questions", async () => { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 43bb5e0f444..4f8512ae3b5 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -216,6 +216,7 @@ async function resolveRuntimeModel(params: { provider: string; model: string; agentDir: string; + workspaceDir?: string; sessionEntry?: StoredSessionEntry; sessionStore?: Record; sessionKey?: string; @@ -226,7 +227,8 @@ async function resolveRuntimeModel(params: { authProfileId?: string; authProfileIdSource?: "auto" | "user"; }> { - await ensureOpenClawModelsJson(params.cfg, params.agentDir); + const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; + await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); const model = resolveModelWithRegistry({ @@ -319,11 +321,17 @@ export async function runBtwSideQuestion( throw new Error("No active session context."); } + const sessionAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); const { model, authProfileId } = await resolveRuntimeModel({ cfg: params.cfg, provider: params.provider, model: params.model, agentDir: params.agentDir, + workspaceDir, sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, sessionKey: params.sessionKey, @@ -341,11 +349,6 @@ export async function runBtwSideQuestion( apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey ? undefined : requireApiKey(apiKeyInfo, model.provider); - const sessionAgentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, - }); - const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); if (apiKey) { const preparedAuth = await prepareProviderRuntimeAuth({ provider: model.provider, diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index ae385fd130f..56914ee1cc9 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -4,6 +4,7 @@ import type { CliBackendConfig } from "../config/types.js"; import type { CliBackendAuthEpochMode, CliBackendNormalizeConfigContext, + CliBackendResolveExecutionArgs, CliBundleMcpMode, } from "../plugins/types.js"; import { @@ -31,6 +32,7 @@ function createBackendEntry(params: { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: () => Promise; + resolveExecutionArgs?: CliBackendResolveExecutionArgs; normalizeConfig?: ( config: CliBackendConfig, context?: CliBackendNormalizeConfigContext, @@ -47,6 +49,7 @@ function createBackendEntry(params: { ...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}), ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), + ...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}), ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), liveTest: { defaultModelRef: @@ -968,6 +971,29 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { expect(resolved?.config.systemPromptWhen).toBe("first"); expect(resolved?.config.imagePathScope).toBe("workspace"); }); + + it("preserves backend-owned per-run arg resolvers", () => { + const resolveExecutionArgs: CliBackendResolveExecutionArgs = ({ baseArgs }) => [ + ...baseArgs, + "--effort", + "high", + ]; + runtimeBackendEntries = [ + createRuntimeBackendEntry({ + pluginId: "anthropic", + id: "claude-cli", + config: { + command: "claude", + args: ["-p"], + }, + resolveExecutionArgs, + }), + ]; + + const resolved = resolveCliBackendConfig("claude-cli"); + + expect(resolved?.resolveExecutionArgs).toBe(resolveExecutionArgs); + }); }); describe("resolveCliBackendConfig alias precedence", () => { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index b86d259e833..ae9718893ee 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -38,6 +38,7 @@ export type ResolvedCliBackend = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: CliBackendPlugin["prepareExecution"]; + resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; }; @@ -62,6 +63,7 @@ type FallbackCliBackendPolicy = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: CliBackendPlugin["prepareExecution"]; + resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; }; @@ -99,6 +101,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic defaultAuthProfileId: entry.backend.defaultAuthProfileId, authEpochMode: entry.backend.authEpochMode, prepareExecution: entry.backend.prepareExecution, + resolveExecutionArgs: entry.backend.resolveExecutionArgs, nativeToolMode: entry.backend.nativeToolMode, }; } @@ -237,6 +240,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: registered.defaultAuthProfileId, authEpochMode: registered.authEpochMode, prepareExecution: registered.prepareExecution, + resolveExecutionArgs: registered.resolveExecutionArgs, nativeToolMode: registered.nativeToolMode, }; } @@ -266,6 +270,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, authEpochMode: fallbackPolicy.authEpochMode, prepareExecution: fallbackPolicy.prepareExecution, + resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, nativeToolMode: fallbackPolicy.nativeToolMode, }; } @@ -292,6 +297,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, authEpochMode: fallbackPolicy?.authEpochMode, prepareExecution: fallbackPolicy?.prepareExecution, + resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, nativeToolMode: fallbackPolicy?.nativeToolMode, }; } diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index b994402f5c0..9099f994e2e 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -59,9 +59,11 @@ function buildPreparedCliRunContext(params: { sessionId?: string; sessionKey?: string; backend?: Partial; + resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"]; config?: PreparedCliRunContext["params"]["config"]; mcpConfigHash?: string; skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"]; + thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"]; workspaceDir?: string; }): PreparedCliRunContext { const workspaceDir = params.workspaceDir ?? "/tmp"; @@ -103,6 +105,7 @@ function buildPreparedCliRunContext(params: { prompt: params.prompt ?? "hi", provider: params.provider, model: params.model, + thinkLevel: params.thinkLevel, timeoutMs: 1_000, runId: params.runId, skillsSnapshot: params.skillsSnapshot, @@ -114,6 +117,7 @@ function buildPreparedCliRunContext(params: { config: backend, bundleMcp: params.provider === "claude-cli", pluginId: params.provider === "claude-cli" ? "anthropic" : "openai", + resolveExecutionArgs: params.resolveExecutionArgs, }, preparedBackend: { backend, @@ -329,6 +333,35 @@ describe("runCliAgent spawn path", () => { expect(input.argv).not.toContain("hi"); }); + it("applies backend-owned per-run args before spawning", async () => { + mockSuccessfulCliRun(); + const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--effort", "high"]); + + await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-claude-thinking-args", + thinkLevel: "high", + resolveExecutionArgs, + }), + ); + + expect(resolveExecutionArgs).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "claude-cli", + modelId: "sonnet", + thinkingLevel: "high", + useResume: false, + baseArgs: ["-p", "--output-format", "stream-json"], + }), + ); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] }; + const effortArgIndex = input.argv?.indexOf("--effort") ?? -1; + expect(effortArgIndex).toBeGreaterThanOrEqual(0); + expect(input.argv?.[effortArgIndex + 1]).toBe("high"); + }); + it("passes OpenClaw skills to Claude as a session plugin", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-skills-")); const skillDir = path.join(workspaceDir, "skills", "weather"); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index e3a265a6dad..b043fb616a1 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -279,12 +279,24 @@ export async function executePreparedCliRun( skillsSnapshot: params.skillsSnapshot, }); let claudeSkillsPluginCleanupOwned = false; + const baseArgsWithSkills = + claudeSkillsPlugin.args.length > 0 + ? [...resolvedArgs, ...claudeSkillsPlugin.args] + : resolvedArgs; + const executionBaseArgs = + context.backendResolved.resolveExecutionArgs?.({ + config: params.config, + workspaceDir: context.workspaceDir, + provider: params.provider, + modelId: context.modelId, + authProfileId: context.effectiveAuthProfileId, + thinkingLevel: params.thinkLevel, + useResume, + baseArgs: baseArgsWithSkills, + }) ?? baseArgsWithSkills; const args = buildCliArgs({ backend, - baseArgs: - claudeSkillsPlugin.args.length > 0 - ? [...resolvedArgs, ...claudeSkillsPlugin.args] - : resolvedArgs, + baseArgs: Array.from(executionBaseArgs), modelId: context.normalizedModel, sessionId: resolvedSessionId, systemPrompt: systemPromptArg, diff --git a/src/agents/command/delivery.test.ts b/src/agents/command/delivery.test.ts index 97eeaf7415b..2e82a135ead 100644 --- a/src/agents/command/delivery.test.ts +++ b/src/agents/command/delivery.test.ts @@ -215,6 +215,52 @@ describe("normalizeAgentCommandReplyPayloads", () => { }); }); + it("reports successful requested delivery", async () => { + deliverOutboundPayloadsMock.mockResolvedValue([]); + + const delivered = await deliverMediaReplyForTest({ + key: "agent:tester:slack:direct:alice", + agentId: "tester", + } as never); + + expect(delivered.deliverySucceeded).toBe(true); + }); + + it("does not report success when best-effort delivery records an error", async () => { + deliverOutboundPayloadsMock.mockImplementationOnce(async (params: unknown) => { + (params as { onError?: (err: unknown) => void }).onError?.(new Error("send failed")); + return []; + }); + + const runtime = { log: vi.fn(), error: vi.fn() }; + const delivered = await deliverAgentCommandResult({ + cfg: { + agents: { + list: [{ id: "tester", workspace: "/tmp/agent-workspace" }], + }, + } as OpenClawConfig, + deps: {} as CliDeps, + runtime: runtime as never, + opts: { + message: "go", + deliver: true, + bestEffortDeliver: true, + replyChannel: "slack", + replyTo: "#general", + } as AgentCommandOpts, + outboundSession: { + key: "agent:tester:slack:direct:alice", + agentId: "tester", + } as never, + sessionEntry: undefined, + payloads: [{ text: "here you go" }], + result: createResult(), + }); + + expect(delivered.deliverySucceeded).toBe(false); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("send failed")); + }); + it("threads agentId into the normalizer when sessionKey is unresolved", async () => { createReplyMediaPathNormalizerMock.mockReturnValue(async (payload: ReplyPayload) => payload); deliverOutboundPayloadsMock.mockResolvedValue([]); diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index 9a1f3c06524..2cad2a18179 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -354,6 +354,8 @@ export async function deliverAgentCommandResult(params: { } const deliveryPayloads = projectOutboundPayloadPlanForOutbound(outboundPayloadPlan); + let deliverySucceeded = false; + let deliveryHadError = false; const logPayload = (payload: NormalizedOutboundPayload) => { if (opts.json) { return; @@ -368,6 +370,10 @@ export async function deliverAgentCommandResult(params: { } runtime.log(output); }; + const markDeliveryError = (err: unknown) => { + deliveryHadError = true; + logDeliveryError(err); + }; if (!deliver) { for (const payload of deliveryPayloads) { logPayload(payload); @@ -385,12 +391,13 @@ export async function deliverAgentCommandResult(params: { replyToId: resolvedReplyToId ?? null, threadId: resolvedThreadTarget ?? null, bestEffort: bestEffortDeliver, - onError: (err) => logDeliveryError(err), + onError: markDeliveryError, onPayload: logPayload, deps: createOutboundSendDeps(deps), }); + deliverySucceeded = !deliveryHadError; } } - return { payloads: normalizedPayloads, meta: resultMeta }; + return { payloads: normalizedPayloads, meta: resultMeta, deliverySucceeded }; } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 0bf84c978ba..2aaba3004cd 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -278,6 +278,43 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:main"]?.abortedLastRun).toBe(true); }); + it("resumes marked sessions with a durable pending final delivery payload (Phase 2)", async () => { + const sessionsDir = await makeSessionsDir(); + const pendingPayload = "The final answer is 42."; + await writeStore(sessionsDir, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now() - 10_000, + status: "running", + abortedLastRun: true, + pendingFinalDelivery: true, + pendingFinalDeliveryText: pendingPayload, + pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + }, + }); + await writeTranscript(sessionsDir, "main-session", [ + { role: "user", content: "calculate the answer" }, + { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "calc" }] }, + { role: "toolResult", content: "42" }, + ]); + + const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir }); + + expect(result).toEqual({ recovered: 1, failed: 0, skipped: 0 }); + expect(callGateway).toHaveBeenCalledOnce(); + const callParams = vi.mocked(callGateway).mock.calls[0]?.[0].params as { message?: string }; + expect(callParams.message).toContain(pendingPayload); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(store["agent:main:main"]?.abortedLastRun).toBe(false); + expect(store["agent:main:main"]?.pendingFinalDelivery).toBe(true); + expect(store["agent:main:main"]?.pendingFinalDeliveryText).toBe(pendingPayload); + expect(store["agent:main:main"]?.pendingFinalDeliveryCreatedAt).toBeDefined(); + expect(store["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBe(1); + expect(store["agent:main:main"]?.pendingFinalDeliveryLastAttemptAt).toBeDefined(); + expect(store["agent:main:main"]?.pendingFinalDeliveryLastError).toBeNull(); + }); + it("does not scan ordinary running sessions without the restart-aborted marker", async () => { const sessionsDir = await makeSessionsDir(); await writeStore(sessionsDir, { diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index 585a931a286..68d0607511a 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -116,12 +116,15 @@ function resolveMainSessionResumeBlockReason(messages: unknown[]): string | null return null; } -function buildResumeMessage(): string { - return ( +function buildResumeMessage(pendingFinalDeliveryText?: string | null): string { + const base = "[System] Your previous turn was interrupted by a gateway restart while " + "OpenClaw was waiting on tool/model work. Continue from the existing " + - "transcript and finish the interrupted response." - ); + "transcript and finish the interrupted response."; + if (pendingFinalDeliveryText) { + return `${base}\n\nNote: The interrupted final reply was captured: "${pendingFinalDeliveryText}"`; + } + return base; } async function markSessionFailed(params: { @@ -140,6 +143,13 @@ async function markSessionFailed(params: { entry.abortedLastRun = true; entry.endedAt = Date.now(); entry.updatedAt = entry.endedAt; + entry.pendingFinalDelivery = undefined; + entry.pendingFinalDeliveryText = undefined; + entry.pendingFinalDeliveryCreatedAt = undefined; + entry.pendingFinalDeliveryLastAttemptAt = undefined; + entry.pendingFinalDeliveryAttemptCount = undefined; + entry.pendingFinalDeliveryLastError = undefined; + entry.pendingFinalDeliveryContext = undefined; store[params.sessionKey] = entry; }, { skipMaintenance: true }, @@ -150,12 +160,13 @@ async function markSessionFailed(params: { async function resumeMainSession(params: { storePath: string; sessionKey: string; + pendingFinalDeliveryText?: string | null; }): Promise { try { await callGateway<{ runId: string }>({ method: "agent", params: { - message: buildResumeMessage(), + message: buildResumeMessage(params.pendingFinalDeliveryText), sessionKey: params.sessionKey, idempotencyKey: crypto.randomUUID(), deliver: false, @@ -170,13 +181,24 @@ async function resumeMainSession(params: { if (!entry) { return; } + const now = Date.now(); entry.abortedLastRun = false; - entry.updatedAt = Date.now(); + entry.updatedAt = now; + if (entry.pendingFinalDelivery || entry.pendingFinalDeliveryText) { + entry.pendingFinalDeliveryLastAttemptAt = now; + entry.pendingFinalDeliveryAttemptCount = + (entry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + entry.pendingFinalDeliveryLastError = null; + } store[params.sessionKey] = entry; }, { skipMaintenance: true }, ); - log.info(`resumed interrupted main session: ${params.sessionKey}`); + log.info( + `resumed interrupted main session: ${params.sessionKey}${ + params.pendingFinalDeliveryText ? " (with pending payload)" : "" + }`, + ); return true; } catch (err) { log.warn(`failed to resume interrupted main session ${params.sessionKey}: ${String(err)}`); @@ -290,6 +312,7 @@ async function recoverStore(params: { const resumed = await resumeMainSession({ storePath: params.storePath, sessionKey, + pendingFinalDeliveryText: entry.pendingFinalDeliveryText, }); if (resumed) { params.resumedSessionKeys.add(sessionKey); diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index a440b4dbe80..c48554b07e5 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -61,7 +61,31 @@ const providerManifestRegistry: PluginManifestRegistry = { }; describe("implicit provider plugin allowlist compatibility", () => { - it("keeps bundled implicit providers discoverable when plugins.allow is set", () => { + it("keeps bundled implicit providers discoverable in explicit compat mode", () => { + const config = withBundledPluginEnablementCompat({ + config: withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + bundledDiscovery: "compat", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }), + pluginIds: ["kilocode", "moonshot"], + }); + + expect( + resolveEnabledProviderPluginIds({ + config, + registry: providerRegistry, + manifestRegistry: providerManifestRegistry, + onlyPluginIds: PROVIDER_PLUGIN_IDS, + }), + ).toEqual(["kilocode", "moonshot", "openrouter"]); + }); + + it("respects allowlist for bundled plugins by default", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ config: { @@ -81,7 +105,76 @@ describe("implicit provider plugin allowlist compatibility", () => { manifestRegistry: providerManifestRegistry, onlyPluginIds: PROVIDER_PLUGIN_IDS, }), - ).toEqual(["kilocode", "moonshot", "openrouter"]); + ).toEqual(["openrouter"]); + }); + + it("respects allowlist for bundled plugins when bundledDiscovery is allowlist", () => { + const config = withBundledPluginEnablementCompat({ + config: withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + bundledDiscovery: "allowlist", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }), + pluginIds: ["kilocode", "moonshot"], + }); + + expect( + resolveEnabledProviderPluginIds({ + config, + registry: providerRegistry, + manifestRegistry: providerManifestRegistry, + onlyPluginIds: PROVIDER_PLUGIN_IDS, + }), + ).toEqual(["openrouter"]); + }); + + it("does not re-enable plugins when allowlist mode rejects every compat plugin", () => { + const config = withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: false, + allow: ["openrouter"], + bundledDiscovery: "allowlist", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }); + + expect(config).toEqual({ + plugins: { + enabled: false, + allow: ["openrouter"], + bundledDiscovery: "allowlist", + }, + }); + }); + + it("re-enables globally disabled plugins when allowlist mode accepts a plugin alias", () => { + const config = withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: false, + allow: [" Google-Gemini-Cli "], + bundledDiscovery: "allowlist", + }, + }, + pluginIds: ["google"], + }); + + expect(config).toEqual({ + plugins: { + enabled: true, + allow: [" Google-Gemini-Cli "], + bundledDiscovery: "allowlist", + entries: { + google: { enabled: true }, + }, + }, + }); }); it("still honors explicit plugin denies over compat allowlist injection", () => { @@ -90,6 +183,7 @@ describe("implicit provider plugin allowlist compatibility", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", deny: ["kilocode"], }, }, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2c63232c19c..9917ddc9361 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -482,7 +482,9 @@ async function compactEmbeddedPiSessionDirectOnce( }; }; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - await ensureOpenClawModelsJson(params.config, agentDir); + await ensureOpenClawModelsJson(params.config, agentDir, { + workspaceDir: resolvedWorkspace, + }); const { model, error, authStorage, modelRegistry } = await resolveModelAsync( provider, modelId, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 5fc93d36a66..8e7126bf32d 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -503,7 +503,9 @@ export async function runEmbeddedPiAgent( dynamicModelResolution.model || pluginHarnessOwnsTransport ? dynamicModelResolution : await (async () => { - await ensureOpenClawModelsJson(params.config, agentDir); + await ensureOpenClawModelsJson(params.config, agentDir, { + workspaceDir: resolvedWorkspace, + }); return await resolveModelAsync(provider, modelId, agentDir, params.config); })(); const { model, error, authStorage, modelRegistry } = modelResolution; diff --git a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts index d73b4983b89..22adc253425 100644 --- a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts +++ b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts @@ -1,5 +1,6 @@ import type { BootstrapMode } from "../../bootstrap-mode.js"; import { resolveBootstrapMode } from "../../bootstrap-mode.js"; +import { DEFAULT_BOOTSTRAP_FILENAME, type WorkspaceBootstrapFile } from "../../workspace.js"; export type AttemptBootstrapRoutingInput = { workspaceBootstrapPending: boolean; @@ -24,6 +25,7 @@ export type AttemptWorkspaceBootstrapRoutingInput = Omit< "workspaceBootstrapPending" > & { isWorkspaceBootstrapPending: (workspaceDir: string) => Promise; + bootstrapFiles?: readonly WorkspaceBootstrapFile[]; }; export function resolveBootstrapContextTargets(params: { @@ -58,14 +60,28 @@ function resolveAttemptBootstrapRouting( }; } +export function hasBootstrapFileContent(files?: readonly WorkspaceBootstrapFile[]): boolean { + return ( + files?.some( + (file) => + file.name === DEFAULT_BOOTSTRAP_FILENAME && + !file.missing && + typeof file.content === "string" && + file.content.trim().length > 0, + ) ?? false + ); +} + export async function resolveAttemptWorkspaceBootstrapRouting( params: AttemptWorkspaceBootstrapRoutingInput, ): Promise { const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending( params.resolvedWorkspace, ); + const hasHookBootstrapContent = hasBootstrapFileContent(params.bootstrapFiles); return resolveAttemptBootstrapRouting({ ...params, - workspaceBootstrapPending, + workspaceBootstrapPending: workspaceBootstrapPending || hasHookBootstrapContent, + hasBootstrapFileAccess: params.hasBootstrapFileAccess || hasHookBootstrapContent, }); } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts index b5ddf161238..4ebb5be5fc6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + hasBootstrapFileContent, resolveBootstrapContextTargets, resolveAttemptWorkspaceBootstrapRouting, } from "./attempt-bootstrap-routing.js"; @@ -46,6 +47,67 @@ describe("runEmbeddedAttempt bootstrap routing", () => { expect(routing.includeBootstrapInRuntimeContext).toBe(false); }); + it("treats hook-provided BOOTSTRAP.md content as pending bootstrap context", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: true, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("uses hook-provided BOOTSTRAP.md content even when normal file reads are unavailable", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: false, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("does not treat empty hook-provided BOOTSTRAP.md as pending bootstrap context", () => { + expect( + hasBootstrapFileContent([ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: " ", + missing: false, + }, + ]), + ).toBe(false); + }); + it("keeps BOOTSTRAP.md in Project Context for full bootstrap turns", () => { expect(resolveBootstrapContextTargets({ bootstrapMode: "full" })).toEqual({ includeBootstrapInSystemContext: true, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 3ce99a1e8bb..93aecfdc561 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -316,6 +316,54 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(systemPrompt).toContain("Ask who I am."); }); + it("includes hook-adjusted bootstrap files preloaded before routing", async () => { + const workspaceDir = "/tmp/openclaw-hook-workspace"; + hoisted.resolveBootstrapFilesForRunMock.mockResolvedValueOnce([ + { + name: "BOOTSTRAP.md", + path: `${workspaceDir}/BOOTSTRAP.md`, + content: "Ask who I am before continuing.", + missing: false, + }, + ]); + + await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + config: { + agents: { + defaults: { + systemPromptOverride: "Custom override prompt.", + }, + }, + } as OpenClawConfig, + prompt: "visible ask", + transcriptPrompt: "visible ask", + trigger: "user", + workspaceDir, + }, + sessionPrompt: async (session) => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(hoisted.resolveBootstrapFilesForRunMock).toHaveBeenCalledOnce(); + expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled(); + const systemPrompt = + hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ?? + ""; + + expect(systemPrompt).toContain("## Bootstrap Pending"); + expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context"); + expect(systemPrompt).toContain(`## ${workspaceDir}/BOOTSTRAP.md`); + expect(systemPrompt).toContain("Ask who I am before continuing."); + }); + it("adds explicit reply context to the current model input without exposing generic runtime context", async () => { let seenPrompt: string | undefined; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 8b05abcecf0..ab5605680cc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -69,6 +69,9 @@ type AttemptSpawnWorkspaceHoisted = { installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; + resolveBootstrapFilesForRunMock: Mock< + (...args: unknown[]) => Promise + >; resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; @@ -139,6 +142,12 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { bootstrapFiles: [], contextFiles: [], })); + const resolveBootstrapFilesForRunMock = vi.fn< + (...args: unknown[]) => Promise + >(async () => { + const context = await resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); const isWorkspaceBootstrapPendingMock = vi.fn<(workspaceDir: string) => Promise>( async () => false, ); @@ -188,6 +197,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { installContextEngineLoopHookMock, flushPendingToolResultsAfterIdleMock, releaseWsSessionMock, + resolveBootstrapFilesForRunMock, resolveBootstrapContextForRunMock, isWorkspaceBootstrapPendingMock, resolveContextInjectionModeMock, @@ -286,6 +296,7 @@ vi.mock("../../bootstrap-files.js", async () => { ...actual, makeBootstrapWarn: () => () => {}, isWorkspaceBootstrapPending: hoisted.isWorkspaceBootstrapPendingMock, + resolveBootstrapFilesForRun: hoisted.resolveBootstrapFilesForRunMock, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock, hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock, @@ -821,6 +832,10 @@ export function resetEmbeddedAttemptHarness( bootstrapFiles: [], contextFiles: [], }); + hoisted.resolveBootstrapFilesForRunMock.mockReset().mockImplementation(async () => { + const context = await hoisted.resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false); hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always"); hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 374a4d50710..9ae369f227d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -62,10 +62,11 @@ import { } from "../../bootstrap-budget.js"; import { FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + buildBootstrapContextForFiles, hasCompletedBootstrapTurn, isWorkspaceBootstrapPending, makeBootstrapWarn, - resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, resolveContextInjectionMode, } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -945,8 +946,26 @@ export async function runEmbeddedAttempt( emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot()); const toolsEnabled = supportsModelTools(params.model); const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read"); + const bootstrapWarn = makeBootstrapWarn({ + sessionLabel, + workspaceDir: resolvedWorkspace, + warn: (message) => log.warn(message), + }); + const preloadedBootstrapFiles = + isRawModelRun || contextInjectionMode === "never" + ? undefined + : await resolveBootstrapFilesForRun({ + workspaceDir: resolvedWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + }); const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({ isWorkspaceBootstrapPending, + bootstrapFiles: preloadedBootstrapFiles, bootstrapContextRunKind: params.bootstrapContextRunKind, trigger: params.trigger, sessionKey: params.sessionKey, @@ -970,20 +989,26 @@ export async function runEmbeddedAttempt( bootstrapMode, sessionFile: params.sessionFile, hasCompletedBootstrapTurn, - resolveBootstrapContextForRun: async () => - await resolveBootstrapContextForRun({ - workspaceDir: resolvedWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ - sessionLabel, + resolveBootstrapContextForRun: async () => { + const bootstrapFiles = + preloadedBootstrapFiles ?? + (await resolveBootstrapFilesForRun({ workspaceDir: resolvedWorkspace, - warn: (message) => log.warn(message), + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + })); + return { + bootstrapFiles, + contextFiles: buildBootstrapContextForFiles(bootstrapFiles, { + config: params.config, + warn: bootstrapWarn, }), - contextMode: params.bootstrapContextMode, - runKind: params.bootstrapContextRunKind, - }), + }; + }, }); prepStages.mark("bootstrap-context"); const remappedContextFiles = remapInjectedContextFilesToWorkspace({ diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index d263d05b07c..89e53214f6a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -8,6 +8,7 @@ import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; import { handleToolExecutionEnd, handleToolExecutionStart, + handleToolExecutionUpdate, } from "./pi-embedded-subscribe.handlers.tools.js"; import type { ToolCallSummary, @@ -713,6 +714,47 @@ describe("handleToolExecutionEnd exec approval prompts", () => { }); describe("handleToolExecutionEnd derived tool events", () => { + it("emits command output deltas for exec update results", async () => { + const { ctx, onAgentEvent } = createTestContext(); + + await handleToolExecutionStart( + ctx as never, + { + type: "tool_execution_start", + toolName: "exec", + toolCallId: "tool-exec-update-output", + args: { command: "npm test" }, + } as never, + ); + + handleToolExecutionUpdate( + ctx as never, + { + type: "tool_execution_update", + toolName: "exec", + toolCallId: "tool-exec-update-output", + partialResult: { + details: { + status: "running", + aggregated: "RUN src/example.test.ts", + }, + }, + } as never, + ); + + expect(onAgentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + stream: "command_output", + data: expect.objectContaining({ + itemId: "command:tool-exec-update-output", + phase: "delta", + output: "RUN src/example.test.ts", + status: "running", + }), + }), + ); + }); + it("emits command output events for exec results", async () => { const { ctx, onAgentEvent } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index ecc1445f287..3df28deeb87 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -772,7 +772,11 @@ export function handleToolExecutionUpdate( }, }); if (isExecToolName(toolName)) { - const output = extractToolResultText(sanitized); + const execDetails = readExecToolDetails(sanitized); + const output = + execDetails && "aggregated" in execDetails + ? execDetails.aggregated + : extractToolResultText(sanitized); const commandData: AgentItemEventData = { itemId: buildCommandItemId(toolCallId), phase: "update", diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 8db3e5353e6..0586edd1055 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -424,6 +424,83 @@ describe("subscribeEmbeddedPiSession", () => { ); }); + it("does not attach generated image media to an early streamed chunk before explicit MEDIA", async () => { + const onToolResult = vi.fn(); + const onBlockReply = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onToolResult, + onBlockReply, + verboseLevel: "full", + blockReplyBreak: "text_end", + blockReplyChunking: { minChars: 5, maxChars: 200, breakPreference: "newline" }, + builtinToolNames: new Set(["image_generate"]), + }); + + emitToolRun({ + emit, + toolName: "image_generate", + toolCallId: "tool-1", + isError: false, + result: { + content: [ + { + type: "text", + text: "Generated 1 image with google/gemini-3.1-flash-image-preview.\nMEDIA:/tmp/generated.png", + }, + ], + details: { + media: { + mediaUrls: ["/tmp/generated.png"], + }, + }, + }, + }); + + await vi.waitFor(() => { + expect(onToolResult).toHaveBeenCalled(); + }); + + emit({ type: "message_start", message: { role: "assistant" } }); + emitAssistantTextDelta(emit, "Generated 1 image.\n"); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Generated 1 image.", + }), + ); + expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + + emitAssistantTextDelta(emit, "MEDIA:/tmp/generated.png"); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_end", + content: "Generated 1 image.\nMEDIA:/tmp/generated.png", + }, + }); + emit({ + type: "message_end", + message: { + role: "assistant", + content: [ + { + type: "text", + text: "Generated 1 image.\nMEDIA:/tmp/generated.png", + }, + ], + }, + }); + emit({ type: "agent_end" }); + await flushBlockReplyCallbacks(); + + const mediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.includes("/tmp/generated.png")); + expect(mediaPayloads).toHaveLength(1); + }); + it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => { const onBlockReply = vi.fn(); const { emit } = createSubscribedHarness({ @@ -469,6 +546,104 @@ describe("subscribeEmbeddedPiSession", () => { ); }); + it.each([ + { + label: "music", + source: "music_generation" as const, + childSessionKey: "music_generate:task-123", + announceType: "music generation task", + taskLabel: "launch anthem", + result: "Generated 1 track.\nMEDIA:/tmp/launch-anthem.mp3", + mediaUrl: "/tmp/launch-anthem.mp3", + firstChunk: "Generated 1 track.\n", + finalText: "Generated 1 track.\nMEDIA:/tmp/launch-anthem.mp3", + }, + { + label: "video", + source: "video_generation" as const, + childSessionKey: "video_generate:task-123", + announceType: "video generation task", + taskLabel: "launch reel", + result: "Generated 1 video.\nMEDIA:/tmp/launch-reel.mp4", + mediaUrl: "/tmp/launch-reel.mp4", + firstChunk: "Generated 1 video.\n", + finalText: "Generated 1 video.\nMEDIA:/tmp/launch-reel.mp4", + }, + ])( + "does not attach $label internal completion media to an early streamed chunk before explicit MEDIA", + async ({ + source, + childSessionKey, + announceType, + taskLabel, + result, + mediaUrl, + firstChunk, + finalText, + }) => { + const onBlockReply = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onBlockReply, + blockReplyBreak: "text_end", + blockReplyChunking: { minChars: 5, maxChars: 200, breakPreference: "newline" }, + internalEvents: [ + { + type: "task_completion", + source, + childSessionKey, + announceType, + taskLabel, + status: "ok", + statusLabel: "completed successfully", + result, + mediaUrls: [mediaUrl], + replyInstruction: "Reply normally.", + }, + ], + }); + + emit({ type: "message_start", message: { role: "assistant" } }); + emitAssistantTextDelta(emit, firstChunk); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ + text: firstChunk.trim(), + }), + ); + expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + + emitAssistantTextDelta(emit, `MEDIA:${mediaUrl}`); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_end", + content: finalText, + }, + }); + emit({ + type: "message_end", + message: { + role: "assistant", + content: [ + { + type: "text", + text: finalText, + }, + ], + }, + }); + emit({ type: "agent_end" }); + await flushBlockReplyCallbacks(); + + const mediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.includes(mediaUrl)); + expect(mediaPayloads).toHaveLength(1); + }, + ); + it("keeps orphaned tool media available for non-block final payload assembly", () => { const { emit, subscription } = createSubscribedSessionHarness({ runId: "run", diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 72d7384ee95..b824aa2a686 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -239,10 +239,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar }; const emitBlockReply = ( payload: BlockReplyPayload, - options?: { assistantMessageIndex?: number }, + options?: { assistantMessageIndex?: number; consumePendingToolMedia?: boolean }, ) => { const withAssistantDirectives = consumePendingAssistantReplyDirectivesIntoReply(state, payload); - emitBlockReplySafely(consumePendingToolMediaIntoReply(state, withAssistantDirectives), options); + const withToolMedia = + options?.consumePendingToolMedia === false + ? withAssistantDirectives + : consumePendingToolMediaIntoReply(state, withAssistantDirectives); + emitBlockReplySafely(withToolMedia, options); }; const resetAssistantMessageState = (nextAssistantTextBaseline: number) => { @@ -761,6 +765,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar }, { assistantMessageIndex: options?.assistantMessageIndex ?? state.assistantMessageIndex, + consumePendingToolMedia: + options?.final === true || Boolean(mediaUrls?.length || audioAsVoice), }, ); }; diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 30753bdcc5e..d4f9f7ef2b8 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -34,7 +34,7 @@ import { resolveAnnounceRetryDelayMs, safeRemoveAttachmentsDir, } from "./subagent-registry-helpers.js"; -import type { SubagentRunRecord } from "./subagent-registry.types.js"; +import type { PendingFinalDeliveryPayload, SubagentRunRecord } from "./subagent-registry.types.js"; import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js"; type CaptureSubagentCompletionReply = @@ -288,6 +288,12 @@ export function createSubagentRegistryLifecycleController(params: { } entry.frozenResultText = nextFrozen; entry.frozenResultCapturedAt = capturedAt; + if (entry.pendingFinalDeliveryPayload) { + entry.pendingFinalDeliveryPayload = { + ...entry.pendingFinalDeliveryPayload, + frozenResultText: nextFrozen, + }; + } changed = true; } if (changed) { @@ -315,11 +321,64 @@ export function createSubagentRegistryLifecycleController(params: { } }; + const clearPendingFinalDelivery = (entry: SubagentRunRecord) => { + entry.pendingFinalDelivery = undefined; + entry.pendingFinalDeliveryCreatedAt = undefined; + entry.pendingFinalDeliveryLastAttemptAt = undefined; + entry.pendingFinalDeliveryAttemptCount = undefined; + entry.pendingFinalDeliveryLastError = undefined; + entry.pendingFinalDeliveryPayload = undefined; + }; + + const loadPendingFinalDeliveryPayload = ( + entry: SubagentRunRecord, + ): PendingFinalDeliveryPayload => { + return { + requesterSessionKey: + entry.pendingFinalDeliveryPayload?.requesterSessionKey ?? entry.requesterSessionKey, + requesterOrigin: entry.pendingFinalDeliveryPayload?.requesterOrigin ?? entry.requesterOrigin, + requesterDisplayKey: + entry.pendingFinalDeliveryPayload?.requesterDisplayKey ?? entry.requesterDisplayKey, + childSessionKey: entry.pendingFinalDeliveryPayload?.childSessionKey ?? entry.childSessionKey, + childRunId: entry.pendingFinalDeliveryPayload?.childRunId ?? entry.runId, + task: entry.pendingFinalDeliveryPayload?.task ?? entry.task, + label: entry.pendingFinalDeliveryPayload?.label ?? entry.label, + startedAt: entry.pendingFinalDeliveryPayload?.startedAt ?? entry.startedAt, + endedAt: entry.pendingFinalDeliveryPayload?.endedAt ?? entry.endedAt, + outcome: entry.pendingFinalDeliveryPayload?.outcome ?? entry.outcome, + expectsCompletionMessage: + entry.pendingFinalDeliveryPayload?.expectsCompletionMessage ?? + entry.expectsCompletionMessage, + spawnMode: entry.pendingFinalDeliveryPayload?.spawnMode ?? entry.spawnMode, + frozenResultText: + entry.pendingFinalDeliveryPayload?.frozenResultText ?? entry.frozenResultText, + fallbackFrozenResultText: + entry.pendingFinalDeliveryPayload?.fallbackFrozenResultText ?? + entry.fallbackFrozenResultText, + wakeOnDescendantSettle: + entry.pendingFinalDeliveryPayload?.wakeOnDescendantSettle ?? entry.wakeOnDescendantSettle, + }; + }; + + const markPendingFinalDelivery = (args: { entry: SubagentRunRecord; error?: string }) => { + const now = Date.now(); + const payload: PendingFinalDeliveryPayload = loadPendingFinalDeliveryPayload(args.entry); + + args.entry.pendingFinalDelivery = true; + args.entry.pendingFinalDeliveryCreatedAt ??= now; + args.entry.pendingFinalDeliveryLastAttemptAt = now; + args.entry.pendingFinalDeliveryAttemptCount = + (args.entry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + args.entry.pendingFinalDeliveryLastError = args.error ?? null; + args.entry.pendingFinalDeliveryPayload = payload; + }; + const finalizeResumedAnnounceGiveUp = async (giveUpParams: { runId: string; entry: SubagentRunRecord; reason: "retry-limit" | "expiry"; }) => { + clearPendingFinalDelivery(giveUpParams.entry); safeSetSubagentTaskDeliveryStatus({ runId: giveUpParams.runId, childSessionKey: giveUpParams.entry.childSessionKey, @@ -486,6 +545,7 @@ export function createSubagentRegistryLifecycleController(params: { entry.completionAnnouncedAt = Date.now(); params.persist(); } + clearPendingFinalDelivery(entry); if (!options?.skipDeliveryStatus) { safeSetSubagentTaskDeliveryStatus({ runId, @@ -544,6 +604,7 @@ export function createSubagentRegistryLifecycleController(params: { } if (deferredDecision.kind === "give-up") { + clearPendingFinalDelivery(entry); safeSetSubagentTaskDeliveryStatus({ runId, childSessionKey: entry.childSessionKey, @@ -571,6 +632,10 @@ export function createSubagentRegistryLifecycleController(params: { return; } + markPendingFinalDelivery({ + entry, + error: didAnnounce ? undefined : "announce deferred or direct delivery failed", + }); entry.cleanupHandled = false; params.resumedRuns.delete(runId); params.persist(); @@ -631,7 +696,8 @@ export function createSubagentRegistryLifecycleController(params: { }); return true; } - const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + const pendingPayload = loadPendingFinalDeliveryPayload(entry); + const requesterOrigin = normalizeDeliveryContext(pendingPayload.requesterOrigin); let latestDeliveryError = entry.lastAnnounceDeliveryError; const finalizeAnnounceCleanup = (didAnnounce: boolean) => { if (!didAnnounce && latestDeliveryError) { @@ -650,24 +716,24 @@ export function createSubagentRegistryLifecycleController(params: { void params .runSubagentAnnounceFlow({ - childSessionKey: entry.childSessionKey, - childRunId: entry.runId, - requesterSessionKey: entry.requesterSessionKey, + childSessionKey: pendingPayload.childSessionKey, + childRunId: pendingPayload.childRunId, + requesterSessionKey: pendingPayload.requesterSessionKey, requesterOrigin, - requesterDisplayKey: entry.requesterDisplayKey, - task: entry.task, + requesterDisplayKey: pendingPayload.requesterDisplayKey, + task: pendingPayload.task, timeoutMs: params.subagentAnnounceTimeoutMs, cleanup: entry.cleanup, - roundOneReply: entry.frozenResultText ?? undefined, - fallbackReply: entry.fallbackFrozenResultText ?? undefined, + roundOneReply: pendingPayload.frozenResultText ?? undefined, + fallbackReply: pendingPayload.fallbackFrozenResultText ?? undefined, waitForCompletion: false, - startedAt: entry.startedAt, - endedAt: entry.endedAt, - label: entry.label, - outcome: entry.outcome, - spawnMode: entry.spawnMode, - expectsCompletionMessage: entry.expectsCompletionMessage, - wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, + startedAt: pendingPayload.startedAt, + endedAt: pendingPayload.endedAt, + label: pendingPayload.label, + outcome: pendingPayload.outcome, + spawnMode: pendingPayload.spawnMode, + expectsCompletionMessage: pendingPayload.expectsCompletionMessage, + wakeOnDescendantSettle: pendingPayload.wakeOnDescendantSettle === true, onDeliveryResult: (delivery) => { if (delivery.delivered) { if (entry.lastAnnounceDeliveryError !== undefined) { diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index 19577062ec1..f8640a9db22 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -3,6 +3,24 @@ import type { SubagentRunOutcome } from "./subagent-announce-output.js"; import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js"; import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; +export type PendingFinalDeliveryPayload = { + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; + requesterDisplayKey: string; + childSessionKey: string; + childRunId: string; + task: string; + label?: string; + startedAt?: number; + endedAt?: number; + outcome?: SubagentRunOutcome; + expectsCompletionMessage?: boolean; + spawnMode?: SpawnSubagentMode; + frozenResultText?: string | null; + fallbackFrozenResultText?: string | null; + wakeOnDescendantSettle?: boolean; +}; + export type SubagentRunRecord = { runId: string; childSessionKey: string; @@ -39,7 +57,15 @@ export type SubagentRunRecord = { frozenResultCapturedAt?: number; fallbackFrozenResultText?: string | null; fallbackFrozenResultCapturedAt?: number; + /** Set after the subagent_ended hook has been emitted successfully once. */ endedHookEmittedAt?: number; + /** Durable marker that final user delivery still needs a retry/resume pass. */ + pendingFinalDelivery?: boolean; + pendingFinalDeliveryCreatedAt?: number; + pendingFinalDeliveryLastAttemptAt?: number; + pendingFinalDeliveryAttemptCount?: number; + pendingFinalDeliveryLastError?: string | null; + pendingFinalDeliveryPayload?: PendingFinalDeliveryPayload; completionAnnouncedAt?: number; attachmentsDir?: string; attachmentsRootDir?: string; diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index eee305d0186..77a30edd71e 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -394,17 +394,31 @@ describe("createPdfTool", () => { it("uses native PDF path without eager extraction", async () => { await withTempPdfAgentDir(async (agentDir) => { + const workspaceDir = path.join(agentDir, "workspace"); await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] }); vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary"); const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent"); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); - const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir })); + const tool = requirePdfTool( + (await loadCreatePdfTool())({ config: cfg, agentDir, workspaceDir }), + ); const result = await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", }); + expect(modelsConfig.ensureOpenClawModelsJson).toHaveBeenCalledWith( + expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + pdfModel: { primary: ANTHROPIC_PDF_MODEL }, + }), + }), + }), + agentDir, + { workspaceDir }, + ); expect(extractSpy).not.toHaveBeenCalled(); expect(result).toMatchObject({ content: [{ type: "text", text: "native summary" }], diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index e848f9f7e83..65fbac9af24 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -127,6 +127,7 @@ type PdfSandboxConfig = { async function runPdfPrompt(params: { cfg?: OpenClawConfig; agentDir: string; + workspaceDir?: string; pdfModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; @@ -142,7 +143,8 @@ async function runPdfPrompt(params: { }> { const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.pdfModelConfig); - await ensureOpenClawModelsJson(effectiveCfg, params.agentDir); + const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; + await ensureOpenClawModelsJson(effectiveCfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); @@ -482,6 +484,7 @@ export function createPdfTool(options?: { const result = await runPdfPrompt({ cfg: options?.config, agentDir, + ...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), pdfModelConfig, modelOverride, prompt: promptRaw, diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index c855e21282d..cdcc4b29b71 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -272,6 +272,7 @@ function createMockReplyOperation(): { attachBackend: vi.fn(), detachBackend: vi.fn(), complete: vi.fn(), + completeThen: vi.fn((afterClear: () => void) => afterClear()), fail: failMock, abortByUser: vi.fn(), abortForRestart: vi.fn(), @@ -1175,7 +1176,7 @@ describe("runAgentTurnWithFallback", () => { }); }); - it("publishes Codex app-server telemetry to agent event subscribers", async () => { + it("leaves Codex app-server telemetry publication to the harness", async () => { const agentEvents = await import("../../infra/agent-events.js"); const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent); state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { @@ -1216,15 +1217,12 @@ describe("runAgentTurnWithFallback", () => { }); expect(result.kind).toBe("success"); - expect(emitAgentEvent).toHaveBeenCalledWith({ - runId: "run-codex", - stream: "codex_app_server.guardian", - sessionKey: "agent:main:subagent:codex-child", - data: { - phase: "blocked", - message: "command requires approval", - }, - }); + expect(emitAgentEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-codex", + stream: "codex_app_server.guardian", + }), + ); }); it("emits an embedded lifecycle terminal backstop when the runner returns without one", async () => { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f80869bed86..984e41bc05d 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1509,14 +1509,6 @@ export async function runAgentTurnWithFallback(params: { onReasoningEnd: params.opts?.onReasoningEnd, onAgentEvent: async (evt) => { lifecycleBackstop.note(evt); - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } // Signal run start only after the embedded agent emits real activity. const hasLifecyclePhase = evt.stream === "lifecycle" && typeof evt.data.phase === "string"; diff --git a/src/auto-reply/reply/agent-runner-helpers.test.ts b/src/auto-reply/reply/agent-runner-helpers.test.ts index 1d7ceaf61ff..d478af66985 100644 --- a/src/auto-reply/reply/agent-runner-helpers.test.ts +++ b/src/auto-reply/reply/agent-runner-helpers.test.ts @@ -4,8 +4,7 @@ import type { TypingSignaler } from "./typing-mode.js"; const hoisted = vi.hoisted(() => { const loadSessionStoreMock = vi.fn(); - const scheduleFollowupDrainMock = vi.fn(); - return { loadSessionStoreMock, scheduleFollowupDrainMock }; + return { loadSessionStoreMock }; }); vi.mock("../../config/sessions.js", async () => { @@ -18,18 +17,9 @@ vi.mock("../../config/sessions.js", async () => { }; }); -vi.mock("./queue.js", async () => { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - scheduleFollowupDrain: (...args: unknown[]) => hoisted.scheduleFollowupDrainMock(...args), - }; -}); - const { createShouldEmitToolOutput, createShouldEmitToolResult, - finalizeWithFollowup, isAudioPayload, signalTypingIfNeeded, } = await import("./agent-runner-helpers.js"); @@ -38,7 +28,6 @@ describe("agent runner helpers", () => { beforeEach(() => { vi.useRealTimers(); hoisted.loadSessionStoreMock.mockReset(); - hoisted.scheduleFollowupDrainMock.mockReset(); }); it("detects audio payloads from mediaUrl/mediaUrls", () => { @@ -119,13 +108,6 @@ describe("agent runner helpers", () => { expect(fallbackFull()).toBe(true); }); - it("schedules followup drain and returns the original value", () => { - const runFollowupTurn = vi.fn(); - const value = { ok: true }; - expect(finalizeWithFollowup(value, "queue-key", runFollowupTurn)).toBe(value); - expect(hoisted.scheduleFollowupDrainMock).toHaveBeenCalledWith("queue-key", runFollowupTurn); - }); - it("signals typing only when any payload has text or media", async () => { const signalRunStart = vi.fn().mockResolvedValue(undefined); const typingSignals = { signalRunStart } as unknown as TypingSignaler; diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 00cd20ce43c..12a35381278 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -6,7 +6,6 @@ import { loadSessionStore } from "../../config/sessions.js"; import { isAudioFileName } from "../../media/mime.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; -import { scheduleFollowupDrain } from "./queue.js"; import type { TypingSignaler } from "./typing-mode.js"; const hasAudioMedia = (urls?: string[]): boolean => @@ -78,15 +77,6 @@ export const createShouldEmitToolOutput = (params: VerboseGateParams): (() => bo return createVerboseGate(params, (level) => level === "full"); }; -export const finalizeWithFollowup = ( - value: T, - queueKey: string, - runFollowupTurn: Parameters[1], -): T => { - scheduleFollowupDrain(queueKey, runFollowupTurn); - return value; -}; - export const signalTypingIfNeeded = async ( payloads: ReplyPayload[], typingSignals: TypingSignaler, diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 3fce0ad29a7..c1d0ba7cfd6 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -22,7 +22,8 @@ import { } from "../../plugins/memory-state.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; -import { __testing as replyRunRegistryTesting } from "./reply-run-registry.js"; +import { scheduleFollowupDrain } from "./queue.js"; +import { __testing as replyRunRegistryTesting, replyRunRegistry } from "./reply-run-registry.js"; import { createMockTypingController } from "./test-helpers.js"; function createCliBackendTestConfig() { @@ -165,6 +166,7 @@ beforeEach(() => { clearSessionQueuesMock.mockReturnValue({ followupCleared: 0, laneCleared: 0, keys: [] }); refreshQueuedFollowupSessionMock.mockReset(); refreshQueuedFollowupSessionMock.mockResolvedValue(undefined); + vi.mocked(scheduleFollowupDrain).mockReset(); loadCronStoreMock.mockClear(); // Default: no cron jobs in store. loadCronStoreMock.mockResolvedValue({ version: 1, jobs: [] }); @@ -326,6 +328,56 @@ describe("runReplyAgent auto-compaction token update", () => { expect(stored[sessionKey].totalTokens).toBe(55_000); }); + it("starts queued followup drain only after clearing the active reply operation", async () => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 50_000, + }; + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { agentMeta: {} }, + }); + + vi.mocked(scheduleFollowupDrain).mockImplementation((key) => { + expect(key).toBe(sessionKey); + expect(replyRunRegistry.get(sessionKey)).toBeUndefined(); + }); + + const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ + storePath: "", + sessionEntry, + }); + + const result = await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 200_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(result).toMatchObject({ text: "ok" }); + expect(scheduleFollowupDrain).toHaveBeenCalledTimes(1); + }); + it("reports live diagnostic context from promptTokens, not provider usage totals", async () => { const { usageEvent } = await runBaseReplyWithAgentMeta({ tmpPrefix: "openclaw-usage-diagnostic-", diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 9aa9d6a56c8..78ee122be98 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; @@ -112,6 +115,7 @@ function createMinimalRun(params?: { isRunActive?: () => boolean; shouldFollowup?: boolean; resolvedQueueMode?: string; + sessionCtx?: Partial; runOverrides?: Partial; }) { const typing = createMockTypingController(); @@ -119,6 +123,7 @@ function createMinimalRun(params?: { const sessionCtx = { Provider: "whatsapp", MessageSid: "msg", + ...params?.sessionCtx, } as unknown as TemplateContext; const resolvedQueue = { mode: params?.resolvedQueueMode ?? "interrupt", @@ -277,6 +282,100 @@ describe("runReplyAgent heartbeat followup guard", () => { }); }); +describe("runReplyAgent pending final delivery capture", () => { + async function createSessionStoreFile(entry: SessionEntry) { + const dir = await mkdtemp(join(tmpdir(), "openclaw-agent-runner-pending-")); + const storePath = join(dir, "sessions.json"); + await writeFile(storePath, JSON.stringify({ main: entry }), "utf8"); + return storePath; + } + + async function readStoredMainSession(storePath: string): Promise { + const raw = await readFile(storePath, "utf8"); + return JSON.parse(raw).main as SessionEntry; + } + + it("does not persist message-tool-only final replies for heartbeat replay", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "private final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + opts: { sourceReplyDeliveryMode: "message_tool_only" }, + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBeUndefined(); + expect(stored.pendingFinalDeliveryText).toBeUndefined(); + }); + + it("does not persist sendPolicy-denied final replies for heartbeat replay", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + sendPolicy: "deny", + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "denied final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBeUndefined(); + expect(stored.pendingFinalDeliveryText).toBeUndefined(); + }); + + it("persists only visible non-reasoning final reply text", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hidden reasoning", isReasoning: true }, { text: "visible final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBe(true); + expect(stored.pendingFinalDeliveryText).toBe("visible final"); + }); +}); + describe("runReplyAgent typing (heartbeat)", () => { it("signals typing for normal runs", async () => { const onPartialReply = vi.fn(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 5581a00e79a..eddefddae0a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -26,6 +26,7 @@ import { } from "../../infra/diagnostic-trace-context.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; +import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { estimateUsageCost, @@ -45,7 +46,6 @@ import { runAgentTurnWithFallback } from "./agent-runner-execution.js"; import { createShouldEmitToolOutput, createShouldEmitToolResult, - finalizeWithFollowup, isAudioPayload, signalTypingIfNeeded, } from "./agent-runner-helpers.js"; @@ -70,6 +70,7 @@ import { enqueueFollowupRun, refreshQueuedFollowupSession, resolvePiSteeringModeForQueueMode, + scheduleFollowupDrain, type FollowupRun, type QueueSettings, } from "./queue.js"; @@ -82,6 +83,7 @@ import { } from "./reply-run-registry.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; +import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; import { createTypingSignaler } from "./typing-mode.js"; import type { TypingController } from "./typing.js"; @@ -804,6 +806,14 @@ function joinCommitmentAssistantText(payloads: ReplyPayload[]): string { .trim(); } +function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string { + return payloads + .filter((payload) => payload.isReasoning !== true) + .map((payload) => payload.text) + .filter((text): text is string => Boolean(text)) + .join("\n\n"); +} + function enqueueCommitmentExtractionForTurn(params: { cfg: OpenClawConfig; commandBody: string; @@ -1054,7 +1064,7 @@ export async function runReplyAgent(params: { // the followup queue idle if the original run already finished. const queuedBehindActiveRun = isRunActive?.() === true; if (!queuedBehindActiveRun) { - finalizeWithFollowup(undefined, queueKey, queuedRunFollowupTurn); + scheduleFollowupDrain(queueKey, queuedRunFollowupTurn); } await touchActiveSessionEntry(); if (queuedBehindActiveRun) { @@ -1138,6 +1148,14 @@ export async function runReplyAgent(params: { throw error; } let runFollowupTurn = queuedRunFollowupTurn; + let shouldDrainQueuedFollowupsAfterClear = false; + const returnWithQueuedFollowupDrain = (value: T): T => { + shouldDrainQueuedFollowupsAfterClear = true; + return value; + }; + const drainQueuedFollowupsAfterClear = () => { + scheduleFollowupDrain(queueKey, runFollowupTurn); + }; const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0; let preflightCompactionApplied = false; @@ -1273,7 +1291,7 @@ export async function runReplyAgent(params: { if (!replyOperation.result) { replyOperation.fail("run_failed", new Error("reply operation exited with final payload")); } - return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn); + return returnWithQueuedFollowupDrain(runOutcome.payload); } const { @@ -1406,7 +1424,7 @@ export async function runReplyAgent(params: { // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and // keep the typing indicator stuck. if (payloadArray.length === 0) { - return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + return returnWithQueuedFollowupDrain(undefined); } const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; @@ -1438,7 +1456,7 @@ export async function runReplyAgent(params: { didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip; if (replyPayloads.length === 0) { - return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + return returnWithQueuedFollowupDrain(undefined); } const successfulCronAdds = runResult.successfulCronAdds ?? 0; @@ -1817,48 +1835,84 @@ export async function runReplyAgent(params: { finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); } - return finalizeWithFollowup( + // Capture only policy-visible final payloads in session store to support + // durable delivery retries. Hidden reasoning, message-tool-only replies, + // and sendPolicy-denied replies must not become heartbeat-replayable text. + if (sessionKey && storePath && finalPayloads.length > 0) { + const sendPolicy = resolveSendPolicy({ + cfg, + entry: activeSessionEntry, + sessionKey: params.runtimePolicySessionKey ?? sessionKey, + channel: + sessionCtx.OriginatingChannel ?? + sessionCtx.Surface ?? + sessionCtx.Provider ?? + activeSessionEntry?.channel, + chatType: activeSessionEntry?.chatType, + }); + const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ + cfg, + ctx: sessionCtx, + requested: opts?.sourceReplyDeliveryMode, + sendPolicy, + }); + const pendingText = sourceReplyPolicy.suppressDelivery + ? "" + : buildPendingFinalDeliveryText(finalPayloads); + if (pendingText) { + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDelivery: true, + pendingFinalDeliveryText: pendingText, + pendingFinalDeliveryCreatedAt: Date.now(), + updatedAt: Date.now(), + }), + }); + } + } + + const result = returnWithQueuedFollowupDrain( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, - queueKey, - runFollowupTurn, ); + + return result; } catch (error) { if ( replyOperation.result?.kind === "aborted" && replyOperation.result.code === "aborted_for_restart" ) { - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnWithQueuedFollowupDrain({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } if (replyOperation.result?.kind === "aborted") { - return finalizeWithFollowup({ text: SILENT_REPLY_TOKEN }, queueKey, runFollowupTurn); + return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN }); } if (error instanceof GatewayDrainingError) { replyOperation.fail("gateway_draining", error); - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnWithQueuedFollowupDrain({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } if (error instanceof CommandLaneClearedError) { replyOperation.fail("command_lane_cleared", error); - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnWithQueuedFollowupDrain({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } replyOperation.fail("run_failed", error); // Keep the followup queue moving even when an unexpected exception escapes // the run path; the caller still receives the original error. - finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + returnWithQueuedFollowupDrain(undefined); throw error; } finally { - replyOperation.complete(); + if (shouldDrainQueuedFollowupsAfterClear) { + replyOperation.completeThen(drainQueuedFollowupsAfterClear); + } else { + replyOperation.complete(); + } blockReplyPipeline?.stop(); typing.markRunComplete(); // Safety net: the dispatcher's onIdle callback normally fires diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index e35311586a2..e979176293e 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -7,11 +7,13 @@ import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; const { + installPluginFromNpmSpecMock, installPluginFromPathMock, installPluginFromClawHubMock, installPluginFromGitSpecMock, persistPluginInstallMock, } = vi.hoisted(() => ({ + installPluginFromNpmSpecMock: vi.fn(), installPluginFromPathMock: vi.fn(), installPluginFromClawHubMock: vi.fn(), installPluginFromGitSpecMock: vi.fn(), @@ -24,6 +26,7 @@ vi.mock("../../plugins/install.js", async () => { ); return { ...actual, + installPluginFromNpmSpec: installPluginFromNpmSpecMock, installPluginFromPath: installPluginFromPathMock, }; }); @@ -64,6 +67,7 @@ function buildPluginsParams(commandBodyNormalized: string, workspaceDir: string) describe("handleCommands /plugins install", () => { afterEach(async () => { + installPluginFromNpmSpecMock.mockReset(); installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); installPluginFromGitSpecMock.mockReset(); @@ -253,4 +257,60 @@ describe("handleCommands /plugins install", () => { ); }); }); + + it("trusts catalog npm package installs with alternate selectors", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "wecom-openclaw-plugin", + targetDir: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + extensions: ["index.js"], + npmResolution: { + name: "@wecom/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", + integrity: "sha512-wecom", + resolvedAt: "2026-05-04T20:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install @wecom/wecom-openclaw-plugin@latest", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "wecom-openclaw-plugin"'); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@latest", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedIntegrity: expect.any(String), + }), + ); + expect(persistPluginInstallMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "wecom-openclaw-plugin", + install: expect.objectContaining({ + source: "npm", + spec: "@wecom/wecom-openclaw-plugin@latest", + installPath: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedName: "@wecom/wecom-openclaw-plugin", + resolvedVersion: "2026.4.23", + }), + }), + ); + }); + }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index d850f37bffd..05036afe148 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; +import { resolveOfficialExternalNpmPackageTrust } from "../../cli/plugin-install-plan.js"; import { createPluginInstallLogger, resolveFileNpmSpecToLocalPath, @@ -20,6 +21,11 @@ import { installPluginFromClawHub } from "../../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; +import { + getOfficialExternalPluginCatalogEntryForPackage, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "../../plugins/official-external-plugin-catalog.js"; import type { PluginRecord } from "../../plugins/registry.js"; import { buildAllPluginInspectReports, @@ -159,6 +165,29 @@ function looksLikeLocalPluginInstallSpec(raw: string): boolean { ); } +function findTrustedCatalogPackageInstall(packageName: string): + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined { + const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!entry) { + return undefined; + } + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId) { + return undefined; + } + const install = resolveOfficialExternalPluginInstall(entry); + return { + pluginId, + ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + }; +} + async function installPluginFromPluginsCommand(params: { raw: string; snapshot: ConfigSnapshotForInstallPersist; @@ -254,8 +283,21 @@ async function installPluginFromPluginsCommand(params: { return { ok: true, pluginId: result.pluginId }; } + const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ + npmSpec: params.raw, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, + }); const result = await installPluginFromNpmSpec({ spec: params.raw, + ...(officialNpmTrust + ? { + expectedPluginId: officialNpmTrust.pluginId, + ...(officialNpmTrust.expectedIntegrity + ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + } + : {}), logger: createPluginInstallLogger(), }); if (!result.ok) { diff --git a/src/auto-reply/reply/directive-handling.model-selection.ts b/src/auto-reply/reply/directive-handling.model-selection.ts index 33cfbe9d7f6..72cee70f949 100644 --- a/src/auto-reply/reply/directive-handling.model-selection.ts +++ b/src/auto-reply/reply/directive-handling.model-selection.ts @@ -80,6 +80,7 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + rawRuntime: params.directives.rawModelRuntime, }) : null; const useStoredNumericProfile = @@ -131,6 +132,7 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + rawRuntime: params.directives.rawModelRuntime, }); if (resolved.error) { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index ff41ea6e17f..758430c6eb0 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -611,6 +611,22 @@ describe("/model chat UX", () => { expect(resolved.errorText).toContain("Browse: /models or /models "); }); + it("includes additive allowlist repair when a runtime switch targets a blocked model", () => { + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/gpt-5.5 --runtime codex", + allowedModelKeys: new Set(["anthropic/claude-opus-4-6"]), + allowedModelCatalog: [], + }); + + expect(resolved.modelSelection).toBeUndefined(); + expect(resolved.errorText).toContain('Model "openai/gpt-5.5" is not allowed.'); + expect(resolved.errorText).toContain( + `openclaw config set agents.defaults.models '{"openai/gpt-5.5":{}}' --strict-json --merge`, + ); + expect(resolved.errorText).toContain("Then retry: /model openai/gpt-5.5 --runtime codex"); + expect(resolved.errorText).toContain("openclaw plugins enable codex"); + }); + it("treats explicit default /model selection as resettable default", () => { const resolved = resolveModelSelectionForCommand({ command: "/model anthropic/claude-opus-4-6", diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 04f30858f09..3203c985bf1 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -265,6 +265,7 @@ export async function maybeHandleModelDirectiveInfo(params: { "", "Tap below to browse models, or use:", "/model to switch", + "/model --runtime to switch harnesses", "/model status for details", ] .filter(Boolean) diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 51b7b32503d..4e36f370733 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -62,6 +62,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({}); sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json"); sessionStoreMocks.resolveSessionStoreEntry.mockReset().mockReturnValue({ existing: undefined }); + sessionStoreMocks.updateSessionStoreEntry.mockClear(); acpManagerRuntimeMocks.getAcpSessionManager.mockReset(); acpManagerRuntimeMocks.getAcpSessionManager.mockImplementation(() => ({ resolveSession: () => ({ kind: "none" as const }), @@ -149,4 +150,67 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { counts: { tool: 0, block: 0, final: 0 }, }); }); + + it("clears pending final delivery after final dispatch succeeds", async () => { + hookMocks.runner.hasHooks.mockReturnValue(false); + sessionStoreMocks.currentEntry = { + sessionKey: "agent:test:session", + pendingFinalDelivery: true, + pendingFinalDeliveryText: "durable reply", + pendingFinalDeliveryCreatedAt: 1, + pendingFinalDeliveryLastAttemptAt: 2, + pendingFinalDeliveryAttemptCount: 3, + pendingFinalDeliveryLastError: "previous failure", + pendingFinalDeliveryContext: { source: "heartbeat" }, + }; + sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ + existing: sessionStoreMocks.currentEntry, + }); + mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + + const result = await dispatchReplyFromConfig({ + ctx: createHookCtx(), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "durable reply" }), + }); + + expect(result.queuedFinal).toBe(true); + expect(sessionStoreMocks.updateSessionStoreEntry).toHaveBeenCalledOnce(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined(); + }); + + it("preserves pending final delivery when final dispatch fails", async () => { + hookMocks.runner.hasHooks.mockReturnValue(false); + sessionStoreMocks.currentEntry = { + sessionKey: "agent:test:session", + pendingFinalDelivery: true, + pendingFinalDeliveryText: "durable reply", + pendingFinalDeliveryCreatedAt: 1, + }; + sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ + existing: sessionStoreMocks.currentEntry, + }); + const dispatcher = createDispatcher(); + vi.mocked(dispatcher.sendFinalReply).mockReturnValue(false); + + const result = await dispatchReplyFromConfig({ + ctx: createHookCtx(), + cfg: emptyConfig, + dispatcher, + replyResolver: async () => ({ text: "durable reply" }), + }); + + expect(result.queuedFinal).toBe(false); + expect(sessionStoreMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.runtime.ts b/src/auto-reply/reply/dispatch-from-config.runtime.ts index 7e14375d12e..3ce5f38ff46 100644 --- a/src/auto-reply/reply/dispatch-from-config.runtime.ts +++ b/src/auto-reply/reply/dispatch-from-config.runtime.ts @@ -1,3 +1,7 @@ export { resolveStorePath } from "../../config/sessions/paths.js"; -export { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js"; +export { + loadSessionStore, + resolveSessionStoreEntry, + updateSessionStoreEntry, +} from "../../config/sessions/store.js"; export { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 2e1bc8c2ac6..0db92384e75 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -92,6 +92,21 @@ const sessionStoreMocks = vi.hoisted(() => ({ loadSessionStore: vi.fn(() => ({})), resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), + updateSessionStoreEntry: vi.fn( + async (params: { + update: (entry: Record) => Promise | null>; + }) => { + if (!sessionStoreMocks.currentEntry) { + return null; + } + const patch = await params.update(sessionStoreMocks.currentEntry); + if (!patch) { + return sessionStoreMocks.currentEntry; + } + sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, ...patch }; + return sessionStoreMocks.currentEntry; + }, + ), })); const acpManagerRuntimeMocks = vi.hoisted(() => ({ getAcpSessionManager: vi.fn(), @@ -192,6 +207,7 @@ vi.mock("./dispatch-from-config.runtime.js", () => ({ resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, resolveStorePath: sessionStoreMocks.resolveStorePath, triggerInternalHook: internalHookMocks.triggerInternalHook, + updateSessionStoreEntry: sessionStoreMocks.updateSessionStoreEntry, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ initializeGlobalHookRunner: vi.fn(), diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index edb37f73ad6..40f5d4427cd 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -109,6 +109,21 @@ const sessionStoreMocks = vi.hoisted(() => ({ loadSessionStore: vi.fn(() => ({})), resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), + updateSessionStoreEntry: vi.fn( + async (params: { + update: (entry: Record) => Promise | null>; + }) => { + if (!sessionStoreMocks.currentEntry) { + return null; + } + const patch = await params.update(sessionStoreMocks.currentEntry); + if (!patch) { + return sessionStoreMocks.currentEntry; + } + sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, ...patch }; + return sessionStoreMocks.currentEntry; + }, + ), })); const acpManagerRuntimeMocks = vi.hoisted(() => ({ getAcpSessionManager: vi.fn(), @@ -358,6 +373,7 @@ vi.mock("./dispatch-from-config.runtime.js", () => ({ resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, resolveStorePath: sessionStoreMocks.resolveStorePath, triggerInternalHook: internalHookMocks.triggerInternalHook, + updateSessionStoreEntry: sessionStoreMocks.updateSessionStoreEntry, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index fccb38f84b6..7ca3c5cea9a 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -84,6 +84,7 @@ import { resolveSessionStoreEntry, resolveStorePath, triggerInternalHook, + updateSessionStoreEntry, } from "./dispatch-from-config.runtime.js"; import type { DispatchFromConfigParams, @@ -326,6 +327,34 @@ const resolveHarnessSourceVisibleRepliesDefault = (params: { } }; +async function clearPendingFinalDeliveryAfterSuccess(params: { + storePath?: string; + sessionKey?: string; +}): Promise { + if (!params.storePath || !params.sessionKey) { + return; + } + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async (entry) => { + if (!entry.pendingFinalDelivery && !entry.pendingFinalDeliveryText) { + return null; + } + return { + pendingFinalDelivery: undefined, + pendingFinalDeliveryText: undefined, + pendingFinalDeliveryCreatedAt: undefined, + pendingFinalDeliveryLastAttemptAt: undefined, + pendingFinalDeliveryAttemptCount: undefined, + pendingFinalDeliveryLastError: undefined, + pendingFinalDeliveryContext: undefined, + updatedAt: Date.now(), + }; + }, + }); +} + export type { DispatchFromConfigParams, DispatchFromConfigResult, @@ -1470,6 +1499,8 @@ export async function dispatchReplyFromConfig( let queuedFinal = false; let routedFinalCount = 0; + let attemptedFinalDelivery = false; + let finalDeliveryFailed = false; if (!suppressDelivery) { for (const reply of replies) { // Suppress reasoning payloads from channel delivery — channels using this @@ -1477,9 +1508,20 @@ export async function dispatchReplyFromConfig( if (reply.isReasoning === true) { continue; } + attemptedFinalDelivery = true; const finalReply = await sendFinalPayload(reply); queuedFinal = finalReply.queuedFinal || queuedFinal; routedFinalCount += finalReply.routedFinalCount; + if (!finalReply.queuedFinal && finalReply.routedFinalCount === 0) { + finalDeliveryFailed = true; + } + } + + if (attemptedFinalDelivery && !finalDeliveryFailed) { + await clearPendingFinalDeliveryAfterSuccess({ + storePath: sessionStoreEntry.storePath, + sessionKey: sessionStoreEntry.sessionKey ?? sessionKey, + }); } const ttsMode = resolveConfiguredTtsMode(cfg, { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 7df8162bf08..cc75197816e 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -15,7 +15,7 @@ import { import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; -import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; @@ -332,14 +332,6 @@ export function createFollowupRunner(params: { bootstrapPromptWarningSignaturesSeen.length - 1 ], onAgentEvent: (evt) => { - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } if (evt.stream !== "compaction") { return; } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 1bbaf5e93ca..d81b02f4646 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -310,6 +310,40 @@ export async function getReplyFromConfig( triggerBodyNormalized, bodyStripped, } = sessionState; + + if (sessionEntry?.pendingFinalDelivery && sessionEntry.pendingFinalDeliveryText) { + const text = sessionEntry.pendingFinalDeliveryText; + + // If it's a heartbeat, we definitely want to try delivering the lost reply now. + // If it's a user message, we deliver the lost reply first, then continue. + // For now, let's just return the lost reply if it's a heartbeat. + if (opts?.isHeartbeat) { + const updatedAt = Date.now(); + const attemptCount = (sessionEntry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + sessionEntry.pendingFinalDeliveryLastAttemptAt = updatedAt; + sessionEntry.pendingFinalDeliveryAttemptCount = attemptCount; + sessionEntry.pendingFinalDeliveryLastError = null; + sessionEntry.updatedAt = updatedAt; + if (sessionKey && sessionStore) { + sessionStore[sessionKey] = sessionEntry; + } + if (sessionKey && storePath) { + const { updateSessionStoreEntry } = await import("../../config/sessions.js"); + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDeliveryLastAttemptAt: updatedAt, + pendingFinalDeliveryAttemptCount: attemptCount, + pendingFinalDeliveryLastError: null, + updatedAt, + }), + }); + } + return { text }; + } + } + if (resetTriggered && normalizeOptionalString(bodyStripped)) { const { applyResetModelOverride } = await loadSessionResetModelRuntime(); await applyResetModelOverride({ diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index bce6fa5d8d1..7200c1577b6 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -20,6 +20,29 @@ export type ModelDirectiveSelection = { alias?: string; }; +function formatAddModelCommand(modelRef: string): string { + return `openclaw config set agents.defaults.models '${JSON.stringify({ [modelRef]: {} })}' --strict-json --merge`; +} + +function formatNotAllowedError(params: { + modelRef: string; + rawRuntime?: string | undefined; +}): string { + const rawRuntime = params.rawRuntime?.trim(); + const retryCommand = rawRuntime + ? `/model ${params.modelRef} --runtime ${rawRuntime}` + : `/model ${params.modelRef}`; + const lines = [ + `Model "${params.modelRef}" is not allowed. Use /models to list providers, or /models to list models.`, + `Add it with: ${formatAddModelCommand(params.modelRef)}`, + `Then retry: ${retryCommand}`, + ]; + if (rawRuntime && normalizeProviderId(rawRuntime) === "codex") { + lines.push("If the Codex runtime is missing, run: openclaw plugins enable codex"); + } + return lines.join("\n"); +} + const FUZZY_VARIANT_TOKENS = [ "lightning", "preview", @@ -238,6 +261,7 @@ export function resolveModelDirectiveSelection(params: { defaultModel: string; aliasIndex: ModelAliasIndex; allowedModelKeys: Set; + rawRuntime?: string | undefined; }): { selection?: ModelDirectiveSelection; error?: string } { const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params; @@ -401,6 +425,9 @@ export function resolveModelDirectiveSelection(params: { } return { - error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models to list models.`, + error: formatNotAllowedError({ + modelRef: `${resolved.ref.provider}/${resolved.ref.model}`, + rawRuntime: params.rawRuntime, + }), }; } diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 848cb368f21..59f89c96b67 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -66,6 +66,23 @@ describe("reply run registry", () => { expect(replyRunRegistry.isActive("agent:main:main")).toBe(false); }); + it("runs completeThen callbacks after active state clears", () => { + const operation = createReplyOperation({ + sessionKey: "agent:main:main", + sessionId: "session-complete", + resetTriggered: false, + }); + const afterClear = vi.fn(() => { + expect(replyRunRegistry.isActive("agent:main:main")).toBe(false); + expect(isReplyRunActiveForSessionId("session-complete")).toBe(false); + }); + + operation.completeThen(afterClear); + + expect(operation.result).toEqual({ kind: "completed" }); + expect(afterClear).toHaveBeenCalledTimes(1); + }); + it("force-clears a running operation after abort without backend cleanup", async () => { vi.useFakeTimers(); try { diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index f0c0127c872..4a37f4b7025 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -54,6 +54,11 @@ export type ReplyOperation = { attachBackend(handle: ReplyBackendHandle): void; detachBackend(handle: ReplyBackendHandle): void; complete(): void; + /** + * Complete the operation, clear active-run state, then run follow-up work. + * Use when the follow-up can create another ReplyOperation for this session. + */ + completeThen(afterClear: () => void): void; fail(code: Exclude, cause?: unknown): void; abortByUser(): void; abortForRestart(): void; @@ -332,6 +337,10 @@ export function createReplyOperation(params: { } clearState(); }, + completeThen(afterClear) { + operation.complete(); + afterClear(); + }, fail(code, cause) { if (!result) { result = { kind: "failed", code, cause }; diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 9114f2211bb..c556cb20802 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -9,7 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; -import { listBundledChannelPluginIds } from "./plugins/bundled-ids.js"; +import { listBundledChannelIds } from "./plugins/bundled-ids.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); @@ -121,7 +121,7 @@ export function listPotentialConfiguredChannelPresenceSignals( signals.push({ channelId, source }); }; const configuredChannelIds = new Set(); - const channelIds = options.channelIds ?? listBundledChannelPluginIds(env); + const channelIds = options.channelIds ?? listBundledChannelIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); const channels = isRecord(cfg.channels) ? cfg.channels : null; if (channels) { @@ -165,7 +165,7 @@ function hasEnvConfiguredChannel( env: NodeJS.ProcessEnv, options: ChannelPresenceOptions = {}, ): boolean { - const channelIds = options.channelIds ?? listBundledChannelPluginIds(env); + const channelIds = options.channelIds ?? listBundledChannelIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 5cc75528eb6..c23cd4b994b 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -10,6 +10,20 @@ export function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +export function listBundledChannelIdsForRoot( + _packageRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + return listChannelCatalogEntries({ origin: "bundled", env }) + .map((entry) => entry.channel.id) + .filter((channelId): channelId is string => Boolean(channelId)) + .toSorted((left, right) => left.localeCompare(right)); +} + export function listBundledChannelPluginIds(env: NodeJS.ProcessEnv = process.env): string[] { return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env); } + +export function listBundledChannelIds(env: NodeJS.ProcessEnv = process.env): string[] { + return listBundledChannelIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env); +} diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts index 71e2340c325..eca47f5fe30 100644 --- a/src/channels/plugins/bundled-root-caches.test.ts +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -62,10 +62,10 @@ describe("bundled root-aware plugin lookups", () => { listChannelCatalogEntries: (params?: { env?: NodeJS.ProcessEnv }) => { const activeRoot = params?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR; if (activeRoot === rootA.pluginsDir) { - return [{ pluginId: "alpha" }]; + return [{ pluginId: "alpha", channel: { id: "alpha-chat" } }]; } if (activeRoot === rootB.pluginsDir) { - return [{ pluginId: "beta" }]; + return [{ pluginId: "beta", channel: { id: "beta-chat" } }]; } return []; }, @@ -78,9 +78,11 @@ describe("bundled root-aware plugin lookups", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; expect(bundledIds.listBundledChannelPluginIds()).toEqual(["alpha"]); + expect(bundledIds.listBundledChannelIds()).toEqual(["alpha-chat"]); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]); + expect(bundledIds.listBundledChannelIds()).toEqual(["beta-chat"]); }); it("reads bootstrap plugins from the active bundled root without re-importing", async () => { diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index d9c11823b07..d26f05d92d9 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getChannelPluginCatalogEntry } from "./catalog.js"; describe("channel plugin catalog", () => { - it("keeps third-party official channel ids mapped to their published plugin ids", () => { + it("keeps third-party channel ids mapped with catalog install trust", () => { const options = { workspaceDir: "/tmp/openclaw-channel-catalog-empty-workspace", env: {}, diff --git a/src/channels/plugins/package-state-probes.test.ts b/src/channels/plugins/package-state-probes.test.ts new file mode 100644 index 00000000000..162d00278a4 --- /dev/null +++ b/src/channels/plugins/package-state-probes.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginChannelCatalogEntry } from "../../plugins/channel-catalog-registry.js"; +import { + hasBundledChannelPackageState, + listBundledChannelIdsForPackageState, +} from "./package-state-probes.js"; + +const listChannelCatalogEntriesMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../plugins/channel-catalog-registry.js", () => ({ + listChannelCatalogEntries: listChannelCatalogEntriesMock, +})); + +function makeBundledChannelCatalogEntry(params: { + pluginId: string; + channelId: string; +}): PluginChannelCatalogEntry { + return { + pluginId: params.pluginId, + origin: "bundled", + rootDir: "/tmp/openclaw-channel-plugin", + channel: { + id: params.channelId, + configuredState: { + env: { + allOf: ["ALIAS_CHAT_TOKEN"], + }, + }, + }, + }; +} + +beforeEach(() => { + listChannelCatalogEntriesMock.mockReset(); +}); + +describe("channel package-state probes", () => { + it("uses channel ids when manifest plugin ids differ", () => { + listChannelCatalogEntriesMock.mockReturnValue([ + makeBundledChannelCatalogEntry({ + pluginId: "vendor-alias-chat-plugin", + channelId: "alias-chat", + }), + ]); + + expect(listBundledChannelIdsForPackageState("configuredState")).toEqual(["alias-chat"]); + expect( + hasBundledChannelPackageState({ + metadataKey: "configuredState", + channelId: "alias-chat", + cfg: {}, + env: { ALIAS_CHAT_TOKEN: "token" }, + }), + ).toBe(true); + expect( + hasBundledChannelPackageState({ + metadataKey: "configuredState", + channelId: "vendor-alias-chat-plugin", + cfg: {}, + env: { ALIAS_CHAT_TOKEN: "token" }, + }), + ).toBe(false); + }); +}); diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index 9431f8310c1..fe4d3e88344 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -137,10 +137,16 @@ function resolveChannelPackageStateChecker(params: { } } +function resolvePackageStateChannelId(entry: PluginChannelCatalogEntry): string | undefined { + return normalizeOptionalString(entry.channel.id); +} + export function listBundledChannelIdsForPackageState( metadataKey: ChannelPackageStateMetadataKey, ): string[] { - return listChannelPackageStateCatalog(metadataKey).map((entry) => entry.pluginId); + return listChannelPackageStateCatalog(metadataKey) + .map((entry) => resolvePackageStateChannelId(entry)) + .filter((channelId): channelId is string => Boolean(channelId)); } export function hasBundledChannelPackageState(params: { @@ -149,8 +155,9 @@ export function hasBundledChannelPackageState(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): boolean { + const requestedChannelId = normalizeOptionalString(params.channelId); const entry = listChannelPackageStateCatalog(params.metadataKey).find( - (candidate) => candidate.pluginId === params.channelId, + (candidate) => resolvePackageStateChannelId(candidate) === requestedChannelId, ); if (!entry) { return false; diff --git a/src/channels/plugins/read-only-command-defaults.test.ts b/src/channels/plugins/read-only-command-defaults.test.ts index 0ad3fec1dfe..ec38f03817f 100644 --- a/src/channels/plugins/read-only-command-defaults.test.ts +++ b/src/channels/plugins/read-only-command-defaults.test.ts @@ -65,4 +65,43 @@ describe("resolveReadOnlyChannelCommandDefaults", () => { workspaceDir: "/workspace", }); }); + + it("resolves command defaults for manifest channel aliases", () => { + loadPluginMetadataSnapshot.mockReturnValue({ + index: { + plugins: [ + { + pluginId: "vendor-demo-plugin", + origin: "global", + enabled: true, + enabledByDefault: true, + }, + ], + }, + plugins: [ + { + id: "vendor-demo-plugin", + origin: "global", + channels: ["demo"], + channelConfigs: { + demo: { + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }, + ], + }); + + expect( + resolveReadOnlyChannelCommandDefaults("demo", { + config: {}, + }), + ).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); + }); }); diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts index 393f2292db9..e4b8d6c9c01 100644 --- a/src/channels/plugins/read-only-command-defaults.ts +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -74,12 +74,6 @@ export function resolveReadOnlyChannelCommandDefaults( if (!record.channels.includes(normalizedChannelId)) { continue; } - if ( - record.id !== normalizedChannelId && - record.channelCatalogMeta?.id !== normalizedChannelId - ) { - continue; - } if (!isInstalledPluginEnabled(snapshot.index, record.id, options.config)) { continue; } @@ -92,9 +86,11 @@ export function resolveReadOnlyChannelCommandDefaults( !Array.isArray(channelConfigValue) ? (channelConfigValue as ManifestChannelConfigRecord) : undefined; - const commands = normalizeChannelCommandDefaults( - channelConfig?.commands ?? record.channelCatalogMeta?.commands, - ); + const catalogCommands = + record.channelCatalogMeta?.id === normalizedChannelId + ? record.channelCatalogMeta.commands + : undefined; + const commands = normalizeChannelCommandDefaults(channelConfig?.commands ?? catalogCommands); if (commands) { return commands; } diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index bd5cb7f3507..0603c738830 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -49,6 +49,7 @@ const probeGateway = vi.fn< configSnapshot: unknown; }> >(); +const callGatewayCli = vi.fn(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn()); @@ -77,6 +78,10 @@ vi.mock("../../gateway/probe.js", () => ({ }) => probeGateway(opts), })); +vi.mock("../../gateway/call.js", () => ({ + callGatewayCli: (opts: unknown) => callGatewayCli(opts), +})); + vi.mock("../../config/commands.js", () => ({ isRestartEnabled: (config?: { commands?: unknown }) => isRestartEnabled(config), })); @@ -113,7 +118,11 @@ vi.mock("./lifecycle-core.js", () => ({ describe("runDaemonRestart health checks", () => { let runDaemonStart: (opts?: { json?: boolean }) => Promise; - let runDaemonRestart: (opts?: { json?: boolean }) => Promise; + let runDaemonRestart: (opts?: { + json?: boolean; + safe?: boolean; + force?: boolean; + }) => Promise; let runDaemonStop: (opts?: { json?: boolean }) => Promise; let envSnapshot: ReturnType; @@ -162,6 +171,7 @@ describe("runDaemonRestart health checks", () => { signalVerifiedGatewayPidSync.mockReset(); formatGatewayPidList.mockReset(); probeGateway.mockReset(); + callGatewayCli.mockReset(); isRestartEnabled.mockReset(); loadConfig.mockReset(); recoverInstalledLaunchAgent.mockReset(); @@ -204,6 +214,31 @@ describe("runDaemonRestart health checks", () => { ok: true, configSnapshot: { commands: { restart: true } }, }); + callGatewayCli.mockResolvedValue({ + ok: true, + status: "deferred", + preflight: { + safe: false, + counts: { + queueSize: 1, + pendingReplies: 0, + embeddedRuns: 0, + activeTasks: 0, + totalActive: 1, + }, + blockers: [{ kind: "queue", count: 1, message: "1 queued or active operation(s)" }], + summary: "restart deferred: 1 queued or active operation(s)", + }, + restart: { + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); isRestartEnabled.mockReturnValue(true); signalVerifiedGatewayPidSync.mockImplementation(() => {}); formatGatewayPidList.mockImplementation((pids) => pids.join(", ")); @@ -230,6 +265,24 @@ describe("runDaemonRestart health checks", () => { expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "started" }); }); + it("requests a safe gateway restart over RPC without touching the service manager", async () => { + await runDaemonRestart({ json: true, safe: true }); + + expect(callGatewayCli).toHaveBeenCalledWith({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe" }, + timeoutMs: 10_000, + }); + expect(runServiceRestart).not.toHaveBeenCalled(); + }); + + it("keeps force restart on the existing non-safe path", async () => { + await runDaemonRestart({ json: true, force: true }); + + expect(callGatewayCli).not.toHaveBeenCalled(); + expect(runServiceRestart).toHaveBeenCalled(); + }); + it("repairs stale loaded service definitions from gateway start", async () => { repairLoadedGatewayServiceForStart.mockResolvedValue({ result: "started", diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index cea631423c4..572530b82f1 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,12 +1,14 @@ import { isRestartEnabled } from "../../config/commands.flags.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { callGatewayCli } from "../../gateway/call.js"; import { probeGateway } from "../../gateway/probe.js"; import { findVerifiedGatewayListenerPidsOnPortSync, formatGatewayPidList, signalVerifiedGatewayPidSync, } from "../../infra/gateway-processes.js"; +import type { SafeGatewayRestartRequestResult } from "../../infra/restart-coordinator.js"; import { type GatewayRestartIntent, writeGatewayRestartIntentSync } from "../../infra/restart.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -139,6 +141,50 @@ function resolveGatewayRestartIntentOptions( return undefined; } +function formatSafeRestartWarnings(result: SafeGatewayRestartRequestResult): string[] | undefined { + if (result.preflight.blockers.length === 0) { + return undefined; + } + return [result.preflight.summary]; +} + +async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise { + if (opts.force) { + throw new Error("--safe cannot be combined with --force; omit --safe to force restart now"); + } + if (opts.wait !== undefined) { + throw new Error("--safe cannot be combined with --wait; safe restart uses gateway deferral"); + } + const result = await callGatewayCli({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe" }, + timeoutMs: 10_000, + }); + const message = + result.status === "coalesced" + ? "safe restart request joined an existing pending gateway restart" + : result.status === "deferred" + ? "safe restart requested; gateway will restart after active work drains" + : "safe restart requested; gateway will restart momentarily"; + const payload = { + ok: true, + result: result.status, + message, + preflight: result.preflight, + restart: result.restart, + warnings: formatSafeRestartWarnings(result), + }; + if (opts.json) { + defaultRuntime.log(JSON.stringify(payload, null, 2)); + } else { + defaultRuntime.log(message); + if (result.preflight.blockers.length > 0) { + defaultRuntime.log(theme.warn(result.preflight.summary)); + } + } + return true; +} + async function restartGatewayWithoutServiceManager( port: number, restartIntent?: GatewayRestartIntent, @@ -218,6 +264,9 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { * Throws/exits on check or restart failures. */ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { + if (opts.safe) { + return await requestSafeGatewayRestart(opts); + } const json = Boolean(opts.json); const service = resolveGatewayService(); let restartedWithoutServiceManager = false; diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index 0d33fe39bdc..983b4d90c93 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -70,6 +70,17 @@ describe("addGatewayServiceCommands", () => { ); }, }, + { + name: "forwards restart safe control", + argv: ["restart", "--safe"], + assert: () => { + expect(runDaemonRestart).toHaveBeenCalledWith( + expect.objectContaining({ + safe: true, + }), + ); + }, + }, { name: "forwards restart force control", argv: ["restart", "--force"], diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 13081d38aff..335865cad6f 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -49,6 +49,7 @@ function resolveRestartOptions(cmdOpts: DaemonLifecycleOptions, command?: Comman return { ...cmdOpts, force: Boolean(cmdOpts.force || parentForce), + safe: Boolean(cmdOpts.safe), }; } @@ -122,6 +123,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .command("restart") .description("Restart the Gateway service (launchd/systemd/schtasks)") .option("--force", "Restart immediately without waiting for active gateway work", false) + .option("--safe", "Request an OpenClaw-aware restart after active work drains", false) .option( "--wait ", "Wait duration before forcing restart (ms, 10s, 5m; 0 waits indefinitely)", diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 90df43a6612..5d50d24fa34 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -27,5 +27,6 @@ export type DaemonInstallOptions = { export type DaemonLifecycleOptions = { json?: boolean; force?: boolean; + safe?: boolean; wait?: string; }; diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 39c3effecd2..a73f9cf566f 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -390,7 +390,9 @@ describe("runGatewayLoop", () => { expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(gatewayLog.warn).toHaveBeenCalledWith( - expect.stringContaining("restart blocked by active task run(s): taskId=task-force"), + expect.stringContaining( + "restart blocked by active background task run(s): taskId=task-force", + ), ); expect(gatewayLog.warn).toHaveBeenCalledWith( "forced restart requested; skipping active work drain", diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 25e028219c7..d671b30521e 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -392,7 +392,7 @@ export async function runGatewayLoop(params: { `draining ${activeTasks} active task(s) and ${activeRuns} active embedded run(s) before restart ${formatRestartDrainBudget()}`, ); if (taskBlockers) { - gatewayLog.warn(`restart blocked by active task run(s): ${taskBlockers}`); + gatewayLog.warn(`restart blocked by active background task run(s): ${taskBlockers}`); } if (restartIntent?.force) { gatewayLog.warn("forced restart requested; skipping active work drain"); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 674c8932a7c..3088ff8b520 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -25,6 +25,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; +import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -158,7 +159,7 @@ function createGatewayCliStartupTrace() { async measure(name: string, run: () => Awaitable): Promise { const before = performance.now(); try { - return await run(); + return await withDiagnosticPhase(name, run); } finally { const now = performance.now(); emit(name, now - before, now - started); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 68da54a32cb..6a0d53451ce 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -734,7 +734,7 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); - it("passes official external catalog integrity to npm installs", async () => { + it("passes third-party external catalog integrity with catalog install trust", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin"); loadConfig.mockReturnValue(cfg); @@ -796,7 +796,7 @@ describe("plugins cli install", () => { }, ); - it("passes official external catalog integrity to hook-pack fallback", async () => { + it("passes third-party external catalog integrity to hook-pack fallback", async () => { loadConfig.mockReturnValue(createEmptyPluginConfig()); findBundledPluginSourceMock.mockReturnValue(undefined); installPluginFromNpmSpec.mockResolvedValue({ @@ -992,6 +992,40 @@ describe("plugins cli install", () => { expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); + it("marks catalog npm package installs with alternate selectors as trusted", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue( + createNpmPluginInstallResult("wecom-openclaw-plugin"), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "@wecom/wecom-openclaw-plugin@latest"]); + + // Alternate selectors stay trusted by catalog package name, but must not + // inherit catalog integrity unless the install spec matches exactly. + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@latest", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedIntegrity: expect.any(String), + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + it("passes the active profile extensions dir to npm installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index ee7593898cd..89e33e2a741 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -64,15 +64,18 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta }; } -function findTrustedOfficialExternalPackageInstall(packageName: string): +function findTrustedCatalogPackageInstall(packageName: string): | { pluginId: string; npmSpec?: string; expectedIntegrity?: string; } | undefined { + // The catalog is the trust list. Raw npm selectors such as + // @scope/pkg@latest inherit install-scan trust when their package name is + // cataloged; integrity remains tied to exact catalog specs in the planner. const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); - if (entry?.source !== "official") { + if (!entry) { return undefined; } const pluginId = resolveOfficialExternalPluginId(entry); @@ -723,7 +726,7 @@ export async function runPluginInstallCommand(params: { } const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ npmSpec: npmPrefixSpec, - findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, }); const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, @@ -870,7 +873,7 @@ export async function runPluginInstallCommand(params: { const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ npmSpec: raw, - findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, }); const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index b4d62771182..a7f0764f8f9 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -191,6 +191,8 @@ describe("registerStatusHealthSessionsCommands", () => { "/tmp/sessions.json", "--active", "120", + "--limit", + "25", ]); expect(setVerbose).toHaveBeenCalledWith(true); @@ -199,6 +201,7 @@ describe("registerStatusHealthSessionsCommands", () => { json: true, store: "/tmp/sessions.json", active: "120", + limit: "25", }), runtime, ); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 1014626e73f..4d39987135e 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -132,6 +132,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { .option("--agent ", "Agent id to inspect (default: configured default agent)") .option("--all-agents", "Aggregate sessions across all configured agents", false) .option("--active ", "Only show sessions updated within the past N minutes") + .option("--limit ", 'Max sessions to show (default: 100; use "all" for full output)') .addHelpText( "after", () => @@ -140,6 +141,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { ["openclaw sessions --agent work", "List sessions for one agent."], ["openclaw sessions --all-agents", "Aggregate sessions across agents."], ["openclaw sessions --active 120", "Only last 2 hours."], + ["openclaw sessions --limit 25", "Show the newest 25 sessions."], ["openclaw sessions --json", "Machine-readable output."], ["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."], ])}\n\n${theme.muted( @@ -160,6 +162,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { agent: opts.agent as string | undefined, allAgents: Boolean(opts.allAgents), active: opts.active as string | undefined, + limit: opts.limit as string | undefined, }, defaultRuntime, ); diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index e595d8f0001..85e4bbc511d 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -96,6 +96,8 @@ describe("route-args", () => { "sqlite", "--active", "true", + "--limit", + "25", ]), ).toEqual({ json: true, @@ -103,8 +105,10 @@ describe("route-args", () => { agent: "default", store: "sqlite", active: "true", + limit: "25", }); expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull(); + expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--limit"])).toBeNull(); expect( parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]), ).toEqual({ diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index a80af17964e..d8f259c5419 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -144,12 +144,17 @@ export function parseSessionsRouteArgs(argv: string[]) { if (!active.ok) { return null; } + const limit = parseOptionalFlagValue(argv, "--limit"); + if (!limit.ok) { + return null; + } return { json: hasFlag(argv, "--json"), allAgents: hasFlag(argv, "--all-agents"), agent: agent.value, store: store.value, active: active.value, + limit: limit.value, }; } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 59dfa45883f..90590a5475a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -623,6 +623,33 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); + it("finishes package updates when the post-core process writes a result but keeps handles open", async () => { + setupUpdatedRootRefresh(); + const kill = vi.fn(); + spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => { + const resultPath = (options as { env?: NodeJS.ProcessEnv }).env + ?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH; + if (!resultPath) { + throw new Error("missing post-core result path"); + } + queueMicrotask(() => { + void fs.writeFile(resultPath, `${JSON.stringify({ status: "ok" })}\n`, "utf-8"); + }); + const child = new EventEmitter() as EventEmitter & { + kill: typeof kill; + once: EventEmitter["once"]; + }; + child.kill = kill; + return child; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(kill).toHaveBeenCalledTimes(1); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("does not carry gateway service markers into the post-core update process", async () => { setupUpdatedRootRefresh(); @@ -750,11 +777,37 @@ describe("update-cli", () => { ["npm", "i", "-g", expect.any(String)], expect.anything(), ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1); expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); expect(spawn).not.toHaveBeenCalled(); }); + it("post-core resume children exit after writing a plugin update result", async () => { + const resultDir = createCaseDir("openclaw-post-core-result"); + const resultPath = path.join(resultDir, "plugins.json"); + await fs.mkdir(resultDir, { recursive: true }); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + OPENCLAW_UPDATE_POST_CORE_RESULT_PATH: resultPath, + }, + async () => { + await updateCommand({ restart: false }); + }, + ); + + const result = JSON.parse(await fs.readFile(resultPath, "utf-8")) as { + status?: string; + }; + expect(result.status).toBe("ok"); + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + it("post-core resume mode persists the requested update channel with the updated process", async () => { vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index f851bfa2c17..22d0a81333c 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -222,6 +222,37 @@ describe("collectMissingPluginInstallPayloads", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("skips disabled tracked records when requested", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-")); + const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing"); + try { + await expect( + collectMissingPluginInstallPayloads({ + env: { HOME: tmpDir } as NodeJS.ProcessEnv, + skipDisabledPlugins: true, + config: { + plugins: { + entries: { + missing: { + enabled: false, + }, + }, + }, + }, + records: { + missing: { + source: "npm", + spec: "@openclaw/missing@beta", + installPath: missingDir, + }, + }, + }), + ).resolves.toEqual([]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); describe("shouldUseLegacyProcessRestartAfterUpdate", () => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 758438c7992..1a5d5678ffd 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -51,6 +51,7 @@ import { resolveGlobalInstallSpec, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, @@ -110,6 +111,7 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; +const POST_CORE_UPDATE_RESULT_POLL_MS = 100; const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ @@ -183,9 +185,15 @@ async function pathExists(filePath: string): Promise { export async function collectMissingPluginInstallPayloads(params: { records: Record; + config?: OpenClawConfig; + skipDisabledPlugins?: boolean; env?: NodeJS.ProcessEnv; }): Promise { const env = params.env ?? process.env; + const normalizedPluginConfig = + params.skipDisabledPlugins && params.config + ? normalizePluginsConfig(params.config.plugins) + : undefined; const missing: MissingPluginInstallPayload[] = []; for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) => left.localeCompare(right), @@ -193,6 +201,17 @@ export async function collectMissingPluginInstallPayloads(params: { if (!isTrackedPackageInstallRecord(record)) { continue; } + if (normalizedPluginConfig && params.config) { + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: "global", + config: normalizedPluginConfig, + rootConfig: params.config, + }); + if (!enableState.enabled) { + continue; + } + } const rawInstallPath = normalizeOptionalString(record.installPath); if (!rawInstallPath) { missing.push({ pluginId, reason: "missing-install-path" }); @@ -1090,7 +1109,11 @@ async function updatePluginsAfterCoreUpdate(params: { const repairMissingPayloads = async ( records: Record, ): Promise => { - const missing = await collectMissingPluginInstallPayloads({ records }); + const missing = await collectMissingPluginInstallPayloads({ + records, + config: pluginConfig, + skipDisabledPlugins: true, + }); if (missing.length === 0) { return []; } @@ -1109,6 +1132,8 @@ async function updatePluginsAfterCoreUpdate(params: { pluginIds: missingIds, timeoutMs: params.timeoutMs, updateChannel: params.channel, + skipDisabledPlugins: true, + disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, }); @@ -1129,6 +1154,7 @@ async function updatePluginsAfterCoreUpdate(params: { updateChannel: params.channel, skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]), skipDisabledPlugins: true, + disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, }); @@ -1139,6 +1165,8 @@ async function updatePluginsAfterCoreUpdate(params: { const remainingMissingPayloads = await collectMissingPluginInstallPayloads({ records: pluginConfig.plugins?.installs ?? {}, + config: pluginConfig, + skipDisabledPlugins: true, }); pluginUpdateOutcomes.push( ...remainingMissingPayloads.map( @@ -1608,6 +1636,25 @@ async function readPostCorePluginUpdateResultFile( return undefined; } +function stopPostCoreUpdateChild(child: ChildProcess): void { + if (process.platform === "win32" && child.pid) { + try { + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => { + child.kill(); + }); + return; + } catch { + child.kill(); + return; + } + } + child.kill(); +} + async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; @@ -1632,11 +1679,8 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } - const resultDir = - params.opts.json === true - ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")) - : null; - const resultPath = resultDir ? path.join(resultDir, "plugins.json") : null; + const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")); + const resultPath = path.join(resultDir, "plugins.json"); try { const child = spawn(resolveNodeRunner(), argv, { @@ -1648,24 +1692,65 @@ async function continuePostCoreUpdateInFreshProcess(params: { ...(params.requestedChannel ? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel } : {}), - ...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}), + [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath, }, }); - const exitCode = await new Promise((resolve, reject) => { - child.once("error", reject); + const childResult = await new Promise< + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult } + >((resolve, reject) => { + let settled = false; + const finish = ( + result: + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult }, + ) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + resolve(result); + }; + const resultPoll = setInterval(() => { + void readPostCorePluginUpdateResultFile(resultPath) + .then((pluginUpdate) => { + if (!pluginUpdate) { + return; + } + stopPostCoreUpdateChild(child); + finish({ kind: "plugin-update", pluginUpdate }); + }) + .catch(() => undefined); + }, POST_CORE_UPDATE_RESULT_POLL_MS); + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + reject(error); + }); child.once("exit", (code, signal) => { + if (settled) { + return; + } if (signal) { + settled = true; + clearInterval(resultPoll); reject(new Error(`post-update process terminated by signal ${signal}`)); return; } - resolve(code ?? 1); + finish({ kind: "exit", exitCode: code ?? 1 }); }); }); - const pluginUpdate = resultPath - ? await readPostCorePluginUpdateResultFile(resultPath) - : undefined; + const pluginUpdate = + childResult.kind === "plugin-update" + ? childResult.pluginUpdate + : await readPostCorePluginUpdateResultFile(resultPath); + const exitCode = childResult.kind === "exit" ? childResult.exitCode : 0; if (exitCode !== 0) { if (pluginUpdate) { return { resumed: true, pluginUpdate }; @@ -1675,9 +1760,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { } return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) }; } finally { - if (resultDir) { - await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); - } + await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); } } @@ -1752,11 +1835,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { opts, timeoutMs: updateStepTimeoutMs, }); - if (opts.json) { + if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { await writePostCorePluginUpdateResultFile( process.env[POST_CORE_UPDATE_RESULT_PATH_ENV], pluginUpdate, ); + } + if (opts.json) { if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { const result: UpdateRunResult = { status: pluginUpdate.status === "error" ? "error" : "ok", @@ -1773,6 +1858,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.exit(1); return; } + defaultRuntime.exit(0); return; } diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index ab8f3d9bf66..74eabf2ea52 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -588,6 +588,44 @@ describe("agentCommand", () => { }); }); + it("does not publish Codex app-server events from the core command callback", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + const codexEvents: Array<{ runId: string; phase?: string }> = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "codex_app_server.lifecycle") { + return; + } + codexEvents.push({ + runId: evt.runId, + phase: typeof evt.data?.phase === "string" ? evt.data.phase : undefined, + }); + }); + + vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => { + ( + params as { + onAgentEvent?: (evt: { stream: string; data: Record }) => void; + } + ).onAgentEvent?.({ + stream: "codex_app_server.lifecycle", + data: { phase: "startup" }, + }); + return { + payloads: [{ text: "hello" }], + meta: { agentMeta: { provider: "p", model: "m" } }, + } as never; + }); + + await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime); + stop(); + + expect(codexEvents).toHaveLength(0); + }); + }); + it("uses default fallback list for auto session model overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/doctor-auth-profile-config.ts b/src/commands/doctor-auth-profile-config.ts new file mode 100644 index 00000000000..bd95da733fd --- /dev/null +++ b/src/commands/doctor-auth-profile-config.ts @@ -0,0 +1,210 @@ +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; +import { collectConfiguredModelRefs } from "../config/model-refs.js"; +import type { AuthProfileConfig } from "../config/types.auth.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { isRecord } from "../utils.js"; + +const AUTH_PROFILE_MODES = new Set(["api_key", "oauth", "token"]); + +export type AuthProfileConfigProtectionResult = { + config: OpenClawConfig; + repairs: string[]; + warnings: string[]; +}; + +function normalizeProviderId(value: unknown): string { + return normalizeLowercaseStringOrEmpty(value); +} + +function normalizeProfileId(value: unknown): string | null { + return normalizeOptionalString(value) ?? null; +} + +function normalizeMode(value: unknown): AuthProfileConfig["mode"] | null { + return typeof value === "string" && AUTH_PROFILE_MODES.has(value as AuthProfileConfig["mode"]) + ? (value as AuthProfileConfig["mode"]) + : null; +} + +function extractProviderFromModelRef(value: string): string | null { + const { model } = splitTrailingAuthProfile(value); + const slash = model.indexOf("/"); + if (slash <= 0) { + return null; + } + return normalizeProviderId(model.slice(0, slash)) || null; +} + +function extractProviderFromProfileId(profileId: string): string | null { + const colon = profileId.indexOf(":"); + if (colon <= 0) { + return null; + } + return normalizeProviderId(profileId.slice(0, colon)) || null; +} + +function collectActiveAuthHints(config: OpenClawConfig): { + activeProviders: Set; + explicitProfileIds: Set; + explicitProfileProviders: Map>; +} { + const activeProviders = new Set(); + const explicitProfileIds = new Set(); + const explicitProfileProviders = new Map>(); + + const models = isRecord(config.models) ? config.models : {}; + const providers = isRecord(models.providers) ? models.providers : {}; + for (const providerId of Object.keys(providers)) { + const normalized = normalizeProviderId(providerId); + if (normalized) { + activeProviders.add(normalized); + } + } + + for (const { value } of collectConfiguredModelRefs(config)) { + const { profile } = splitTrailingAuthProfile(value); + const provider = extractProviderFromModelRef(value); + if (profile) { + explicitProfileIds.add(profile); + if (provider) { + const providers = explicitProfileProviders.get(profile) ?? new Set(); + providers.add(provider); + explicitProfileProviders.set(profile, providers); + } + } + if (provider) { + activeProviders.add(provider); + } + } + + const auth = isRecord(config.auth) ? config.auth : {}; + const order = isRecord(auth.order) ? auth.order : {}; + for (const [providerId, profileIds] of Object.entries(order)) { + const provider = normalizeProviderId(providerId); + if (!provider || !activeProviders.has(provider) || !Array.isArray(profileIds)) { + continue; + } + for (const profileId of profileIds) { + const normalized = normalizeProfileId(profileId); + if (normalized) { + explicitProfileIds.add(normalized); + } + } + } + + return { activeProviders, explicitProfileIds, explicitProfileProviders }; +} + +function isValidProfileMetadata(value: unknown): value is AuthProfileConfig { + if (!isRecord(value)) { + return false; + } + return normalizeProviderId(value.provider) !== "" && normalizeMode(value.mode) !== null; +} + +function buildProfileMetadata(params: { + profileId: string; + before: unknown; + after: unknown; + providerHint?: string; +}): AuthProfileConfig | null { + const before = isRecord(params.before) ? params.before : {}; + const after = isRecord(params.after) ? params.after : {}; + const provider = + normalizeProviderId(after.provider) || + normalizeProviderId(before.provider) || + extractProviderFromProfileId(params.profileId) || + normalizeProviderId(params.providerHint); + if (!provider) { + return null; + } + const mode = normalizeMode(after.mode) ?? normalizeMode(before.mode) ?? "api_key"; + const repaired: AuthProfileConfig = { provider, mode }; + const email = normalizeOptionalString(after.email) ?? normalizeOptionalString(before.email); + const displayName = + normalizeOptionalString(after.displayName) ?? normalizeOptionalString(before.displayName); + if (email) { + repaired.email = email; + } + if (displayName) { + repaired.displayName = displayName; + } + return repaired; +} + +function ensureAuthProfiles(config: OpenClawConfig): Record { + const root = config as Record; + const auth: Record = isRecord(root.auth) ? root.auth : {}; + if (root.auth !== auth) { + root.auth = auth; + } + if (!isRecord(auth.profiles)) { + auth.profiles = {}; + } + return auth.profiles as Record; +} + +export function protectActiveAuthProfileConfig(params: { + before: OpenClawConfig; + after: OpenClawConfig; +}): AuthProfileConfigProtectionResult { + const { activeProviders, explicitProfileIds, explicitProfileProviders } = collectActiveAuthHints( + params.before, + ); + const beforeAuth = isRecord(params.before.auth) ? params.before.auth : {}; + const beforeProfiles = isRecord(beforeAuth.profiles) ? beforeAuth.profiles : {}; + if (Object.keys(beforeProfiles).length === 0) { + return { config: params.after, repairs: [], warnings: [] }; + } + + const config = structuredClone(params.after); + const afterAuth = isRecord(config.auth) ? config.auth : {}; + const afterProfiles = isRecord(afterAuth.profiles) ? afterAuth.profiles : {}; + const repairs: string[] = []; + const warnings: string[] = []; + + for (const [profileId, beforeProfile] of Object.entries(beforeProfiles)) { + const afterProfile = afterProfiles[profileId]; + const afterProfileRecord = isRecord(afterProfile) ? afterProfile : null; + const beforeProfileRecord = isRecord(beforeProfile) ? beforeProfile : null; + if (isValidProfileMetadata(afterProfile)) { + continue; + } + const provider = + normalizeProviderId(afterProfileRecord?.provider) || + normalizeProviderId(beforeProfileRecord?.provider) || + extractProviderFromProfileId(profileId); + const protectsActiveProvider = !!provider && activeProviders.has(provider); + const protectsExplicitProfile = explicitProfileIds.has(profileId); + if (!protectsActiveProvider && !protectsExplicitProfile) { + continue; + } + + const repaired = buildProfileMetadata({ + profileId, + before: beforeProfile, + after: afterProfile, + providerHint: + explicitProfileProviders.get(profileId)?.size === 1 + ? [...(explicitProfileProviders.get(profileId) ?? [])][0] + : undefined, + }); + if (!repaired) { + warnings.push( + `auth.profiles.${profileId}: active auth profile metadata could not be inferred; repair manually before running doctor --fix.`, + ); + continue; + } + const profiles = ensureAuthProfiles(config); + profiles[profileId] = repaired; + repairs.push( + `Repaired auth.profiles.${profileId} metadata for active ${repaired.provider} auth.`, + ); + } + + return { config, repairs, warnings }; +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 0f834da73d6..8ae28bdd659 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -161,12 +161,15 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } - const { collectPluginToolAllowlistWarnings } = + const { collectBundledProviderAllowlistPolicyWarnings, collectPluginToolAllowlistWarnings } = await import("./doctor/shared/plugin-tool-allowlist-warnings.js"); - const pluginToolAllowlistWarnings = collectPluginToolAllowlistWarnings({ - cfg: candidate, - env: process.env, - }); + const pluginToolAllowlistWarnings = [ + ...collectPluginToolAllowlistWarnings({ + cfg: candidate, + env: process.env, + }), + ...collectBundledProviderAllowlistPolicyWarnings({ cfg: candidate }), + ]; if (pluginToolAllowlistWarnings.length > 0) { note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings"); } @@ -257,10 +260,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { doctorFixCommand, }); ({ cfg, candidate, pendingChanges, fixHints } = unknownStep.state); - if (unknownStep.removed.length > 0) { - const lines = unknownStep.removed.map((path) => `- ${path}`).join("\n"); + if (unknownStep.removed.length > 0 || unknownStep.repairs.length > 0) { + const lines = [ + ...unknownStep.removed.map((path) => `- ${path}`), + ...unknownStep.repairs.map((change) => `- ${change}`), + ].join("\n"); note(lines, shouldRepair ? "Doctor changes" : "Unknown config keys"); } + if (unknownStep.warnings.length > 0) { + note(unknownStep.warnings.join("\n"), "Doctor warnings"); + } const finalized = await finalizeDoctorConfigFlow({ cfg, diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 21536a5a108..33183e2425d 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -10,6 +10,7 @@ import { import { noteStateIntegrity } from "./doctor-state-integrity.js"; vi.mock("../channels/plugins/bundled-ids.js", () => ({ + listBundledChannelIds: () => ["matrix", "whatsapp"], listBundledChannelPluginIds: () => ["matrix", "whatsapp"], })); diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts index 77f9e48c41c..5cefdfa1717 100644 --- a/src/commands/doctor/shared/config-flow-steps.test.ts +++ b/src/commands/doctor/shared/config-flow-steps.test.ts @@ -160,4 +160,332 @@ describe("doctor config flow steps", () => { expect(result.state.candidate).toEqual({}); expect(result.state.fixHints).toContain('Run "openclaw doctor --fix" to remove these keys.'); }); + + it("repairs active malformed auth profile metadata after unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:default metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("keeps valid active auth profile metadata while stripping stale secret fields", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": { provider: "openai", mode: "api_key" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-test", + }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("repairs non-default auth profiles for active providers", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:work": {}, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:work": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:work metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:work"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("preserves explicit model auth profile refs during unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("infers providers for bare auth profile suffixes", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + work: {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + }, + removed: ["auth.profiles.work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + work: { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.warnings).toEqual([]); + expect(result.state.cfg.auth?.profiles?.work).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("protects auth profiles referenced only by channel model overrides", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); }); diff --git a/src/commands/doctor/shared/config-flow-steps.ts b/src/commands/doctor/shared/config-flow-steps.ts index 79f17abe604..3c73da4c0ea 100644 --- a/src/commands/doctor/shared/config-flow-steps.ts +++ b/src/commands/doctor/shared/config-flow-steps.ts @@ -1,4 +1,5 @@ import { formatConfigIssueLines } from "../../../config/issue-format.js"; +import { protectActiveAuthProfileConfig } from "../../doctor-auth-profile-config.js"; import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js"; import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js"; import type { DoctorConfigMutationState } from "./config-mutation-state.js"; @@ -75,21 +76,29 @@ export function applyUnknownConfigKeyStep(params: { }): { state: DoctorConfigMutationState; removed: string[]; + repairs: string[]; + warnings: string[]; } { const unknown = stripUnknownConfigKeys(params.state.candidate); if (unknown.removed.length === 0) { - return { state: params.state, removed: [] }; + return { state: params.state, removed: [], repairs: [], warnings: [] }; } + const protectedAuth = protectActiveAuthProfileConfig({ + before: params.state.candidate, + after: unknown.config, + }); return { state: { - cfg: params.shouldRepair ? unknown.config : params.state.cfg, - candidate: unknown.config, + cfg: params.shouldRepair ? protectedAuth.config : params.state.cfg, + candidate: protectedAuth.config, pendingChanges: true, fixHints: params.shouldRepair ? params.state.fixHints : [...params.state.fixHints, `Run "${params.doctorFixCommand}" to remove these keys.`], }, removed: unknown.removed, + repairs: protectedAuth.repairs, + warnings: protectedAuth.warnings, }; } diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 8e422ce2647..6d008e3be03 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -182,7 +182,56 @@ describe("legacy migrate audio transcription", () => { }); describe("legacy migrate mention routing", () => { - it("does not rewrite removed routing.groupChat.requireMention migrations", () => { + it("moves legacy routing group chat settings into current channel and message config", () => { + const res = migrateLegacyConfigForTest({ + routing: { + allowFrom: ["+15550001111"], + groupChat: { + requireMention: false, + historyLimit: 12, + mentionPatterns: ["@openclaw"], + }, + }, + channels: { + whatsapp: {}, + telegram: { + groups: { + "*": { requireMention: true }, + }, + }, + imessage: {}, + }, + }); + + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]); + expect(res.config?.channels?.whatsapp?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.channels?.telegram?.groups).toEqual({ + "*": { requireMention: true }, + }); + expect(res.config?.channels?.imessage?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.messages?.groupChat).toEqual({ + historyLimit: 12, + mentionPatterns: ["@openclaw"], + }); + expect(res.changes).toEqual( + expect.arrayContaining([ + "Moved routing.allowFrom → channels.whatsapp.allowFrom.", + 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', + 'Removed routing.groupChat.requireMention (channels.telegram.groups."*" already set).', + 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', + "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", + "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", + ]), + ); + }); + + it("removes legacy routing requireMention when no compatible channel exists", () => { const res = migrateLegacyConfigForTest({ routing: { groupChat: { @@ -191,11 +240,14 @@ describe("legacy migrate mention routing", () => { }, }); - expect(res.changes).toEqual([]); - expect(res.config).toBeNull(); + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.changes).toEqual([ + "Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).", + ]); }); - it("does not rewrite removed channels.telegram.requireMention migrations", () => { + it("moves channels.telegram.requireMention into the wildcard group default", () => { const res = migrateLegacyConfigForTest({ channels: { telegram: { @@ -204,8 +256,41 @@ describe("legacy migrate mention routing", () => { }, }); - expect(res.changes).toEqual([]); + expect(res.config?.channels?.telegram).toEqual({ + groups: { + "*": { requireMention: false }, + }, + }); + expect(res.changes).toContain( + 'Moved channels.telegram.requireMention → channels.telegram.groups."*".requireMention.', + ); + }); +}); + +describe("legacy bundled provider discovery migrate", () => { + it("sets compat mode for existing restrictive plugin allowlists", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + }, + }); + + expect(res.config?.plugins?.bundledDiscovery).toBe("compat"); + expect(res.changes).toContain( + 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', + ); + }); + + it("does not override explicit bundled discovery mode", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + bundledDiscovery: "allowlist", + }, + }); + expect(res.config).toBeNull(); + expect(res.changes).toEqual([]); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts index fb470a1e94e..ae53796654f 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts @@ -2,6 +2,41 @@ import { describe, expect, it } from "vitest"; import { migrateLegacyConfig } from "./legacy-config-migrate.js"; describe("legacy config migrate validation", () => { + it("returns valid migrated config for legacy group chat routing drift", () => { + const res = migrateLegacyConfig({ + routing: { + allowFrom: ["+15550001111"], + groupChat: { + requireMention: false, + historyLimit: 8, + mentionPatterns: ["@openclaw"], + }, + }, + channels: { + whatsapp: {}, + telegram: {}, + }, + }); + + expect(res.partiallyValid).toBeUndefined(); + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]); + expect(res.config?.channels?.whatsapp?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.channels?.telegram?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.messages?.groupChat).toEqual({ + historyLimit: 8, + mentionPatterns: ["@openclaw"], + }); + expect(res.changes).toContain( + 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.', + ); + }); + it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => { const res = migrateLegacyConfig({ agents: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index 65b0c8826a6..2eee9cd6e71 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -1,5 +1,6 @@ import { defineLegacyConfigMigration, + ensureRecord, getRecord, type LegacyConfigMigrationSpec, type LegacyConfigRule, @@ -9,6 +10,196 @@ function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } +function cleanupEmptyRecord(parent: Record, key: string): void { + const value = getRecord(parent[key]); + if (value && Object.keys(value).length === 0) { + delete parent[key]; + } +} + +function resolveCompatibleDefaultGroupEntry(section: Record): { + groups: Record; + entry: Record; +} | null { + const existingGroups = section.groups; + if (existingGroups !== undefined && !getRecord(existingGroups)) { + return null; + } + const groups = getRecord(existingGroups) ?? {}; + const defaultKey = "*"; + const existingEntry = groups[defaultKey]; + if (existingEntry !== undefined && !getRecord(existingEntry)) { + return null; + } + const entry = getRecord(existingEntry) ?? {}; + return { groups, entry }; +} + +function migrateChannelDefaultRequireMention(params: { + section: Record; + channelId: string; + legacyPath: string; + requireMention: unknown; + changes: string[]; +}): boolean { + const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(params.section); + if (!defaultGroupEntry) { + params.changes.push( + `Removed ${params.legacyPath} (channels.${params.channelId}.groups has an incompatible shape; fix remaining issues manually).`, + ); + return false; + } + + const { groups, entry } = defaultGroupEntry; + if (entry.requireMention === undefined) { + entry.requireMention = params.requireMention; + groups["*"] = entry; + params.section.groups = groups; + params.changes.push( + `Moved ${params.legacyPath} → channels.${params.channelId}.groups."*".requireMention.`, + ); + return true; + } + + params.changes.push( + `Removed ${params.legacyPath} (channels.${params.channelId}.groups."*" already set).`, + ); + return false; +} + +function migrateRoutingAllowFrom(raw: Record, changes: string[]): void { + const routing = getRecord(raw.routing); + if (!routing || routing.allowFrom === undefined) { + return; + } + + const channels = getRecord(raw.channels); + const whatsapp = getRecord(channels?.whatsapp); + if (!channels || !whatsapp) { + delete routing.allowFrom; + cleanupEmptyRecord(raw, "routing"); + changes.push("Removed routing.allowFrom (channels.whatsapp not configured)."); + return; + } + + if (whatsapp.allowFrom === undefined) { + whatsapp.allowFrom = routing.allowFrom; + changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom."); + } else { + changes.push("Removed routing.allowFrom (channels.whatsapp.allowFrom already set)."); + } + + delete routing.allowFrom; + channels.whatsapp = whatsapp; + raw.channels = channels; + cleanupEmptyRecord(raw, "routing"); +} + +function migrateRoutingGroupChatMessages(params: { + raw: Record; + routing: Record; + groupChat: Record; + changes: string[]; +}): void { + const migrateMessageGroupField = (field: "historyLimit" | "mentionPatterns") => { + const value = params.groupChat[field]; + if (value === undefined) { + return; + } + + const messages = ensureRecord(params.raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup[field] === undefined) { + messagesGroup[field] = value; + params.changes.push(`Moved routing.groupChat.${field} → messages.groupChat.${field}.`); + } else { + params.changes.push( + `Removed routing.groupChat.${field} (messages.groupChat.${field} already set).`, + ); + } + delete params.groupChat[field]; + }; + + migrateMessageGroupField("historyLimit"); + migrateMessageGroupField("mentionPatterns"); + + if (Object.keys(params.groupChat).length === 0) { + delete params.routing.groupChat; + } else { + params.routing.groupChat = params.groupChat; + } +} + +function migrateRoutingGroupChatRequireMention(params: { + raw: Record; + groupChat: Record; + changes: string[]; +}): void { + const requireMention = params.groupChat.requireMention; + if (requireMention === undefined) { + return; + } + + const channels = getRecord(params.raw.channels); + let matchedChannel = false; + if (channels) { + for (const channelId of ["whatsapp", "telegram", "imessage"]) { + const section = getRecord(channels[channelId]); + if (!section) { + continue; + } + matchedChannel = true; + migrateChannelDefaultRequireMention({ + section, + channelId, + legacyPath: "routing.groupChat.requireMention", + requireMention, + changes: params.changes, + }); + channels[channelId] = section; + } + params.raw.channels = channels; + } + + if (!matchedChannel) { + params.changes.push( + "Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).", + ); + } + delete params.groupChat.requireMention; +} + +function migrateRoutingGroupChat(raw: Record, changes: string[]): void { + const routing = getRecord(raw.routing); + const groupChat = getRecord(routing?.groupChat); + if (!routing || !groupChat) { + return; + } + + migrateRoutingGroupChatRequireMention({ raw, groupChat, changes }); + migrateRoutingGroupChatMessages({ raw, routing, groupChat, changes }); + cleanupEmptyRecord(raw, "routing"); +} + +function migrateTelegramRequireMention(raw: Record, changes: string[]): void { + const channels = getRecord(raw.channels); + const telegram = getRecord(channels?.telegram); + if (!channels || !telegram || telegram.requireMention === undefined) { + return; + } + + migrateChannelDefaultRequireMention({ + section: telegram, + channelId: "telegram", + legacyPath: "channels.telegram.requireMention", + requireMention: telegram.requireMention, + changes, + }); + delete telegram.requireMention; + channels.telegram = telegram; + raw.channels = channels; +} + function hasLegacyThreadBindingTtl(value: unknown): boolean { const threadBindings = getRecord(value); return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours")); @@ -190,7 +381,46 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ }, ]; +const GROUP_ROUTING_RULES: LegacyConfigRule[] = [ + { + path: ["routing", "allowFrom"], + message: + 'routing.allowFrom was removed; use channels.whatsapp.allowFrom instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "requireMention"], + message: + 'routing.groupChat.requireMention was removed; use channels..groups."*".requireMention instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "historyLimit"], + message: + 'routing.groupChat.historyLimit was moved; use messages.groupChat.historyLimit instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "mentionPatterns"], + message: + 'routing.groupChat.mentionPatterns was moved; use messages.groupChat.mentionPatterns instead. Run "openclaw doctor --fix".', + }, + { + path: ["channels", "telegram", "requireMention"], + message: + 'channels.telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".', + }, +]; + export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "legacy-group-routing->channel-groups", + describe: + "Move legacy routing group chat settings to current channel group and messages config", + legacyRules: GROUP_ROUTING_RULES, + apply: (raw, changes) => { + migrateRoutingAllowFrom(raw, changes); + migrateRoutingGroupChat(raw, changes); + migrateTelegramRequireMention(raw, changes); + }, + }), defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", describe: diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index 89e26d797e0..bce98e874ac 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -3,6 +3,7 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { isRecord } from "./legacy-config-record-shared.js"; import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js"; const X_SEARCH_RULE: LegacyConfigRule = { @@ -11,7 +12,40 @@ const X_SEARCH_RULE: LegacyConfigRule = { 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', }; +const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { + path: ["plugins", "allow"], + message: + 'plugins.allow now gates bundled provider discovery by default; run "openclaw doctor --fix" to preserve legacy bundled provider compatibility as plugins.bundledDiscovery="compat", or set plugins.bundledDiscovery="allowlist" to keep the stricter behavior.', + requireSourceLiteral: true, + match: (value, root) => { + if (!Array.isArray(value) || value.length === 0) { + return false; + } + const plugins = isRecord(root.plugins) ? root.plugins : undefined; + return plugins?.bundledDiscovery === undefined; + }, +}; + export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "plugins.allow->plugins.bundledDiscovery.compat", + describe: "Preserve legacy bundled provider discovery for existing restrictive allowlists", + legacyRules: [BUNDLED_DISCOVERY_COMPAT_RULE], + apply: (raw, changes) => { + const plugins = isRecord(raw.plugins) ? raw.plugins : undefined; + if (!plugins || plugins.bundledDiscovery !== undefined) { + return; + } + const allow = plugins.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return; + } + plugins.bundledDiscovery = "compat"; + changes.push( + 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', + ); + }, + }), defineLegacyConfigMigration({ id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey", describe: "Move legacy x_search auth into the xAI plugin webSearch config", diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts index 6f6375b870e..fdf18a2b636 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js"; -import { collectPluginToolAllowlistWarnings } from "./plugin-tool-allowlist-warnings.js"; +import { + collectBundledProviderAllowlistPolicyWarnings, + collectPluginToolAllowlistWarnings, +} from "./plugin-tool-allowlist-warnings.js"; const manifestRegistry: PluginManifestRegistry = { diagnostics: [], @@ -109,4 +112,34 @@ describe("collectPluginToolAllowlistWarnings", () => { expect(warnings).toEqual([]); }); + + it("warns when restrictive plugins.allow leaves bundled provider discovery in explicit compat mode", () => { + const warnings = collectBundledProviderAllowlistPolicyWarnings({ + cfg: { + plugins: { + allow: ["telegram"], + bundledDiscovery: "compat", + }, + }, + }); + + expect(warnings).toEqual([ + expect.stringContaining('set plugins.bundledDiscovery to "allowlist"'), + ]); + }); + + it.each([ + { name: "default", plugins: { allow: ["telegram"] } }, + { + name: "explicit allowlist", + plugins: { allow: ["telegram"], bundledDiscovery: "allowlist" as const }, + }, + ])( + "does not warn when bundled provider discovery follows the allowlist ($name)", + ({ plugins }) => { + const warnings = collectBundledProviderAllowlistPolicyWarnings({ cfg: { plugins } }); + + expect(warnings).toEqual([]); + }, + ); }); diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 500cd9cabdb..3dfd6b4188f 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -193,3 +193,21 @@ export function collectPluginToolAllowlistWarnings(params: { return warnings; } + +export function collectBundledProviderAllowlistPolicyWarnings(params: { + cfg: OpenClawConfig; +}): string[] { + if (params.cfg.plugins?.enabled === false) { + return []; + } + const allow = params.cfg.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return []; + } + if (params.cfg.plugins?.bundledDiscovery !== "compat") { + return []; + } + return [ + '- plugins.allow is restrictive, but bundled provider discovery is still in legacy compatibility mode. Bundled provider plugins can still appear in runtime provider inventories; set plugins.bundledDiscovery to "allowlist" after confirming omitted bundled providers are intentionally blocked.', + ]; +} diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index b1bfbf6894a..6d3ddb0133f 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -2,6 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-r import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; import { normalizeChatChannelId } from "../../../channels/registry.js"; import { isChannelConfigured } from "../../../config/channel-configured.js"; +import { collectConfiguredModelRefs } from "../../../config/model-refs.js"; import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; @@ -151,49 +152,13 @@ function collectConfiguredProviderIds(cfg: OpenClawConfig): Set { for (const providerId of Object.keys(asObjectRecord(cfg.models?.providers) ?? {})) { add(providerId); } - const collectModelRef = (value: unknown) => { - const ref = normalizeId(value); - const slash = ref?.indexOf("/") ?? -1; - if (ref && slash > 0) { - add(ref.slice(0, slash)); + for (const { value } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { + const slash = value.indexOf("/"); + if (slash > 0) { + add(value.slice(0, slash)); } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - collectModelRef(value); - return; - } - const record = asObjectRecord(value); - if (!record) { - return; - } - collectModelRef(record.primary); - if (Array.isArray(record.fallbacks)) { - for (const fallback of record.fallbacks) { - collectModelRef(fallback); - } - } - }; - const collectAgent = (agent: unknown) => { - const record = asObjectRecord(agent); - if (!record) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(record[key]); - } - for (const modelRef of Object.keys(asObjectRecord(record.models) ?? {})) { - collectModelRef(modelRef); - } - }; - collectAgent(cfg.agents?.defaults); - for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) { - collectAgent(agent); } return ids; } diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index 8c365bef07a..c989152656b 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -31,35 +31,37 @@ export const SESSION_KEY_PAD = 26; export const SESSION_AGE_PAD = 9; export const SESSION_MODEL_PAD = 14; +export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDisplayRow { + const updatedAt = entry?.updatedAt ?? null; + return { + key, + updatedAt, + ageMs: updatedAt ? Date.now() - updatedAt : null, + sessionId: entry?.sessionId, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + traceLevel: entry?.traceLevel, + reasoningLevel: entry?.reasoningLevel, + elevatedLevel: entry?.elevatedLevel, + responseUsage: entry?.responseUsage, + groupActivation: entry?.groupActivation, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: entry?.totalTokens, + totalTokensFresh: entry?.totalTokensFresh, + model: entry?.model, + modelProvider: entry?.modelProvider, + providerOverride: entry?.providerOverride, + modelOverride: entry?.modelOverride, + contextTokens: entry?.contextTokens, + }; +} + export function toSessionDisplayRows(store: Record): SessionDisplayRow[] { return Object.entries(store) - .map(([key, entry]) => { - const updatedAt = entry?.updatedAt ?? null; - return { - key, - updatedAt, - ageMs: updatedAt ? Date.now() - updatedAt : null, - sessionId: entry?.sessionId, - systemSent: entry?.systemSent, - abortedLastRun: entry?.abortedLastRun, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - traceLevel: entry?.traceLevel, - reasoningLevel: entry?.reasoningLevel, - elevatedLevel: entry?.elevatedLevel, - responseUsage: entry?.responseUsage, - groupActivation: entry?.groupActivation, - inputTokens: entry?.inputTokens, - outputTokens: entry?.outputTokens, - totalTokens: entry?.totalTokens, - totalTokensFresh: entry?.totalTokensFresh, - model: entry?.model, - modelProvider: entry?.modelProvider, - providerOverride: entry?.providerOverride, - modelOverride: entry?.modelOverride, - contextTokens: entry?.contextTokens, - } satisfies SessionDisplayRow; - }) + .map(([key, entry]) => toSessionDisplayRow(key, entry)) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); } diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index bfdd4e33a22..c5fe142835f 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -73,12 +73,13 @@ export function writeStore(data: unknown, prefix = "sessions"): string { export async function runSessionsJson( run: ( - opts: { json?: boolean; store?: string; active?: string }, + opts: { json?: boolean; store?: string; active?: string; limit?: string | number }, runtime: RuntimeEnv, ) => Promise, store: string, options?: { active?: string; + limit?: string | number; }, ): Promise { const { runtime, logs } = makeRuntime(); @@ -88,6 +89,7 @@ export async function runSessionsJson( store, json: true, active: options?.active, + limit: options?.limit, }, runtime, ); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 881e4c06283..52c6d287ff0 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -154,6 +154,106 @@ describe("sessionsCommand", () => { expect(payload.sessions?.map((row) => row.key)).toEqual(["recent"]); }); + it("limits JSON output to the newest 100 sessions by default", async () => { + const entries: Record = {}; + for (let i = 0; i < 105; i += 1) { + entries[`session-${String(i).padStart(3, "0")}`] = { + sessionId: `session-${i}`, + updatedAt: Date.now() - i * 60_000, + model: "pi:opus", + }; + } + const store = writeStore(entries, "sessions-default-limit"); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store); + + expect(payload.count).toBe(100); + expect(payload.totalCount).toBe(105); + expect(payload.limitApplied).toBe(100); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.at(0)?.key).toBe("session-000"); + expect(payload.sessions?.some((row) => row.key === "session-104")).toBe(false); + }); + + it("honors explicit JSON output limits", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-explicit-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "2" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(3); + expect(payload.limitApplied).toBe(2); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "middle"]); + }); + + it("allows full JSON output with --limit all", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-limit-all", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "all" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBeNull(); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + + it("sorts and slices large explicit limits instead of using top-N insertion", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-large-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "100000" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBe(100000); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + it("rejects invalid --active values", async () => { const store = writeStore( { @@ -171,4 +271,22 @@ describe("sessionsCommand", () => { fs.rmSync(store); }); + + it("rejects invalid --limit values", async () => { + const store = writeStore( + { + demo: { + sessionId: "demo", + updatedAt: Date.now() - 5 * 60_000, + }, + }, + "sessions-limit-invalid", + ); + const { runtime, errors } = makeRuntime(); + + await expect(sessionsCommand({ store, limit: "0" }, runtime)).rejects.toThrow("exit 1"); + expect(errors[0]).toContain('--limit must be a positive integer or "all"'); + + fs.rmSync(store); + }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 2c405c27cbd..96c41d418e3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -23,7 +23,7 @@ import { SESSION_KEY_PAD, SESSION_MODEL_PAD, type SessionDisplayRow, - toSessionDisplayRows, + toSessionDisplayRow, } from "./sessions-table.js"; type SessionRow = SessionDisplayRow & { @@ -35,10 +35,58 @@ type SessionRow = SessionDisplayRow & { const AGENT_PAD = 10; const KIND_PAD = 6; const TOKENS_PAD = 20; +const DEFAULT_SESSIONS_LIMIT = 100; +const TOP_N_SELECTION_LIMIT = 200; const contextLookupRuntimeLoader = createLazyImportLoader(() => import("../agents/context.js")); const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; +function compareSessionRowsByUpdatedAt(a: SessionRow, b: SessionRow): number { + return (b.updatedAt ?? 0) - (a.updatedAt ?? 0); +} + +function selectNewestSessionRows(rows: SessionRow[], limit: number | undefined): SessionRow[] { + if (limit === undefined) { + return rows.toSorted(compareSessionRowsByUpdatedAt); + } + if (limit > TOP_N_SELECTION_LIMIT) { + return rows.toSorted(compareSessionRowsByUpdatedAt).slice(0, limit); + } + const selected: SessionRow[] = []; + for (const row of rows) { + const insertAt = selected.findIndex( + (candidate) => compareSessionRowsByUpdatedAt(row, candidate) < 0, + ); + if (insertAt >= 0) { + selected.splice(insertAt, 0, row); + if (selected.length > limit) { + selected.pop(); + } + } else if (selected.length < limit) { + selected.push(row); + } + } + return selected; +} + +function parseSessionsLimit(value: string | number | undefined): number | undefined | null { + if (value === undefined) { + return DEFAULT_SESSIONS_LIMIT; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "all") { + return undefined; + } + if (!/^\d+$/.test(trimmed)) { + return null; + } + const parsed = Number.parseInt(trimmed, 10); + return parsed > 0 ? parsed : null; + } + return Number.isInteger(value) && value > 0 ? value : null; +} + const colorByPct = (label: string, pct: number | null, rich: boolean) => { if (!rich || pct === null) { return label; @@ -115,7 +163,14 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { }; export async function sessionsCommand( - opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean }, + opts: { + json?: boolean; + store?: string; + active?: string; + agent?: string; + allAgents?: boolean; + limit?: string | number; + }, runtime: RuntimeEnv, ) { const aggregateAgents = opts.allAgents === true; @@ -150,10 +205,25 @@ export async function sessionsCommand( activeMinutes = parsed; } - const rows = targets - .flatMap((target) => { - const store = loadSessionStore(target.storePath); - return toSessionDisplayRows(store).map((row) => { + const limit = parseSessionsLimit(opts.limit); + if (limit === null) { + runtime.error('--limit must be a positive integer or "all"'); + runtime.exit(1); + return; + } + + const allRows = targets.flatMap((target) => { + const store = loadSessionStore(target.storePath); + return Object.entries(store) + .filter(([, entry]) => { + if (activeMinutes === undefined) { + return true; + } + const updatedAt = entry?.updatedAt; + return typeof updatedAt === "number" && Date.now() - updatedAt <= activeMinutes * 60_000; + }) + .map(([key, entry]) => { + const row = toSessionDisplayRow(key, entry); const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId; return Object.assign({}, row, { agentId, @@ -161,17 +231,10 @@ export async function sessionsCommand( kind: classifySessionKey(row.key, store[row.key]), }); }); - }) - .filter((row) => { - if (activeMinutes === undefined) { - return true; - } - if (!row.updatedAt) { - return false; - } - return Date.now() - row.updatedAt <= activeMinutes * 60_000; - }) - .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }); + const totalCount = allRows.length; + const rows = selectNewestSessionRows(allRows, limit); + const hasMore = rows.length < totalCount; if (opts.json) { const multi = targets.length > 1; @@ -186,6 +249,9 @@ export async function sessionsCommand( : undefined, allAgents: aggregateAgents ? true : undefined, count: rows.length, + totalCount, + limitApplied: limit ?? null, + hasMore, activeMinutes: activeMinutes ?? null, sessions: await Promise.all( rows.map(async (r) => { @@ -217,7 +283,13 @@ export async function sessionsCommand( info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`), ); } - runtime.log(info(`Sessions listed: ${rows.length}`)); + runtime.log( + info( + hasMore && limit !== undefined + ? `Sessions listed: ${rows.length} of ${totalCount} (limit ${limit})` + : `Sessions listed: ${rows.length}`, + ), + ); if (activeMinutes) { runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`)); } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 0201f010ca1..d10fc9e1013 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -253,6 +253,44 @@ describe("config plugin validation", () => { } }); + it("reports catalog install hints for missing configured official external plugins", async () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { brave: { enabled: true } }, + allow: ["brave"], + }, + }, + { + env: suiteEnv(), + pluginMetadataSnapshot: { + manifestRegistry: { + plugins: [], + diagnostics: [], + }, + }, + }, + ); + + expect(res.ok).toBe(true); + const message = + "plugin not installed: brave — install the official external plugin with: openclaw plugins install @openclaw/brave-plugin"; + expect(res.warnings ?? []).toEqual( + expect.arrayContaining([ + { path: "plugins.entries.brave", message }, + { path: "plugins.allow", message }, + ]), + ); + expect( + (res.warnings ?? []).some( + (warning) => + (warning.path === "plugins.entries.brave" || warning.path === "plugins.allow") && + warning.message.includes("remove it from plugins config"), + ), + ).toBe(false); + }); + it.runIf(process.platform !== "win32")( "reports configured blocked plugins without stale not-found wording", async () => { @@ -493,7 +531,7 @@ describe("config plugin validation", () => { expect(res.warnings ?? []).toContainEqual({ path: "plugins.allow", message: - "plugin not found: discord (stale config entry ignored; remove it from plugins config)", + "plugin not installed: discord — install the official external plugin with: openclaw plugins install @openclaw/discord", }); }); diff --git a/src/config/model-refs.ts b/src/config/model-refs.ts new file mode 100644 index 00000000000..8008aa8b911 --- /dev/null +++ b/src/config/model-refs.ts @@ -0,0 +1,77 @@ +import { isRecord } from "../utils.js"; + +export type ConfiguredModelRef = { + path: string; + value: string; +}; + +export const AGENT_MODEL_CONFIG_KEYS = [ + "model", + "imageModel", + "imageGenerationModel", + "videoGenerationModel", + "musicGenerationModel", + "pdfModel", +] as const; + +export function collectConfiguredModelRefs( + config: unknown, + options: { includeChannelModelOverrides?: boolean } = {}, +): ConfiguredModelRef[] { + const refs: ConfiguredModelRef[] = []; + const pushModelRef = (path: string, value: unknown) => { + if (typeof value === "string" && value.trim()) { + refs.push({ path, value: value.trim() }); + } + }; + const collectModelConfig = (path: string, value: unknown) => { + if (typeof value === "string") { + pushModelRef(path, value); + return; + } + if (!isRecord(value)) { + return; + } + pushModelRef(`${path}.primary`, value.primary); + if (Array.isArray(value.fallbacks)) { + for (const [index, entry] of value.fallbacks.entries()) { + pushModelRef(`${path}.fallbacks.${index}`, entry); + } + } + }; + const collectFromAgent = (path: string, agent: unknown) => { + if (!isRecord(agent)) { + return; + } + for (const key of AGENT_MODEL_CONFIG_KEYS) { + collectModelConfig(`${path}.${key}`, agent[key]); + } + if (isRecord(agent.models)) { + for (const modelRef of Object.keys(agent.models)) { + pushModelRef(`${path}.models.${modelRef}`, modelRef); + } + } + }; + + const root = isRecord(config) ? config : {}; + const agents = isRecord(root.agents) ? root.agents : {}; + collectFromAgent("agents.defaults", agents.defaults); + if (Array.isArray(agents.list)) { + for (const [index, entry] of agents.list.entries()) { + collectFromAgent(`agents.list.${index}`, entry); + } + } + if (options.includeChannelModelOverrides !== false) { + const channels = isRecord(root.channels) ? root.channels : {}; + const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; + for (const [channelId, channelMap] of Object.entries(modelByChannel)) { + if (!isRecord(channelMap)) { + continue; + } + for (const [targetId, modelRef] of Object.entries(channelMap)) { + pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef); + } + } + } + return refs; +} diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 52e38e10645..66060bd2ecf 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -335,6 +335,56 @@ describe("applyPluginAutoEnable channels", () => { }); describe("preferOver channel prioritization", () => { + it("uses the plugin manifest id for built-in channel claims", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + wecom: { token: "enabled" }, + }, + plugins: { + allow: ["existing-plugin"], + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "wecom-openclaw-plugin", + channels: ["wecom"], + }, + ]), + }); + + expect(result.config.plugins?.entries?.["wecom-openclaw-plugin"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.wecom).toBeUndefined(); + expect(result.config.plugins?.allow).toEqual(["existing-plugin", "wecom-openclaw-plugin"]); + expect(result.changes.join("\n")).toContain("enabled automatically."); + }); + + it("preserves same-name official channel plugin ids", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + discord: { token: "enabled" }, + }, + plugins: { + allow: ["existing-plugin"], + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "discord", + channels: ["discord"], + }, + ]), + }); + + expect(result.config.channels?.discord?.enabled).toBe(true); + expect(result.config.plugins?.entries?.discord).toBeUndefined(); + expect(result.config.plugins?.allow).toEqual(["existing-plugin", "discord"]); + expect(result.changes.join("\n")).toContain("Discord configured, enabled automatically."); + }); + it("uses manifest channel config preferOver metadata for plugin channels", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index bb6c125e9bf..71148a45186 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -15,6 +15,7 @@ import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.j import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { isChannelConfigured } from "./channel-configured.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js"; import type { PluginAutoEnableCandidate, @@ -47,61 +48,6 @@ function resolveAutoEnableProviderPluginIds( return Object.fromEntries(entries); } -function collectModelRefs(cfg: OpenClawConfig): string[] { - const refs: string[] = []; - const pushModelRef = (value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push(value.trim()); - } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - pushModelRef(value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(value.primary); - const fallbacks = value.fallbacks; - if (Array.isArray(fallbacks)) { - for (const entry of fallbacks) { - pushModelRef(entry); - } - } - }; - const collectFromAgent = (agent: Record | null | undefined) => { - if (!agent) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(agent[key]); - } - const models = agent.models; - if (isRecord(models)) { - for (const key of Object.keys(models)) { - pushModelRef(key); - } - } - }; - - collectFromAgent(cfg.agents?.defaults as Record | undefined); - const list = cfg.agents?.list; - if (Array.isArray(list)) { - for (const entry of list) { - if (isRecord(entry)) { - collectFromAgent(entry); - } - } - } - return refs; -} - function extractProviderFromModelRef(value: string): string | null { const trimmed = value.trim(); const slash = trimmed.indexOf("/"); @@ -157,7 +103,9 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean } } - for (const ref of collectModelRefs(cfg)) { + for (const { value: ref } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { const provider = extractProviderFromModelRef(ref); if (provider && provider === normalized) { return true; @@ -303,7 +251,7 @@ function collectPluginIdsForConfiguredChannel( if (preferredIds.size > 0) { return [...preferredIds].toSorted((left, right) => left.localeCompare(right)); } - return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId]; + return [claims[0]?.plugin.id ?? builtInId ?? normalizedChannelId]; } function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { @@ -493,7 +441,7 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) { return true; } - if (collectModelRefs(cfg).length > 0) { + if (collectConfiguredModelRefs(cfg, { includeChannelModelOverrides: false }).length > 0) { return true; } return hasConfiguredEmbeddedHarnessRuntime(cfg, env); @@ -618,7 +566,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const modelRef of collectModelRefs(params.config)) { + for (const { value: modelRef } of collectConfiguredModelRefs(params.config, { + includeChannelModelOverrides: false, + })) { const owningPluginIds = resolveOwningPluginIdsForModelRef({ model: modelRef, config: params.config, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index fccb1e06d4e..940b6e97094 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24186,6 +24186,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", }, + bundledDiscovery: { + type: "string", + enum: ["compat", "allowlist"], + title: "Bundled Plugin Discovery", + description: + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', + }, }, additionalProperties: false, title: "Plugins", @@ -28865,6 +28872,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Configured bundled chat channels can still activate their bundled plugin when the channel is explicitly enabled in config. Use this to enforce approved extension inventories in controlled environments.", tags: ["access"], }, + "plugins.bundledDiscovery": { + label: "Bundled Plugin Discovery", + help: 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', + tags: ["advanced"], + }, "plugins.deny": { label: "Plugin Denylist", help: "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d53ce928333..78410289d5a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1212,6 +1212,8 @@ export const FIELD_HELP: Record = { 'Select the active memory plugin by id, or "none" to disable memory plugins.', "plugins.slots.contextEngine": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", + "plugins.bundledDiscovery": + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ac38254f107..641c3a9847d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -905,6 +905,7 @@ export const FIELD_LABELS: Record = { plugins: "Plugins", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", + "plugins.bundledDiscovery": "Bundled Plugin Discovery", "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index fcffc810ab6..6e8062a5a86 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -267,6 +267,16 @@ export type SessionEntry = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + /** Durable marker that final user reply delivery still needs a retry/resume pass. */ + pendingFinalDelivery?: boolean; + pendingFinalDeliveryCreatedAt?: number; + pendingFinalDeliveryLastAttemptAt?: number; + pendingFinalDeliveryAttemptCount?: number; + pendingFinalDeliveryLastError?: string | null; + /** Frozen reply text that needs delivery. */ + pendingFinalDeliveryText?: string | null; + /** Original delivery context (channel, recipient, etc). */ + pendingFinalDeliveryContext?: DeliveryContext; /** * Whether totalTokens reflects a fresh context snapshot for the latest run. * Undefined means legacy/unknown freshness; false forces consumers to treat diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 0f5818baf37..6025a11b44f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -51,6 +51,16 @@ export type PluginsConfig = { allow?: string[]; /** Optional plugin denylist (plugin ids). */ deny?: string[]; + /** + * Controls how bundled plugins participate in runtime provider discovery when + * `allow` is configured. + * + * - `"allowlist"` (default): bundled provider plugins are gated by `allow` + * and `entries..enabled` like third-party plugins. + * - `"compat"`: legacy mode for migrated configs; bundled provider plugins + * can be force-loaded regardless of the allowlist. + */ + bundledDiscovery?: "compat" | "allowlist"; load?: PluginsLoadConfig; slots?: PluginSlotsConfig; entries?: Record; diff --git a/src/config/validation.ts b/src/config/validation.ts index 60d4375cff7..debc4f1526c 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -12,6 +12,10 @@ import { import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js"; import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { + getOfficialExternalPluginCatalogEntry, + resolveOfficialExternalPluginInstall, +} from "../plugins/official-external-plugin-catalog.js"; import { loadPluginMetadataSnapshot, type PluginMetadataSnapshot, @@ -35,6 +39,7 @@ import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-value import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js"; import { collectChannelSchemaMetadata } from "./channel-config-metadata.js"; import { materializeRuntimeConfig } from "./materialize.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -50,10 +55,6 @@ type AllowedValuesCollection = { hasValues: boolean; }; type JsonSchemaLike = Record; -type ConfiguredModelRef = { - path: string; - value: string; -}; function stripDeprecatedValidationKeys(raw: unknown): unknown { if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) { @@ -96,6 +97,22 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string { return segments.join("."); } +function formatMissingOfficialExternalPluginWarning(pluginId: string): string | null { + const catalogEntry = getOfficialExternalPluginCatalogEntry(pluginId); + if (!catalogEntry) { + return null; + } + const install = resolveOfficialExternalPluginInstall(catalogEntry); + const npmSpec = install?.npmSpec?.trim(); + const clawhubSpec = install?.clawhubSpec?.trim(); + const installSpec = + install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec); + if (!installSpec) { + return null; + } + return `plugin not installed: ${pluginId} — install the official external plugin with: openclaw plugins install ${installSpec}`; +} + function asJsonSchemaLike(value: unknown): JsonSchemaLike | null { return value && typeof value === "object" ? (value as JsonSchemaLike) : null; } @@ -1110,58 +1127,6 @@ function validateConfigObjectWithPluginsBase( issues.push(issue); }; - const collectConfiguredModelRefs = (): ConfiguredModelRef[] => { - const refs: ConfiguredModelRef[] = []; - const pushModelRef = (path: string, value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push({ path, value: value.trim() }); - } - }; - const collectModelConfig = (path: string, value: unknown) => { - if (typeof value === "string") { - pushModelRef(path, value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(`${path}.primary`, value.primary); - if (Array.isArray(value.fallbacks)) { - for (const [index, entry] of value.fallbacks.entries()) { - pushModelRef(`${path}.fallbacks.${index}`, entry); - } - } - }; - const collectFromAgent = (path: string, agent: unknown) => { - if (!isRecord(agent)) { - return; - } - for (const key of [ - "model", - "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - "pdfModel", - ]) { - collectModelConfig(`${path}.${key}`, agent[key]); - } - if (isRecord(agent.models)) { - for (const modelRef of Object.keys(agent.models)) { - pushModelRef(`${path}.models.${modelRef}`, modelRef); - } - } - }; - - collectFromAgent("agents.defaults", config.agents?.defaults); - if (Array.isArray(config.agents?.list)) { - for (const [index, entry] of config.agents.list.entries()) { - collectFromAgent(`agents.list.${index}`, entry); - } - } - return refs; - }; - const parseProviderModelRef = (value: string): { provider: string; model: string } | null => { const slashIndex = value.indexOf("/"); if (slashIndex <= 0 || slashIndex >= value.length - 1) { @@ -1173,7 +1138,7 @@ function validateConfigObjectWithPluginsBase( }; const validateConfiguredModelRefs = () => { - const configuredRefs = collectConfiguredModelRefs(); + const configuredRefs = collectConfiguredModelRefs(config); if (configuredRefs.length === 0) { return; } @@ -1496,6 +1461,16 @@ function validateConfigObjectWithPluginsBase( } return; } + if (opts?.warnOnly) { + const externalInstallWarning = formatMissingOfficialExternalPluginWarning(pluginId); + if (externalInstallWarning) { + warnings.push({ + path, + message: externalInstallWarning, + }); + return; + } + } if (opts?.warnOnly) { warnings.push({ path, diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 33a4e8c0592..9de258fe3f6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1073,6 +1073,7 @@ export const OpenClawSchema = z .strict() .optional(), entries: z.record(z.string(), PluginEntrySchema).optional(), + bundledDiscovery: z.enum(["compat", "allowlist"]).optional(), }) .strict() .optional(), diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 78293f65f5f..5508081cc5a 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -623,4 +623,16 @@ describe("scripts/docker/setup.sh", () => { const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); }); + + it("pins container-side workspace and config dirs on both services so host .env paths cannot leak (#77436)", async () => { + const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); + // Both gateway and CLI services must override the env_file values with the + // canonical container paths so a host-style OPENCLAW_WORKSPACE_DIR like + // `/Users//.openclaw/workspace` written to `.env` by docker-setup.sh + // cannot reach runtime code inside Linux Docker. + expect(compose.match(/OPENCLAW_CONFIG_DIR: \/home\/node\/\.openclaw$/gm)).toHaveLength(2); + expect( + compose.match(/OPENCLAW_WORKSPACE_DIR: \/home\/node\/\.openclaw\/workspace$/gm), + ).toHaveLength(2); + }); }); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index d550f9dd45d..ef7e2d35f88 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -105,12 +105,24 @@ describe("Dockerfile", () => { it("prunes runtime dependencies after the build stage", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); + const normalizedExtensionLoop = + "for ext in $(printf '%s\\n' \"$OPENCLAW_EXTENSIONS\" | tr ',' ' '); do \\"; expect(dockerfile).toContain("FROM build AS runtime-assets"); expect(dockerfile).toContain("ARG OPENCLAW_EXTENSIONS"); expect(dockerfile).toContain("ARG OPENCLAW_BUNDLED_PLUGIN_DIR"); + expect(dockerfile).toContain( + "Opt-in plugin dependencies at build time (space- or comma-separated directory names).", + ); + expect(dockerfile).toContain( + 'Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" .', + ); + expect(dockerfile.split(normalizedExtensionLoop).length - 1).toBe(2); expect(dockerfile).toContain("pnpm-workspace.runtime.yaml"); expect(dockerfile).toContain(" - ui\\n"); expect(dockerfile).toContain("CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod"); + expect(dockerfile).toContain( + 'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs', + ); expect(dockerfile).toContain("prune must not rediscover unrelated workspaces"); expect(dockerfile).not.toContain( `npm install --prefix "${BUNDLED_PLUGIN_ROOT_DIR}/$ext" --omit=dev --silent`, diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index c43f1d9f3e7..576aeafc4ab 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -1,12 +1,15 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js"; import { buildOpenClawCompileCacheRespawnPlan, isSourceCheckoutInstallRoot, resolveOpenClawCompileCacheDirectory, resolveEntryInstallRoot, + runOpenClawCompileCacheRespawnPlan, shouldEnableOpenClawCompileCache, } from "./entry.compile-cache.js"; @@ -122,4 +125,109 @@ describe("entry compile cache", () => { }), ).toBeUndefined(); }); + + it("runs compile-cache respawn plans with the child-process bridge", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const attachChildProcessBridge = vi.fn(); + const exit = vi.fn(); + const writeError = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js", "status"], + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge, + exit: exit as unknown as (code?: number) => never, + writeError, + }, + ); + + expect(spawn).toHaveBeenCalledWith( + "/usr/bin/node", + ["/repo/openclaw/dist/entry.js", "status"], + { + stdio: "inherit", + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + ); + expect(attachChildProcessBridge).toHaveBeenCalledWith(child, { + onSignal: expect.any(Function), + }); + + child.emit("exit", 0, null); + + expect(exit).toHaveBeenCalledWith(0); + expect(writeError).not.toHaveBeenCalled(); + }); + + it("marks signal-terminated compile-cache respawn children as failed without forcing another exit", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn(), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + child.emit("exit", null, "SIGTERM"); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it("terminates before force-killing a signaled compile-cache respawn child", () => { + vi.useFakeTimers(); + const child = new EventEmitter() as ChildProcess; + const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true); + child.kill = kill as ChildProcess["kill"]; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + let onSignal: ((signal: NodeJS.Signals) => void) | undefined; + + try { + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn((_child, options) => { + onSignal = options?.onSignal; + return { detach: vi.fn() }; + }), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + onSignal?.("SIGTERM"); + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith("SIGTERM"); + expect(exit).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + expect(exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index f3b2c8905a1..72595d1bff4 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -1,8 +1,12 @@ -import { spawnSync } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { enableCompileCache, getCompileCacheDir } from "node:module"; import os from "node:os"; import path from "node:path"; +import { attachChildProcessBridge } from "./process/child-process-bridge.js"; + +const COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS = 1_000; +const COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS = 1_000; export function resolveEntryInstallRoot(entryFile: string): string { const entryDir = path.dirname(entryFile); @@ -84,12 +88,19 @@ export function resolveOpenClawCompileCacheDirectory(params: { ); } -type OpenClawCompileCacheRespawnPlan = { +export type OpenClawCompileCacheRespawnPlan = { command: string; args: string[]; env: NodeJS.ProcessEnv; }; +type OpenClawCompileCacheRespawnRuntime = { + spawn: typeof spawn; + attachChildProcessBridge: typeof attachChildProcessBridge; + exit: (code?: number) => never; + writeError: (message: string) => void; +}; + export function buildOpenClawCompileCacheRespawnPlan(params: { currentFile: string; env?: NodeJS.ProcessEnv; @@ -138,15 +149,89 @@ export function respawnWithoutOpenClawCompileCacheIfNeeded(params: { if (!plan) { return false; } - const result = spawnSync(plan.command, plan.args, { + runOpenClawCompileCacheRespawnPlan(plan); + return true; +} + +export function runOpenClawCompileCacheRespawnPlan( + plan: OpenClawCompileCacheRespawnPlan, + runtime: OpenClawCompileCacheRespawnRuntime = { + spawn, + attachChildProcessBridge, + exit: process.exit.bind(process) as (code?: number) => never, + writeError: (message: string) => process.stderr.write(message), + }, +): ChildProcess { + const child = runtime.spawn(plan.command, plan.args, { stdio: "inherit", env: plan.env, }); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); - return true; + // Give the child a moment to honor forwarded signals, then exit the parent so + // a child that ignores SIGTERM cannot keep the compile-cache wrapper alive indefinitely. + let signalExitTimer: NodeJS.Timeout | undefined; + let signalForceKillTimer: NodeJS.Timeout | undefined; + const clearSignalExitTimer = (): void => { + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = undefined; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = undefined; + } + }; + const forceKillChild = (): void => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = (): void => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + runtime.exit(1); + }, COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = (): void => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS); + signalExitTimer.unref?.(); + }; + + runtime.attachChildProcessBridge(child, { + onSignal: scheduleParentExit, + }); + + child.once("exit", (code, signal) => { + clearSignalExitTimer(); + if (signal) { + runtime.exit(1); + } + runtime.exit(code ?? 1); + }); + + child.once("error", (error) => { + clearSignalExitTimer(); + runtime.writeError( + `[openclaw] Failed to respawn CLI without compile cache: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + runtime.exit(1); + }); + + return child; } export function enableOpenClawCompileCache(params: { diff --git a/src/entry.ts b/src/entry.ts index 4e15f468a27..22632b5612d 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -84,92 +84,94 @@ if ( } else { const entryFile = fileURLToPath(import.meta.url); const installRoot = resolveEntryInstallRoot(entryFile); - respawnWithoutOpenClawCompileCacheIfNeeded({ + const waitingForCompileCacheRespawn = respawnWithoutOpenClawCompileCacheIfNeeded({ currentFile: entryFile, installRoot, }); - process.title = "openclaw"; - ensureOpenClawExecMarkerOnProcess(); - installProcessWarningFilter(); - normalizeEnv(); - enableOpenClawCompileCache({ - installRoot, - }); - gatewayEntryStartupTrace.mark("bootstrap"); + if (!waitingForCompileCacheRespawn) { + process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); + installProcessWarningFilter(); + normalizeEnv(); + enableOpenClawCompileCache({ + installRoot, + }); + gatewayEntryStartupTrace.mark("bootstrap"); - if (shouldForceReadOnlyAuthStore(process.argv)) { - process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; - } - - if (process.argv.includes("--no-color")) { - process.env.NO_COLOR = "1"; - process.env.FORCE_COLOR = "0"; - } - - function ensureCliRespawnReady(): boolean { - const plan = buildCliRespawnPlan(); - if (!plan) { - return false; + if (shouldForceReadOnlyAuthStore(process.argv)) { + process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; } - const child = spawn(plan.command, plan.argv, { - stdio: "inherit", - env: plan.env, - }); + if (process.argv.includes("--no-color")) { + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + } - attachChildProcessBridge(child); - - child.once("exit", (code, signal) => { - if (signal) { - process.exitCode = 1; - return; + function ensureCliRespawnReady(): boolean { + const plan = buildCliRespawnPlan(); + if (!plan) { + return false; } - process.exit(code ?? 1); - }); - child.once("error", (error) => { - console.error( - "[openclaw] Failed to respawn CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exit(1); - }); + const child = spawn(plan.command, plan.argv, { + stdio: "inherit", + env: plan.env, + }); - // Parent must not continue running the CLI. - return true; - } + attachChildProcessBridge(child); - process.argv = normalizeWindowsArgv(process.argv); + child.once("exit", (code, signal) => { + if (signal) { + process.exitCode = 1; + return; + } + process.exit(code ?? 1); + }); - if (!ensureCliRespawnReady()) { - const parsedContainer = parseCliContainerArgs(process.argv); - if (!parsedContainer.ok) { - console.error(`[openclaw] ${parsedContainer.error}`); - process.exit(2); + child.once("error", (error) => { + console.error( + "[openclaw] Failed to respawn CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); + }); + + // Parent must not continue running the CLI. + return true; } - const parsed = parseCliProfileArgs(parsedContainer.argv); - if (!parsed.ok) { - // Keep it simple; Commander will handle rich help/errors after we strip flags. - console.error(`[openclaw] ${parsed.error}`); - process.exit(2); - } + process.argv = normalizeWindowsArgv(process.argv); - const containerTargetName = resolveCliContainerTarget(process.argv); - if (containerTargetName && parsed.profile) { - console.error("[openclaw] --container cannot be combined with --profile/--dev"); - process.exit(2); - } + if (!ensureCliRespawnReady()) { + const parsedContainer = parseCliContainerArgs(process.argv); + if (!parsedContainer.ok) { + console.error(`[openclaw] ${parsedContainer.error}`); + process.exit(2); + } - if (parsed.profile) { - applyCliProfileEnv({ profile: parsed.profile }); - // Keep Commander and ad-hoc argv checks consistent. - process.argv = parsed.argv; - } - gatewayEntryStartupTrace.mark("argv"); + const parsed = parseCliProfileArgs(parsedContainer.argv); + if (!parsed.ok) { + // Keep it simple; Commander will handle rich help/errors after we strip flags. + console.error(`[openclaw] ${parsed.error}`); + process.exit(2); + } - if (!tryHandleRootVersionFastPath(process.argv)) { - await runMainOrRootHelp(process.argv); + const containerTargetName = resolveCliContainerTarget(process.argv); + if (containerTargetName && parsed.profile) { + console.error("[openclaw] --container cannot be combined with --profile/--dev"); + process.exit(2); + } + + if (parsed.profile) { + applyCliProfileEnv({ profile: parsed.profile }); + // Keep Commander and ad-hoc argv checks consistent. + process.argv = parsed.argv; + } + gatewayEntryStartupTrace.mark("argv"); + + if (!tryHandleRootVersionFastPath(process.argv)) { + await runMainOrRootHelp(process.argv); + } } } } diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index af96bc0f09b..6cf956d3138 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -112,6 +112,7 @@ const METHOD_SCOPE_GROUPS: Record = { "cron.status", "cron.runs", "gateway.identity.get", + "gateway.restart.preflight", "system-presence", "last-heartbeat", "node.list", @@ -199,6 +200,7 @@ const METHOD_SCOPE_GROUPS: Record = { "system-event", "agents.files.set", "update.status", + "gateway.restart.request", ], [TALK_SECRETS_SCOPE]: [], }; diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index 5e83f96ac0b..dcdeba0ede6 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -118,7 +118,11 @@ export async function withOperatorApprovalsGatewayClient( clientOptions: { preauthHandshakeTimeoutMs: params.config.gateway?.handshakeTimeoutMs }, }); if (!readiness.ready) { - throw new Error("gateway event loop readiness timeout"); + throw new Error( + readiness.aborted + ? "gateway approval client start aborted before readiness" + : "gateway readiness unavailable before approval client start", + ); } await ready; return await run(gatewayClient); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index c89d34e2b6a..48ade335a2c 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -148,6 +148,8 @@ const BASE_METHODS = [ "cron.run", "cron.runs", "gateway.identity.get", + "gateway.restart.preflight", + "gateway.restart.request", "system-presence", "system-event", "message.action", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 78a613a9188..dcb42620a5d 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -30,6 +30,7 @@ import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; import { pluginHostHookHandlers } from "./server-methods/plugin-host-hooks.js"; import { pushHandlers } from "./server-methods/push.js"; +import { restartHandlers } from "./server-methods/restart.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; @@ -47,7 +48,12 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js"; import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; -const CONTROL_PLANE_WRITE_METHODS = new Set(["config.apply", "config.patch", "update.run"]); +const CONTROL_PLANE_WRITE_METHODS = new Set([ + "config.apply", + "config.patch", + "gateway.restart.request", + "update.run", +]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { if (!client?.connect) { return null; @@ -110,6 +116,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...nodeHandlers, ...nodePendingHandlers, ...pushHandlers, + ...restartHandlers, ...sendHandlers, ...usageHandlers, ...agentHandlers, diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index 522a8988335..deffd26af02 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -13,7 +13,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; -import { resolveEffectiveSharedGatewayAuth } from "../auth.js"; +import { resolveEffectiveSharedGatewayAuth, resolveGatewayAuth } from "../auth.js"; import { buildGatewayReloadPlan } from "../config-reload-plan.js"; import { resolveGatewayReloadSettings } from "../config-reload-settings.js"; import { formatControlPlaneActor, type ControlPlaneActor } from "../control-plane-audit.js"; @@ -31,7 +31,51 @@ export function resolveGatewayConfigPath(snapshot?: Pick): { + userHeader: string | undefined; + requiredHeaders: string[]; + allowUsers: string[]; + allowLoopback: boolean | undefined; +} { + return { + userHeader: auth.trustedProxy?.userHeader, + requiredHeaders: normalizeStringListForAuthCompare(auth.trustedProxy?.requiredHeaders), + allowUsers: normalizeStringListForAuthCompare(auth.trustedProxy?.allowUsers), + allowLoopback: auth.trustedProxy?.allowLoopback, + }; +} + export function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean { + const prevResolvedAuth = resolveGatewayAuth({ + authConfig: prev.gateway?.auth, + env: process.env, + tailscaleMode: prev.gateway?.tailscale?.mode, + }); + const nextResolvedAuth = resolveGatewayAuth({ + authConfig: next.gateway?.auth, + env: process.env, + tailscaleMode: next.gateway?.tailscale?.mode, + }); + if (prevResolvedAuth.mode === "trusted-proxy" || nextResolvedAuth.mode === "trusted-proxy") { + if (prevResolvedAuth.mode !== nextResolvedAuth.mode) { + return true; + } + return ( + !isDeepStrictEqual( + normalizeTrustedProxyAuthForCompare(prevResolvedAuth), + normalizeTrustedProxyAuthForCompare(nextResolvedAuth), + ) || + !isDeepStrictEqual( + normalizeStringListForAuthCompare(prev.gateway?.trustedProxies), + normalizeStringListForAuthCompare(next.gateway?.trustedProxies), + ) + ); + } + const prevAuth = resolveEffectiveSharedGatewayAuth({ authConfig: prev.gateway?.auth, env: process.env, diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index cffa90c73ce..78f5e6437a5 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -168,6 +168,123 @@ describe("config shared auth disconnects", () => { expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled(); }); + it("disconnects gateway-auth clients when active trusted-proxy policy changes", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["alice@example.com"], + }, + }, + trustedProxies: ["127.0.0.1"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + auth: { + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["bob@example.com"], + }, + }, + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1); + }); + + it("disconnects gateway-auth clients when trusted-proxy source list changes", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["127.0.0.1"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + trustedProxies: ["10.0.0.10"], + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1); + }); + + it("does not disconnect gateway-auth clients when trusted-proxy lists are reordered", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["alice@example.com", "bob@example.com"], + }, + }, + trustedProxies: ["127.0.0.1", "10.0.0.10"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + auth: { + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"], + allowUsers: ["bob@example.com", "alice@example.com"], + }, + }, + trustedProxies: ["10.0.0.10", "127.0.0.1"], + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled(); + }); + it("still schedules a direct restart for hot mode when the reloader cannot apply the change", async () => { const prevConfig: OpenClawConfig = { gateway: { diff --git a/src/gateway/server-methods/restart.ts b/src/gateway/server-methods/restart.ts new file mode 100644 index 00000000000..7fdcabf15bf --- /dev/null +++ b/src/gateway/server-methods/restart.ts @@ -0,0 +1,22 @@ +import { + createSafeGatewayRestartPreflight, + requestSafeGatewayRestart, +} from "../../infra/restart-coordinator.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function normalizeReason(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim().slice(0, 200) : undefined; +} + +export const restartHandlers: GatewayRequestHandlers = { + "gateway.restart.request": async ({ respond, params }) => { + const result = requestSafeGatewayRestart({ + reason: normalizeReason(params.reason), + delayMs: 0, + }); + respond(true, result); + }, + "gateway.restart.preflight": async ({ respond }) => { + respond(true, createSafeGatewayRestartPreflight()); + }, +}; diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 9e038f29996..f5ea25d6ce3 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -126,7 +126,9 @@ describe("gateway restart deferral preflight", () => { ); expect(logReload.warn).toHaveBeenCalledWith( - expect.stringContaining("restart blocked by active task run(s): taskId=task-nightly"), + expect.stringContaining( + "restart blocked by active background task run(s): taskId=task-nightly", + ), ); expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("runId=run-nightly")); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index d04f9caa139..92754844cc8 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -22,7 +22,6 @@ import { } from "../secrets/runtime.js"; import { getInspectableActiveTaskRestartBlockers, - getInspectableTaskRegistrySummary, type ActiveTaskRestartBlocker, } from "../tasks/task-registry.maintenance.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; @@ -143,7 +142,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) const queueSize = getTotalQueueSize(); const pendingReplies = getTotalPendingReplies(); const embeddedRuns = getActiveEmbeddedRunCount(); - const activeTasks = getInspectableTaskRegistrySummary().active; + const activeTasks = getInspectableActiveTaskRestartBlockers().length; return { queueSize, pendingReplies, @@ -164,7 +163,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) details.push(`${counts.embeddedRuns} embedded run(s)`); } if (counts.activeTasks > 0) { - details.push(`${counts.activeTasks} task run(s)`); + details.push(`${counts.activeTasks} background task run(s)`); } return details; }; @@ -420,7 +419,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) ); const taskBlockers = formatTaskBlockers(); if (taskBlockers) { - params.logReload.warn(`restart blocked by active task run(s): ${taskBlockers}`); + params.logReload.warn(`restart blocked by active background task run(s): ${taskBlockers}`); } deferGatewayRestartUntilIdle({ diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 7153839b5cb..6d96316bcce 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -119,7 +119,8 @@ export async function startGatewayEarlyRuntime(params: { cronRuntimeAuthoritative: true, }); taskRegistryMaintenance.startTaskRegistryMaintenance(); - getActiveTaskCount = () => taskRegistryMaintenance.getInspectableTaskRegistrySummary().active; + getActiveTaskCount = () => + taskRegistryMaintenance.getInspectableActiveTaskRestartBlockers().length; } const skillsChangeUnsub = params.minimalTestGateway diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index 4a89820016d..781d0e3f3e0 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { logGatewayStartup } from "./server-startup-log.js"; +import { formatAgentModelStartupDetails, logGatewayStartup } from "./server-startup-log.js"; describe("gateway startup log", () => { afterEach(() => { @@ -49,6 +49,100 @@ describe("gateway startup log", () => { expect(warn).not.toHaveBeenCalled(); }); + it("logs configured model thinking and fast mode defaults with the startup model", () => { + const info = vi.fn(); + const warn = vi.fn(); + + logGatewayStartup({ + cfg: { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + models: { + "openai-codex/gpt-5.5": { + params: { + fastMode: true, + thinking: "medium", + }, + }, + }, + reasoningDefault: "stream", + }, + }, + }, + bindHost: "127.0.0.1", + loadedPluginIds: [], + port: 18789, + log: { info, warn }, + isNixMode: false, + }); + + expect(info).toHaveBeenCalledWith( + "agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on)", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on)", + ), + }), + ); + }); + + it("defaults unset startup thinking to medium", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + list: [{ id: "main", default: true, fastModeDefault: true }], + }, + }, + provider: "openai-codex", + model: "gpt-5.5", + }), + ).toBe("thinking=medium, fast=on"); + }); + + it("preserves explicit startup thinking off", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.5": { params: { thinking: "off", fastMode: true } }, + }, + }, + }, + }, + provider: "openai-codex", + model: "gpt-5.5", + }), + ).toBe("thinking=off, fast=on"); + }); + + it("uses default agent mode overrides in the startup model details", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + thinkingDefault: "low", + reasoningDefault: "off", + models: { + "openai/gpt-5.5": { params: { fastMode: false } }, + }, + }, + list: [{ id: "alpha", default: true, thinkingDefault: "high", fastModeDefault: true }], + }, + }, + provider: "openai", + model: "gpt-5.5", + }), + ).toBe("thinking=high, fast=on"); + }); + it("logs a compact listening line with loaded plugin ids and duration", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-03T10:00:16.000Z")); diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index b510e498e6e..1e355072394 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -1,10 +1,27 @@ import chalk from "chalk"; +import { resolveDefaultAgentId, resolveAgentConfig } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { resolveFastModeState } from "../agents/fast-mode.js"; +import { + resolveConfiguredModelRef, + resolveThinkingDefault, + legacyModelKey, + modelKey, +} from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { collectEnabledInsecureOrDangerousFlags } from "../security/dangerous-config-flags.js"; +type StartupThinkLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "adaptive" + | "max"; + export function logGatewayStartup(params: { cfg: OpenClawConfig; bindHost: string; @@ -22,8 +39,13 @@ export function logGatewayStartup(params: { defaultModel: DEFAULT_MODEL, }); const modelRef = `${agentProvider}/${agentModel}`; - params.log.info(`agent model: ${modelRef}`, { - consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, + const modelDetails = formatAgentModelStartupDetails({ + cfg: params.cfg, + provider: agentProvider, + model: agentModel, + }); + params.log.info(`agent model: ${modelRef} (${modelDetails})`, { + consoleMessage: `agent model: ${chalk.whiteBright(modelRef)} (${modelDetails})`, }); const startupDurationMs = typeof params.startupStartedAt === "number" ? Date.now() - params.startupStartedAt : null; @@ -46,6 +68,67 @@ export function logGatewayStartup(params: { } } +function normalizeStartupThinkLevel(value: unknown): StartupThinkLevel | undefined { + return value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" || + value === "adaptive" || + value === "max" + ? value + : undefined; +} + +function resolveExplicitStartupThinking(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + defaultAgentThinking: unknown; +}): StartupThinkLevel | undefined { + const models = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + return ( + normalizeStartupThinkLevel(params.defaultAgentThinking) ?? + normalizeStartupThinkLevel(models?.[canonicalKey]?.params?.thinking) ?? + normalizeStartupThinkLevel(legacyKey ? models?.[legacyKey]?.params?.thinking : undefined) ?? + normalizeStartupThinkLevel(params.cfg.agents?.defaults?.thinkingDefault) + ); +} + +export function formatAgentModelStartupDetails(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): string { + const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentConfig = resolveAgentConfig(params.cfg, defaultAgentId); + const explicitThinking = resolveExplicitStartupThinking({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + defaultAgentThinking: defaultAgentConfig?.thinkingDefault, + }); + const resolvedThinking = + explicitThinking ?? + resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); + const thinking = explicitThinking ?? (resolvedThinking === "off" ? "medium" : resolvedThinking); + const fast = resolveFastModeState({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + agentId: defaultAgentId, + }); + + return `thinking=${thinking}, fast=${fast.enabled ? "on" : "off"}`; +} + function formatReadyDetails( loadedPluginIds: readonly string[], startupDurationLabel: string | null, diff --git a/src/gateway/server-startup-unavailable-methods.ts b/src/gateway/server-startup-unavailable-methods.ts index 294b980486a..0e162e7f75e 100644 --- a/src/gateway/server-startup-unavailable-methods.ts +++ b/src/gateway/server-startup-unavailable-methods.ts @@ -2,6 +2,7 @@ export const STARTUP_UNAVAILABLE_GATEWAY_METHODS = [ "agent.wait", "chat.history", "models.list", + "sessions.list", "sessions.abort", "sessions.create", "sessions.send", diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 7542b03c2f2..285f021a855 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -6,7 +6,9 @@ import { connectReq, CONTROL_UI_CLIENT, ConnectErrorDetailCodes, + createSignedDevice, getFreePort, + readConnectChallengeNonce, openWs, originForPort, rpcReq, @@ -312,7 +314,7 @@ describe("gateway auth compatibility baseline", () => { testState.gatewayAuth = { mode: "none" }; delete process.env.OPENCLAW_GATEWAY_TOKEN; port = await getFreePort(); - server = await startGatewayServer(port); + server = await startGatewayServer(port, { controlUiEnabled: true }); }); afterAll(async () => { @@ -329,5 +331,89 @@ describe("gateway auth compatibility baseline", () => { ws.close(); } }); + + test("keeps auth-none control ui first-connect token absence unchanged", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-first-${process.pid}-${port}.json`, + ); + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...CONTROL_UI_CLIENT }, + scopes: ["operator.read"], + deviceIdentityPath, + }); + expect(res.ok).toBe(true); + const helloOk = res.payload as + | { + auth?: { + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.deviceToken).toBeUndefined(); + } finally { + ws.close(); + } + }); + + test("keeps auth-none control ui stale-key token handoff unchanged", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = + await import("../infra/device-identity.js"); + const { approveDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const nonce = await readConnectChallengeNonce(ws); + const identityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-${process.pid}-${port}.json`, + ); + const staleIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-stale-${process.pid}-${port}.json`, + ); + const { identity, device } = await createSignedDevice({ + token: null, + scopes: ["operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + identityPath, + nonce, + }); + const staleIdentity = loadOrCreateDeviceIdentity(staleIdentityPath); + const pending = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(staleIdentity.publicKeyPem), + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + role: "operator", + scopes: ["operator.read"], + }); + await approveDevicePairing(pending.request.requestId, { + callerScopes: ["operator.admin"], + }); + + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...CONTROL_UI_CLIENT }, + scopes: ["operator.read"], + device, + }); + expect(res.ok).toBe(true); + const helloOk = res.payload as + | { + auth?: { + deviceToken?: unknown; + }; + } + | undefined; + expect(typeof helloOk?.auth?.deviceToken).toBe("string"); + } finally { + ws.close(); + } + }); }); }); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index bc2048bb856..a8589113cd5 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -314,6 +314,68 @@ export function registerControlUiAndPairingSuite(): void { }); }); + test("clamps trusted-proxy control ui scopes for unpaired device identity", async () => { + const { replaceConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = undefined; + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + allowedOrigins: ["https://localhost"], + }; + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: ["https://localhost"], + }, + }, + }, + afterWrite: { mode: "auto" }, + }); + await withControlUiGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const challengeNonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token: null, + role: "operator", + scopes: ["operator.admin"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + nonce: challengeNonce, + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin"], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + auth?: { scopes?: string[]; deviceToken?: string }; + } + | undefined; + expect(payload?.auth?.scopes).toEqual([]); + expect(payload?.auth?.deviceToken).toBeUndefined(); + + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message ?? "").toContain("missing scope"); + } finally { + ws.close(); + } + }); + }); + test("allows localhost ui clients without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index eab2c1eda32..35af73e73c0 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -439,6 +439,109 @@ describe("gateway server chat", () => { } }); + test("chat.send starts the next WebChat turn after the prior internal run finishes", async () => { + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + try { + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; + const context = { + loadGatewayModelCatalog: vi.fn(), + logGateway: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + agentRunSeq: new Map(), + chatAbortControllers: new Map(), + chatAbortedRuns: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + addChatRun: vi.fn(), + removeChatRun: vi.fn(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + registerToolEventRecipient: vi.fn(), + dedupe: new Map(), + } as unknown as GatewayRequestContext; + dispatchInboundMessageMock.mockResolvedValue(undefined); + + const { chatHandlers } = await import("./server-methods/chat.js"); + const callSend = (id: string, message: string, idempotencyKey: string) => + chatHandlers["chat.send"]({ + req: { + type: "req", + id, + method: "chat.send", + params: { + sessionKey: "main", + message, + idempotencyKey, + }, + }, + params: { + sessionKey: "main", + message, + idempotencyKey, + }, + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + scopes: ["operator.write"], + }, + } as never, + isWebchatConnect: () => true, + respond: ((ok, payload, error) => { + responses.push({ id, ok, payload, error }); + }) as RespondFn, + context, + }); + + await callSend("first", "first message", "idem-sequential-a"); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(1); + }, FAST_WAIT_OPTS); + + await callSend("second", "second message", "idem-sequential-b"); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(2); + }, FAST_WAIT_OPTS); + + expect(responses).toContainEqual({ + id: "first", + ok: true, + payload: { runId: "idem-sequential-a", status: "started" }, + error: undefined, + }); + expect(responses).toContainEqual({ + id: "second", + ok: true, + payload: { runId: "idem-sequential-b", status: "started" }, + error: undefined, + }); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2); + expect(context.addChatRun).toHaveBeenCalledTimes(2); + } finally { + dispatchInboundMessageMock.mockReset(); + testState.sessionStorePath = undefined; + clearConfigCache(); + await fs.rm(sessionDir, { recursive: true, force: true }); + } + }); + test("chat.history backfills claude-cli sessions from Claude project files", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8c92fa07008..aa13bd722ac 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -37,6 +37,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js"; +import { withDiagnosticPhase } from "../logging/diagnostic-phase.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { @@ -355,7 +356,7 @@ function createGatewayStartupTrace() { timelineOptions(), ); try { - const result = await run(); + const result = await withDiagnosticPhase(mapTimelineName(name), run, { traceName: name }); const now = performance.now(); emitDiagnosticsTimelineEvent( { @@ -735,9 +736,13 @@ export async function startGatewayServer( env: process.env, tailscaleMode, }), + config.gateway?.trustedProxies, ); const resolveCurrentSharedGatewaySessionGeneration = () => - resolveSharedGatewaySessionGeneration(getResolvedAuth()); + resolveSharedGatewaySessionGeneration( + getResolvedAuth(), + getRuntimeConfig().gateway?.trustedProxies, + ); const resolveSharedGatewaySessionGenerationForRuntimeSnapshot = () => resolveSharedGatewaySessionGeneration( resolveGatewayAuth({ @@ -746,6 +751,7 @@ export async function startGatewayServer( env: process.env, tailscaleMode, }), + getRuntimeConfig().gateway?.trustedProxies, ); const sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState = { current: resolveCurrentSharedGatewaySessionGeneration(), diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index f5f87acd370..e2296c94df4 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -763,7 +763,11 @@ describe("gateway hot reload", () => { const restartTesting = (await import("../infra/restart.js")).__testing; restartTesting.resetSigusr1State(); - hoisted.activeTaskCount.value = 1; + hoisted.activeTaskBlockers.push({ + taskId: "task-running-1", + status: "running", + runtime: "subagent", + }); const signalSpy = vi.fn(); process.once("SIGUSR1", signalSpy); vi.useFakeTimers(); @@ -792,7 +796,7 @@ describe("gateway hot reload", () => { await Promise.resolve(); expect(signalSpy).toHaveBeenCalledTimes(1); } finally { - hoisted.activeTaskCount.value = 0; + hoisted.activeTaskBlockers.length = 0; vi.useRealTimers(); process.removeListener("SIGUSR1", signalSpy); restartTesting.resetSigusr1State(); diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index c36aa488906..7ea32a99e42 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Socket } from "node:net"; import type { RawData, WebSocket, WebSocketServer } from "ws"; +import { getRuntimeConfig } from "../../config/io.js"; import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js"; import { removeRemoteNodeInfo } from "../../infra/skills-remote.js"; import { upsertPresence } from "../../infra/system-presence.js"; @@ -205,7 +206,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti resolvedAuth, getResolvedAuth = () => resolvedAuth, getRequiredSharedGatewaySessionGeneration = () => - resolveSharedGatewaySessionGeneration(getResolvedAuth()), + resolveSharedGatewaySessionGeneration( + getResolvedAuth(), + getRuntimeConfig().gateway?.trustedProxies, + ), rateLimiter, browserRateLimiter, isStartupPending, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9cd048aa56d..84b35be9118 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -837,9 +837,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar rejectUnauthorized(authResult); return; } - if (authMethod === "token" || authMethod === "password") { - const sharedGatewaySessionGeneration = - resolveSharedGatewaySessionGeneration(resolvedAuth); + if (authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy") { + const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration( + resolvedAuth, + trustedProxies, + ); const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration?.(); if ( @@ -874,6 +876,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar resolvedAuth.mode, authMethod, ); + let hasServerApprovedDeviceTokenBaseline = false; if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { @@ -1133,8 +1136,17 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar if (!ok) { return; } + hasServerApprovedDeviceTokenBaseline = true; + } else if (trustedProxyAuthOk) { + clearUnboundScopes(); + } else if ( + skipControlUiPairingForDevice || + (skipLocalBackendSelfPairing && authMethod !== "device-token") + ) { + hasServerApprovedDeviceTokenBaseline = true; } } else { + hasServerApprovedDeviceTokenBaseline = true; const claimedPlatform = connectParams.client.platform; const pairedPlatform = paired.platform; const claimedDeviceFamily = connectParams.client.deviceFamily; @@ -1222,9 +1234,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } } - const deviceToken = device - ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) - : null; + const deviceToken = + device && hasServerApprovedDeviceTokenBaseline + ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) + : null; const bootstrapDeviceTokens: Array<{ deviceToken: string; role: string; @@ -1303,9 +1316,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar const canvasCapabilityExpiresAtMs = canvasCapability ? Date.now() + CANVAS_CAPABILITY_TTL_MS : undefined; - const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password"; + const usesSharedGatewayAuth = + authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy"; const sharedGatewaySessionGeneration = usesSharedGatewayAuth - ? resolveSharedGatewaySessionGeneration(resolvedAuth) + ? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies) : undefined; const scopedCanvasHostUrl = canvasHostUrl && canvasCapability diff --git a/src/gateway/server/ws-shared-generation.test.ts b/src/gateway/server/ws-shared-generation.test.ts new file mode 100644 index 00000000000..9bc2f32b4f2 --- /dev/null +++ b/src/gateway/server/ws-shared-generation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js"; + +describe("resolveSharedGatewaySessionGeneration", () => { + it("tracks trusted-proxy policy inputs", () => { + const baseAuth = { + mode: "trusted-proxy" as const, + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["alice@example.com", "bob@example.com"], + }, + }; + + const base = resolveSharedGatewaySessionGeneration(baseAuth, ["127.0.0.1", "10.0.0.10"]); + expect(base).toBeDefined(); + expect( + resolveSharedGatewaySessionGeneration( + { + ...baseAuth, + trustedProxy: { + ...baseAuth.trustedProxy, + requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"], + allowUsers: ["bob@example.com", "alice@example.com"], + }, + }, + ["10.0.0.10", "127.0.0.1"], + ), + ).toBe(base); + expect( + resolveSharedGatewaySessionGeneration( + { + ...baseAuth, + trustedProxy: { + ...baseAuth.trustedProxy, + allowUsers: ["carol@example.com"], + }, + }, + ["127.0.0.1", "10.0.0.10"], + ), + ).not.toBe(base); + expect(resolveSharedGatewaySessionGeneration(baseAuth, ["10.0.0.11"])).not.toBe(base); + }); + + it("keeps shared-secret generations independent from proxy allowlists", () => { + const auth = { + mode: "token" as const, + allowTailscale: false, + token: "shared-token", + }; + + expect(resolveSharedGatewaySessionGeneration(auth, ["127.0.0.1"])).toBe( + resolveSharedGatewaySessionGeneration(auth, ["10.0.0.10"]), + ); + }); +}); diff --git a/src/gateway/server/ws-shared-generation.ts b/src/gateway/server/ws-shared-generation.ts index 27890458594..1003a25f612 100644 --- a/src/gateway/server/ws-shared-generation.ts +++ b/src/gateway/server/ws-shared-generation.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { GatewayTrustedProxyConfig } from "../../config/types.gateway.js"; import type { ResolvedGatewayAuth } from "../auth.js"; function resolveSharedSecret( @@ -18,14 +19,41 @@ function resolveSharedSecret( return null; } +function normalizeTrustedProxyConfig(trustedProxy: GatewayTrustedProxyConfig | undefined): { + userHeader: string | undefined; + requiredHeaders: string[]; + allowUsers: string[]; + allowLoopback: boolean | undefined; +} { + return { + userHeader: trustedProxy?.userHeader, + requiredHeaders: [...(trustedProxy?.requiredHeaders ?? [])].toSorted(), + allowUsers: [...(trustedProxy?.allowUsers ?? [])].toSorted(), + allowLoopback: trustedProxy?.allowLoopback, + }; +} + export function resolveSharedGatewaySessionGeneration( auth: ResolvedGatewayAuth, + trustedProxies?: readonly string[], ): string | undefined { const shared = resolveSharedSecret(auth); - if (!shared) { - return undefined; + if (shared) { + return createHash("sha256") + .update(`${shared.mode}\u0000${shared.secret}`, "utf8") + .digest("base64url"); } - return createHash("sha256") - .update(`${shared.mode}\u0000${shared.secret}`, "utf8") - .digest("base64url"); + if (auth.mode === "trusted-proxy") { + return createHash("sha256") + .update( + JSON.stringify({ + mode: auth.mode, + trustedProxy: normalizeTrustedProxyConfig(auth.trustedProxy), + trustedProxies: [...(trustedProxies ?? [])].toSorted(), + }), + "utf8", + ) + .digest("base64url"); + } + return undefined; } diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e604300dd6a..c47d9b7bf32 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -28,6 +28,8 @@ import { } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { resetTaskRegistryForTests } from "../tasks/runtime-internal.js"; +import { resetTaskFlowRegistryForTests } from "../tasks/task-flow-runtime-internal.js"; import { captureEnv } from "../test-utils/env.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -254,6 +256,8 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { } applyGatewaySkipEnv(); delete process.env.OPENCLAW_GATEWAY_TOKEN; + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); const stateDir = process.env.OPENCLAW_STATE_DIR; if (stateDir) { await fs.rm(stateDir, { @@ -365,6 +369,8 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { vi.useRealTimers(); clearGatewaySubagentRuntime(); resetLogger(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); if (options.restoreEnv) { gatewayEnvSnapshot?.restore(); gatewayEnvSnapshot = undefined; diff --git a/src/infra/approval-handler-bootstrap.test.ts b/src/infra/approval-handler-bootstrap.test.ts index 4b4aa42d2c0..307b4a7bea0 100644 --- a/src/infra/approval-handler-bootstrap.test.ts +++ b/src/infra/approval-handler-bootstrap.test.ts @@ -232,6 +232,55 @@ describe("startChannelApprovalHandlerBootstrap", () => { await cleanup(); }); + it("defers retryable gateway readiness startup failures without terminal error logs", async () => { + vi.useFakeTimers(); + const channelRuntime = createRuntimeChannel(); + const readinessError = new Error("gateway event loop readiness timeout"); + const start = vi.fn().mockRejectedValueOnce(readinessError).mockResolvedValueOnce(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const logger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + child: vi.fn(), + isEnabled: vi.fn().mockReturnValue(true), + isVerboseEnabled: vi.fn().mockReturnValue(false), + verbose: vi.fn(), + }; + createChannelApprovalHandlerFromCapability + .mockResolvedValueOnce({ start, stop }) + .mockResolvedValueOnce({ start, stop }); + + const cleanup = await startTestBootstrap({ channelRuntime, logger }); + + registerApprovalContext(channelRuntime); + await flushTransitions(); + + expect(start).toHaveBeenCalledTimes(1); + await flushTransitions(); + expect(logger.error).not.toHaveBeenCalledWith( + expect.stringContaining("failed to start native approval handler"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("native approval handler deferred until gateway readiness recovers"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("gateway readiness unavailable before approval handler start"), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("gateway event loop readiness timeout"), + ); + + await vi.advanceTimersByTimeAsync(1_000); + await flushTransitions(); + + expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2); + expect(start).toHaveBeenCalledTimes(2); + + await cleanup(); + }); + it("does not retry terminal native approval startup failures", async () => { vi.useFakeTimers(); const channelRuntime = createRuntimeChannel(); diff --git a/src/infra/approval-handler-bootstrap.ts b/src/infra/approval-handler-bootstrap.ts index a1ccce4424a..34f7663e697 100644 --- a/src/infra/approval-handler-bootstrap.ts +++ b/src/infra/approval-handler-bootstrap.ts @@ -17,6 +17,28 @@ import { isExecApprovalChannelRuntimeTerminalStartError } from "./exec-approval- type ApprovalBootstrapHandler = ChannelApprovalHandler; const APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS = 1_000; +function isRetryableApprovalBootstrapStartError(error: unknown): boolean { + const message = String(error); + return ( + message.includes("gateway readiness unavailable before approval client start") || + message.includes("gateway approval client start aborted before readiness") || + message.includes("gateway readiness unavailable before exec approval runtime start") || + message.includes("gateway approval runtime start aborted before readiness") || + message.includes("gateway event loop readiness timeout") || + message.includes("gateway starting") || + message.includes("code=1013") || + message.includes("close code 1013") + ); +} + +function formatRetryableApprovalBootstrapStartError(error: unknown): string { + const message = String(error); + if (message.includes("gateway event loop readiness timeout")) { + return "gateway readiness unavailable before approval handler start"; + } + return message; +} + export async function startChannelApprovalHandlerBootstrap(params: { plugin: Pick; cfg: OpenClawConfig; @@ -122,6 +144,13 @@ export async function startChannelApprovalHandlerBootstrap(params: { logger.error(`native approval handler disabled: ${String(error)}`); return; } + if (isRetryableApprovalBootstrapStartError(error)) { + logger.warn( + `native approval handler deferred until gateway readiness recovers: ${formatRetryableApprovalBootstrapStartError(error)}`, + ); + scheduleRetryForContext(context, generation); + return; + } logger.error(`failed to start native approval handler: ${String(error)}`); scheduleRetryForContext(context, generation); } diff --git a/src/infra/browser-open.test.ts b/src/infra/browser-open.test.ts new file mode 100644 index 00000000000..1547af15fbe --- /dev/null +++ b/src/infra/browser-open.test.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveBrowserOpenCommand } from "./browser-open.js"; +import { _resetWindowsInstallRootsForTests } from "./windows-install-roots.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + _resetWindowsInstallRootsForTests(); +}); + +describe("resolveBrowserOpenCommand", () => { + it("does not resolve Windows browser launching through a relative SystemRoot", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.stubEnv("SystemRoot", ".\\fake-root"); + vi.stubEnv("windir", ".\\fake-windir"); + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); + + const resolved = await resolveBrowserOpenCommand(); + + const rundll32 = path.win32.join("C:\\Windows", "System32", "rundll32.exe"); + expect(resolved.argv).toEqual([rundll32, "url.dll,FileProtocolHandler"]); + expect(resolved.command).toBe(rundll32); + }); + + it("prefers the registry-backed Windows system root over process env", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.stubEnv("SystemRoot", "C:\\PoisonedWindows"); + _resetWindowsInstallRootsForTests({ + queryRegistryValue: (key, valueName) => { + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && + valueName === "SystemRoot" + ) { + return "D:\\Windows"; + } + return null; + }, + }); + + const resolved = await resolveBrowserOpenCommand(); + + const rundll32 = path.win32.join("D:\\Windows", "System32", "rundll32.exe"); + expect(resolved.argv).toEqual([rundll32, "url.dll,FileProtocolHandler"]); + expect(resolved.command).toBe(rundll32); + }); +}); diff --git a/src/infra/browser-open.ts b/src/infra/browser-open.ts index d48261aa7ad..13d29ab8671 100644 --- a/src/infra/browser-open.ts +++ b/src/infra/browser-open.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { detectBinary } from "./detect-binary.js"; +import { getWindowsInstallRoots } from "./windows-install-roots.js"; import { isWSL } from "./wsl.js"; type BrowserOpenCommand = { @@ -23,7 +24,7 @@ function shouldSkipBrowserOpenInTests(): boolean { } function resolveWindowsRundll32Path(): string { - const systemRoot = process.env.SystemRoot?.trim() || process.env.windir?.trim() || "C:\\Windows"; + const { systemRoot } = getWindowsInstallRoots(); return path.win32.join(systemRoot, "System32", "rundll32.exe"); } diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 1e404208df7..45e8d85b61c 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -100,6 +100,12 @@ describe("clawhub helpers", () => { expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false); }); + it("treats OpenClaw CalVer correction versions as stable plugin API hosts", () => { + expect(satisfiesPluginApiRange("2026.5.3-1", ">=2026.5.3")).toBe(true); + expect(satisfiesPluginApiRange("2026.5.3-2", ">=2026.5.3")).toBe(true); + expect(satisfiesPluginApiRange("2026.5.3-beta.1", ">=2026.5.3")).toBe(false); + }); + it("accepts legacy bare major.minor plugin api ranges as lower bounds", () => { expect(satisfiesPluginApiRange("2026.5.2", "2026.4")).toBe(true); expect(satisfiesPluginApiRange("2026.4.0", "2026.4")).toBe(true); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 1d2b0472348..d8f4a4df59c 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -542,6 +542,13 @@ function satisfiesSemverRange(version: string, range: string): boolean { return tokens.every((token) => satisfiesComparator(version, token)); } +const OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN = /^[vV]?(\d{4}\.\d{1,2}\.\d{1,2})-\d+$/; + +function normalizeCalVerCorrectionForPluginApi(pluginApiVersion: string): string { + const match = OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN.exec(pluginApiVersion.trim()); + return match?.[1] ?? pluginApiVersion; +} + function buildUrl(params: Pick): URL { const url = new URL(params.path, `${normalizeBaseUrl(params.baseUrl)}/`); for (const [key, value] of Object.entries(params.search ?? {})) { @@ -1046,7 +1053,10 @@ export function satisfiesPluginApiRange( if (!pluginApiRange) { return true; } - return satisfiesSemverRange(pluginApiVersion, pluginApiRange); + return satisfiesSemverRange( + normalizeCalVerCorrectionForPluginApi(pluginApiVersion), + pluginApiRange, + ); } export function satisfiesGatewayMinimum( diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 24dd86f0e5b..08015523027 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -146,6 +146,7 @@ type DiagnosticSessionAttentionBaseEvent = DiagnosticBaseEvent & { activeToolName?: string; activeToolCallId?: string; activeToolAgeMs?: number; + terminalProgressStale?: boolean; }; export type DiagnosticSessionLongRunningEvent = DiagnosticSessionAttentionBaseEvent & { @@ -206,6 +207,20 @@ export type DiagnosticHeartbeatEvent = DiagnosticBaseEvent & { export type DiagnosticLivenessWarningReason = "event_loop_delay" | "event_loop_utilization" | "cpu"; +export type DiagnosticPhaseDetails = Record; + +export type DiagnosticPhaseSnapshot = { + name: string; + startedAt: number; + endedAt?: number; + durationMs?: number; + cpuUserMs?: number; + cpuSystemMs?: number; + cpuTotalMs?: number; + cpuCoreRatio?: number; + details?: DiagnosticPhaseDetails; +}; + export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & { type: "diagnostic.liveness.warning"; reasons: DiagnosticLivenessWarningReason[]; @@ -220,8 +235,18 @@ export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & { active: number; waiting: number; queued: number; + phase?: string; + recentPhases?: DiagnosticPhaseSnapshot[]; + activeWorkLabels?: string[]; + waitingWorkLabels?: string[]; + queuedWorkLabels?: string[]; }; +export type DiagnosticPhaseCompletedEvent = DiagnosticBaseEvent & + DiagnosticPhaseSnapshot & { + type: "diagnostic.phase.completed"; + }; + export type DiagnosticToolLoopEvent = DiagnosticBaseEvent & { type: "tool.loop"; sessionKey?: string; @@ -501,6 +526,7 @@ export type DiagnosticEventPayload = | DiagnosticRunProgressEvent | DiagnosticHeartbeatEvent | DiagnosticLivenessWarningEvent + | DiagnosticPhaseCompletedEvent | DiagnosticToolLoopEvent | DiagnosticToolExecutionStartedEvent | DiagnosticToolExecutionCompletedEvent diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index a2f364bfd41..d9f73a84dfd 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -44,6 +44,8 @@ const BUNDLED_TRUST_ROOT_ENV_KEYS = BUNDLED_TRUST_ROOT_ENV_LINES.map( const WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS = [ "ComSpec", "COMSPEC", + "LocalAppData", + "LOCALAPPDATA", "ProgramFiles", "PROGRAMFILES", "ProgramW6432", @@ -228,8 +230,10 @@ describe("loadDotEnv", () => { "HTTP_PROXY=http://evil-proxy:8080", "HOMEBREW_BREW_FILE=./evil-brew/bin/brew", "HOMEBREW_PREFIX=./evil-brew", + "SystemRoot=.\\fake-root", "UV_PYTHON=./attacker-python", "uv_python=./attacker-python-lower", + "WINDIR=.\\fake-windir", ].join("\n"), ); await writeEnvFile(path.join(stateDir, ".env"), "BAR=from-global\n"); @@ -245,8 +249,10 @@ describe("loadDotEnv", () => { delete process.env.HTTP_PROXY; delete process.env.HOMEBREW_BREW_FILE; delete process.env.HOMEBREW_PREFIX; + delete process.env.SystemRoot; delete process.env.UV_PYTHON; delete process.env.uv_python; + delete process.env.WINDIR; loadDotEnv({ quiet: true }); @@ -262,8 +268,10 @@ describe("loadDotEnv", () => { expect(process.env.HTTP_PROXY).toBeUndefined(); expect(process.env.HOMEBREW_BREW_FILE).toBeUndefined(); expect(process.env.HOMEBREW_PREFIX).toBeUndefined(); + expect(process.env.SystemRoot).toBeUndefined(); expect(process.env.UV_PYTHON).toBeUndefined(); expect(process.env.uv_python).toBeUndefined(); + expect(process.env.WINDIR).toBeUndefined(); }); }); }); @@ -332,6 +340,8 @@ describe("loadDotEnv", () => { [ "ComSpec=.\\evil-comspec", "COMSPEC=.\\evil-comspec-upper", + "LocalAppData=.\\evil-local-app-data", + "LOCALAPPDATA=.\\evil-local-app-data-upper", "ProgramFiles=.\\evil-pfiles", "PROGRAMFILES=.\\evil-pfiles-upper", "ProgramW6432=.\\evil-pw6432", @@ -709,6 +719,7 @@ describe("workspace .env blocklist completeness", () => { "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index d3ac84122b2..9bdb55277e1 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -29,6 +29,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/exec-approval-channel-runtime.test.ts b/src/infra/exec-approval-channel-runtime.test.ts index b15ff27238a..181eea169cb 100644 --- a/src/infra/exec-approval-channel-runtime.test.ts +++ b/src/infra/exec-approval-channel-runtime.test.ts @@ -291,7 +291,9 @@ describe("createExecApprovalChannelRuntime", () => { finalizeResolved: async () => undefined, }); - await expect(runtime.start()).rejects.toThrow("gateway event loop readiness timeout"); + await expect(runtime.start()).rejects.toThrow( + "gateway readiness unavailable before exec approval runtime start", + ); expect(mockGatewayClientStarts).not.toHaveBeenCalled(); expect(mockGatewayClientStops).toHaveBeenCalledTimes(1); diff --git a/src/infra/exec-approval-channel-runtime.ts b/src/infra/exec-approval-channel-runtime.ts index 3c0cd134396..0c313eafc66 100644 --- a/src/infra/exec-approval-channel-runtime.ts +++ b/src/infra/exec-approval-channel-runtime.ts @@ -365,7 +365,11 @@ export function createExecApprovalChannelRuntime< }, }); if (!readiness.ready) { - throw new Error("gateway event loop readiness timeout"); + throw new Error( + readiness.aborted + ? "gateway approval runtime start aborted before readiness" + : "gateway readiness unavailable before exec approval runtime start", + ); } await ready; if (stopClientIfInactive(client)) { diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts index f96304e32de..6c21233c535 100644 --- a/src/infra/gateway-watch-tmux.test.ts +++ b/src/infra/gateway-watch-tmux.test.ts @@ -43,6 +43,7 @@ describe("gateway-watch tmux wrapper", () => { env: { OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_PROFILE: "Dev Profile", + OPENCLAW_TRACE_SYNC_IO: "0", SHELL: "/bin/zsh", }, nodePath: "/opt/node", @@ -57,6 +58,7 @@ describe("gateway-watch tmux wrapper", () => { expect(command).toContain("'FORCE_COLOR=1'"); expect(command).toContain("'OPENCLAW_GATEWAY_PORT=19001'"); expect(command).toContain("'OPENCLAW_PROFILE=Dev Profile'"); + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'"); expect(command).toContain("/opt/node"); expect(command).toContain("scripts/watch-node.mjs"); expect(command).toContain("gateway"); @@ -87,6 +89,7 @@ describe("gateway-watch tmux wrapper", () => { expect(code).toBe(0); const command = spawnSync.mock.calls[1]?.[1]?.[6] as string; expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'"); + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'"); expect(command).not.toContain("--benchmark"); expect(command).toContain("'gateway'"); expect(command).toContain("'--force'"); @@ -95,6 +98,38 @@ describe("gateway-watch tmux wrapper", () => { ); }); + it("preserves explicit sync I/O tracing in benchmark mode", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force", "--benchmark"], + cwd: "/repo", + env: { OPENCLAW_TRACE_SYNC_IO: "1", SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdout: stdout.stream, + }); + + expect(code).toBe(0); + const command = spawnSync.mock.calls[1]?.[1]?.[6] as string; + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=1'"); + expect(command).toContain( + "'OPENCLAW_RUN_NODE_OUTPUT_LOG=.artifacts/gateway-watch-profiles/gateway-watch-output.log'", + ); + expect(command).toContain("'OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR=1'"); + expect(stderr.chunks.join("")).toContain( + "gateway:watch benchmark trace output: .artifacts/gateway-watch-profiles/gateway-watch-output.log", + ); + }); + it("can remove --force from benchmarked watch runs", () => { const stdout = createOutput(); const stderr = createOutput(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c7ef602e922..1e0dd20c75e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1208,6 +1208,24 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: HEARTBEAT_SKIP_LANES_BUSY }; } + // Phase 2: Stronger heartbeat deferral while a final delivery replay is pending. + // Plain `updatedAt` changes are normal for heartbeat sessions and should not + // suppress heartbeat runs; only defer when final delivery recovery is active. + const { entry: recentSessionEntry } = resolveHeartbeatSession( + cfg, + agentId, + heartbeat, + opts.sessionKey, + ); + const HEARTBEAT_DEFER_WINDOW_MS = 30_000; + if ( + recentSessionEntry?.pendingFinalDelivery === true && + recentSessionEntry?.updatedAt && + startedAt - recentSessionEntry.updatedAt < HEARTBEAT_DEFER_WINDOW_MS + ) { + return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }; + } + // Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating. const preflight = await resolveHeartbeatPreflight({ cfg, diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index f74a0952e4e..91583dbc4ec 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -29,6 +29,15 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { }; } +function createPnpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { + return { + manager: "pnpm", + command: "pnpm", + globalRoot, + packageRoot: path.join(globalRoot, "openclaw"), + }; +} + function createRootRunner(globalRoot: string): CommandRunner { return async (argv) => { if (argv.join(" ") === "npm root -g") { @@ -115,6 +124,99 @@ describe("runGlobalPackageUpdateSteps", () => { }); }); + it("stages pnpm-detected updates through npm when the global root has npm prefix layout", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-staged-" }, async (base) => { + const prefix = path.join(base, "prefix"); + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + const staleChunk = path.join(packageRoot, "dist", "install-C_GuuNz6.js"); + await writePackageRoot(packageRoot, "1.0.0"); + await fs.writeFile(staleChunk, 'import "./install.runtime-Xom5hOHq.js";\n', "utf8"); + + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + if (name !== "global update") { + throw new Error(`unexpected step ${name}`); + } + expect(argv[0]).toBe("npm"); + expect(argv).toEqual(expect.arrayContaining(["i", "-g", "--prefix", "openclaw@2.0.0"])); + expect(argv).not.toContain("pnpm"); + const prefixIndex = argv.indexOf("--prefix"); + const stagePrefix = argv[prefixIndex + 1]; + if (!stagePrefix) { + throw new Error("missing staged prefix"); + } + await writePackageRoot(path.join(stagePrefix, "lib", "node_modules", "openclaw"), "2.0.0"); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createPnpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.steps.map((step) => step.name)).toEqual([ + "global update", + "global install swap", + ]); + await expect(fs.access(staleChunk)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + + it("keeps Windows pnpm global roots on the pnpm update path", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + await withTempDir({ prefix: "openclaw-package-update-win32-pnpm-" }, async (base) => { + const globalRoot = path.join(base, "pnpm", "global", "5", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + if (name !== "global update") { + throw new Error(`unexpected step ${name}`); + } + expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]); + await writePackageRoot(packageRoot, "2.0.0"); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createPnpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.steps.map((step) => step.name)).toEqual(["global update"]); + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("keeps a successful staged swap when old package cleanup hits a transient Windows native module error", async () => { await withTempDir({ prefix: "openclaw-package-update-staged-cleanup-" }, async (base) => { const prefix = path.join(base, "prefix"); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 18bcb372568..3a3f15e7cd9 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -36,6 +36,7 @@ type StagedNpmInstall = { prefix: string; layout: NpmGlobalPrefixLayout; packageRoot: string; + installTarget: ResolvedGlobalInstallTarget; }; type NpmBinShimBackup = { @@ -82,24 +83,60 @@ async function readPackageVersionIfPresent(packageRoot: string | null): Promise< } } +function isUnambiguousNpmPrefixGlobalRoot(globalRoot: string | null): boolean { + const trimmed = globalRoot?.trim(); + if (!trimmed) { + return false; + } + const normalized = path.resolve(trimmed); + if (path.basename(normalized) !== "node_modules") { + return false; + } + const parentDir = path.dirname(normalized); + if (path.basename(parentDir) === "lib") { + return true; + } + return process.platform === "win32" && path.basename(parentDir).toLowerCase() === "npm"; +} + +function resolveStagedNpmTargetLayout( + installTarget: ResolvedGlobalInstallTarget, +): NpmGlobalPrefixLayout | null { + const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot); + if (!targetLayout) { + return null; + } + if ( + installTarget.manager === "npm" || + isUnambiguousNpmPrefixGlobalRoot(installTarget.globalRoot) + ) { + return targetLayout; + } + return null; +} + async function createStagedNpmInstall( installTarget: ResolvedGlobalInstallTarget, packageName: string, ): Promise { - if (installTarget.manager !== "npm") { - return null; - } - const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot); + const targetLayout = resolveStagedNpmTargetLayout(installTarget); if (!targetLayout) { return null; } await fs.mkdir(targetLayout.globalRoot, { recursive: true }); const prefix = await fs.mkdtemp(path.join(targetLayout.globalRoot, ".openclaw-update-stage-")); const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix); + const command = installTarget.manager === "npm" ? installTarget.command : "npm"; return { prefix, layout, packageRoot: path.join(layout.globalRoot, packageName), + installTarget: { + manager: "npm", + command, + globalRoot: layout.globalRoot, + packageRoot: path.join(layout.globalRoot, packageName), + }, }; } @@ -329,10 +366,11 @@ export async function runGlobalPackageUpdateSteps(params: { }; } + const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget; const updateStep = await params.runStep({ name: "global update", argv: globalInstallArgs( - params.installTarget, + installCommandTarget, params.installSpec, undefined, stagedInstall?.prefix, @@ -363,7 +401,7 @@ export async function runGlobalPackageUpdateSteps(params: { } const fallbackArgv = globalInstallFallbackArgs( - params.installTarget, + stagedInstall?.installTarget ?? params.installTarget, params.installSpec, undefined, stagedInstall?.prefix, diff --git a/src/infra/restart-coordinator.test.ts b/src/infra/restart-coordinator.test.ts new file mode 100644 index 00000000000..c96567965fc --- /dev/null +++ b/src/infra/restart-coordinator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSafeGatewayRestartPreflight, + requestSafeGatewayRestart, +} from "./restart-coordinator.js"; + +const scheduleGatewaySigusr1Restart = vi.hoisted(() => vi.fn()); + +vi.mock("./restart.js", () => ({ + scheduleGatewaySigusr1Restart: (opts: unknown) => scheduleGatewaySigusr1Restart(opts), +})); + +describe("safe gateway restart coordinator", () => { + it("reports safe when no restart blockers are active", () => { + const preflight = createSafeGatewayRestartPreflight({ + getQueueSize: () => 0, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }); + + expect(preflight).toEqual({ + safe: true, + counts: { + queueSize: 0, + pendingReplies: 0, + embeddedRuns: 0, + activeTasks: 0, + totalActive: 0, + }, + blockers: [], + summary: "safe to restart now", + }); + }); + + it("returns structured blockers for active work", () => { + const preflight = createSafeGatewayRestartPreflight({ + getQueueSize: () => 2, + getPendingReplies: () => 1, + getEmbeddedRuns: () => 1, + getActiveTasks: () => 1, + getTaskBlockers: () => [ + { + taskId: "task-1", + runId: "run-1", + status: "running", + runtime: "acp", + label: "build", + title: "Build branch", + }, + ], + }); + + expect(preflight.safe).toBe(false); + expect(preflight.counts.totalActive).toBe(5); + expect(preflight.blockers.map((blocker) => blocker.kind)).toEqual([ + "queue", + "reply", + "embedded-run", + "task", + ]); + expect(preflight.summary).toContain("restart deferred"); + expect(preflight.summary).toContain("taskId=task-1"); + }); + + it("schedules one restart request and marks active work as deferred", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }); + + const result = requestSafeGatewayRestart({ + reason: "test.safe", + inspect: { + getQueueSize: () => 1, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(result.status).toBe("deferred"); + expect(scheduleGatewaySigusr1Restart).toHaveBeenCalledWith({ + delayMs: 0, + reason: "test.safe", + }); + }); + + it("surfaces coalesced restart requests", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 500, + mode: "emit", + coalesced: true, + cooldownMsApplied: 0, + }); + + const result = requestSafeGatewayRestart({ + inspect: { + getQueueSize: () => 0, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(result.status).toBe("coalesced"); + }); +}); diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts new file mode 100644 index 00000000000..aa2310c7ed3 --- /dev/null +++ b/src/infra/restart-coordinator.ts @@ -0,0 +1,166 @@ +import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/run-state.js"; +import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; +import { getTotalQueueSize } from "../process/command-queue.js"; +import { + getInspectableActiveTaskRestartBlockers, + type ActiveTaskRestartBlocker, +} from "../tasks/task-registry.maintenance.js"; +import { scheduleGatewaySigusr1Restart, type ScheduledRestart } from "./restart.js"; + +export type SafeGatewayRestartCounts = { + queueSize: number; + pendingReplies: number; + embeddedRuns: number; + activeTasks: number; + totalActive: number; +}; + +export type SafeGatewayRestartBlocker = { + kind: "queue" | "reply" | "embedded-run" | "task"; + count: number; + message: string; + task?: ActiveTaskRestartBlocker; +}; + +export type SafeGatewayRestartPreflight = { + safe: boolean; + counts: SafeGatewayRestartCounts; + blockers: SafeGatewayRestartBlocker[]; + summary: string; +}; + +export type SafeGatewayRestartRequestResult = { + ok: true; + status: "scheduled" | "deferred" | "coalesced"; + preflight: SafeGatewayRestartPreflight; + restart: ScheduledRestart; +}; + +type SafeRestartInspectors = { + getQueueSize: () => number; + getPendingReplies: () => number; + getEmbeddedRuns: () => number; + getActiveTasks: () => number; + getTaskBlockers: () => ActiveTaskRestartBlocker[]; +}; + +const defaultInspectors: SafeRestartInspectors = { + getQueueSize: getTotalQueueSize, + getPendingReplies: getTotalPendingReplies, + getEmbeddedRuns: getActiveEmbeddedRunCount, + getActiveTasks: () => getInspectableActiveTaskRestartBlockers().length, + getTaskBlockers: getInspectableActiveTaskRestartBlockers, +}; + +function normalizeCount(value: number): number { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; +} + +function formatTaskBlocker(task: ActiveTaskRestartBlocker): string { + return [ + `taskId=${task.taskId}`, + task.runId ? `runId=${task.runId}` : null, + `status=${task.status}`, + `runtime=${task.runtime}`, + task.label ? `label=${task.label}` : null, + task.title ? `title=${task.title.slice(0, 80)}` : null, + ] + .filter((value): value is string => Boolean(value)) + .join(" "); +} + +function createFallbackTaskBlocker(count: number): SafeGatewayRestartBlocker { + return { + kind: "task", + count, + message: `${count} active background task run(s)`, + }; +} + +export function createSafeGatewayRestartPreflight( + inspectors: Partial = {}, +): SafeGatewayRestartPreflight { + const resolved = { ...defaultInspectors, ...inspectors }; + const counts: SafeGatewayRestartCounts = { + queueSize: normalizeCount(resolved.getQueueSize()), + pendingReplies: normalizeCount(resolved.getPendingReplies()), + embeddedRuns: normalizeCount(resolved.getEmbeddedRuns()), + activeTasks: normalizeCount(resolved.getActiveTasks()), + totalActive: 0, + }; + counts.totalActive = + counts.queueSize + counts.pendingReplies + counts.embeddedRuns + counts.activeTasks; + + const blockers: SafeGatewayRestartBlocker[] = []; + if (counts.queueSize > 0) { + blockers.push({ + kind: "queue", + count: counts.queueSize, + message: `${counts.queueSize} queued or active operation(s)`, + }); + } + if (counts.pendingReplies > 0) { + blockers.push({ + kind: "reply", + count: counts.pendingReplies, + message: `${counts.pendingReplies} pending reply delivery operation(s)`, + }); + } + if (counts.embeddedRuns > 0) { + blockers.push({ + kind: "embedded-run", + count: counts.embeddedRuns, + message: `${counts.embeddedRuns} active embedded run(s)`, + }); + } + if (counts.activeTasks > 0) { + const taskBlockers = resolved.getTaskBlockers(); + if (taskBlockers.length === 0) { + blockers.push(createFallbackTaskBlocker(counts.activeTasks)); + } else { + for (const task of taskBlockers.slice(0, 8)) { + blockers.push({ + kind: "task", + count: 1, + message: formatTaskBlocker(task), + task, + }); + } + const omitted = counts.activeTasks - taskBlockers.length; + if (omitted > 0) { + blockers.push(createFallbackTaskBlocker(omitted)); + } + } + } + + const summary = + blockers.length === 0 + ? "safe to restart now" + : `restart deferred: ${blockers.map((blocker) => blocker.message).join("; ")}`; + return { + safe: counts.totalActive === 0, + counts, + blockers, + summary, + }; +} + +export function requestSafeGatewayRestart( + opts: { + reason?: string; + delayMs?: number; + inspect?: Partial; + } = {}, +): SafeGatewayRestartRequestResult { + const preflight = createSafeGatewayRestartPreflight(opts.inspect); + const restart = scheduleGatewaySigusr1Restart({ + delayMs: opts.delayMs ?? 0, + reason: opts.reason ?? "gateway.restart.safe", + }); + return { + ok: true, + status: restart.coalesced ? "coalesced" : preflight.safe ? "scheduled" : "deferred", + preflight, + restart, + }; +} diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index b621158853b..25ff7b966d1 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -448,6 +448,59 @@ describe("run-node script", () => { }); }); + it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp); + const outputPath = path.join(tmp, ".artifacts", "gateway-watch-profiles", "output.log"); + const childStderr = [ + "normal before\n", + "(node:12345) WARNING: Detected use of sync API\n", + " at statSync (node:fs:1739:25)\n", + " at loadConfig (/repo/src/config.ts:1:1)\n", + "\n", + "normal after\n", + ].join(""); + const spawn = (_cmd: string, args: string[]) => + createPipedExitedProcess({ + stderr: args[0] === "openclaw.mjs" ? childStderr : "", + }); + const stderrChunks: string[] = []; + const stderr = { + write: (chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; + const stdout = { + write: () => true, + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR: "1", + OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, + }, + spawn, + stderr, + stdout, + execPath: process.execPath, + platform: process.platform, + } as Parameters[0] & { stdout: NodeJS.WriteStream }); + + expect(exitCode).toBe(0); + const terminalStderr = stderrChunks.join(""); + expect(terminalStderr).toContain("normal before\n"); + expect(terminalStderr).toContain("normal after\n"); + expect(terminalStderr).not.toContain("Detected use of sync API"); + expect(terminalStderr).not.toContain("statSync"); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain(childStderr); + }); + }); + it("adds Node CPU profiling flags to the launched OpenClaw child when requested", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { @@ -497,6 +550,44 @@ describe("run-node script", () => { }); }); + it("adds Node sync I/O tracing flag to the launched OpenClaw child when requested", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + const spawnCalls: string[][] = []; + const spawn = (_cmd: string, args: string[]) => { + spawnCalls.push(args); + return createExitedProcess(0); + }; + const { spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", + }); + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["gateway", "--force"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + OPENCLAW_TRACE_SYNC_IO: "1", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls.at(-1)).toEqual(["--trace-sync-io", "openclaw.mjs", "gateway", "--force"]); + }); + }); + it("surfaces generic output log stream errors", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp); @@ -834,6 +925,46 @@ describe("run-node script", () => { }); }); + it("runs QA coverage report from source without rebuilding private QA dist", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + "extensions/qa-lab/src/cli.runtime.ts": "export {};\n", + }, + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["qa", "coverage", "--json"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + [ + process.execPath, + "--import", + "tsx", + path.join(tmp, "scripts", "qa-coverage-report.ts"), + "--json", + ], + ]); + }); + }); + it("skips runtime postbuild restaging when the runtime stamp is current", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 73d44192b0f..e399aba43ac 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; @@ -150,6 +151,95 @@ describe("update global helpers", () => { }); }); + it("uses an absolute POSIX script shell for npm lifecycle scripts during global installs", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const existsSyncSpy = vi + .spyOn(fsSync, "existsSync") + .mockImplementation((candidate) => candidate === "/bin/sh"); + try { + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + PATH: "/home/peter/.npm-global/bin", + }), + ).resolves.toMatchObject({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + NPM_CONFIG_SCRIPT_SHELL: "/bin/sh", + }); + } finally { + existsSyncSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + + it("preserves explicit npm script shell config for global installs", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + try { + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + NPM_CONFIG_SCRIPT_SHELL: "/custom/sh", + }), + ).resolves.toMatchObject({ + NPM_CONFIG_SCRIPT_SHELL: "/custom/sh", + }); + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + npm_config_script_shell: "/custom/lower-sh", + }), + ).resolves.toMatchObject({ + npm_config_script_shell: "/custom/lower-sh", + }); + } finally { + platformSpy.mockRestore(); + } + }); + + it("resolves portable Git paths from process-local app data only", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + await withTempDir({ prefix: "openclaw-update-portable-git-" }, async (base) => { + envSnapshot = captureEnv(["LOCALAPPDATA"]); + const injectedLocalAppData = path.join(base, "injected-local-app-data"); + const trustedLocalAppData = path.join(base, "trusted-local-app-data"); + const injectedGitDir = path.join( + injectedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + const trustedGitDir = path.join( + trustedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + await fs.mkdir(injectedGitDir, { recursive: true }); + await fs.mkdir(trustedGitDir, { recursive: true }); + + delete process.env.LOCALAPPDATA; + const injectedOnlyEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(injectedOnlyEnv?.PATH).not.toContain(injectedGitDir); + + process.env.LOCALAPPDATA = trustedLocalAppData; + const trustedEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(trustedEnv?.PATH).toContain(trustedGitDir); + expect(trustedEnv?.PATH).not.toContain(injectedGitDir); + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("classifies main and raw install specs separately from registry selectors", () => { expect(isMainPackageTarget("main")).toBe(true); expect(isMainPackageTarget(" MAIN ")).toBe(true); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 88f910c2f55..e1628e64380 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -41,6 +41,7 @@ const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ "--omit=optional", ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, ] as const; +const NPM_CONFIG_SCRIPT_SHELL_KEYS = ["NPM_CONFIG_SCRIPT_SHELL", "npm_config_script_shell"]; const FIRST_PACKAGED_DIST_INVENTORY_VERSION = { major: 2026, minor: 4, patch: 15 }; const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([ "dist/extensions/qa-channel", @@ -274,13 +275,11 @@ export function canResolveRegistryVersionForPackageTarget(value: string): boolea return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed); } -async function resolvePortableGitPathPrepend( - env: NodeJS.ProcessEnv | undefined, -): Promise { +async function resolvePortableGitPathPrepend(): Promise { if (process.platform !== "win32") { return []; } - const localAppData = env?.LOCALAPPDATA?.trim() || process.env.LOCALAPPDATA?.trim(); + const localAppData = process.env.LOCALAPPDATA?.trim(); if (!localAppData) { return []; } @@ -317,6 +316,31 @@ function applyCorepackDownloadPromptEnv(env: Record) { } } +function hasNpmScriptShellSetting(env: NodeJS.ProcessEnv): boolean { + return NPM_CONFIG_SCRIPT_SHELL_KEYS.some((key) => Boolean(env[key]?.trim())); +} + +function resolvePosixNpmScriptShell(env: NodeJS.ProcessEnv): string | null { + if (process.platform === "win32") { + return null; + } + if (fsSync.existsSync("/bin/sh")) { + return "/bin/sh"; + } + const shell = env.SHELL?.trim(); + return shell && path.isAbsolute(shell) && fsSync.existsSync(shell) ? shell : null; +} + +function applyPosixNpmScriptShellEnv(env: Record) { + if (hasNpmScriptShellSetting(env)) { + return; + } + const scriptShell = resolvePosixNpmScriptShell(env); + if (scriptShell) { + env.NPM_CONFIG_SCRIPT_SHELL = scriptShell; + } +} + export function resolveGlobalInstallSpec(params: { packageName: string; tag: string; @@ -341,13 +365,18 @@ export function resolveGlobalInstallSpec(params: { export async function createGlobalInstallEnv( env?: NodeJS.ProcessEnv, ): Promise { - const pathPrepend = await resolvePortableGitPathPrepend(env); + const pathPrepend = await resolvePortableGitPathPrepend(); const sourceEnv = env ?? process.env; const hasCorepackDownloadPromptSetting = Boolean( sourceEnv.COREPACK_ENABLE_DOWNLOAD_PROMPT?.trim(), ); + const missingPosixScriptShell = + Boolean(resolvePosixNpmScriptShell(sourceEnv)) && !hasNpmScriptShellSetting(sourceEnv); const requiresMergedEnv = - pathPrepend.length > 0 || process.platform === "win32" || !hasCorepackDownloadPromptSetting; + pathPrepend.length > 0 || + process.platform === "win32" || + !hasCorepackDownloadPromptSetting || + missingPosixScriptShell; if (!requiresMergedEnv) { return env; } @@ -359,6 +388,7 @@ export async function createGlobalInstallEnv( applyPathPrepend(merged, pathPrepend); applyWindowsPackageInstallEnv(merged); applyCorepackDownloadPromptEnv(merged); + applyPosixNpmScriptShellEnv(merged); return merged; } diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index edaf8b6147e..c54094981fb 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1365,6 +1365,7 @@ describe("runGatewayUpdate", () => { const createGlobalInstallHarness = (params: { pkgRoot: string; npmRootOutput?: string; + pnpmRootOutput?: string; installCommand: string; gitRootMode?: "not-git" | "missing"; onInstall?: (options?: { @@ -1390,6 +1391,9 @@ describe("runGatewayUpdate", () => { return { stdout: "", stderr: "", code: 1 }; } if (key === "pnpm root -g") { + if (params.pnpmRootOutput) { + return { stdout: params.pnpmRootOutput, stderr: "", code: 0 }; + } return { stdout: "", stderr: "", code: 1 }; } if (key === params.installCommand) { @@ -1747,6 +1751,38 @@ describe("runGatewayUpdate", () => { ); }); + it("uses clean staged npm swaps for pnpm installs that resolve to an npm global root", async () => { + const prefix = path.join(tempDir, "npm-prefix"); + const nodeModules = path.join(prefix, "lib", "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const staleInstallChunk = path.join(pkgRoot, "dist", "install-C_GuuNz6.js"); + await seedGlobalPackageRoot(pkgRoot); + await fs.writeFile( + staleInstallChunk, + 'const pluginRuntime = () => import("./install.runtime-Xom5hOHq.js");\n', + "utf-8", + ); + + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + pnpmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + onInstall: async (options) => { + await writeGlobalPackageVersion(options?.packageRoot ?? pkgRoot); + }, + }); + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("pnpm"); + expect(result.after?.version).toBe("2.0.0"); + expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true); + expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); + expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]); + await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => { const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); const expectedInstallCommand = diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 3e6f787e3c9..84fff891353 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -143,6 +143,7 @@ describe("watch-node script", () => { OPENCLAW_WATCH_MODE: "1", OPENCLAW_WATCH_SESSION: "1700000000000-4242", OPENCLAW_NO_RESPAWN: "1", + OPENCLAW_TRACE_SYNC_IO: "1", OPENCLAW_WATCH_COMMAND: "gateway --force", }), }), @@ -155,6 +156,35 @@ describe("watch-node script", () => { }); }); + it("preserves explicit sync I/O trace overrides for gateway watch", async () => { + const { child, spawn, createWatcher, fakeProcess } = createWatchHarness(); + await withTempDir({ prefix: "openclaw-watch-node-" }, async (cwd) => { + const runPromise = runWatch({ + args: ["gateway", "--force"], + cwd, + createWatcher, + env: { OPENCLAW_TRACE_SYNC_IO: "0" }, + lockDisabled: true, + process: fakeProcess, + spawn, + }); + + expect(spawn).toHaveBeenCalledWith( + "/usr/local/bin/node", + ["scripts/run-node.mjs", "gateway", "--force"], + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_TRACE_SYNC_IO: "0", + }), + }), + ); + + fakeProcess.emit("SIGINT"); + await runPromise; + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + }); + it("starts the runner before loading chokidar", async () => { const child = Object.assign(new EventEmitter(), { kill: vi.fn(() => {}), diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts index 55466bb0469..112cf1baca4 100644 --- a/src/infra/windows-install-roots.test.ts +++ b/src/infra/windows-install-roots.test.ts @@ -171,25 +171,25 @@ describe("getWindowsProgramFilesRoots", () => { }); describe("locateWindowsRegExe", () => { - it("prefers SystemRoot and WINDIR candidates over arbitrary drive scans", () => { - expect( - _private.getWindowsRegExeCandidates({ - SystemRoot: "D:\\Windows", - WINDIR: "E:\\Windows", - }), - ).toEqual([ - "D:\\Windows\\System32\\reg.exe", - "E:\\Windows\\System32\\reg.exe", - "C:\\Windows\\System32\\reg.exe", - ]); + it("uses the fixed Windows system reg.exe candidate", () => { + expect(_private.getWindowsRegExeCandidates()).toEqual(["C:\\Windows\\System32\\reg.exe"]); }); - it("dedupes equivalent roots case-insensitively", () => { - expect( - _private.getWindowsRegExeCandidates({ - SystemRoot: "D:\\Windows\\", - windir: "d:\\windows", - }), - ).toEqual(["D:\\Windows\\System32\\reg.exe", "C:\\Windows\\System32\\reg.exe"]); + it("does not resolve readable reg.exe files from env-derived roots", () => { + _resetWindowsInstallRootsForTests({ + isReadableFile: (filePath) => filePath === "D:\\Windows\\System32\\reg.exe", + }); + + const originalEnv = process.env; + try { + process.env = { + ...originalEnv, + SystemRoot: "D:\\Windows", + WINDIR: "E:\\Windows", + }; + expect(_private.locateWindowsRegExe()).toBeNull(); + } finally { + process.env = originalEnv; + } }); }); diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts index 2c580d87028..ea3ba520c7a 100644 --- a/src/infra/windows-install-roots.ts +++ b/src/infra/windows-install-roots.ts @@ -92,29 +92,12 @@ function getEnvValueCaseInsensitive( return actualKey ? env[actualKey] : undefined; } -function getWindowsRegExeCandidates(env: Record): readonly string[] { - const seen = new Set(); - const candidates: string[] = []; - for (const root of [ - normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")), - normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")), - DEFAULT_WINDOWS_SYSTEM_ROOT, - ]) { - if (!root) { - continue; - } - const key = normalizeLowercaseStringOrEmpty(root); - if (seen.has(key)) { - continue; - } - seen.add(key); - candidates.push(path.win32.join(root, "System32", "reg.exe")); - } - return candidates; +function getWindowsRegExeCandidates(): readonly string[] { + return [path.win32.join(DEFAULT_WINDOWS_SYSTEM_ROOT, "System32", "reg.exe")]; } -function locateWindowsRegExe(env: Record = process.env): string | null { - for (const candidate of getWindowsRegExeCandidates(env)) { +function locateWindowsRegExe(): string | null { + for (const candidate of getWindowsRegExeCandidates()) { if (isReadableFileFn(candidate)) { return candidate; } @@ -151,7 +134,7 @@ function runRegQuery( } function defaultQueryRegistryValue(key: string, valueName: string): string | null { - const regExe = locateWindowsRegExe(process.env); + const regExe = locateWindowsRegExe(); if (!regExe) { return null; } diff --git a/src/logging/diagnostic-phase.ts b/src/logging/diagnostic-phase.ts new file mode 100644 index 00000000000..d6b8ddbb1bc --- /dev/null +++ b/src/logging/diagnostic-phase.ts @@ -0,0 +1,96 @@ +import { performance } from "node:perf_hooks"; +import { + areDiagnosticsEnabledForProcess, + emitDiagnosticEvent, + type DiagnosticPhaseDetails, + type DiagnosticPhaseSnapshot, +} from "../infra/diagnostic-events.js"; + +const RECENT_PHASE_CAPACITY = 40; + +type ActiveDiagnosticPhase = { + name: string; + startedAt: number; + startedWallMs: number; + cpuStarted: NodeJS.CpuUsage; + details?: DiagnosticPhaseDetails; +}; + +let activePhaseStack: ActiveDiagnosticPhase[] = []; +let recentPhases: DiagnosticPhaseSnapshot[] = []; + +function roundMetric(value: number, digits = 1): number { + if (!Number.isFinite(value)) { + return 0; + } + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function pushRecentPhase(snapshot: DiagnosticPhaseSnapshot): void { + recentPhases.push(snapshot); + if (recentPhases.length > RECENT_PHASE_CAPACITY) { + recentPhases = recentPhases.slice(-RECENT_PHASE_CAPACITY); + } +} + +export function getCurrentDiagnosticPhase(): string | undefined { + return activePhaseStack.at(-1)?.name; +} + +export function getRecentDiagnosticPhases(limit = 8): DiagnosticPhaseSnapshot[] { + return recentPhases.slice(-Math.max(0, limit)).map((phase) => Object.assign({}, phase)); +} + +export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { + pushRecentPhase(snapshot); + if (!areDiagnosticsEnabledForProcess()) { + return; + } + emitDiagnosticEvent({ + type: "diagnostic.phase.completed", + ...snapshot, + }); +} + +export async function withDiagnosticPhase( + name: string, + run: () => Promise | T, + details?: DiagnosticPhaseDetails, +): Promise { + const active: ActiveDiagnosticPhase = { + name, + startedAt: Date.now(), + startedWallMs: performance.now(), + cpuStarted: process.cpuUsage(), + details, + }; + activePhaseStack.push(active); + try { + return await run(); + } finally { + const endedAt = Date.now(); + const durationMs = roundMetric(performance.now() - active.startedWallMs, 1); + const cpu = process.cpuUsage(active.cpuStarted); + const cpuUserMs = roundMetric(cpu.user / 1_000, 1); + const cpuSystemMs = roundMetric(cpu.system / 1_000, 1); + const cpuTotalMs = roundMetric(cpuUserMs + cpuSystemMs, 1); + activePhaseStack = activePhaseStack.filter((entry) => entry !== active); + recordDiagnosticPhase({ + name, + startedAt: active.startedAt, + endedAt, + durationMs, + cpuUserMs, + cpuSystemMs, + cpuTotalMs, + cpuCoreRatio: roundMetric(cpuTotalMs / Math.max(1, durationMs), 3), + details: active.details, + }); + } +} + +export function resetDiagnosticPhasesForTest(): void { + activePhaseStack = []; + recentPhases = []; +} diff --git a/src/logging/diagnostic-run-activity.ts b/src/logging/diagnostic-run-activity.ts index f8155e94fcb..513b0d727a1 100644 --- a/src/logging/diagnostic-run-activity.ts +++ b/src/logging/diagnostic-run-activity.ts @@ -294,6 +294,19 @@ export function getDiagnosticSessionActivitySnapshot( }; } +export function markDiagnosticRunProgressForTest(params: { + sessionId?: string; + sessionKey?: string; + runId?: string; + reason: string; +}): void { + const activity = resolveSessionActivity({ ...params, create: true }); + if (!activity) { + return; + } + touchSessionActivity(activity, params.reason); +} + export function resetDiagnosticRunActivityForTest(): void { activityByRef.clear(); activityByRunId.clear(); diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index b8b4984ccbf..7d9d96542a6 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -348,6 +348,7 @@ function readStabilityEventRecord( assignOptionalCodeString(sanitized, "reason", record.reason, `${label}.reason`); assignOptionalCodeString(sanitized, "outcome", record.outcome, `${label}.outcome`); assignOptionalCodeString(sanitized, "level", record.level, `${label}.level`); + assignOptionalCodeString(sanitized, "phase", record.phase, `${label}.phase`); assignOptionalCodeString(sanitized, "detector", record.detector, `${label}.detector`); assignOptionalCodeString(sanitized, "toolName", record.toolName, `${label}.toolName`); assignOptionalCodeString( diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index dd4c361111b..332b6806ee7 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -24,6 +24,7 @@ export type DiagnosticStabilityEventRecord = { outcome?: string; mode?: string; level?: string; + phase?: string; detector?: string; deliveryKind?: string; toolName?: string; @@ -292,6 +293,17 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.active = event.active; record.waiting = event.waiting; record.queued = event.queued; + record.phase = event.phase; + if (event.activeWorkLabels?.length) { + record.source = event.activeWorkLabels[0]; + } else if (event.queuedWorkLabels?.length) { + record.source = event.queuedWorkLabels[0]; + } + break; + case "diagnostic.phase.completed": + record.phase = event.name; + record.durationMs = event.durationMs; + record.cpuCoreRatio = event.cpuCoreRatio; break; case "tool.loop": record.toolName = event.toolName; diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 22d5d3c829e..26eae67d146 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -8,8 +8,10 @@ import { setDiagnosticsEnabledForProcess, type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; +import { withDiagnosticPhase } from "./diagnostic-phase.js"; import { getDiagnosticSessionActivitySnapshot, + markDiagnosticRunProgressForTest, markDiagnosticEmbeddedRunStarted, } from "./diagnostic-run-activity.js"; import { @@ -368,6 +370,39 @@ describe("stuck session diagnostics threshold", () => { expect(recoverStuckSession).not.toHaveBeenCalled(); }); + it("flags stale terminal bridge progress in stalled session diagnostics", async () => { + const events: DiagnosticEventPayload[] = []; + const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); + const unsubscribe = onDiagnosticEvent((event) => { + events.push(event); + }); + try { + logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); + markDiagnosticEmbeddedRunStarted({ sessionId: "s1", sessionKey: "main" }); + markDiagnosticRunProgressForTest({ + sessionId: "s1", + sessionKey: "main", + reason: "codex_app_server:notification:rawResponseItem/completed", + }); + startDiagnosticHeartbeat({ + diagnostics: { + enabled: true, + stuckSessionWarnMs: 30_000, + }, + }); + + vi.advanceTimersByTime(61_000); + } finally { + unsubscribe(); + } + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("terminalProgressStale=true")); + expect(events.findLast((event) => event.type === "session.stalled")).toMatchObject({ + terminalProgressStale: true, + lastProgressReason: "codex_app_server:notification:rawResponseItem/completed", + }); + }); + it("aborts and drains embedded runs after an extended no-progress stall", () => { const events: DiagnosticEventPayload[] = []; const recoverStuckSession = vi.fn(); @@ -678,6 +713,53 @@ describe("stuck session diagnostics threshold", () => { ); }); + it("adds phase and work labels to liveness warnings", async () => { + const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); + const events: DiagnosticEventPayload[] = []; + const unsubscribe = onDiagnosticEvent((event) => events.push(event)); + let finishPhase!: () => void; + const phase = withDiagnosticPhase( + "startup.plugins.load", + () => + new Promise((resolve) => { + finishPhase = resolve; + }), + ); + + try { + startDiagnosticHeartbeat( + { + diagnostics: { + enabled: true, + }, + }, + { + emitMemorySample: createEmitMemorySampleMock(), + sampleLiveness: () => ({ + reasons: ["event_loop_delay"], + intervalMs: 30_000, + eventLoopDelayP99Ms: 1_500, + eventLoopDelayMaxMs: 2_000, + }), + }, + ); + + logMessageQueued({ sessionId: "s1", sessionKey: "main", source: "telegram" }); + vi.advanceTimersByTime(30_000); + } finally { + finishPhase(); + await phase; + unsubscribe(); + } + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("phase=startup.plugins.load")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("work=[queued=main(")); + expect(events.findLast((event) => event.type === "diagnostic.liveness.warning")).toMatchObject({ + phase: "startup.plugins.load", + queuedWorkLabels: [expect.stringContaining("main(")], + }); + }); + it("keeps transient event-loop max spikes debug-only when only background work is active", () => { const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index c8840561499..65db6574ab4 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -5,9 +5,15 @@ import { areDiagnosticsEnabledForProcess, emitDiagnosticEvent, isDiagnosticsEnabled, + type DiagnosticPhaseSnapshot, type DiagnosticLivenessWarningReason, } from "../infra/diagnostic-events.js"; import { emitDiagnosticMemorySample, resetDiagnosticMemoryForTest } from "./diagnostic-memory.js"; +import { + getCurrentDiagnosticPhase, + getRecentDiagnosticPhases, + resetDiagnosticPhasesForTest, +} from "./diagnostic-phase.js"; import { getDiagnosticSessionActivitySnapshot, resetDiagnosticRunActivityForTest, @@ -81,6 +87,9 @@ type DiagnosticWorkSnapshot = { activeCount: number; waitingCount: number; queuedCount: number; + activeLabels: string[]; + waitingLabels: string[]; + queuedLabels: string[]; }; type RecoverStuckSession = (params: { @@ -142,21 +151,56 @@ function recoverStuckSession(params: { }); } -function getDiagnosticWorkSnapshot(): DiagnosticWorkSnapshot { +function formatDiagnosticWorkLabel( + state: { + sessionId?: string; + sessionKey?: string; + state: SessionStateValue; + queueDepth: number; + lastActivity: number; + }, + now: number, +): string { + const label = state.sessionKey ?? state.sessionId ?? "unknown"; + const ageSeconds = Math.round(Math.max(0, now - state.lastActivity) / 1000); + const activity = getDiagnosticSessionActivitySnapshot( + { sessionId: state.sessionId, sessionKey: state.sessionKey }, + now, + ); + const workKind = activity.activeWorkKind ? `/${activity.activeWorkKind}` : ""; + const lastProgress = activity.lastProgressReason ? ` last=${activity.lastProgressReason}` : ""; + return `${label}(${state.state}${workKind},q=${state.queueDepth},age=${ageSeconds}s${lastProgress})`; +} + +function pushLimitedDiagnosticLabel(labels: string[], label: string, limit = 5): void { + if (labels.length < limit) { + labels.push(label); + } +} + +function getDiagnosticWorkSnapshot(now = Date.now()): DiagnosticWorkSnapshot { let activeCount = 0; let waitingCount = 0; let queuedCount = 0; + const activeLabels: string[] = []; + const waitingLabels: string[] = []; + const queuedLabels: string[] = []; for (const state of diagnosticSessionStates.values()) { if (state.state === "processing") { activeCount += 1; + pushLimitedDiagnosticLabel(activeLabels, formatDiagnosticWorkLabel(state, now)); } else if (state.state === "waiting") { waitingCount += 1; + pushLimitedDiagnosticLabel(waitingLabels, formatDiagnosticWorkLabel(state, now)); + } + if (state.queueDepth > 0) { + pushLimitedDiagnosticLabel(queuedLabels, formatDiagnosticWorkLabel(state, now)); } queuedCount += state.queueDepth; } - return { activeCount, waitingCount, queuedCount }; + return { activeCount, waitingCount, queuedCount, activeLabels, waitingLabels, queuedLabels }; } function hasOpenDiagnosticWork(snapshot: DiagnosticWorkSnapshot): boolean { @@ -306,6 +350,10 @@ function emitDiagnosticLivenessWarning( sample: DiagnosticLivenessSample, work: DiagnosticWorkSnapshot, ): void { + const phase = getCurrentDiagnosticPhase(); + const recentPhases = getRecentDiagnosticPhases(6); + const recentPhaseSummary = formatRecentDiagnosticPhases(recentPhases); + const workLabelSummary = formatDiagnosticWorkLabels(work); const message = `liveness warning: reasons=${sample.reasons.join(",")} interval=${Math.round( sample.intervalMs / 1000, )}s eventLoopDelayP99Ms=${formatOptionalDiagnosticMetric( @@ -316,7 +364,11 @@ function emitDiagnosticLivenessWarning( sample.eventLoopUtilization, )} cpuCoreRatio=${formatOptionalDiagnosticMetric(sample.cpuCoreRatio)} active=${ work.activeCount - } waiting=${work.waitingCount} queued=${work.queuedCount}`; + } waiting=${work.waitingCount} queued=${work.queuedCount}${ + phase ? ` phase=${phase}` : "" + }${recentPhaseSummary ? ` recentPhases=${recentPhaseSummary}` : ""}${ + workLabelSummary ? ` work=[${workLabelSummary}]` : "" + }`; const hasBlockingWork = work.waitingCount > 0 || work.queuedCount > 0; const hasSustainedEventLoopDelay = (sample.eventLoopDelayP99Ms ?? 0) >= DEFAULT_LIVENESS_EVENT_LOOP_DELAY_WARN_MS; @@ -339,10 +391,28 @@ function emitDiagnosticLivenessWarning( active: work.activeCount, waiting: work.waitingCount, queued: work.queuedCount, + phase, + recentPhases, + activeWorkLabels: work.activeLabels, + waitingWorkLabels: work.waitingLabels, + queuedWorkLabels: work.queuedLabels, }); markActivity(); } +function formatRecentDiagnosticPhases(phases: DiagnosticPhaseSnapshot[]): string { + return phases.map((phase) => `${phase.name}:${Math.round(phase.durationMs ?? 0)}ms`).join(","); +} + +function formatDiagnosticWorkLabels(work: DiagnosticWorkSnapshot): string { + const parts = [ + work.activeLabels.length > 0 ? `active=${work.activeLabels.join("|")}` : "", + work.waitingLabels.length > 0 ? `waiting=${work.waitingLabels.join("|")}` : "", + work.queuedLabels.length > 0 ? `queued=${work.queuedLabels.join("|")}` : "", + ].filter(Boolean); + return parts.join(" "); +} + export function resolveStuckSessionWarnMs(config?: OpenClawConfig): number { const raw = config?.diagnostics?.stuckSessionWarnMs; if (typeof raw !== "number" || !Number.isFinite(raw)) { @@ -588,6 +658,9 @@ function sessionAttentionFields(params: { classification: SessionAttentionClassification; activity: DiagnosticSessionActivitySnapshot; }) { + const terminalProgressStale = isTerminalDiagnosticProgressReason( + params.activity.lastProgressReason, + ); return { ...(params.classification.activeWorkKind ? { activeWorkKind: params.classification.activeWorkKind } @@ -605,9 +678,24 @@ function sessionAttentionFields(params: { ...(params.activity.activeToolAgeMs !== undefined ? { activeToolAgeMs: params.activity.activeToolAgeMs } : {}), + ...(terminalProgressStale ? { terminalProgressStale: true } : {}), }; } +function isTerminalDiagnosticProgressReason(reason: string | undefined): boolean { + if (!reason) { + return false; + } + return ( + reason === "run:completed" || + reason === "embedded_run:ended" || + reason.includes("response.completed") || + reason.includes("rawResponseItem/completed") || + reason.includes("raw_response_item.completed") || + reason.includes("output_item.done") + ); +} + function formatSessionActivityLogFields(activity: DiagnosticSessionActivitySnapshot): string { const fields: string[] = []; if (activity.lastProgressReason) { @@ -625,6 +713,9 @@ function formatSessionActivityLogFields(activity: DiagnosticSessionActivitySnaps if (activity.activeToolAgeMs !== undefined) { fields.push(`activeToolAge=${Math.round(activity.activeToolAgeMs / 1000)}s`); } + if (isTerminalDiagnosticProgressReason(activity.lastProgressReason)) { + fields.push("terminalProgressStale=true"); + } return fields.join(" "); } @@ -835,7 +926,7 @@ export function startDiagnosticHeartbeat( const stuckSessionWarnMs = resolveStuckSessionWarnMs(heartbeatConfig); const now = Date.now(); pruneDiagnosticSessionStates(now, true); - const work = getDiagnosticWorkSnapshot(); + const work = getDiagnosticWorkSnapshot(now); const livenessSample = (opts?.sampleLiveness ?? sampleDiagnosticLiveness)(now, work); const shouldEmitLivenessEvent = livenessSample !== null && shouldEmitDiagnosticLivenessEvent(now); @@ -943,6 +1034,7 @@ export function resetDiagnosticStateForTest(): void { webhookStats.lastReceived = 0; stopDiagnosticHeartbeat(); resetDiagnosticMemoryForTest(); + resetDiagnosticPhasesForTest(); resetDiagnosticStabilityRecorderForTest(); resetDiagnosticStabilityBundleForTest(); } diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 738165d4e29..82191e48381 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -6,6 +6,9 @@ export type { CliBackendPlugin, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, } from "../plugins/types.js"; export { CLI_FRESH_WATCHDOG_DEFAULTS, diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index a1ee1428bb7..962b38b0bc5 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -244,13 +244,16 @@ export function isOpenAICompatibleThinkingEnabled(params: { } export type DeepSeekV4ThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"]; +export type DeepSeekV4ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; function isDisabledDeepSeekV4ThinkingLevel(thinkingLevel: DeepSeekV4ThinkingLevel): boolean { const normalized = typeof thinkingLevel === "string" ? thinkingLevel.toLowerCase() : ""; return normalized === "off" || normalized === "none"; } -function resolveDeepSeekV4ReasoningEffort(thinkingLevel: DeepSeekV4ThinkingLevel): "high" | "max" { +function resolveDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { return thinkingLevel === "xhigh" || thinkingLevel === "max" ? "max" : "high"; } @@ -288,11 +291,13 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { baseStreamFn: StreamFn | undefined; thinkingLevel: DeepSeekV4ThinkingLevel; shouldPatchModel: (model: Parameters[0]) => boolean; + resolveReasoningEffort?: (thinkingLevel: DeepSeekV4ThinkingLevel) => DeepSeekV4ReasoningEffort; }): StreamFn | undefined { if (!params.baseStreamFn) { return undefined; } const underlying = params.baseStreamFn; + const resolveReasoningEffort = params.resolveReasoningEffort ?? resolveDeepSeekV4ReasoningEffort; return (model, context, options) => { if (!params.shouldPatchModel(model)) { return underlying(model, context, options); @@ -308,7 +313,7 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { } payload.thinking = { type: "enabled" }; - payload.reasoning_effort = resolveDeepSeekV4ReasoningEffort(params.thinkingLevel); + payload.reasoning_effort = resolveReasoningEffort(params.thinkingLevel); ensureDeepSeekV4AssistantReasoningContent(payload); }); }; diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index fa9add9df4c..851a390f2bc 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -78,7 +78,12 @@ export function withActivatedPluginIds(params: { if (params.pluginIds.length === 0) { return params.config; } - const allow = new Set(params.config?.plugins?.allow ?? []); + const originalAllow = params.config?.plugins?.allow ?? []; + // Empty allowlists are still open; only explicit compat widens configured allowlists. + const useAllowlistDiscovery = + params.config?.plugins?.bundledDiscovery !== "compat" && originalAllow.length > 0; + const originalAllowSet = useAllowlistDiscovery ? new Set(originalAllow) : undefined; + const allow = new Set(originalAllow); const entries = { ...params.config?.plugins?.entries, }; @@ -87,6 +92,9 @@ export function withActivatedPluginIds(params: { if (!normalized) { continue; } + if (originalAllowSet && !originalAllowSet.has(normalized)) { + continue; + } allow.add(normalized); const existingEntry = entries[normalized]; entries[normalized] = { diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index 1fd096a90a2..67f739e2768 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -1,11 +1,15 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginEntryConfig } from "../config/types.plugins.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; +import { normalizePluginId } from "./config-state.js"; export function withBundledPluginAllowlistCompat(params: { config: OpenClawConfig | undefined; pluginIds: readonly string[]; }): OpenClawConfig | undefined { + if (params.config?.plugins?.bundledDiscovery !== "compat") { + return params.config; + } const allow = params.config?.plugins?.allow; if (!Array.isArray(allow) || allow.length === 0) { return params.config; @@ -39,10 +43,21 @@ export function withBundledPluginEnablementCompat(params: { }): OpenClawConfig | undefined { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; + const useCompatDiscovery = params.config?.plugins?.bundledDiscovery === "compat"; + const allow = params.config?.plugins?.allow; + const allowSet = + !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 + ? new Set(allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean)) + : undefined; + let hasEligiblePlugin = false; let changed = false; const nextEntries: Record = { ...existingEntries }; for (const pluginId of params.pluginIds) { + if (allowSet && !allowSet.has(pluginId)) { + continue; + } + hasEligiblePlugin = true; if (existingEntries[pluginId] !== undefined) { continue; } @@ -51,7 +66,7 @@ export function withBundledPluginEnablementCompat(params: { } if (!changed) { - if (!forcePluginsEnabled) { + if (!forcePluginsEnabled || !hasEligiblePlugin) { return params.config; } } diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 99cc2a922a9..63d0ebb3eb2 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -852,6 +852,36 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("installs when a CalVer correction runtime satisfies the base plugin API range", async () => { + resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.5.3-1"); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.5.3", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + compatibility: { + pluginApiRange: ">=2026.5.3", + minGatewayVersion: "2026.3.0", + }, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expectSuccessfulClawHubInstall(result); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledTimes(1); + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/clawhub-demo/archive.zip", + }), + ); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); + }); + it("does not let a wildcard plugin API range hide an invalid runtime version", async () => { resolveCompatibilityHostVersionMock.mockReturnValueOnce("invalid"); fetchClawHubPackageVersionMock.mockResolvedValueOnce({ diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 97492e4a834..01771a80292 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -33,6 +33,31 @@ export type CliBackendPreparedExecution = { cleanup?: () => Promise; }; +export type CliBackendThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "adaptive" + | "max"; + +export type CliBackendResolveExecutionArgsContext = { + config?: OpenClawConfig; + workspaceDir: string; + provider: string; + modelId: string; + authProfileId?: string; + thinkingLevel?: CliBackendThinkingLevel; + useResume: boolean; + baseArgs: readonly string[]; +}; + +export type CliBackendResolveExecutionArgs = ( + ctx: CliBackendResolveExecutionArgsContext, +) => readonly string[] | null | undefined; + export type CliBackendAuthEpochMode = "combined" | "profile-only"; export type CliBackendNativeToolMode = "none" | "always-on"; @@ -141,6 +166,14 @@ export type CliBackendPlugin = { | CliBackendPreparedExecution | null | undefined; + /** + * Backend-owned per-run argv rewrite. + * + * Use this for request-scoped CLI dialect flags that should not be modeled + * as static config, such as mapping OpenClaw thinking levels to a backend's + * native effort flag. + */ + resolveExecutionArgs?: CliBackendResolveExecutionArgs; /** * Whether this CLI backend can expose native tools outside OpenClaw's tool * catalog. Backends that cannot provide a true no-tools mode must mark diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 78442219e53..03b4fe714d6 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -26,6 +26,7 @@ function createAllowlistCompatConfig(pluginIds: string[]) { config: { plugins: { allow: [demoAllowEntry], + bundledDiscovery: "compat", }, }, pluginIds, @@ -51,6 +52,7 @@ describe("plugin loader contract", () => { config: { plugins: { allow: [demoAllowEntry], + bundledDiscovery: "compat", }, }, }); diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index 373981ec8bd..26ea17c217e 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -19,6 +19,7 @@ export function createPluginRecord(params: { rootDir?: string; origin: PluginRecord["origin"]; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; compat?: readonly PluginCompatCode[]; activationState?: PluginActivationState; @@ -41,6 +42,7 @@ export function createPluginRecord(params: { rootDir: params.rootDir, origin: params.origin, workspaceDir: params.workspaceDir, + trustedOfficialInstall: params.trustedOfficialInstall, enabled: params.enabled, compat: params.compat, explicitlyEnabled: params.activationState?.explicitlyEnabled, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 714d837b8bc..d3c4efff640 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1739,6 +1739,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -1777,6 +1778,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2563,6 +2565,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2601,6 +2604,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a33fbe57eaf..e3d7d44ae3e 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -534,6 +534,89 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.origin).toBe("global"); }); + it("marks official installed npm globals as trusted official installs", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBe(true); + }); + + it("preserves trusted official installs when a config path selects the installed package", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "config", + }), + ], + }); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toEqual( + expect.objectContaining({ + origin: "config", + trustedOfficialInstall: true, + }), + ); + }); + + it("does not trust unrecorded globals that spoof official ids", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: {}, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBeUndefined(); + }); + it("preserves provider auth env metadata from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 8cb00aabdab..08aadabc5ee 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -46,6 +46,8 @@ import { checkMinHostVersion } from "./min-host-version.js"; import { getOfficialExternalPluginCatalogEntryForPackage, getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; @@ -140,6 +142,7 @@ export type PluginManifestRecord = { packageOptionalDependencies?: PluginDependencySpecMap; packageChannel?: PluginPackageChannel; packageInstall?: PluginPackageInstall; + trustedOfficialInstall?: boolean; qaRunners?: PluginManifestQaRunner[]; skills: string[]; settingsFiles?: string[]; @@ -365,6 +368,7 @@ function buildRecord(params: { schemaCacheKey?: string; configSchema?: Record; bundledChannelConfigCollector?: BundledChannelConfigCollector; + trustedOfficialInstall?: boolean; }): PluginManifestRecord { const manifestChannelConfigs = params.candidate.origin === "bundled" && params.bundledChannelConfigCollector @@ -434,6 +438,7 @@ function buildRecord(params: { packageOptionalDependencies: params.candidate.packageOptionalDependencies, packageChannel: params.candidate.packageManifest?.channel, packageInstall: params.candidate.packageManifest?.install, + trustedOfficialInstall: params.trustedOfficialInstall === true ? true : undefined, qaRunners: params.manifest.qaRunners, skills: params.manifest.skills ?? [], settingsFiles: [], @@ -634,7 +639,7 @@ function matchesInstalledPluginRecord(params: { env: NodeJS.ProcessEnv; installRecords: Record; }): boolean { - if (params.candidate.origin !== "global") { + if (params.candidate.origin !== "global" && params.candidate.origin !== "config") { return false; } const record = params.installRecords[params.pluginId]; @@ -653,6 +658,72 @@ function matchesInstalledPluginRecord(params: { }); } +function npmSpecMatchesPackage(value: string | undefined, packageName: string): boolean { + const normalized = value?.trim(); + if (!normalized) { + return false; + } + if (normalized === packageName) { + return true; + } + return normalized.startsWith(`${packageName}@`); +} + +function isTrustedOfficialPluginInstall(params: { + pluginId: string; + candidate: PluginCandidate; + env: NodeJS.ProcessEnv; + installRecords: Record; +}): boolean { + if ( + (params.candidate.origin !== "global" && params.candidate.origin !== "config") || + !matchesInstalledPluginRecord({ + pluginId: params.pluginId, + candidate: params.candidate, + env: params.env, + installRecords: params.installRecords, + }) + ) { + return false; + } + const packageName = params.candidate.packageName?.trim(); + if (!packageName) { + return false; + } + const catalogEntry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!catalogEntry || resolveOfficialExternalPluginId(catalogEntry) !== params.pluginId) { + return false; + } + const officialInstall = resolveOfficialExternalPluginInstall(catalogEntry); + const installRecord = params.installRecords[params.pluginId]; + if (!installRecord) { + return false; + } + if ( + installRecord.source === "npm" && + officialInstall?.npmSpec === packageName && + [ + installRecord.resolvedName, + installRecord.spec, + installRecord.resolvedSpec, + params.candidate.packageName, + ].some((value) => npmSpecMatchesPackage(value, packageName)) + ) { + return true; + } + if ( + installRecord.source === "clawhub" && + officialInstall?.clawhubSpec && + installRecord.clawhubChannel === "official" && + (installRecord.clawhubPackage === packageName || + installRecord.spec === officialInstall.clawhubSpec || + installRecord.resolvedSpec === officialInstall.clawhubSpec) + ) { + return true; + } + return false; +} + function resolveDuplicatePrecedenceRank(params: { pluginId: string; candidate: PluginCandidate; @@ -858,6 +929,12 @@ export function loadPluginManifestRegistry( manifestPath: manifestRes.manifestPath, schemaCacheKey, configSchema, + trustedOfficialInstall: isTrustedOfficialPluginInstall({ + pluginId: manifest.id, + candidate, + env, + installRecords: getInstallRecords(), + }), ...(params.bundledChannelConfigCollector ? { bundledChannelConfigCollector: params.bundledChannelConfigCollector } : {}), diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index d936b62ba67..c5386ee0c16 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -295,6 +295,7 @@ function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat" as const, }, }, bundledProviderAllowlistCompat: true, @@ -593,6 +594,75 @@ describe("resolvePluginProviders", () => { ).toEqual(["legacy-auth-owner"]); }); + it("filters bundled provider plugins by allowlist by default", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "kilocode", + providerIds: ["kilocode"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "moonshot", + providerIds: ["moonshot"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "openrouter", + providerIds: ["openrouter"], + origin: "bundled", + enabledByDefault: true, + }), + ]); + + const discovered = resolveDiscoveredProviderPluginIds({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + env: {} as NodeJS.ProcessEnv, + }); + + expect(discovered).toEqual(["openrouter"]); + }); + + it("returns all bundled provider plugins in explicit compat mode", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "kilocode", + providerIds: ["kilocode"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "moonshot", + providerIds: ["moonshot"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "openrouter", + providerIds: ["openrouter"], + origin: "bundled", + enabledByDefault: true, + }), + ]); + + const discovered = resolveDiscoveredProviderPluginIds({ + config: { + plugins: { + allow: ["openrouter"], + bundledDiscovery: "compat", + }, + }, + env: {} as NodeJS.ProcessEnv, + }); + + expect(discovered).toEqual(["kilocode", "moonshot", "openrouter"]); + }); + it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => { expect( resolveEnabledProviderPluginIds({ @@ -760,6 +830,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", }, }, bundledProviderAllowlistCompat: true, @@ -773,11 +844,38 @@ describe("resolvePluginProviders", () => { }); }); - it("loads all discovered provider plugins in setup mode", () => { + it("scopes setup provider plugin discovery to the allowlist by default", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["google"], + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google"], + }); + expect(getLastSetupLoadedPluginConfig()).toEqual( + expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["google"], + entries: expect.objectContaining({ + google: { enabled: true }, + }), + }), + }), + ); + }); + + it("loads all discovered provider plugins in setup mode for explicit compat configs", () => { resolvePluginProviders({ config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", entries: { google: { enabled: false }, }, @@ -815,6 +913,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", }, }, mode: "setup", @@ -831,6 +930,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter", "workspace-provider"], + bundledDiscovery: "compat", entries: { "workspace-provider": { enabled: false }, }, @@ -850,6 +950,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter", "workspace-provider"], + bundledDiscovery: "compat", deny: ["workspace-provider"], entries: { "workspace-provider": { enabled: false }, @@ -879,9 +980,7 @@ describe("resolvePluginProviders", () => { includeUntrustedWorkspacePlugins: false, }); - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("loads provider plugins from the auto-enabled config snapshot", () => { @@ -1173,16 +1272,7 @@ describe("resolvePluginProviders", () => { mode: "setup", }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - enabled: false, - allow: ["setup-owned-provider"], - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("does not override explicitly disabled setup owners", () => { @@ -1209,18 +1299,7 @@ describe("resolvePluginProviders", () => { mode: "setup", }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["setup-owned-provider"], - entries: { - "setup-owned-provider": { enabled: false }, - }, - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("filters explicit setup owners through the untrusted workspace discovery gate", () => { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index b9903b5109b..af75d5f41e7 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -255,6 +255,7 @@ export function resolveDiscoveredProviderPluginIds(params: { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds(registry, (plugin) => { if ( @@ -268,6 +269,7 @@ export function resolveDiscoveredProviderPluginIds(params: { return isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, + shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }); @@ -277,10 +279,15 @@ export function resolveDiscoveredProviderPluginIds(params: { function isProviderPluginEligibleForSetupDiscovery(params: { plugin: PluginRegistryRecord; shouldFilterUntrustedWorkspacePlugins: boolean; + shouldFilterBundledByAllowlist: boolean; normalizedConfig: NormalizedPluginsConfig; rootConfig?: PluginLoadOptions["config"]; }): boolean { - if (!params.shouldFilterUntrustedWorkspacePlugins || params.plugin.origin !== "workspace") { + if (params.plugin.origin === "workspace") { + if (!params.shouldFilterUntrustedWorkspacePlugins) { + return true; + } + } else if (!params.shouldFilterBundledByAllowlist) { return true; } if ( @@ -291,6 +298,9 @@ function isProviderPluginEligibleForSetupDiscovery(params: { ) { return false; } + if (params.plugin.origin === "bundled") { + return true; + } return isActivatedManifestOwner({ plugin: toManifestOwnerRecord(params.plugin), normalizedConfig: params.normalizedConfig, @@ -306,12 +316,14 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; return resolveProviderOwnerPluginIds({ ...params, isEligible: (plugin, normalizedConfig) => isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, + shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }), diff --git a/src/plugins/prune-docker-plugin-dist.test.ts b/src/plugins/prune-docker-plugin-dist.test.ts new file mode 100644 index 00000000000..d162c83f334 --- /dev/null +++ b/src/plugins/prune-docker-plugin-dist.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + parseDockerPluginKeepList, + pruneDockerPluginDist, +} from "../../scripts/prune-docker-plugin-dist.mjs"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + return makeTempRepoRoot(tempDirs, prefix); +} + +function writeDistPluginFile(repoRoot: string, root: "dist" | "dist-runtime", pluginId: string) { + const pluginDir = path.join(repoRoot, root, "extensions", pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), "{}\n", "utf8"); +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("pruneDockerPluginDist", () => { + it("parses space and comma separated Docker plugin keep lists", () => { + expect([...parseDockerPluginKeepList("diagnostics-otel feishu,discord")]).toEqual([ + "diagnostics-otel", + "feishu", + "discord", + ]); + }); + + it("removes package-excluded plugin dist unless Docker explicitly opts it in", () => { + const repoRoot = makeRepoRoot("openclaw-docker-plugin-dist-"); + writeJsonFile(path.join(repoRoot, "package.json"), { + files: ["dist/**", "!dist/extensions/diagnostics-otel/**", "!dist/extensions/feishu/**"], + }); + writeDistPluginFile(repoRoot, "dist", "diagnostics-otel"); + writeDistPluginFile(repoRoot, "dist", "feishu"); + writeDistPluginFile(repoRoot, "dist-runtime", "feishu"); + writeDistPluginFile(repoRoot, "dist", "telegram"); + + const removed = pruneDockerPluginDist({ + repoRoot, + env: { OPENCLAW_EXTENSIONS: "diagnostics-otel" } as NodeJS.ProcessEnv, + }); + + expect(removed).toEqual(["dist/extensions/feishu", "dist-runtime/extensions/feishu"]); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "diagnostics-otel"))).toBe(true); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "telegram"))).toBe(true); + }); +}); diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index b8820c72481..d4eff1a0176 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -205,6 +205,7 @@ export type PluginServiceRegistration = { service: OpenClawPluginService; source: string; origin: PluginOrigin; + trustedOfficialInstall?: boolean; rootDir?: string; }; @@ -337,6 +338,7 @@ export type PluginRecord = { rootDir?: string; origin: PluginOrigin; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; explicitlyEnabled?: boolean; activated?: boolean; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 497e1d37661..fd734365a4d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1452,6 +1452,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { service, source: record.source, origin: record.origin, + trustedOfficialInstall: record.trustedOfficialInstall, rootDir: record.rootDir, }); }; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index f6b8fbfc016..6615eae0acb 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -22,6 +22,7 @@ function createRegistry( services: OpenClawPluginService[], pluginId = "plugin:test", origin: PluginOrigin = "workspace", + trustedOfficialInstall = false, ) { const registry = createEmptyPluginRegistry(); registry.services = services.map((service) => ({ @@ -29,6 +30,7 @@ function createRegistry( service, source: "test", origin, + ...(trustedOfficialInstall ? { trustedOfficialInstall } : {}), rootDir: "/plugins/test-plugin", })) as typeof registry.services; return registry; @@ -181,7 +183,7 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); - it("grants internal diagnostics only to bundled diagnostics exporter services", async () => { + it("grants internal diagnostics only to trusted diagnostics exporter services", async () => { const contexts: OpenClawPluginServiceContext[] = []; const diagnosticsService = createTrackingService("diagnostics-otel", { contexts }); await startPluginServices({ @@ -204,6 +206,18 @@ describe("startPluginServices", () => { expect(prometheusContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); expect(prometheusContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const officialInstallContexts: OpenClawPluginServiceContext[] = []; + const officialInstallService = createTrackingService("diagnostics-prometheus", { + contexts: officialInstallContexts, + }); + await startPluginServices({ + registry: createRegistry([officialInstallService], "diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(officialInstallContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); + expect(officialInstallContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const untrustedContexts: OpenClawPluginServiceContext[] = []; const untrustedService = createTrackingService("diagnostics-otel", { contexts: untrustedContexts, @@ -214,5 +228,16 @@ describe("startPluginServices", () => { }); expect(untrustedContexts[0]?.internalDiagnostics).toBeUndefined(); + + const spoofedContexts: OpenClawPluginServiceContext[] = []; + const spoofedService = createTrackingService("diagnostics-prometheus", { + contexts: spoofedContexts, + }); + await startPluginServices({ + registry: createRegistry([spoofedService], "not-diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(spoofedContexts[0]?.internalDiagnostics).toBeUndefined(); }); }); diff --git a/src/plugins/services.ts b/src/plugins/services.ts index abbc0e9462d..a4179bc3806 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -24,11 +24,13 @@ function createServiceContext(params: { workspaceDir?: string; service?: PluginServiceRegistration; }): OpenClawPluginServiceContext { + const isDiagnosticsExporter = + params.service?.pluginId === params.service?.service.id && + (params.service?.service.id === "diagnostics-otel" || + params.service?.service.id === "diagnostics-prometheus"); const grantsInternalDiagnostics = - params.service?.origin === "bundled" && - params.service.pluginId === params.service.service.id && - (params.service.service.id === "diagnostics-otel" || - params.service.service.id === "diagnostics-prometheus"); + isDiagnosticsExporter && + (params.service?.origin === "bundled" || params.service?.trustedOfficialInstall === true); return { config: params.config, diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 8415d82249c..2873878c3c9 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -66,6 +66,13 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "inputTokens", "outputTokens", "totalTokens", + "pendingFinalDelivery", + "pendingFinalDeliveryCreatedAt", + "pendingFinalDeliveryLastAttemptAt", + "pendingFinalDeliveryAttemptCount", + "pendingFinalDeliveryLastError", + "pendingFinalDeliveryText", + "pendingFinalDeliveryContext", "totalTokensFresh", "estimatedCostUsd", "cacheRead", diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b7df4b8b781..3718330c388 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -84,6 +84,9 @@ import type { CliBackendNormalizeConfigContext, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement, @@ -194,6 +197,9 @@ export type { CliBackendNativeToolMode, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1f44d43c48f..655d387e32b 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1032,6 +1032,61 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("disables enabled tracked plugin update failures when requested", async () => { + const warn = vi.fn(); + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + error: "registry timeout", + }); + const config = { + plugins: { + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + installs: { + demo: { + source: "npm" as const, + spec: "@acme/demo", + installPath: "/tmp/demo", + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await updateNpmInstalledPlugins({ + config, + skipDisabledPlugins: true, + disableOnFailure: true, + logger: { warn }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo", + expectedPluginId: "demo", + }), + ); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout'; + expect(warn).toHaveBeenCalledWith(message); + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message, + }, + ]); + }); + it("aborts exact pinned npm plugin updates on integrity drift by default", async () => { const warn = vi.fn(); installPluginFromNpmSpecMock.mockImplementation( @@ -1293,6 +1348,87 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("falls back to the default npm spec when the beta package exists but is invalid", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.6", + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }, + }), + ); + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + spec: "openclaw-codex-app-server", + }), + ); + expect(warnMessages).toEqual([expect.stringContaining("failed beta npm update")]); + expectCodexAppServerInstallState({ + result, + spec: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }); + }); + + it("reports the fallback npm spec when beta fallback also fails", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce({ + ok: false, + code: "npm_package_not_found", + error: "npm package not found", + }); + + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(2); + expect(result.outcomes).toEqual([ + { + pluginId: "openclaw-codex-app-server", + status: "error", + message: + "Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.", + }, + ]); + }); + it("preserves explicit npm tags when updating on the beta channel", async () => { installPluginFromNpmSpecMock.mockResolvedValue( createSuccessfulNpmUpdateResult({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 4d7f098185c..c3b97338c4f 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -431,13 +431,31 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): return shouldFallbackClawHubBridgeToNpm(result); } -function shouldFallbackBetaNpmUpdate(result: { ok: false; code?: string; error: string }): boolean { - if (result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { - return true; +function describeBetaNpmFallback(params: { + pluginId: string; + betaSpec: string | undefined; + fallbackSpec: string; + result: { ok: false; code?: string; error: string }; +}): string { + const betaSpec = params.betaSpec ?? "the beta npm release"; + const missingBeta = + params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND || + /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( + params.result.error, + ); + const reason = missingBeta ? "has no beta npm release" : "failed beta npm update"; + return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; falling back to ${params.fallbackSpec}.`; +} + +function npmUpdateFailureSpec(params: { + effectiveSpec: string | undefined; + fallbackSpec: string | undefined; + usedFallback: boolean; +}): string { + if (params.usedFallback && params.fallbackSpec) { + return params.fallbackSpec; } - return /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( - result.error, - ); + return params.effectiveSpec ?? params.fallbackSpec ?? "unknown"; } function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null { @@ -729,12 +747,30 @@ function createPluginUpdateIntegrityDriftHandler(params: { }; } +function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig { + const existingEntry = config.plugins?.entries?.[pluginId]; + return { + ...config, + plugins: { + ...config.plugins, + entries: { + ...config.plugins?.entries, + [pluginId]: { + ...existingEntry, + enabled: false, + }, + }, + }, + }; +} + export async function updateNpmInstalledPlugins(params: { config: OpenClawConfig; logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; skipDisabledPlugins?: boolean; + disableOnFailure?: boolean; timeoutMs?: number; dryRun?: boolean; updateChannel?: UpdateChannel; @@ -753,6 +789,28 @@ export async function updateNpmInstalledPlugins(params: { let next = params.config; let changed = false; + const recordFailure = (pluginId: string, message: string) => { + if (params.disableOnFailure && !params.dryRun) { + const disabledMessage = + `Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` + + message; + logger.warn?.(disabledMessage); + next = disablePluginConfigEntry(next, pluginId); + changed = true; + outcomes.push({ + pluginId, + status: "skipped", + message: disabledMessage, + }); + return; + } + outcomes.push({ + pluginId, + status: "error", + message, + }); + }; + for (const pluginId of targets) { if (params.skipIds?.has(pluginId)) { outcomes.push({ @@ -910,11 +968,7 @@ export async function updateNpmInstalledPlugins(params: { record.installPath?.trim() || resolvePluginInstallDir(pluginId), ); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Invalid install path for "${pluginId}": ${String(err)}`, - }); + recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`); continue; } const currentVersion = await readInstalledPackageVersion(installPath); @@ -1019,22 +1073,20 @@ export async function updateNpmInstalledPlugins(params: { logger, }); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Failed to check ${pluginId}: ${String(err)}`, - }); + recordFailure(pluginId, `Failed to check ${pluginId}: ${String(err)}`); continue; } - if ( - !probe.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(probe) - ) { + let usedNpmFallback = false; + if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result: probe, + }), ); + usedNpmFallback = true; probe = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1076,39 +1128,41 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!probe.ok) { - outcomes.push({ + recordFailure( pluginId, - status: "error", - message: - record.source === "npm" - ? formatNpmInstallFailure({ + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), + phase: "check", + result: probe, + }) + : record.source === "clawhub" + ? formatClawHubInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "check", - result: probe, + error: probe.error, }) - : record.source === "clawhub" - ? formatClawHubInstallFailure({ + : record.source === "git" + ? formatGitInstallFailure({ pluginId, - spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + spec: effectiveSpec!, phase: "check", error: probe.error, }) - : record.source === "git" - ? formatGitInstallFailure({ - pluginId, - spec: effectiveSpec!, - phase: "check", - error: probe.error, - }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "check", - error: probe.error, - }), - }); + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "check", + error: probe.error, + }), + ); continue; } @@ -1200,22 +1254,20 @@ export async function updateNpmInstalledPlugins(params: { logger, }); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Failed to update ${pluginId}: ${String(err)}`, - }); + recordFailure(pluginId, `Failed to update ${pluginId}: ${String(err)}`); continue; } - if ( - !result.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(result) - ) { + let usedNpmFallback = false; + if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result, + }), ); + usedNpmFallback = true; result = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1255,39 +1307,41 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!result.ok) { - outcomes.push({ + recordFailure( pluginId, - status: "error", - message: - record.source === "npm" - ? formatNpmInstallFailure({ + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), + phase: "update", + result: result, + }) + : record.source === "clawhub" + ? formatClawHubInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "update", - result: result, + error: result.error, }) - : record.source === "clawhub" - ? formatClawHubInstallFailure({ + : record.source === "git" + ? formatGitInstallFailure({ pluginId, - spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + spec: effectiveSpec!, phase: "update", error: result.error, }) - : record.source === "git" - ? formatGitInstallFailure({ - pluginId, - spec: effectiveSpec!, - phase: "update", - error: result.error, - }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "update", - error: result.error, - }), - }); + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "update", + error: result.error, + }), + ); continue; } diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index d200de9fc46..f951c9b43d0 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -87,4 +87,102 @@ describe("web provider public artifact manifest fallback", () => { pluginId: "fallback-fetch", }); }); + + it("keeps explicit bundled web-search public artifact candidates inside allowlist discovery", () => { + const resolveExplicitWebSearchProviders = + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as { + mockImplementation: ( + implementation: (params: { + onlyPluginIds: readonly string[]; + }) => { id: string; pluginId: string }[], + ) => void; + }; + resolveExplicitWebSearchProviders.mockImplementation((params) => + params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), + ); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-search"], + bundledDiscovery: "allowlist", + }, + }, + onlyPluginIds: ["blocked-search", "fallback-search"], + }); + + expect(providers).toEqual([{ id: "fallback-search", pluginId: "fallback-search" }]); + expect(mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts).toHaveBeenCalledWith({ + onlyPluginIds: ["fallback-search"], + }); + }); + + it("keeps manifest bundled web-fetch public artifact candidates inside allowlist discovery", () => { + mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ + diagnostics: [], + plugins: [ + { + id: "blocked-fetch", + origin: "bundled", + rootDir: "/tmp/blocked-fetch", + contracts: { webFetchProviders: ["blocked-fetch"] }, + }, + { + id: "fallback-fetch", + origin: "bundled", + rootDir: "/tmp/fallback-fetch", + contracts: { webFetchProviders: ["fallback-fetch"] }, + }, + ], + }); + + const providers = resolveBundledWebFetchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-fetch"], + bundledDiscovery: "allowlist", + }, + }, + }); + + expect(providers).toEqual([{ id: "fallback-fetch", pluginId: "fallback-fetch" }]); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledOnce(); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledWith({ + dirName: "fallback-fetch", + pluginId: "fallback-fetch", + }); + }); + + it("matches bundled web-search candidates through provider alias allowlist entries", () => { + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts.mockReturnValueOnce(null); + mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ + diagnostics: [], + plugins: [ + { + id: "google", + origin: "bundled", + rootDir: "/tmp/google", + contracts: { webSearchProviders: ["gemini"] }, + }, + ], + }); + mocks.loadBundledWebSearchProviderEntriesFromDir.mockReturnValueOnce([ + { id: "gemini", pluginId: "google" }, + ]); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["google-gemini-cli"], + bundledDiscovery: "allowlist", + }, + }, + }); + + expect(providers).toEqual([{ id: "gemini", pluginId: "google" }]); + expect(mocks.loadBundledWebSearchProviderEntriesFromDir).toHaveBeenCalledWith({ + dirName: "google", + pluginId: "google", + }); + }); }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index bcd89d29a0c..34ab13233b9 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizePluginId } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -26,6 +27,24 @@ type BundledCandidateResolution = { manifestRecords?: readonly PluginManifestRecord[]; }; +function filterAllowlistedBundledPluginIds( + config: PluginLoadOptions["config"] | undefined, + pluginIds: readonly string[], +) { + const allow = config?.plugins?.allow; + if ( + config?.plugins?.bundledDiscovery === "compat" || + !Array.isArray(allow) || + allow.length === 0 + ) { + return [...pluginIds]; + } + const allowedPluginIds = new Set( + allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean), + ); + return pluginIds.filter((pluginId) => allowedPluginIds.has(pluginId)); +} + function resolveBundledCandidatePluginIds(params: { contract: "webSearchProviders" | "webFetchProviders"; configKey: "webSearch" | "webFetch"; @@ -35,17 +54,17 @@ function resolveBundledCandidatePluginIds(params: { bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }): BundledCandidateResolution { - if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { - return { - pluginIds: [...new Set(params.onlyPluginIds)].toSorted((left, right) => - left.localeCompare(right), - ), - }; - } const resolvedConfig = params.contract === "webSearchProviders" ? resolveBundledWebSearchResolutionConfig(params).config : resolveBundledWebFetchResolutionConfig(params).config; + if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { + return { + pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, [ + ...new Set(params.onlyPluginIds), + ]).toSorted((left, right) => left.localeCompare(right)), + }; + } const candidates = resolveManifestDeclaredWebProviderCandidates({ contract: params.contract, configKey: params.configKey, @@ -56,7 +75,7 @@ function resolveBundledCandidatePluginIds(params: { origin: "bundled", }); return { - pluginIds: candidates.pluginIds ?? [], + pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []), ...(candidates.manifestRecords ? { manifestRecords: candidates.manifestRecords } : {}), }; } diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index b537b330366..5b310d1f164 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -182,6 +182,27 @@ function createManifestRegistryFixture(): PluginManifestRegistry { }; } +function createWebSearchManifestRecord(params: { + id: string; + providerId: string; +}): PluginManifestRegistry["plugins"][number] { + return { + id: params.id, + origin: "bundled", + rootDir: `/tmp/${params.id}`, + source: `/tmp/${params.id}/index.js`, + manifestPath: `/tmp/${params.id}/openclaw.plugin.json`, + channels: [], + providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], + skills: [], + hooks: [], + contracts: { webSearchProviders: [params.providerId] }, + }; +} + function expectLoaderCallCount(count: number) { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(count); } @@ -424,7 +445,7 @@ describe("resolvePluginWebSearchProviders", () => { const providers = resolvePluginWebSearchProviders({ config: { plugins: { - allow: ["perplexity"], + allow: ["brave"], }, }, mode: "setup", @@ -461,6 +482,42 @@ describe("resolvePluginWebSearchProviders", () => { expectScopedWebSearchCandidates(["brave"]); }); + it("keeps allowlist web-search provider discovery scoped to the configured allowlist", () => { + loadInstalledPluginManifestRegistryMock.mockReturnValueOnce({ + plugins: [ + createWebSearchManifestRecord({ id: "brave", providerId: "brave" }), + createWebSearchManifestRecord({ id: "google", providerId: "gemini" }), + ], + diagnostics: [], + }); + + const providers = resolvePluginWebSearchProviders({ + config: { + plugins: { + allow: ["brave"], + bundledDiscovery: "allowlist", + }, + }, + bundledAllowlistCompat: true, + env: createWebSearchEnv(), + workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, + }); + + expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]); + expectScopedWebSearchCandidates(["brave"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["brave"], + bundledDiscovery: "allowlist", + entries: { brave: { enabled: true } }, + }), + }), + }), + ); + }); + it("uses the active registry workspace for candidate discovery and snapshot loads when workspaceDir is omitted", () => { const env = createWebSearchEnv(); const rawConfig = createBraveAllowConfig(); diff --git a/src/process/exec.ts b/src/process/exec.ts index c0ed7128900..d0bd312f113 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -9,6 +9,7 @@ import { decodeWindowsOutputBuffer, resolveWindowsConsoleEncoding, } from "../infra/windows-encoding.js"; +import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { logDebug, logError } from "../logger.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveCommandStdio } from "./spawn-utils.js"; @@ -45,6 +46,13 @@ function buildCmdExeCommandLine(resolvedCommand: string, args: string[]): string return [escapeForCmdExe(resolvedCommand), ...args.map(escapeForCmdExe)].join(" "); } +function resolveTrustedWindowsCmdExe(): string { + if (process.platform !== "win32") { + return "cmd.exe"; + } + return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe"); +} + /** * On Windows, Node 18.20.2+ (CVE-2024-27980) rejects spawning .cmd/.bat directly * without shell, causing EINVAL. Resolve npm/npx to node + cli script so we @@ -107,7 +115,7 @@ function resolveChildProcessInvocation(params: { const useCmdWrapper = isWindowsBatchCommand(resolvedCommand); return { - command: useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand, + command: useCmdWrapper ? resolveTrustedWindowsCmdExe() : resolvedCommand, args: useCmdWrapper ? ["/d", "/s", "/c", buildCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))] : finalArgv.slice(1), @@ -333,6 +341,17 @@ export async function runCommandWithTimeout( return; } killIssuedByTimeout = true; + if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) { + try { + spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return; + } catch { + // Fall through to Node's direct child kill as a last resort. + } + } child.kill("SIGKILL"); }; diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 66065bec8be..7feccf18e81 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -3,6 +3,10 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetWindowsInstallRootsForTests, + getWindowsInstallRoots, +} from "../infra/windows-install-roots.js"; const { spawnMock, spawnSyncMock, execFileMock, execFilePromisifyMock } = vi.hoisted(() => { const execFilePromisifyMock = vi.fn(); @@ -101,6 +105,10 @@ function expectCmdWrappedInvocation(params: { expect(params.captured[2].windowsVerbatimArguments).toBe(true); } +function expectedTrustedCmdExe(): string { + return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe"); +} + async function expectShimmedWindowsCommandWithoutExitCodeSucceeds(params?: { killed?: boolean }) { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const child = createMockChild({ @@ -127,6 +135,10 @@ describe("windows command wrapper behavior", () => { }); beforeEach(() => { + // Stub the registry probe so install-root resolution is fully driven by + // process.env in tests; on real Windows runners the registry returns the + // canonical SystemRoot and would shadow the test's env setup. + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); spawnMock.mockReset(); spawnSyncMock.mockReset(); spawnSyncMock.mockReturnValue({ stdout: "Active code page: 936", stderr: "" }); @@ -157,7 +169,7 @@ describe("windows command wrapper behavior", () => { it("wraps .cmd commands via cmd.exe in runCommandWithTimeout", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -173,9 +185,91 @@ describe("windows command wrapper behavior", () => { } }); + it("ignores ComSpec when selecting the Windows command wrapper", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousComSpec = process.env.ComSpec; + const previousSystemRoot = process.env.SystemRoot; + process.env.ComSpec = "C:\\workspace\\evil\\cmd.exe"; + process.env.SystemRoot = "C:\\Windows"; + + spawnMock.mockImplementation( + (_command: string, _args: string[], _options: Record) => createMockChild(), + ); + + try { + const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 }); + expect(result.code).toBe(0); + const captured = spawnMock.mock.calls[0] as SpawnCall | undefined; + expectCmdWrappedInvocation({ + captured, + expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"), + }); + } finally { + if (previousComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = previousComSpec; + } + if (previousSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = previousSystemRoot; + } + platformSpy.mockRestore(); + } + }); + + it("rejects unsafe Windows root values when selecting the command wrapper", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousSystemRoot = process.env.SystemRoot; + const previousWindir = process.env.WINDIR; + + spawnMock.mockImplementation( + (_command: string, _args: string[], _options: Record) => createMockChild(), + ); + + try { + for (const unsafeRoot of [ + "\\\\evil\\share", + "C:\\Windows;C:\\evil", + "\\Windows", + "relative\\path", + ]) { + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); + // Set every install-root env source to the unsafe value so the + // resolver rejects each one and falls through to the safe default. + // Deleting WINDIR here is unreliable on real Windows runners, so + // overwrite it with the same rejected payload. + process.env.SystemRoot = unsafeRoot; + process.env.WINDIR = unsafeRoot; + spawnMock.mockClear(); + + const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 }); + expect(result.code).toBe(0); + const captured = spawnMock.mock.calls[0] as SpawnCall | undefined; + expectCmdWrappedInvocation({ + captured, + expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"), + }); + } + } finally { + if (previousSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = previousSystemRoot; + } + if (previousWindir === undefined) { + delete process.env.WINDIR; + } else { + process.env.WINDIR = previousWindir; + } + platformSpy.mockRestore(); + } + }); + it("wraps corepack.cmd via cmd.exe in runCommandWithTimeout", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -243,7 +337,7 @@ describe("windows command wrapper behavior", () => { it("falls back to npm.cmd when npm-cli.js is unavailable", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -297,7 +391,7 @@ describe("windows command wrapper behavior", () => { it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); execFileMock.mockImplementation( ( @@ -370,6 +464,37 @@ describe("windows command wrapper behavior", () => { } }); + it("kills the Windows process tree when the overall timeout elapses", async () => { + vi.useFakeTimers(); + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const child = createMockChild({ autoClose: false }); + const taskkillChild = createMockChild(); + + spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild); + + try { + const resultPromise = runCommandWithTimeout(["node", "idle.js"], { timeoutMs: 80 }); + + await vi.advanceTimersByTimeAsync(81); + expect(child.kill).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[1]?.[0]).toBe("taskkill"); + expect(spawnMock.mock.calls[1]?.[1]).toEqual(["/PID", "1234", "/T", "/F"]); + expect(spawnMock.mock.calls[1]?.[2]).toMatchObject({ + stdio: "ignore", + windowsHide: true, + }); + + child.emit("close", null, "SIGKILL"); + const result = await resultPromise; + expect(result.termination).toBe("timeout"); + expect(result.code).not.toBe(0); + } finally { + platformSpy.mockRestore(); + vi.useRealTimers(); + } + }); + it("decodes GBK stdout and stderr from runExec on Windows", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const stdout = Buffer.from([0xb2, 0xe2, 0xca, 0xd4]); diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 47a4b9a16ad..8fff506a62c 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -98,6 +98,66 @@ describe("external channel secret contract api", () => { expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); + it("loads dist/ secret-contract-api sidecars for compiled npm-published external channel plugins", () => { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract-dist", tempDirs); + fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "dist", "secret-contract-api.cjs"), + ` +module.exports = { + secretTargetRegistryEntries: [ + { + id: "channels.discord.token", + targetType: "channels.discord.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true + } + ], + collectRuntimeConfigAssignments(params) { + params.context.assignments.push({ + path: "channels.discord.token", + ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + expected: "string", + apply() {} + }); + } +}; +`, + "utf8", + ); + const record = { + id: "discord", + origin: "global", + channels: ["discord"], + channelConfigs: {}, + rootDir, + }; + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env: {}, + loadablePluginOrigins: new Map([["discord", "global"]]), + }); + + expect(api?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channels.discord.token", + }), + ]), + ); + expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + }); + it("skips external channel records outside the loadable plugin origin set", () => { const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); loadPluginMetadataSnapshotMock.mockReturnValue({ diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 9f97ff59e78..908b2d48e5b 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -87,16 +87,21 @@ function orderedContractApiExtensions(): readonly string[] { } function resolvePluginContractApiPath(rootDir: string): string | null { - for (const extension of orderedContractApiExtensions()) { - const candidate = path.join(rootDir, `secret-contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; - } - } - for (const extension of orderedContractApiExtensions()) { - const candidate = path.join(rootDir, `contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; + // Compiled npm-published plugins place their public artifacts under /dist/ + // (per package.json `openclaw.runtimeExtensions`), while flat-layout plugins keep + // them at /. Search both, preferring dist/ when running from built openclaw + // artifacts and rootDir/ when running from source. + const searchDirs = RUNNING_FROM_BUILT_ARTIFACT + ? [path.join(rootDir, "dist"), rootDir] + : [rootDir, path.join(rootDir, "dist")]; + for (const basename of ["secret-contract-api", "contract-api"]) { + for (const dir of searchDirs) { + for (const extension of orderedContractApiExtensions()) { + const candidate = path.join(dir, `${basename}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } } } return null; diff --git a/src/tasks/task-registry.maintenance.issue-60299.test.ts b/src/tasks/task-registry.maintenance.issue-60299.test.ts index 9996e776898..fd010a6c4c8 100644 --- a/src/tasks/task-registry.maintenance.issue-60299.test.ts +++ b/src/tasks/task-registry.maintenance.issue-60299.test.ts @@ -10,6 +10,7 @@ import { getDetachedTaskLifecycleRuntime, } from "./detached-task-runtime.js"; import { + getInspectableActiveTaskRestartBlockers, previewTaskRegistryMaintenance, reconcileInspectableTasks, resetTaskRegistryMaintenanceRuntimeForTests, @@ -250,6 +251,44 @@ describe("task-registry maintenance issue #60299", () => { expect(currentTasks.get(task.taskId)).toMatchObject({ status: "running" }); }); + it("only treats started non-ended running tasks as restart blockers", () => { + const now = Date.now(); + const activeRunning = makeStaleTask({ + taskId: "task-running-live", + runtime: "cli", + status: "running", + createdAt: now, + startedAt: now, + lastEventAt: now, + runId: "run-running-live", + }); + const queued = makeStaleTask({ + taskId: "task-queued-durable", + runtime: "acp", + status: "queued", + createdAt: now, + startedAt: undefined, + lastEventAt: now, + }); + const staleInconsistent = makeStaleTask({ + taskId: "task-running-ended", + runtime: "subagent", + status: "running", + endedAt: now - 1_000, + }); + + createTaskRegistryMaintenanceHarness({ tasks: [activeRunning, queued, staleInconsistent] }); + + expect(getInspectableActiveTaskRestartBlockers()).toEqual([ + expect.objectContaining({ + taskId: "task-running-live", + status: "running", + runtime: "cli", + runId: "run-running-live", + }), + ]); + }); + it("marks subagent tasks lost when their child session recovery is tombstoned", async () => { const childSessionKey = "agent:main:subagent:wedged-child"; const task = makeStaleTask({ diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index 7b437baf3b6..11f66456584 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -839,7 +839,7 @@ configureTaskAuditTaskProvider(reconcileInspectableTasks); export type ActiveTaskRestartBlocker = { taskId: string; - status: Extract; + status: Extract; runtime: TaskRecord["runtime"]; runId?: string; label?: string; @@ -849,13 +849,23 @@ export type ActiveTaskRestartBlocker = { function isActiveTaskRestartBlockerStatus( status: TaskStatus, ): status is ActiveTaskRestartBlocker["status"] { - return status === "queued" || status === "running"; + return status === "running"; +} + +function isTaskRestartBlocker(task: TaskRecord): task is TaskRecord & { + status: ActiveTaskRestartBlocker["status"]; +} { + // A task that is merely queued has not started user work yet; durable queued + // work can survive a gateway restart and should not indefinitely block one. + // Likewise, stale records that still say "running" but already have endedAt + // are registry inconsistencies, not live restart blockers. + return isActiveTaskRestartBlockerStatus(task.status) && !task.endedAt; } export function getInspectableActiveTaskRestartBlockers(): ActiveTaskRestartBlocker[] { const blockers: ActiveTaskRestartBlocker[] = []; for (const task of reconcileInspectableTasks()) { - if (!isActiveTaskRestartBlockerStatus(task.status)) { + if (!isTaskRestartBlocker(task)) { continue; } const blocker: ActiveTaskRestartBlocker = { diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 59ab00993f9..9a5f69cae6b 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -36,6 +36,41 @@ async function addCompileCacheProbe(fixtureRoot: string): Promise { ); } +async function waitForFile(filePath: string, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw new Error(`timed out waiting for ${filePath}`); +} + +async function waitUntil(check: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); +} + +function isProcessAlive(pid: number | undefined): boolean { + if (!pid) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function launcherEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env, ...extra }; delete env.NODE_COMPILE_CACHE; @@ -138,6 +173,103 @@ describe("openclaw launcher", () => { expect(result.stdout).toBe("cache:disabled;respawn:1"); }); + it.runIf(process.platform !== "win32")( + "forwards SIGTERM to source-checkout compile-cache respawn children", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-test-child";', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil(() => !isProcessAlive(respawnChildPid), 5000); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "exits after SIGTERM when the respawn child ignores the forwarded signal", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-ignore-test-child";', + 'process.on("SIGTERM", () => {});', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil( + () => !isProcessAlive(launcher.pid) && !isProcessAlive(respawnChildPid), + 5000, + ); + expect(isProcessAlive(launcher.pid)).toBe(false); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + it.runIf(process.platform !== "win32")( "respawns symlinked source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => { diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index b3034f6dcb1..89dcc202a2a 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -8,8 +8,10 @@ import { isPackageScriptOnlyChange, } from "../../scripts/changed-lanes.mjs"; import { + buildChangedCheckTestboxArgs, createChangedCheckChildEnv, createChangedCheckPlan, + shouldDelegateChangedCheckToTestbox, } from "../../scripts/check-changed.mjs"; import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js"; @@ -215,6 +217,44 @@ describe("scripts/changed-lanes", () => { }); }); + it("delegates local Testbox-mode changed gates before running locally", () => { + expect( + shouldDelegateChangedCheckToTestbox(["--base", "origin/main"], { + OPENCLAW_TESTBOX: "1", + PATH: "/usr/bin", + }), + ).toBe(true); + + expect(buildChangedCheckTestboxArgs(["--base", "origin/main", "--head", "HEAD"])).toEqual([ + "testbox:run", + "--", + "OPENCLAW_TESTBOX=1", + "OPENCLAW_TESTBOX_REMOTE_RUN=1", + "pnpm", + "check:changed", + "--base", + "origin/main", + "--head", + "HEAD", + ]); + }); + + it("does not delegate dry-run, CI, or already-remote changed gates", () => { + expect(shouldDelegateChangedCheckToTestbox(["--dry-run"], { OPENCLAW_TESTBOX: "1" })).toBe( + false, + ); + expect( + shouldDelegateChangedCheckToTestbox([], { OPENCLAW_TESTBOX: "1", GITHUB_ACTIONS: "true" }), + ).toBe(false); + expect(shouldDelegateChangedCheckToTestbox([], { OPENCLAW_TESTBOX: "1", CI: "1" })).toBe(false); + expect( + shouldDelegateChangedCheckToTestbox([], { + OPENCLAW_TESTBOX: "1", + OPENCLAW_TESTBOX_REMOTE_RUN: "1", + }), + ).toBe(false); + }); + it("runs changed-check lint lanes under the parent heavy-check lock", () => { const result = detectChangedLanes(["extensions/discord/src/index.ts"]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); diff --git a/test/scripts/codex-app-server-protocol-source.test.ts b/test/scripts/codex-app-server-protocol-source.test.ts new file mode 100644 index 00000000000..d9c47e93a42 --- /dev/null +++ b/test/scripts/codex-app-server-protocol-source.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveCodexAppServerProtocolSource } from "../../scripts/lib/codex-app-server-protocol-source.js"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const { createTempDir } = createScriptTestHarness(); +const originalOpenClawCodexRepo = process.env.OPENCLAW_CODEX_REPO; + +afterEach(() => { + if (originalOpenClawCodexRepo === undefined) { + delete process.env.OPENCLAW_CODEX_REPO; + } else { + process.env.OPENCLAW_CODEX_REPO = originalOpenClawCodexRepo; + } +}); + +describe("codex app-server protocol source resolver", () => { + it("uses OPENCLAW_CODEX_REPO when provided", async () => { + const root = createTempDir("openclaw-protocol-source-root-"); + const codexRepo = createTempDir("openclaw-protocol-source-codex-"); + createProtocolSchema(codexRepo); + process.env.OPENCLAW_CODEX_REPO = codexRepo; + + await expect(resolveCodexAppServerProtocolSource(root)).resolves.toEqual({ + codexRepo, + sourceRoot: path.join(codexRepo, "codex-rs/app-server-protocol/schema"), + }); + }); + + it("finds the primary checkout sibling from a git worktree", async () => { + const parentDir = createTempDir("openclaw-protocol-source-parent-"); + const primaryOpenClaw = path.join(parentDir, "openclaw"); + const codexRepo = path.join(parentDir, "codex"); + const worktreeRoot = createTempDir("openclaw-protocol-source-worktree-"); + fs.mkdirSync(path.join(primaryOpenClaw, ".git", "worktrees", "codex-harness"), { + recursive: true, + }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + fs.writeFileSync( + path.join(worktreeRoot, ".git"), + `gitdir: ${path.join(primaryOpenClaw, ".git", "worktrees", "codex-harness")}\n`, + ); + createProtocolSchema(codexRepo); + delete process.env.OPENCLAW_CODEX_REPO; + + await expect(resolveCodexAppServerProtocolSource(worktreeRoot)).resolves.toMatchObject({ + codexRepo, + sourceRoot: path.join(codexRepo, "codex-rs/app-server-protocol/schema"), + }); + }); +}); + +function createProtocolSchema(codexRepo: string): void { + fs.mkdirSync(path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript"), { + recursive: true, + }); +} diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index d4160b16557..c5b5cabf24a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -382,6 +382,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", "published-upgrade-survivor-2026.4.29-configured-plugin-installs", + "published-upgrade-survivor-2026.4.29-stale-source-plugin-shadow", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", ]); @@ -400,12 +401,14 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", "published-upgrade-survivor-2026.4.29-configured-plugin-installs", + "published-upgrade-survivor-2026.4.29-stale-source-plugin-shadow", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", "published-upgrade-survivor-2026.3.13", "published-upgrade-survivor-2026.3.13-feishu-channel", "published-upgrade-survivor-2026.3.13-bootstrap-persona", "published-upgrade-survivor-2026.3.13-configured-plugin-installs", + "published-upgrade-survivor-2026.3.13-stale-source-plugin-shadow", "published-upgrade-survivor-2026.3.13-tilde-log-path", "published-upgrade-survivor-2026.3.13-versioned-runtime-deps", ]); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index ba536f51e0d..a334d09bd06 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -16,6 +16,7 @@ import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-m import { agentOutputHasExpectedOkMarker, buildCrossOsReleaseSmokePluginAllowlist, + buildPackagedUpgradeUpdateArgs, buildReleaseOnboardArgs, buildWindowsDevUpdateToolchainCheckScript, buildWindowsFreshShellVersionCheckScript, @@ -37,6 +38,7 @@ import { CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS, isImmutableReleaseRef, isRecoverableWindowsPackagedUpgradeSwapCleanupFailure, + isRecoverableWindowsPackagedUpgradeTimeoutError, looksLikeReleaseVersionRef, normalizeRequestedRef, normalizeWindowsCommandShimPath, @@ -232,6 +234,19 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ]); }); + it("keeps packaged-upgrade release updates out of service restart flow", () => { + const args = buildPackagedUpgradeUpdateArgs("http://127.0.0.1:49152/openclaw-current.tgz"); + expect(args.slice(0, 6)).toEqual([ + "update", + "--tag", + "http://127.0.0.1:49152/openclaw-current.tgz", + "--yes", + "--json", + "--no-restart", + ]); + expect(args.at(-2)).toBe("--timeout"); + }); + it("keeps cross-OS live smoke agent turns on GPT-5-safe timeouts and minimal context", () => { const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8"); const providerOverride = "models.providers.${params.providerConfig.extensionId}"; @@ -730,6 +745,29 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ).toBe(true); }); + it("recognizes the shipped Windows updater packaged-upgrade timeout", () => { + const error = new Error( + "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-2026.5.4-beta.1.tgz --yes --json --no-restart --timeout 1500", + ); + + expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true); + expect( + isRecoverableWindowsPackagedUpgradeTimeoutError( + new Error( + "Command timed out: C:\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-current.tgz --yes --json --timeout 1500", + ), + "win32", + ), + ).toBe(true); + expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "linux")).toBe(false); + expect( + isRecoverableWindowsPackagedUpgradeTimeoutError( + new Error("Command timed out: node openclaw.mjs update --tag openclaw@beta"), + "win32", + ), + ).toBe(false); + }); + it("skips the packaged upgrade status probe after the Windows fallback install", () => { expect( shouldRunPackagedUpgradeStatusProbe({ diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b4ac1ca9d04..3e1fd42f7de 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -253,6 +253,11 @@ describe("package artifact reuse", () => { expect(publishedUpgradeSurvivor).toContain( "assert_legacy_plugin_dependency_debris_before_doctor", ); + expect(publishedUpgradeSurvivor.indexOf("phase seed-source-only-plugin-shadow")).toBeLessThan( + publishedUpgradeSurvivor.indexOf("phase assert-baseline"), + ); + expect(publishedUpgradeSurvivor).toContain('"id": "opik-openclaw"'); + expect(publishedUpgradeSurvivor).toContain('"configSchema": {'); expect(publishedUpgradeSurvivor).toContain( "Legacy plugin dependency debris was already removed before doctor", ); @@ -523,8 +528,12 @@ describe("package artifact reuse", () => { expect(workflow).toContain( "docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update", ); - expect(workflow).toContain("published_upgrade_survivor_baselines: all-since-2026.4.23"); - expect(workflow).toContain("published_upgrade_survivor_scenarios: reported-issues"); + expect(workflow).toContain( + "published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'all-since-2026.4.23' || '' }}", + ); + expect(workflow).toContain( + "published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}", + ); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating", @@ -546,7 +555,7 @@ describe("package artifact reuse", () => { "contains(fromJSON('[\"all\",\"cross-os\",\"package\"]'), needs.resolve_target.outputs.rerun_group) || (needs.resolve_target.outputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')", ); expect(workflow).toContain( - "contains(fromJSON('[\"all\",\"live-e2e\"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.live_suite_filter == ''", + "(needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true')) && needs.resolve_target.outputs.live_suite_filter == ''", ); expect(workflow).toContain("- live-e2e"); expect(workflow).toContain("- qa-live"); @@ -575,6 +584,18 @@ describe("package artifact reuse", () => { ); }); + it("keeps Slack live QA disabled in CI until credentials are provisioned", () => { + const releaseWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); + const qaWorkflow = readFileSync(QA_LIVE_TRANSPORTS_WORKFLOW, "utf8"); + + expect(releaseWorkflow).toContain("qa_live_slack_enabled=false"); + expect(releaseWorkflow).toContain( + "RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }}", + ); + expect(releaseWorkflow).toContain("vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'"); + expect(qaWorkflow).toContain("if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'"); + }); + it("names package acceptance Telegram as artifact-backed package validation", () => { const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); @@ -656,7 +677,7 @@ describe("package artifact reuse", () => { "Focused `npm-telegram` reruns require `npm_telegram_package_spec`", ); expectTextToIncludeAll(fullReleaseDocs, [ - "full pre-publish candidate", + "pre-publish candidate", "silently skip that", "Telegram package lane", "| `npm-telegram` | Published-package Telegram E2E; requires `npm_telegram_package_spec`. |", diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 0184e6d0f62..744fea8792c 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -1,4 +1,4 @@ -import { readFileSync as readFileSyncOriginal } from "node:fs"; +import { existsSync as existsSyncOriginal, readFileSync as readFileSyncOriginal } from "node:fs"; import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -51,6 +51,13 @@ async function writePluginPackage( } describe("bundled plugin postinstall", () => { + function existsSyncWithoutGlobalCompileCache(value: string) { + if (path.resolve(value) === path.join(tmpdir(), "node-compile-cache")) { + return false; + } + return existsSyncOriginal(value); + } + it("recognizes direct invocation through symlinked temp prefixes", () => { const realpathSync = vi.fn((value: string) => value.replace(/^\/var\/folders\//u, "/private/var/folders/"), @@ -448,6 +455,7 @@ describe("bundled plugin postinstall", () => { STATE_DIRECTORY: systemState, }, packageRoot, + existsSync: existsSyncWithoutGlobalCompileCache, log, }); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index c8fe9daeb42..319dd3d3203 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -49,6 +49,27 @@ describe("run-oxlint", () => { hadExplicitTargets: true, remainingExplicitTargets: 1, skippedTargets: ["ui", "packages"], + skippedConfigs: [], + }); + }); + + it("filters tracked tsconfig files missing from sparse checkouts", () => { + const result = filterSparseMissingOxlintTargets( + ["--tsconfig", "config/tsconfig/oxlint.core.json", "src"], + { + fileExists: (target: string) => target.endsWith("/src"), + isSparseCheckoutEnabled: () => true, + isTrackedPath: ({ target }: { target: string }) => + target === "config/tsconfig/oxlint.core.json", + }, + ); + + expect(result).toEqual({ + args: ["src"], + hadExplicitTargets: true, + remainingExplicitTargets: 1, + skippedTargets: [], + skippedConfigs: ["config/tsconfig/oxlint.core.json"], }); }); @@ -63,6 +84,7 @@ describe("run-oxlint", () => { args: ["src", "typo"], remainingExplicitTargets: 2, skippedTargets: [], + skippedConfigs: [], }); }); }); diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 707b73e3de7..2d09d7527ed 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -145,6 +145,34 @@ describe("runtime postbuild static assets", () => { await expect(fs.stat(path.join(distDir, "install.runtime.js"))).rejects.toThrow(); }); + it("writes a stable plugin install runtime alias when install runtimes collide", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "install.runtime-Aaa111.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-Bbb222.js"), + "export const daemonInstall = true;\n", + "utf8", + ); + + writeStableRootRuntimeAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "install.runtime.js"), "utf8")).toBe( + 'export * from "./install.runtime-Aaa111.js";\n', + ); + }); + it("keeps stable aliases when one colliding root runtime chunk re-exports the implementation", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -294,6 +322,47 @@ describe("runtime postbuild static assets", () => { ); }); + it("rewrites plugin install runtime imports to stable aliases when install runtimes collide", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "install.runtime-Aaa111.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-Bbb222.js"), + "export const daemonInstall = true;\n", + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install-OldHash.js"), + [ + 'const pluginRuntime = () => import("./install.runtime-Aaa111.js");', + 'const daemonRuntime = () => import("./install.runtime-Bbb222.js");', + "", + ].join("\n"), + "utf8", + ); + + rewriteRootRuntimeImportsToStableAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "install-OldHash.js"), "utf8")).toBe( + [ + 'const pluginRuntime = () => import("./install.runtime.js");', + 'const daemonRuntime = () => import("./install.runtime-Bbb222.js");', + "", + ].join("\n"), + ); + }); + it("leaves stable alias files pointing at their hashed runtime chunks", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -330,6 +399,22 @@ describe("runtime postbuild static assets", () => { 'export * from "./provider-dispatcher.runtime-NewHash.js";\n', "utf8", ); + await fs.writeFile( + path.join(distDir, "install.runtime-NewPluginHash.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-OtherHash.js"), + "export const installFromValidatedNpmSpecArchive = true;\n", + "utf8", + ); writeLegacyRootRuntimeCompatAliases({ rootDir }); @@ -342,6 +427,33 @@ describe("runtime postbuild static assets", () => { expect(await fs.readFile(path.join(distDir, "provider-dispatcher-6EQEtc-t.js"), "utf8")).toBe( 'export * from "./provider-dispatcher.runtime.js";\n', ); + expect(await fs.readFile(path.join(distDir, "install.runtime-D7SL02B2.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-Deq6Beal.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-BRVACueI.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-DX8jy7tN.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-D6FSd9v2.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-DQ-ui3nL.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-Xom5hOHq.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-tnhNR9WW.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-CNHwKOIb.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); }); it("writes compatibility aliases for previous gateway shutdown chunk names", async () => { diff --git a/test/scripts/test-live-codex-harness-docker.test.ts b/test/scripts/test-live-codex-harness-docker.test.ts new file mode 100644 index 00000000000..ff1ab0da32f --- /dev/null +++ b/test/scripts/test-live-codex-harness-docker.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = path.resolve( + import.meta.dirname, + "../../scripts/test-live-codex-harness-docker.sh", +); + +describe("scripts/test-live-codex-harness-docker.sh", () => { + it("mounts cache and npm tool dirs outside the bind-mounted Docker home", () => { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain('DOCKER_CACHE_CONTAINER_DIR="/tmp/openclaw-cache"'); + expect(script).toContain('DOCKER_CLI_TOOLS_CONTAINER_DIR="/tmp/openclaw-npm-global"'); + expect(script).toContain("openclaw_live_codex_harness_is_ci()"); + expect(script).toContain('[[ -n "${CI:-}" && "${CI:-}" != "false" ]]'); + expect(script).toContain('-e XDG_CACHE_HOME="$DOCKER_CACHE_CONTAINER_DIR"'); + expect(script).toContain('-e NPM_CONFIG_PREFIX="$DOCKER_CLI_TOOLS_CONTAINER_DIR"'); + expect(script).toContain('chmod 0777 "$CLI_TOOLS_DIR" "$CACHE_HOME_DIR" || true'); + expect(script).toContain('-v "$CACHE_HOME_DIR":"$DOCKER_CACHE_CONTAINER_DIR"'); + expect(script).toContain('-v "$CLI_TOOLS_DIR":"$DOCKER_CLI_TOOLS_CONTAINER_DIR"'); + expect(script).not.toContain('-v "$CACHE_HOME_DIR":/home/node/.cache'); + expect(script).not.toContain('-v "$CLI_TOOLS_DIR":/home/node/.npm-global'); + }); + + it("fails before Docker build when codex-auth has no host auth file", () => { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain( + "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=codex-auth requires ~/.codex/auth.json before building the live Docker image", + ); + expect(script).toContain( + "If this is a Testbox/API-key run, set OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key and run through openclaw-testbox-env.", + ); + expect(script.indexOf("requires ~/.codex/auth.json before building")).toBeLessThan( + script.indexOf('OPENCLAW_LIVE_DOCKER_REPO_ROOT="$ROOT_DIR"'), + ); + }); +});