mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
Merge branch 'main' into fix/dashboard-tokenized-url-fallback-72081
This commit is contained in:
40
.github/workflows/full-release-validation.yml
vendored
40
.github/workflows/full-release-validation.yml
vendored
@@ -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
|
||||
|
||||
47
.github/workflows/openclaw-release-checks.yml
vendored
47
.github/workflows/openclaw-release-checks.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/plugin-clawhub-release.yml
vendored
2
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -219,3 +219,4 @@ extensions/**/.openclaw-runtime-deps-stamp.json
|
||||
|
||||
# Output dir for scripts/run-opengrep.sh (local opengrep scans)
|
||||
/.opengrep-out/
|
||||
/.crabbox-artifacts
|
||||
|
||||
63
CHANGELOG.md
63
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 <spec>` 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 `<rootDir>/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 <id>] [--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 <provider/model> --runtime <runtime>`. Thanks @vincentkoc.
|
||||
- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `<provider>: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 <n|all>` 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 <plugin>` 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 <duration>`, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<you>/.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
10
docs/ci.md
10
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
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ openclaw daemon uninstall
|
||||
|
||||
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
|
||||
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
|
||||
- `restart`: `--force`, `--wait <duration>`, `--json`
|
||||
- `restart`: `--safe`, `--force`, `--wait <duration>`, `--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
|
||||
|
||||
|
||||
@@ -105,6 +105,16 @@ openclaw gateway run
|
||||
Raw stream jsonl path.
|
||||
</ParamField>
|
||||
|
||||
## 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.
|
||||
|
||||
<Warning>
|
||||
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
|
||||
</Warning>
|
||||
|
||||
@@ -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 <n>` 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 <id>`: one configured agent store
|
||||
- `--all-agents`: aggregate all configured agent stores
|
||||
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
|
||||
- `--limit <n|all>`: 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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
|
||||
@@ -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 <provider> to list models.
|
||||
Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge
|
||||
```
|
||||
|
||||
<Warning>
|
||||
@@ -131,6 +132,8 @@ This happens **before** a normal reply is generated, so the message can feel lik
|
||||
|
||||
</Warning>
|
||||
|
||||
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 <provider>`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="2. Legacy config key migrations">
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <path>` 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`,
|
||||
|
||||
@@ -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 <provider> 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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAI-only request shaping">
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
|
||||
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**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.<br />**Rerun:** `rerun_group=ci`. |
|
||||
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
|
||||
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, live/E2E suites, Docker release-path chunks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
|
||||
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**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`.<br />**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. |
|
||||
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**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.<br />**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. |
|
||||
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
|
||||
| Stage | Details |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Target resolution | **Job:** `Resolve target ref`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
|
||||
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**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.<br />**Rerun:** `rerun_group=ci`. |
|
||||
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
|
||||
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**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.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
|
||||
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**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`.<br />**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. |
|
||||
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**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.<br />**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. |
|
||||
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**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`<br />**Backing workflow:** none<br />**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.<br />**Rerun:** `rerun_group=release-checks`. |
|
||||
| Package artifact | **Job:** `Prepare release package artifact`<br />**Backing workflow:** none<br />**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.<br />**Rerun:** the affected package, cross-OS, or live/E2E group. |
|
||||
| Install smoke | **Job:** `Run install smoke`<br />**Backing workflow:** `Install Smoke`<br />**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.<br />**Rerun:** `rerun_group=install-smoke`. |
|
||||
| Cross-OS | **Job:** `cross_os_release_checks`<br />**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`<br />**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.<br />**Rerun:** `rerun_group=cross-os`. |
|
||||
| Repo and live E2E | **Job:** `Run repo/live E2E validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**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`.<br />**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. |
|
||||
| Docker release path | **Job:** `Run Docker release-path validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** release-path Docker chunks against the shared package artifact.<br />**Rerun:** `rerun_group=live-e2e`. |
|
||||
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**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.<br />**Rerun:** `rerun_group=package`. |
|
||||
| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`<br />**Backing workflow:** direct jobs<br />**Tests:** candidate and baseline agentic parity packs, then the parity report.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
|
||||
| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`<br />**Backing workflow:** direct job<br />**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
|
||||
| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`<br />**Backing workflow:** direct job<br />**Tests:** live Telegram QA with Convex CI credential leases.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
|
||||
| Release verifier | **Job:** `Verify release checks`<br />**Backing workflow:** none<br />**Tests:** required release-check jobs for the selected rerun group.<br />**Rerun:** rerun after focused child jobs pass. |
|
||||
| Stage | Details |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Release target | **Job:** `Resolve target ref`<br />**Backing workflow:** none<br />**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.<br />**Rerun:** `rerun_group=release-checks`. |
|
||||
| Package artifact | **Job:** `Prepare release package artifact`<br />**Backing workflow:** none<br />**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.<br />**Rerun:** the affected package, cross-OS, or live/E2E group. |
|
||||
| Install smoke | **Job:** `Run install smoke`<br />**Backing workflow:** `Install Smoke`<br />**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.<br />**Rerun:** `rerun_group=install-smoke`. |
|
||||
| Cross-OS | **Job:** `cross_os_release_checks`<br />**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`<br />**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.<br />**Rerun:** `rerun_group=cross-os`. |
|
||||
| Repo and live E2E | **Job:** `Run repo/live E2E validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**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`.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. |
|
||||
| Docker release path | **Job:** `Run Docker release-path validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** release-path Docker chunks against the shared package artifact.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`. |
|
||||
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**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.<br />**Rerun:** `rerun_group=package`. |
|
||||
| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`<br />**Backing workflow:** direct jobs<br />**Tests:** candidate and baseline agentic parity packs, then the parity report.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
|
||||
| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`<br />**Backing workflow:** direct job<br />**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
|
||||
| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`<br />**Backing workflow:** direct job<br />**Tests:** live Telegram QA with Convex CI credential leases.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
|
||||
| Release verifier | **Job:** `Verify release checks`<br />**Backing workflow:** none<br />**Tests:** required release-check jobs for the selected rerun group.<br />**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 |
|
||||
| --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.\<id\>` | 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.\<id\>` | 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,
|
||||
|
||||
@@ -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.<provider>.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)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -211,8 +211,8 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
/<<<EXTERNAL_UNTRUSTED_CONTENT\b[^>]*>>>[\s\S]*?<<<END_EXTERNAL_UNTRUSTED_CONTENT\b[^>]*>>>/g,
|
||||
" ",
|
||||
);
|
||||
}
|
||||
|
||||
function stripJsonFences(text: string): string {
|
||||
return text.replace(/```(?:json)?\s*[\s\S]*?```/gi, " ");
|
||||
}
|
||||
|
||||
function stripActiveMemoryXmlBlocks(text: string): string {
|
||||
return text.replace(/<active_memory_plugin>[\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<ActiveRecallResult> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"undici": "8.1.0"
|
||||
"undici": "8.2.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,11 @@ type RouteWithTabParams<T> = {
|
||||
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<T>;
|
||||
};
|
||||
|
||||
@@ -119,6 +124,17 @@ export async function withRouteTabContext<T>(
|
||||
}
|
||||
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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> = {
|
||||
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<T>;
|
||||
};
|
||||
|
||||
@@ -182,6 +207,7 @@ export async function withPlaywrightRouteContext<T>(
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> }) => {
|
||||
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<void>;
|
||||
}) => {
|
||||
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()),
|
||||
});
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<T>(params: {
|
||||
rootDir: string;
|
||||
run: (relativePath: string) => Promise<T>;
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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>,
|
||||
): 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>,
|
||||
): 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<string>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<unknown>,
|
||||
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<void>((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");
|
||||
|
||||
@@ -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<T>(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) {
|
||||
|
||||
@@ -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 <thread>` 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 = [
|
||||
|
||||
@@ -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<AgentMessage, { role: "user" | "assistant" }>;
|
||||
|
||||
// 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<string> {
|
||||
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)}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AgentMessage, { role: "user" | "assistant" }>;
|
||||
|
||||
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<T extends AgentMessage>(message: T, identity: string): T {
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
const existing = record.__openclaw;
|
||||
const baseMeta =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)[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<void> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> {
|
||||
|
||||
@@ -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<JsonValue | undefined>,
|
||||
): 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 <thread-id>";
|
||||
}
|
||||
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 || "<unknown>";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<PluginCommandResult> {
|
||||
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 <path>] [--model <model>] [--provider <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<ReturnType<PluginCommandContext["requestConversationBinding"]>>;
|
||||
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<string> {
|
||||
const [threadId] = args;
|
||||
const normalizedThreadId = threadId?.trim();
|
||||
if (!normalizedThreadId) {
|
||||
if (!normalizedThreadId || args.length !== 1) {
|
||||
return "Usage: /codex resume <thread-id>";
|
||||
}
|
||||
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<string> {
|
||||
if (args.length > 1) {
|
||||
return "Usage: /codex model <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 <model>";
|
||||
return binding?.model
|
||||
? `Codex model: ${formatCodexDisplayText(binding.model)}`
|
||||
: "Usage: /codex model <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<string> {
|
||||
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<string> {
|
||||
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 <token>`,
|
||||
`Usage: ${commandPrefix} cancel <token>`,
|
||||
].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<string> {
|
||||
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;
|
||||
|
||||
@@ -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 <thread-id>",
|
||||
});
|
||||
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 <token>",
|
||||
"Usage: /codex diagnostics cancel <token>",
|
||||
].join("\n"),
|
||||
});
|
||||
await expect(
|
||||
handleCodexCommand(createContext(`diagnostics cancel ${token} extra`, sessionFile), {
|
||||
deps,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: [
|
||||
"Usage: /codex diagnostics [note]",
|
||||
"Usage: /codex diagnostics confirm <token>",
|
||||
"Usage: /codex diagnostics cancel <token>",
|
||||
].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 <thread-id>",
|
||||
);
|
||||
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 <path>] [--model <model>] [--provider <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 <path>] [--model <model>] [--provider <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 <path>] [--model <model>] [--provider <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 <path>] [--model <model>] [--provider <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 <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 <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 {
|
||||
|
||||
@@ -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<CodexCommandDeps> } = {},
|
||||
): Promise<PluginCommandResult> {
|
||||
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))}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
const assistantTextByItem = new Map<string, string>();
|
||||
const assistantOrder: string[] = [];
|
||||
const pendingNotificationsByTurnId = new Map<string, CodexServerNotification[]>();
|
||||
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<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<typeof HttpsAgent>
|
||||
| InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
|
||||
const registrationPromises = new WeakMap<discordGateway.GatewayPlugin, Promise<void>>();
|
||||
type DiscordGatewayClient = Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
|
||||
type GatewayPluginTestingOptions = {
|
||||
@@ -100,7 +106,7 @@ function createGatewayPlugin(params: {
|
||||
gatewayInfoTimeoutMs: number;
|
||||
fetchImpl: DiscordGatewayFetch;
|
||||
fetchInit?: DiscordGatewayFetchInit;
|
||||
wsAgent?: InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
|
||||
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<typeof httpsProxyAgent.HttpsProxyAgent<string>> | undefined;
|
||||
let wsAgent: DiscordGatewayWebSocketAgent = new HttpsAgent({
|
||||
lookup: discordDnsLookup,
|
||||
});
|
||||
|
||||
if (proxy) {
|
||||
try {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<GatewayPlugin>("gateway") as MutableDiscordGateway | undefined;
|
||||
await waitForDiscordGatewayPluginRegistration(gateway);
|
||||
const gatewaySupervisor = params.createGatewaySupervisor({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user