mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
196 Commits
codex/8606
...
v2026.5.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097daf917d | ||
|
|
9798e95786 | ||
|
|
7d6ba4c602 | ||
|
|
2e6916571e | ||
|
|
6104c0cc79 | ||
|
|
9ce0f4a6d0 | ||
|
|
6a8c966546 | ||
|
|
7286513910 | ||
|
|
df43026427 | ||
|
|
ad5efe9e9e | ||
|
|
21aa8b5bd7 | ||
|
|
31f7eff71a | ||
|
|
55791151bf | ||
|
|
09d3b643a1 | ||
|
|
cd0d3a1d84 | ||
|
|
f158cc77db | ||
|
|
50969b50a3 | ||
|
|
0958ee1e94 | ||
|
|
177ca55589 | ||
|
|
6d421be4f1 | ||
|
|
dfc5746b53 | ||
|
|
3729ea5769 | ||
|
|
c2ae792c32 | ||
|
|
fab7ad8dc9 | ||
|
|
bb70396682 | ||
|
|
fe3460c895 | ||
|
|
3f156b30da | ||
|
|
885bdb8a4b | ||
|
|
f2182ac3ba | ||
|
|
a79e4d81f3 | ||
|
|
03d9f84d84 | ||
|
|
64b9eaacdb | ||
|
|
e07e2e6e16 | ||
|
|
79101b50ff | ||
|
|
89738fa9ef | ||
|
|
6e3b13e14c | ||
|
|
4e0fd50012 | ||
|
|
e0dfdb9184 | ||
|
|
e91d682b22 | ||
|
|
c66a8bfa3b | ||
|
|
93ce22b632 | ||
|
|
2f8db8ea03 | ||
|
|
59d7f03eaa | ||
|
|
833b9627b5 | ||
|
|
f09d067a5e | ||
|
|
e714cacb87 | ||
|
|
9363bd2706 | ||
|
|
ddb205822e | ||
|
|
f9652c7b09 | ||
|
|
1d3eb9f1c7 | ||
|
|
a18a8d5b72 | ||
|
|
2b8ddd77b5 | ||
|
|
fbc8ed2da9 | ||
|
|
e15754c7a7 | ||
|
|
f40326301a | ||
|
|
91a8fdd079 | ||
|
|
a03a6e3f13 | ||
|
|
0904505071 | ||
|
|
86885ccc24 | ||
|
|
015ec8baf2 | ||
|
|
7e5943a62b | ||
|
|
049573f276 | ||
|
|
944b21e6b8 | ||
|
|
2fea235f62 | ||
|
|
c70e4fea7b | ||
|
|
f561768a7c | ||
|
|
d1b5b243ae | ||
|
|
89f641a005 | ||
|
|
5cd8ef8f91 | ||
|
|
e0698db057 | ||
|
|
dc7d9a9168 | ||
|
|
f730f969b1 | ||
|
|
f5393b6be9 | ||
|
|
bde31cbb7b | ||
|
|
38a8068954 | ||
|
|
a7fdf60d0d | ||
|
|
b8bc73aea2 | ||
|
|
f86e092e2d | ||
|
|
c4292e053d | ||
|
|
e2c32243e9 | ||
|
|
ab8b4eacce | ||
|
|
5f9249b059 | ||
|
|
485dbeb5ba | ||
|
|
1f18e8864d | ||
|
|
bd840af600 | ||
|
|
7470221401 | ||
|
|
bde26a965c | ||
|
|
a44012c087 | ||
|
|
2cdd69a303 | ||
|
|
f084cf2335 | ||
|
|
b6c34ab055 | ||
|
|
95582f03de | ||
|
|
d3950fbd64 | ||
|
|
2906fb833f | ||
|
|
a6d878376b | ||
|
|
38cb3d0041 | ||
|
|
bdbc224841 | ||
|
|
02ae92cade | ||
|
|
9375a72d56 | ||
|
|
7e8b2d48ce | ||
|
|
a56727d8fe | ||
|
|
4165893843 | ||
|
|
bf0a5fe5dc | ||
|
|
033d74ce2e | ||
|
|
d0796a9b13 | ||
|
|
1b572f2fe2 | ||
|
|
1415c06fc4 | ||
|
|
8b840b28e5 | ||
|
|
c949a35534 | ||
|
|
159d6a610b | ||
|
|
2d088b2c55 | ||
|
|
e0c744869c | ||
|
|
d513271bb6 | ||
|
|
0f24a78b24 | ||
|
|
70bce0d9ed | ||
|
|
a50d65f63a | ||
|
|
6a64f05bcc | ||
|
|
32487b4906 | ||
|
|
798ba972ea | ||
|
|
a8f03295c4 | ||
|
|
552c575a5c | ||
|
|
f96e0ff23c | ||
|
|
2873631873 | ||
|
|
9ca2f075c4 | ||
|
|
6a8dac800d | ||
|
|
e427846225 | ||
|
|
a57b646af4 | ||
|
|
0e324f6d2b | ||
|
|
3c62ebddb6 | ||
|
|
e88f8476c1 | ||
|
|
daff6d8797 | ||
|
|
6926481646 | ||
|
|
55b7d0c181 | ||
|
|
05b23f8fb8 | ||
|
|
b27f0899e4 | ||
|
|
d1246255d0 | ||
|
|
bdbdd17b90 | ||
|
|
4acf72cf03 | ||
|
|
e126e74a58 | ||
|
|
a2abb63f6e | ||
|
|
a25650e044 | ||
|
|
13c44a2432 | ||
|
|
1ffb4126dc | ||
|
|
2b7e1ab1e4 | ||
|
|
738a8f574e | ||
|
|
410e009d54 | ||
|
|
80c9015b56 | ||
|
|
c612dd9529 | ||
|
|
a97399a25c | ||
|
|
87d6b868b7 | ||
|
|
e309a8732e | ||
|
|
b28a70369d | ||
|
|
c5140a09e1 | ||
|
|
3c3cef1785 | ||
|
|
7c606f834c | ||
|
|
fec979cf96 | ||
|
|
b258035a8d | ||
|
|
f6c919cb9a | ||
|
|
f810103509 | ||
|
|
2aa4dc03c1 | ||
|
|
a0a2eeefb9 | ||
|
|
4182fbaad0 | ||
|
|
ab9893a4f5 | ||
|
|
d7472ab015 | ||
|
|
95434cd497 | ||
|
|
a90a5fc4d1 | ||
|
|
cc46ca9bee | ||
|
|
9fd79d7b69 | ||
|
|
b251a74b1c | ||
|
|
fdb6e92ff5 | ||
|
|
7f0fc0bab4 | ||
|
|
8f212d0b6f | ||
|
|
b86c387d6c | ||
|
|
23dc2bfcd8 | ||
|
|
985bc40711 | ||
|
|
eab66220f8 | ||
|
|
22a6717e11 | ||
|
|
a4743ad180 | ||
|
|
1df4df6eed | ||
|
|
ca8bc5500d | ||
|
|
930046df04 | ||
|
|
03e4b035f1 | ||
|
|
6115eada6d | ||
|
|
84a2060a64 | ||
|
|
bc6090502c | ||
|
|
d3a8a45119 | ||
|
|
b12cd4358d | ||
|
|
1824464bf2 | ||
|
|
6eebba3920 | ||
|
|
7b544a7976 | ||
|
|
441041f92d | ||
|
|
7284608461 | ||
|
|
56d96b3b8d | ||
|
|
41bf26ede3 | ||
|
|
6820d18160 | ||
|
|
e6fb7aa1a8 |
@@ -137,8 +137,10 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-6' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
|
||||
|
||||
|
||||
118
.github/workflows/full-release-validation.yml
vendored
118
.github/workflows/full-release-validation.yml
vendored
@@ -297,6 +297,7 @@ jobs:
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -396,6 +397,7 @@ jobs:
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -504,6 +506,7 @@ jobs:
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -726,6 +729,7 @@ jobs:
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
summary:
|
||||
@@ -735,62 +739,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Request private evidence update
|
||||
env:
|
||||
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
|
||||
TARGET_REF: ${{ inputs.ref }}
|
||||
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
|
||||
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
|
||||
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_id="${TARGET_REF#refs/tags/}"
|
||||
release_id="${release_id#v}"
|
||||
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
|
||||
release_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "::error::Could not derive release evidence id from target ref '${TARGET_REF}'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
payload="$(
|
||||
jq -cn \
|
||||
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
|
||||
--arg release_id "$release_id" \
|
||||
--arg release_ref "$TARGET_REF" \
|
||||
--arg package_spec "$PACKAGE_SPEC" \
|
||||
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
|
||||
'{
|
||||
event_type: "openclaw_full_release_validation_completed",
|
||||
client_payload: {
|
||||
full_validation_run_id: $full_validation_run_id,
|
||||
release_id: $release_id,
|
||||
release_ref: $release_ref,
|
||||
package_spec: $package_spec,
|
||||
notes: $notes
|
||||
}
|
||||
}'
|
||||
)"
|
||||
|
||||
curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
-d "$payload"
|
||||
|
||||
- name: Verify child workflow results
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -969,3 +917,61 @@ jobs:
|
||||
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
|
||||
|
||||
exit "$failed"
|
||||
|
||||
- name: Request private evidence update
|
||||
env:
|
||||
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
|
||||
TARGET_REF: ${{ inputs.ref }}
|
||||
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
|
||||
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
|
||||
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_id="${TARGET_REF#refs/tags/}"
|
||||
release_id="${release_id#v}"
|
||||
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
|
||||
release_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
payload="$(
|
||||
jq -cn \
|
||||
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
|
||||
--arg release_id "$release_id" \
|
||||
--arg release_ref "$TARGET_REF" \
|
||||
--arg package_spec "$PACKAGE_SPEC" \
|
||||
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
|
||||
'{
|
||||
event_type: "openclaw_full_release_validation_completed",
|
||||
client_payload: {
|
||||
full_validation_run_id: $full_validation_run_id,
|
||||
release_id: $release_id,
|
||||
release_ref: $release_ref,
|
||||
package_spec: $package_spec,
|
||||
notes: $notes
|
||||
}
|
||||
}'
|
||||
)"
|
||||
|
||||
if ! curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
fi
|
||||
|
||||
42
.github/workflows/install-smoke.yml
vendored
42
.github/workflows/install-smoke.yml
vendored
@@ -316,7 +316,19 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if timeout 1200s docker pull "$IMAGE_REF"; then
|
||||
exit 0
|
||||
fi
|
||||
status=$?
|
||||
if [ "$attempt" = "2" ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
env:
|
||||
@@ -421,7 +433,19 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if timeout 1200s docker pull "$IMAGE_REF"; then
|
||||
exit 0
|
||||
fi
|
||||
status=$?
|
||||
if [ "$attempt" = "2" ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
@@ -488,7 +512,19 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if timeout 1200s docker pull "$IMAGE_REF"; then
|
||||
exit 0
|
||||
fi
|
||||
status=$?
|
||||
if [ "$attempt" = "2" ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Setup Node environment for Bun smoke
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -100,7 +100,7 @@ jobs:
|
||||
run_package_telegram_e2e:
|
||||
name: Run package Telegram E2E
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
environment: qa-live-shared
|
||||
permissions:
|
||||
actions: read
|
||||
|
||||
@@ -433,6 +433,10 @@ jobs:
|
||||
add_profile_suite native-live-extensions-media-music-google "full"
|
||||
add_profile_suite native-live-extensions-media-music-minimax "full"
|
||||
add_profile_suite native-live-extensions-media-video "full"
|
||||
add_profile_suite native-live-extensions-media-video-a "full"
|
||||
add_profile_suite native-live-extensions-media-video-b "full"
|
||||
add_profile_suite native-live-extensions-media-video-c "full"
|
||||
add_profile_suite native-live-extensions-media-video-d "full"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2198,6 +2202,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-anthropic' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-anthropic-')) || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-opencode-go' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-opencode-go-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
@@ -2414,6 +2419,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
@@ -2602,6 +2608,7 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-extensions-media-video' && startsWith(matrix.suite_id, 'native-live-extensions-media-video-')))
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
run: |
|
||||
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }}
|
||||
suite_profile: custom
|
||||
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
|
||||
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
|
||||
4
.github/workflows/package-acceptance.yml
vendored
4
.github/workflows/package-acceptance.yml
vendored
@@ -386,10 +386,10 @@ jobs:
|
||||
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
|
||||
;;
|
||||
package)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update"
|
||||
;;
|
||||
product)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
include_openwebui=true
|
||||
;;
|
||||
full)
|
||||
|
||||
@@ -31,6 +31,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use public barrels, SDK facade, generic contracts.
|
||||
- Owner boundary: owner-specific repair/detection/onboarding/auth/defaults/provider behavior lives in owner plugin. Shared/core gets generic seams only.
|
||||
- Dependency ownership follows runtime ownership: plugin-only deps stay plugin-local; root deps only for core imports or intentionally internalized bundled plugin runtime.
|
||||
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Moving external: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
|
||||
143
CHANGELOG.md
143
CHANGELOG.md
@@ -4,19 +4,156 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Amazon Bedrock: externalize the Bedrock and Bedrock Mantle provider packages so core installs no longer pull AWS SDK dependencies unless those providers are installed.
|
||||
- Plugins: externalize Slack, OpenShell sandbox, and Anthropic Vertex so their runtime dependency cones install only when those plugins are installed.
|
||||
- Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.
|
||||
- ACP: add `acp.fallbacks` so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/subagents: apply `agents.defaults.subagents.model` before target agent primary models during `sessions_spawn`, so model-scoped runtimes such as `claude-cli` stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant.
|
||||
- Telegram: keep Bot API polling alive during main event-loop stalls by moving ingress to an isolated worker with a durable local spool. Fixes #81132. (#81746) Thanks @joshavant.
|
||||
- Telegram: preserve rendered HTML formatting through lazy cron announce delivery so Markdown links stay clickable instead of falling back to literal anchor tags. Fixes #81742. (#81758)
|
||||
- Telegram: skip unmentioned group media before download when `requireMention` is active, avoiding failed media-download replies for messages that should be ignored. Fixes #81181. (#81785) Thanks @joshavant.
|
||||
- CLI/plugins: keep bare plugin and parent-command help on the lightweight path, avoiding plugin registry discovery before rendering help.
|
||||
- Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.
|
||||
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
|
||||
- Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.
|
||||
- Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.
|
||||
- CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf.
|
||||
- CLI/migrate: drop trailing periods from Codex migrate item messages and `REASON_CODE_MESSAGES` strings so plan/result rows read as labels instead of sentence fragments. (#81705) Thanks @sjf.
|
||||
- Slack: treat malformed private-file redirect `Location` headers as unfollowable redirects instead of failing Slack media downloads.
|
||||
- Plugins: discover provider plugins from `setup.providers[].envVars` credentials during provider discovery while keeping the deprecated `providerAuthEnvVars` fallback. (#81542) Thanks @JARVIS-Glasses.
|
||||
- Docs/Codex harness: clarify that per-agent `CODEX_HOME` isolates `~/.codex` while inherited `HOME` intentionally keeps `.agents` discovery and subprocess user-home state available.
|
||||
- Auth: reclaim dead-owner stale file locks before retrying locked writes, so crashed OAuth refreshes no longer wedge `auth-profiles.json` until manual cleanup.
|
||||
- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable.
|
||||
- Process execution: collapse case-insensitive duplicate child environment keys on Windows so caller-provided overrides such as `PATH` cannot be shadowed by host `Path`.
|
||||
- Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.
|
||||
- Codex harness: keep `oauthRef`-backed Codex OAuth profiles usable and stop high-confidence app-server OAuth refresh invalidation from retry-spamming raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
|
||||
- Browser CLI: request the existing `operator.admin` gateway scope explicitly for browser control commands, avoiding unnecessary scope-upgrade approval loops. Fixes #81555. (#81716) Thanks @joshavant.
|
||||
- Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.
|
||||
- Plugin SDK: restore the deprecated `openclaw/plugin-sdk/memory-core` package subpath as an alias of `memory-host-core`, so published memory companion plugins that still import it resolve on current hosts.
|
||||
- Control UI/i18n: use the installed workspace pi runtime for locale refreshes, update the fallback package pin, prefer the Anthropic CI provider when available, and skip invalid provider credentials instead of failing main.
|
||||
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.
|
||||
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.
|
||||
- Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.
|
||||
- Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty.
|
||||
- WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.
|
||||
- Replies: deliver rich-only block replies even when block-streaming coalescing is enabled, keeping card and button payloads from being dropped by the text coalescer. Thanks @pashpashpash.
|
||||
- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.
|
||||
- Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.
|
||||
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
|
||||
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
|
||||
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.
|
||||
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
|
||||
- iMessage: stop sending visible `<media:image>` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.
|
||||
- Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet.
|
||||
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
|
||||
- Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong.
|
||||
- GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.
|
||||
- Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong.
|
||||
- Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong.
|
||||
- Plugins/install: limit install-time code safety scans to plugin-owned runtime entrypoints while keeping dependency manifest denylist checks, so trusted packages with large dependency trees no longer get blocked or warned on third-party runtime internals.
|
||||
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
|
||||
- Installer: honor `--no-git-update` for existing git checkouts before resolving release refs, preventing pinned source installs from moving during reinstall.
|
||||
- Plugins/install: refresh OpenClaw-managed peer dependency pins when installed plugin peer ranges change, while preserving user-owned dependency pins.
|
||||
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
|
||||
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
|
||||
- Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's `memory_recall` tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.
|
||||
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
|
||||
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
|
||||
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
|
||||
- Anthropic: reseed Claude CLI fresh-session retries from bounded OpenClaw transcript history after session rotation, preventing conversation amnesia. Fixes #80905. (#80934) Thanks @bitloi.
|
||||
- Require explicit browser device pairing [AI]. (#81289) Thanks @pgondhi987.
|
||||
- Require Control UI pairing before proxy-scoped access [AI]. (#81288) Thanks @pgondhi987.
|
||||
- Installer: honor `--version` for git installs and install from the checked-in lockfile, preventing recent dependency pins from tripping pnpm's minimum-release-age gate during tag installs.
|
||||
- Agents: deliver same-process subagent completion handoffs through the in-process agent dispatcher instead of opening a Gateway RPC loopback.
|
||||
- Harden trusted-proxy source validation [AI]. (#81290) Thanks @pgondhi987.
|
||||
- Agents: add permissive item schemas to array tool parameters before provider submission, preventing OpenAI-compatible schema validation from rejecting plugin tools that omit `items`. Fixes #81175. (#81217) Thanks @JARVIS-Glasses.
|
||||
- Agents: escalate LLM idle watchdog timeouts through profile rotation and configured model fallback instead of leaving agent turns stuck after a silent model stream. Fixes #76877. (#80449) Thanks @jimdawdy-hub.
|
||||
- Discord voice: treat OpenAI Realtime startup auth failures as fatal, suppress duplicate realtime error logs, and stop autoJoin from retrying the same broken voice channel until credentials are fixed.
|
||||
- ACPX: stop forwarding unsupported timeout config options to Claude ACP while preserving OpenClaw's own turn timeout. (#80812) Thanks @sxxtony.
|
||||
- Session transcripts: redact sensitive message content in the centralized JSONL append path so CLI turns, gateway transcript injection, transcript mirrors, and guarded tool results use the same configured redaction behavior. Fixes #73565. Refs #73563. (#79645) Thanks @Ziy1-Tan.
|
||||
- Channels/iMessage: ignore Apple link-preview plugin payload attachments when users paste URLs, keeping the URL text while avoiding phantom media context. (#79374) Thanks @homer-byte.
|
||||
- Telegram: detect polling stalls from `getUpdates` liveness only, so outbound API calls no longer mask dead inbound polling; log polling-cycle starts after transport rebuilds. Fixes #78473.
|
||||
- fix: scan plugin runtime entries during install [AI]. (#80998) Thanks @pgondhi987.
|
||||
- fix(plugins): scan installed dependency runtime code [AI]. (#81066) Thanks @pgondhi987.
|
||||
- Inherit tool restrictions for delegated sessions [AI]. (#80979) Thanks @pgondhi987.
|
||||
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
|
||||
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
|
||||
- Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.
|
||||
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
|
||||
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
|
||||
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
|
||||
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
|
||||
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
|
||||
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
|
||||
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
|
||||
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
|
||||
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
|
||||
- Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)
|
||||
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
|
||||
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime and MCP projection SDK helpers, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
- Codex harness: keep auth-profile-backed media tools such as `image_generate` available when OpenAI auth lives in the agent's auth-profile store instead of environment variables.
|
||||
- WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.
|
||||
- Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987.
|
||||
- fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987.
|
||||
- Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987.
|
||||
- Enforce inline shell wrapper payload matching [AI]. (#80978) Thanks @pgondhi987.
|
||||
- fix(node-pairing): replace changed pending requests [AI]. (#80894) Thanks @pgondhi987.
|
||||
- Rate limit Google Chat webhook requests [AI]. (#80974) Thanks @pgondhi987.
|
||||
- Docker: mount the auth-profile secret key directory so OAuth-backed auth profiles survive container rebuilds. (#80991)
|
||||
- Onboarding: accept Codex auth profiles for canonical OpenAI model checks, avoiding false missing-auth warnings. (#80913) Thanks @rubencu.
|
||||
- fix(feishu): normalize webhook rate-limit client keys [AI]. (#80975) Thanks @pgondhi987.
|
||||
- fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987.
|
||||
- Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987.
|
||||
- Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987.
|
||||
- fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987.
|
||||
- slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987.
|
||||
- Enforce gateway command scopes by caller context [AI]. (#80891) Thanks @pgondhi987.
|
||||
- Telegram/groups: in single-account setups, treat an explicit empty `accounts.<id>.groups: {}` map the same as undefined so the root `channels.telegram.groups` allowlist still applies, instead of silently dropping every group update under the default `groupPolicy: "allowlist"`. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains `groupPolicy: "disabled"`. Fixes #79427. (#81030) Thanks @kinjitakabe.
|
||||
- Codex (app-server): project user-configured `mcp.servers` into new Codex thread configs, matching the codex-cli runtime's existing `-c mcp_servers=...` behavior so app-server-runtime agents see the same user MCP servers the CLI runtime already exposes. Plugin-curated apps remain attached via the separate `apps` config patch. Fixes #80814. Thanks @kinjitakabe.
|
||||
- Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.
|
||||
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
|
||||
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.
|
||||
- Telegram: render supported HTML tags in streamed and durable replies instead of showing literal markup. (#80977)
|
||||
- Scrub streamable MCP redirect headers [AI]. (#80906) Thanks @pgondhi987.
|
||||
- fix(memory-wiki): require admin scope for ingest [AI]. (#80897) Thanks @pgondhi987.
|
||||
- memory-wiki: require write scope for Obsidian search [AI]. (#80904) Thanks @pgondhi987.
|
||||
- WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.
|
||||
- WhatsApp: externalize the channel as a ClawHub/npm plugin outside the core npm runtime bundle, and bump Baileys to `7.0.0-rc11` so libsignal resolves from the registry instead of a GitHub tarball.
|
||||
- WhatsApp: keep optional audio decoding dependencies local to the external plugin so the core npm install no longer pulls WhatsApp-only media helpers.
|
||||
- Build: skip copied metadata for bundled plugins that are excluded from build entries, preventing update/status rebuilds from advertising missing QQ Bot runtime files. (#80925)
|
||||
- Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual `└─ ` prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.
|
||||
- Auto-reply: surface a visible error when the configured model backend fails and fallback produces no visible reply, while preserving intentional silent turns and side-effect-only deliveries. (#80917) Thanks @dutifulbob.
|
||||
- Agents/exec: skip redundant heartbeat wake-ups for subagent session exec completions, preventing spurious LLM invocations on parent sessions. Fixes #66748. (#66749) Thanks @ggzeng.
|
||||
- Provider streams: keep OpenAI-compatible SSE and JSON fallback streams draining across split chunks and fail Azure Responses streams with a bounded first-event diagnostic instead of stalling. Refs #80926. (#80927) Thanks @galiniliev and @CaptainTimon.
|
||||
- Agents: rewrite generic provider internal errors with support request IDs into user-friendly transient error copy. (#49401) Thanks @y471823206.
|
||||
- WhatsApp: finish handling pending debounced inbound messages before closing the socket. (#81246) Thanks @mcaxtr.
|
||||
- CLI/commitments: write `--json` output to stdout instead of diagnostic logs so automation can parse commitment list and dismiss results. (#81215) Thanks @giodl73-repo.
|
||||
- Update: allow pnpm GitHub-source OpenClaw updates to approve the OpenClaw package build, so source installs complete their prepare/prepack lifecycle. (#81294) Thanks @fuller-stack-dev.
|
||||
- Telegram: preserve supported HTML tags in visible replies and durable mirrors so formatted messages render correctly instead of degrading to escaped text. (#80977) Thanks @obviyus.
|
||||
- Plugins/runtime: attribute deprecated runtime config load/write warnings to the plugin id and source that triggered them so logs and plugin doctor runs are actionable. Refs #81394. (#81425) Thanks @BKF-Gitty.
|
||||
- Agents/cron: honor a cron payload's explicit `timeoutSeconds` for the LLM idle watchdog even when it numerically equals `agents.defaults.timeoutSeconds`, preserving explicit per-run timeout intent and preventing stalled streaming replies from being cut to the implicit 120s cap. (#79426) Thanks @legolaz8451.
|
||||
- Codex app-server: keep the short post-tool completion watchdog armed across dynamic tool completion bookkeeping so embedded Codex runs fail fast and release their session lane when Codex goes quiet after a tool result. (#81697) Thanks @mbelinky.
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/OpenAI HTTP: honor `max_completion_tokens` and `max_tokens` on inbound `/v1/chat/completions` requests so client-provided token caps reach the upstream provider via `streamParams.maxTokens`, with `max_completion_tokens` taking precedence when both are sent. Thanks @Lellansin.
|
||||
- Models/OpenAI CLI auth: make `openclaw models auth login --provider openai` start the ChatGPT/Codex account login by default, while `--method api-key` remains the explicit OpenAI API-key setup path.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside explicit SDK OAuth auth-result config patches, so provider helpers emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside SDK OAuth auth-result default config patches, so helper-built provider auth flows emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids returned by direct `openclaw models auth login --set-default` provider auth flows before writing config, so Gemini testing targets `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in per-agent config defaults and auth patches, so agent-specific emitted config keeps targeting `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows when API-key onboarding only reapplies the agent default, so emitted config keeps testing `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in `config set` mutation output for agent overrides and provider catalog rows, so current config emits `google/gemini-3.1-pro-preview`.
|
||||
- Google/Gemini: canonicalize provider-qualified retired Gemini 3 Pro Preview refs during Google forward-compatible model resolution, so emitted config uses `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize proxy-prefixed retired Gemini 3 Pro Preview catalog rows, so emitted configs use `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside per-agent model overrides before writing config, so agent-specific config emits `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in subagent, heartbeat, compaction, and subagent-tool model config during writes, so current config keeps emitting `google/gemini-3.1-pro-preview`.
|
||||
- Docs/subagents: document `agents.defaults.subagents.announceTimeoutMs` in the sub-agent and configuration references. (#75509) Thanks @akrimm702.
|
||||
- Cron: add direct `cron.get`, `openclaw cron get <id>`, and agent-tool `get` support for inspecting one stored cron job by id. (#75117) Thanks @samzong.
|
||||
- Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC.
|
||||
@@ -85,6 +222,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction: read post-compaction AGENTS.md refresh context from the queued run workspace instead of the runner process cwd, so CLI-backed follow-up turns re-inject the correct workspace startup rules after compaction. Fixes #70541. (#75532) Thanks @vyctorbrzezowski.
|
||||
- Agents/read tool: treat positive offsets beyond EOF as empty ranges instead of surfacing the upstream read error, so stale pagination cursors no longer crash tool calls while unrelated read failures still fail loud. Fixes #62466. (#75536) Thanks @vyctorbrzezowski.
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview refs left in Google API-key onboarding model allowlists and fallbacks, so setup-emitted config keeps testing `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
|
||||
- Telegram/context: bound selected topic context to the active session so messages from before `/new` or `/reset` are not replayed into later turns. (#80848) Thanks @VACInc.
|
||||
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids when resolving exact configured proxy-provider refs, so `kilocode/google/gemini-3-pro-preview` resolves to `kilocode/google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
|
||||
- CLI: strip generic OSC terminal escape payloads from sanitized output fields, preventing clipboard/title escape bodies from leaking into commitment tables and other terminal-safe text. Thanks @shakkernerd.
|
||||
- Codex app-server: match connector-backed plugin approval elicitations by stable connector id so enabled destructive actions no longer fall through to display-name-only rejection.
|
||||
@@ -221,6 +359,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong.
|
||||
- Gateway/mobile: reuse bootstrap-issued device-token scopes on handoff reconnects and surface device-token scope mismatches separately from token mismatches while preserving full shared-token dashboard/native sessions. Fixes #79292. Thanks @BunsDev.
|
||||
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
|
||||
- Plugins/install: retry managed npm plugin installs without npm alias overrides after npm's `Invalid comparator: npm:` failure, so older npm versions can install official plugins instead of aborting. (#80539) Thanks @rubencu.
|
||||
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
|
||||
- Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump.
|
||||
- CLI/agents: surface durable message delivery status from `sendDurableMessageBatch` in `deliverAgentCommandResult` and `openclaw agent --json --deliver`, preserving suppressed hook outcomes as terminal no-retry results while exposing partial and failed sends for automation. Supersedes #53961 and #57755. Thanks @Kaspre.
|
||||
@@ -322,6 +461,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn.
|
||||
- Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.
|
||||
- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.
|
||||
- Plugin SDK/Windows: enable the native require fast path for root `openclaw/plugin-sdk` dist aliases instead of forcing Jiti transforms. (#80878) Thanks @medns.
|
||||
|
||||
## 2026.5.9
|
||||
|
||||
@@ -579,7 +719,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev.
|
||||
- CLI/infer: canonicalize case-only catalog model refs in `infer model run --model` so mixed-case provider/model strings resolve to the canonical catalog entry instead of failing with `Unknown model`. (#78940) Thanks @ai-hpc.
|
||||
- CLI/infer: allow explicit local `infer model run --model <provider/model>` probes to use exact bundled static catalog rows before the provider is written to config, surfacing missing credentials as auth errors instead of `Unknown model`.
|
||||
- CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago.
|
||||
- CLI/install: revert the beta-only global root-refusal guard so existing root-managed VPS installs keep working; the DigitalOcean split-brain protection will move to a narrower image/install-specific path. Refs #67478 and #67509. Thanks @vincentkoc.
|
||||
- Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments.
|
||||
- Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy.
|
||||
- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers.
|
||||
@@ -659,6 +799,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/groups: tell Discord-channel agents to wrap bare URLs as `<https://example.com>` so link previews do not expand into uninvited embeds. (#78614)
|
||||
- Agents/fallback: fail fast on session write-lock timeouts instead of trying fallback models for local file contention. Fixes #66646. Thanks @sallyom.
|
||||
- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang.
|
||||
- iMessage: stage native inbound attachments into OpenClaw-managed media and convert HEIC/HEIF images to JPEG before dispatch, so image tools can read photos sent over native iMessage without requiring BlueBubbles.
|
||||
- Agents/Gateway: throttle and cap live exec command-output events so noisy tool runs cannot flood Gateway WebSocket clients or starve RPC handling. (#78645) Thanks @joshavant.
|
||||
- Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight.
|
||||
- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu.
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -116,20 +116,25 @@ ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# Reinstall production dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-runtime-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
echo "==> runtime-assets: install prod dependencies" && \
|
||||
rm -rf node_modules && \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
echo "==> runtime-assets: refresh bundled plugin registry" && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
echo "==> runtime-assets: prune non-selected plugin dist" && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
echo "==> runtime-assets: remove dist type and sourcemap files" && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
echo "==> runtime-assets: check package dist imports" && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
# ── Runtime base image ──────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 4
|
||||
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
|
||||
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 4
|
||||
public let GATEWAY_MIN_PROTOCOL_VERSION = 3
|
||||
public let GATEWAY_MIN_PROTOCOL_VERSION = 4
|
||||
|
||||
private struct GatewayAnyCodingKey: CodingKey, Hashable {
|
||||
let stringValue: String
|
||||
@@ -6224,12 +6224,138 @@ public struct ChatInjectParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public struct ChatDeltaEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: AnyCodable
|
||||
public let state: String
|
||||
public let message: AnyCodable?
|
||||
public let deltatext: String
|
||||
public let replace: Bool?
|
||||
public let usage: AnyCodable?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
message: AnyCodable?,
|
||||
deltatext: String,
|
||||
replace: Bool?,
|
||||
usage: AnyCodable?)
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
self.message = message
|
||||
self.deltatext = deltatext
|
||||
self.replace = replace
|
||||
self.usage = usage
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
case message
|
||||
case deltatext = "deltaText"
|
||||
case replace
|
||||
case usage
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatFinalEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
public let message: AnyCodable?
|
||||
public let usage: AnyCodable?
|
||||
public let stopreason: String?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
message: AnyCodable?,
|
||||
usage: AnyCodable?,
|
||||
stopreason: String?)
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
self.message = message
|
||||
self.usage = usage
|
||||
self.stopreason = stopreason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
case message
|
||||
case usage
|
||||
case stopreason = "stopReason"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatAbortedEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
public let message: AnyCodable?
|
||||
public let stopreason: String?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
message: AnyCodable?,
|
||||
stopreason: String?)
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
self.message = message
|
||||
self.stopreason = stopreason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
case message
|
||||
case stopreason = "stopReason"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatErrorEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
public let message: AnyCodable?
|
||||
public let errormessage: String?
|
||||
public let errorkind: AnyCodable?
|
||||
@@ -6241,7 +6367,7 @@ public struct ChatEvent: Codable, Sendable {
|
||||
sessionkey: String,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: AnyCodable,
|
||||
state: String,
|
||||
message: AnyCodable?,
|
||||
errormessage: String?,
|
||||
errorkind: AnyCodable?,
|
||||
@@ -6373,6 +6499,43 @@ public enum PluginsSessionActionResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatEvent: Codable, Sendable {
|
||||
case delta(ChatDeltaEvent)
|
||||
case final(ChatFinalEvent)
|
||||
case aborted(ChatAbortedEvent)
|
||||
case error(ChatErrorEvent)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "state"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "delta": self = try .delta(ChatDeltaEvent(from: decoder))
|
||||
case "final": self = try .final(ChatFinalEvent(from: decoder))
|
||||
case "aborted": self = try .aborted(ChatAbortedEvent(from: decoder))
|
||||
case "error": self = try .error(ChatErrorEvent(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown ChatEvent discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .delta(let value): try value.encode(to: encoder)
|
||||
case .final(let value): try value.encode(to: encoder)
|
||||
case .aborted(let value): try value.encode(to: encoder)
|
||||
case .error(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayFrame: Codable, Sendable {
|
||||
case req(RequestFrame)
|
||||
case res(ResponseFrame)
|
||||
|
||||
@@ -66,7 +66,6 @@ const rootBundledPluginRuntimeDependencies = [
|
||||
"@slack/bolt",
|
||||
"@slack/types",
|
||||
"@slack/web-api",
|
||||
"audio-decode",
|
||||
"grammy",
|
||||
"linkedom",
|
||||
"minimatch",
|
||||
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
|
||||
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
|
||||
## Uncomment the lines below to enable sandbox isolation
|
||||
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
||||
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
||||
@@ -112,6 +113,7 @@ services:
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
|
||||
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
f95819d93e9bec5d059440ab54fb4ccb487425cb91d647c8688cd18ef1d4d848 config-baseline.json
|
||||
3325af3a6292959bb38166e9136c638dce5d2093d2339076742890848088a972 config-baseline.core.json
|
||||
2b1f93d96441194f9501300666192df1409ccfd304499c9a9a4d5f96834372ce config-baseline.json
|
||||
0b2ccb0cc75ab874b51e6abdccbf20addf27d1c1327d30c5de203cada645b7ae config-baseline.core.json
|
||||
ad1d3cb596115d66c21e93de95e229c14c585f0dd4799b4ae3cc29b84761adc6 config-baseline.channel.json
|
||||
0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f26833e053032e3da94025c8a5a8cb62dcddd275797b527440a19be5886a4783 plugin-sdk-api-baseline.json
|
||||
429fe1d6d119379b914bf84b15705233dc8d2d9e1a8131bb28ea19b19afbe6a0 plugin-sdk-api-baseline.jsonl
|
||||
65c3bc31cec30b758d4cc560e30358bb80a730daf3002e2e095501b6a3d36ab6 plugin-sdk-api-baseline.json
|
||||
58929aa8bc43d6be7eab3d56bb1c723a7c6a2de55b55203ae7d05355f94f6a7a plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -16,8 +16,8 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- Slack multi-person DMs route as group chats, so group policy, mention
|
||||
behavior, and group-session rules apply to MPIM conversations.
|
||||
- WhatsApp setup is install-on-demand: onboarding can show the setup flow before
|
||||
the plugin package is installed, and the Gateway loads the WhatsApp runtime
|
||||
only when the channel is actually active.
|
||||
the plugin package is installed, and the Gateway loads the external
|
||||
ClawHub/npm plugin only when the channel is actually active.
|
||||
|
||||
## Supported channels
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
|
||||
</Note>
|
||||
|
||||
## Install
|
||||
|
||||
Install Slack before configuring the channel:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/slack
|
||||
```
|
||||
|
||||
`plugins install` registers and enables the plugin. The plugin still does nothing until you configure the Slack app and channel settings below. See [Plugins](/tools/plugin) for general plugin behavior and install rules.
|
||||
|
||||
## Quick setup
|
||||
|
||||
<Tabs>
|
||||
@@ -181,7 +191,7 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
**Recommended** matches the bundled Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
|
||||
**Recommended** matches the Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
|
||||
</Note>
|
||||
|
||||
After Slack creates the app:
|
||||
@@ -383,7 +393,7 @@ openclaw gateway
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
**Recommended** matches the bundled Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
|
||||
**Recommended** matches the Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
|
||||
@@ -395,7 +395,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
Outbound text uses Telegram `parse_mode: "HTML"`.
|
||||
|
||||
- Markdown-ish text is rendered to Telegram-safe HTML.
|
||||
- Raw model HTML is escaped to reduce Telegram parse failures.
|
||||
- Supported Telegram HTML tags are preserved; unsupported HTML is escaped.
|
||||
- If Telegram rejects parsed HTML, OpenClaw retries as plain text.
|
||||
|
||||
Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`.
|
||||
|
||||
@@ -14,27 +14,19 @@ Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session
|
||||
- `openclaw channels login --channel whatsapp` also offers the install flow when
|
||||
the plugin is not present yet.
|
||||
- Dev channel + git checkout: defaults to the local plugin path.
|
||||
- Stable/Beta: uses the npm package `@openclaw/whatsapp` on the current official
|
||||
release tag.
|
||||
- Stable/Beta: installs the official `@openclaw/whatsapp` plugin from ClawHub
|
||||
first, with npm as the fallback.
|
||||
- The WhatsApp runtime is distributed outside the core OpenClaw npm package so
|
||||
WhatsApp-specific runtime dependencies stay with the external plugin.
|
||||
|
||||
Manual install stays available:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/whatsapp
|
||||
openclaw plugins install clawhub:@openclaw/whatsapp
|
||||
```
|
||||
|
||||
Use the bare package to follow the current official release tag. Pin an exact
|
||||
version only when you need a reproducible install.
|
||||
|
||||
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because
|
||||
one of its Baileys/libsignal dependencies is fetched from a git URL. Install
|
||||
Git for Windows, then restart the shell and rerun the install:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e
|
||||
```
|
||||
|
||||
Portable Git also works if its `bin` directory is on `PATH`.
|
||||
Use the bare npm package (`@openclaw/whatsapp`) only when you need the registry
|
||||
fallback. Pin an exact version only when you need a reproducible install.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
|
||||
@@ -22,6 +22,7 @@ openclaw migrate claude --dry-run
|
||||
openclaw migrate codex --dry-run
|
||||
openclaw migrate codex --skill gog-vault77-google-workspace
|
||||
openclaw migrate codex --plugin google-calendar --dry-run
|
||||
openclaw migrate codex --plugin google-calendar --verify-plugin-apps --dry-run
|
||||
openclaw migrate hermes --dry-run
|
||||
openclaw migrate hermes
|
||||
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
@@ -59,6 +60,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
<ParamField path="--plugin <name>" type="string">
|
||||
Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. When omitted, interactive Codex migrations show a native Codex plugin checkbox selector and non-interactive migrations keep all planned plugins. This only applies to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory.
|
||||
</ParamField>
|
||||
<ParamField path="--verify-plugin-apps" type="boolean">
|
||||
Codex only. Force a fresh source Codex app-server `app/list` traversal before planning native plugin activation. Off by default to keep migration planning fast.
|
||||
</ParamField>
|
||||
<ParamField path="--no-backup" type="boolean">
|
||||
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
|
||||
</ParamField>
|
||||
@@ -119,13 +123,15 @@ inventory a specific Codex home.
|
||||
|
||||
Use this provider when moving to the OpenClaw Codex harness and you want to
|
||||
promote useful personal Codex CLI assets deliberately. Local Codex app-server
|
||||
launches use per-agent `CODEX_HOME` and `HOME` directories, so they do not read
|
||||
your personal Codex CLI state by default.
|
||||
launches use a per-agent `CODEX_HOME`, so they do not read your personal Codex
|
||||
CLI state by default, while subprocesses still inherit the normal process
|
||||
`HOME` unless the app-server launch explicitly overrides it.
|
||||
|
||||
Running `openclaw migrate codex` in an interactive terminal previews the full
|
||||
plan, then opens checkbox selectors before the final apply confirmation. Skill
|
||||
copy items are prompted first. Use `Toggle all on` or `Toggle all off` for bulk
|
||||
selection; planned skills start checked, conflict skills start unchecked, and
|
||||
selection. Press Space to toggle rows, or press Enter to activate the highlighted
|
||||
row and continue. Planned skills start checked, conflict skills start unchecked, and
|
||||
`Skip for now` skips skill copies for this run while still continuing to plugin
|
||||
selection. When source-installed curated Codex plugins are migratable and
|
||||
`--plugin` was not supplied, migration then prompts for native Codex plugin
|
||||
@@ -156,17 +162,36 @@ openclaw migrate apply codex --yes --plugin google-calendar
|
||||
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
|
||||
OpenClaw agent workspace when you want per-agent ownership.
|
||||
- Source-installed `openai-curated` Codex plugins discovered through Codex
|
||||
app-server `plugin/list`. Apply calls app-server `plugin/install` for each
|
||||
selected plugin, even if the target app-server already reports that plugin as
|
||||
installed and enabled. Migrated Codex plugins are usable only in sessions that
|
||||
select the native Codex harness; they are not exposed to Pi, normal OpenAI
|
||||
provider runs, ACP conversation bindings, or other harnesses.
|
||||
app-server `plugin/list`. Planning reads `plugin/read` for each enabled
|
||||
installed plugin. App-backed plugins require the source Codex app-server
|
||||
account response to be a ChatGPT subscription account; non-ChatGPT or missing
|
||||
account responses are skipped with `codex_subscription_required`. By default,
|
||||
migration does not call source `app/list`, so app-backed plugins that pass the
|
||||
account gate are planned without source app accessibility verification, and
|
||||
account lookup transport failures skip with `codex_account_unavailable`. Pass
|
||||
`--verify-plugin-apps` when you want migration to force a fresh source
|
||||
`app/list` snapshot and require every owned app to be present, enabled, and
|
||||
accessible before planning native activation. In that mode, account lookup
|
||||
transport failures fall through to source app inventory verification. The
|
||||
source app inventory snapshot is kept in memory for the current process; it
|
||||
is not written to migration output or target config. Disabled plugins,
|
||||
unreadable plugin details, subscription-gated source accounts, and, when
|
||||
verification is requested, missing apps, disabled apps, inaccessible apps, or
|
||||
source app inventory failures become manual skipped items with typed reasons
|
||||
instead of target config entries.
|
||||
Apply calls app-server `plugin/install` for each selected eligible plugin,
|
||||
even if the target app-server already reports that plugin as installed and
|
||||
enabled. Migrated Codex plugins are usable only in sessions that select the
|
||||
native Codex harness; they are not exposed to Pi, normal OpenAI provider runs,
|
||||
ACP conversation bindings, or other harnesses.
|
||||
|
||||
### Manual-review Codex state
|
||||
|
||||
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, and
|
||||
cached plugin bundles that are not source-installed curated plugins are not
|
||||
activated automatically. They are copied or reported in the migration report for
|
||||
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, cached
|
||||
plugin bundles that are not source-installed curated plugins, and source-installed
|
||||
plugins that fail the source subscription gate are not activated automatically.
|
||||
When `--verify-plugin-apps` is set, plugins that fail the source app-inventory
|
||||
gate are also skipped. They are copied or reported in the migration report for
|
||||
manual review.
|
||||
|
||||
For migrated source-installed curated plugins, apply writes:
|
||||
@@ -178,7 +203,13 @@ For migrated source-installed curated plugins, apply writes:
|
||||
`pluginName` for each selected plugin
|
||||
|
||||
Migration never writes `plugins["*"]` and never stores local marketplace cache
|
||||
paths. Auth-required installs are reported on the affected plugin item with
|
||||
paths. Source-side subscription failures are reported on manual items with typed
|
||||
reasons such as `codex_subscription_required`, `codex_account_unavailable`,
|
||||
`plugin_disabled`, or `plugin_read_unavailable`. With `--verify-plugin-apps`,
|
||||
source app-inventory failures can also appear as `app_inaccessible`,
|
||||
`app_disabled`, `app_missing`, or `app_inventory_unavailable`. Skipped plugins
|
||||
are not written to target config.
|
||||
Target-side auth-required installs are reported on the affected plugin item with
|
||||
`status: "skipped"`, `reason: "auth_required"`, and sanitized app identifiers.
|
||||
Their explicit config entries are written disabled until you reauthorize and
|
||||
enable them. Other install failures are item-scoped `error` results.
|
||||
|
||||
@@ -133,7 +133,7 @@ is available, then fall back to `latest`.
|
||||
|
||||
This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow.
|
||||
|
||||
If a plugin you published on ClawHub is blocked by a registry scan, use the publisher steps in [ClawHub](/clawhub/security).
|
||||
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` only affects installs on your own machine; it does not ask ClawHub to rescan the plugin or make a blocked release public.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
|
||||
@@ -1353,7 +1353,6 @@
|
||||
"pages": [
|
||||
"clawhub/api",
|
||||
"clawhub/http-api",
|
||||
"clawhub/security",
|
||||
"clawhub/acceptable-usage"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -201,6 +201,10 @@ Set `stream: true` to receive Server-Sent Events (SSE):
|
||||
- `tool_choice`: `"auto"`, `"none"`
|
||||
- `messages[*].role: "tool"` follow-up turns
|
||||
- `messages[*].tool_call_id` for binding tool results back to a prior tool call
|
||||
- `max_completion_tokens`: number; per-call cap for total completion tokens (reasoning tokens included). Current OpenAI Chat Completions field name; preferred when both `max_completion_tokens` and `max_tokens` are sent.
|
||||
- `max_tokens`: number; legacy alias accepted for backwards compatibility. Ignored when `max_completion_tokens` is also present.
|
||||
|
||||
When either field is set, the value is forwarded to the upstream provider via the agent stream-param channel. The actual wire field name sent to the upstream provider is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, and `max_tokens` for providers that only accept the legacy name (such as Mistral and Chutes).
|
||||
|
||||
### Unsupported variants
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ and an optional `mirror` workspace mode.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenShell plugin installed (`openclaw plugins install @openclaw/openshell-sandbox`)
|
||||
- The `openshell` CLI installed and on `PATH` (or set a custom path via
|
||||
`plugins.entries.openshell.config.command`)
|
||||
- An OpenShell account with sandbox access
|
||||
@@ -25,7 +26,11 @@ and an optional `mirror` workspace mode.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Enable the plugin and set the sandbox backend:
|
||||
1. Install and enable the plugin, then set the sandbox backend:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/openshell-sandbox
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -476,7 +476,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
### Common event families
|
||||
|
||||
- `chat`: UI chat updates such as `chat.inject` and other transcript-only chat
|
||||
events.
|
||||
events. In protocol v4, delta payloads carry `deltaText`; `message` remains
|
||||
the cumulative assistant snapshot. Non-prefix replacements set `replace=true`
|
||||
and use `deltaText` as the replacement text.
|
||||
- `session.message` and `session.tool`: transcript/event-stream updates for a
|
||||
subscribed session.
|
||||
- `sessions.changed`: session index or metadata changed.
|
||||
@@ -632,8 +634,8 @@ terminal summary, and sanitized error text.
|
||||
|
||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`.
|
||||
- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that
|
||||
do not include its current protocol. Native clients use a v3 lower bound so
|
||||
additive v4 clients can still reach v3 gateways.
|
||||
do not include its current protocol. Current clients and servers require
|
||||
protocol v4.
|
||||
- Schemas + models are generated from TypeBox definitions:
|
||||
- `pnpm protocol:gen`
|
||||
- `pnpm protocol:gen:swift`
|
||||
@@ -647,7 +649,7 @@ stable across protocol v4 and are the expected baseline for third-party clients.
|
||||
| Constant | Default | Source |
|
||||
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
|
||||
| `MIN_CLIENT_PROTOCOL_VERSION` | `3` | `src/gateway/protocol/version.ts` |
|
||||
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
|
||||
| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) |
|
||||
| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) |
|
||||
| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) |
|
||||
|
||||
@@ -39,6 +39,13 @@ Preview migration from the source Codex home:
|
||||
openclaw migrate codex --dry-run
|
||||
```
|
||||
|
||||
Use strict source app verification when you want migration to check source app
|
||||
accessibility before planning native plugin activation:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run --verify-plugin-apps
|
||||
```
|
||||
|
||||
Apply the migration when the plan looks right:
|
||||
|
||||
```bash
|
||||
@@ -87,8 +94,19 @@ The integration has three separate states:
|
||||
- Accessible: Codex app-server confirms the plugin's app entries are available
|
||||
for the active account and can be mapped to the migrated plugin identity.
|
||||
|
||||
Migration is the durable install/eligibility step. Runtime app inventory is the
|
||||
accessibility check. Codex harness session setup then computes a restrictive
|
||||
Migration is the durable install/eligibility step. During planning, OpenClaw
|
||||
reads source Codex `plugin/read` details and checks that the source Codex
|
||||
app-server account response is a ChatGPT subscription account. Non-ChatGPT or
|
||||
missing account responses skip app-backed plugins with
|
||||
`codex_subscription_required`. By default, migration does not call source
|
||||
`app/list`; app-backed source plugins that pass the account gate are planned
|
||||
without source app accessibility verification, and account lookup transport
|
||||
failures skip with `codex_account_unavailable`. With `--verify-plugin-apps`,
|
||||
migration takes a fresh source `app/list` snapshot and requires every owned app
|
||||
to be present, enabled, and accessible before planning native activation. In
|
||||
that mode, account lookup transport failures fall through to the source
|
||||
app-inventory gate. Runtime app inventory is the target-session accessibility
|
||||
check after migration. Codex harness session setup then computes a restrictive
|
||||
thread app config for the enabled and accessible plugin apps.
|
||||
|
||||
Thread app config is computed when OpenClaw establishes a Codex harness session
|
||||
@@ -100,6 +118,12 @@ V1 is intentionally narrow:
|
||||
|
||||
- Only `openai-curated` plugins that were already installed in the source Codex
|
||||
app-server inventory are migration-eligible.
|
||||
- App-backed source plugins must pass the migration-time subscription gate.
|
||||
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
|
||||
accounts plus, in verification mode, inaccessible, disabled, missing source
|
||||
apps or source app-inventory refresh failures are reported as skipped manual
|
||||
items instead of enabled config entries. Unreadable plugin details are skipped
|
||||
before the source app-inventory gate.
|
||||
- Migration writes explicit plugin identities with `marketplaceName` and
|
||||
`pluginName`; it does not write local `marketplacePath` cache paths.
|
||||
- `codexPlugins.enabled` is the global enablement switch.
|
||||
@@ -111,7 +135,18 @@ V1 is intentionally narrow:
|
||||
## App inventory and ownership
|
||||
|
||||
OpenClaw reads Codex app inventory through app-server `app/list`, caches it for
|
||||
one hour, and refreshes stale or missing entries asynchronously.
|
||||
one hour, and refreshes stale or missing entries asynchronously. The cache is
|
||||
in memory only; restarting the CLI or gateway drops it, and OpenClaw rebuilds it
|
||||
from the next `app/list` read.
|
||||
|
||||
Migration and runtime use separate cache keys:
|
||||
|
||||
- Source migration verification uses the source Codex home and source app-server
|
||||
start options. This runs only when `--verify-plugin-apps` is set, and it
|
||||
forces a fresh source `app/list` traversal for that planning run.
|
||||
- Target runtime setup uses the target agent's Codex app-server identity when it
|
||||
builds the Codex thread app config. Plugin activation invalidates that target
|
||||
cache key and then force-refreshes it after `plugin/install`.
|
||||
|
||||
A plugin app is exposed only when OpenClaw can map it back to the migrated
|
||||
plugin through stable ownership:
|
||||
@@ -161,6 +196,27 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
|
||||
needs authentication. The explicit plugin entry is written disabled until you
|
||||
reauthorize and enable it.
|
||||
|
||||
**`app_inaccessible`, `app_disabled`, or `app_missing`:**
|
||||
migration did not install the plugin because the source Codex app inventory did
|
||||
not show all owned apps as present, enabled, and accessible while
|
||||
`--verify-plugin-apps` was set. Reauthorize or enable the app in Codex, then
|
||||
rerun migration with `--verify-plugin-apps`.
|
||||
|
||||
**`app_inventory_unavailable`:** migration did not install the plugin because
|
||||
strict source app verification was requested and source Codex app inventory
|
||||
refresh failed. Fix source Codex app-server access or retry without
|
||||
`--verify-plugin-apps` if you accept the faster account-gated plan.
|
||||
|
||||
**`codex_subscription_required`:** migration did not install the app-backed
|
||||
plugin because the source Codex app-server account was not logged in with a
|
||||
ChatGPT subscription account. Log in to the Codex app with subscription auth,
|
||||
then rerun migration.
|
||||
|
||||
**`codex_account_unavailable`:** migration did not install the app-backed plugin
|
||||
because the source Codex app-server account could not be read. Fix source Codex
|
||||
app-server auth or rerun with `--verify-plugin-apps` if you want source app
|
||||
inventory to decide eligibility when account lookup fails.
|
||||
|
||||
**`marketplace_missing` or `plugin_missing`:** the target Codex app-server
|
||||
cannot see the expected `openai-curated` marketplace or plugin. Rerun migration
|
||||
against the target runtime or inspect Codex app-server plugin status.
|
||||
|
||||
@@ -51,10 +51,7 @@ uninstall, and publishing commands.
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
|
||||
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
|
||||
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
|
||||
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
|
||||
@@ -109,7 +106,6 @@ uninstall, and publishing commands.
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`<br />included in OpenClaw | providers: qianfan |
|
||||
| [qwen](/plugins/reference/qwen) | Adds Qwen, Qwen Cloud, Model Studio, DashScope model provider support to OpenClaw. | `@openclaw/qwen-provider`<br />included in OpenClaw | providers: qwen, qwencloud, modelstudio, dashscope; contracts: mediaUnderstandingProviders, videoGenerationProviders |
|
||||
@@ -119,7 +115,6 @@ uninstall, and publishing commands.
|
||||
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
|
||||
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
|
||||
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
|
||||
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
|
||||
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
|
||||
| [tavily](/plugins/reference/tavily) | Adds agent-callable tools. Adds web search provider support. | `@openclaw/tavily-plugin`<br />included in OpenClaw | contracts: tools, webSearchProviders; skills |
|
||||
@@ -142,33 +137,38 @@ uninstall, and publishing commands.
|
||||
|
||||
## Official external packages
|
||||
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
|
||||
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
|
||||
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
|
||||
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
|
||||
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
|
||||
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
|
||||
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
|
||||
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
|
||||
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
|
||||
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
|
||||
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
|
||||
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
|
||||
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
|
||||
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
|
||||
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
|
||||
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
|
||||
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />npm; ClawHub | channels: whatsapp |
|
||||
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
|
||||
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
|
||||
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
|
||||
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
|
||||
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
|
||||
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
|
||||
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
|
||||
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
|
||||
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
|
||||
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
|
||||
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
|
||||
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
|
||||
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
|
||||
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
|
||||
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
|
||||
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
|
||||
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
|
||||
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
|
||||
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
|
||||
|
||||
## Source checkout only
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ pnpm plugins:inventory:gen
|
||||
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
|
||||
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
|
||||
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
|
||||
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
|
||||
@@ -93,7 +93,7 @@ pnpm plugins:inventory:gen
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |
|
||||
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`<br />source checkout only | plugin |
|
||||
@@ -107,7 +107,7 @@ pnpm plugins:inventory:gen
|
||||
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
|
||||
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
|
||||
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
|
||||
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
|
||||
@@ -128,7 +128,7 @@ pnpm plugins:inventory:gen
|
||||
| [vydra](/plugins/reference/vydra) | Adds Vydra model provider support to OpenClaw. | `@openclaw/vydra-provider`<br />included in OpenClaw | providers: vydra; contracts: imageGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
|
||||
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />npm; ClawHub | channels: whatsapp |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
|
||||
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
|
||||
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Amazon Bedrock Mantle model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/amazon-bedrock-mantle-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Amazon Bedrock model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/amazon-bedrock-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Anthropic Vertex model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/anthropic-vertex-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-base
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/openshell-sandbox`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds the Slack channel surface for sending and receiving OpenClaw messages.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/slack`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,22 +12,12 @@ Adds the WhatsApp channel surface for sending and receiving OpenClaw messages.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/whatsapp`
|
||||
- Install route: npm; ClawHub
|
||||
- Install route: ClawHub: `clawhub:@openclaw/whatsapp`; npm
|
||||
|
||||
## Surface
|
||||
|
||||
channels: whatsapp
|
||||
|
||||
## Windows install note
|
||||
|
||||
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because one of its Baileys/libsignal dependencies is fetched from a git URL. Install Git for Windows, then restart the shell and rerun the install:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e
|
||||
```
|
||||
|
||||
Portable Git also works if its `bin` directory is on `PATH`.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [whatsapp](/channels/whatsapp)
|
||||
|
||||
@@ -41,6 +41,13 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
|
||||
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
|
||||
`plugin-test-runtime` is still an active focused test helper subpath.
|
||||
|
||||
### Reserved bundled plugin helper subpaths
|
||||
|
||||
These subpaths are plugin-owned compatibility surfaces reserved for their owning
|
||||
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
|
||||
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
|
||||
by package contract guardrails.
|
||||
|
||||
### Deprecated unused public subpaths
|
||||
|
||||
These public subpaths existed for at least one month and currently have no
|
||||
@@ -215,6 +222,8 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
|
||||
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
|
||||
| `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
|
||||
| `plugin-sdk/codex-mcp-projection` | Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Reserved bundled Codex helper for native task mirror/runtime wiring; not for third-party plugins |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/matrix` | Deprecated Matrix compatibility facade for older third-party channel packages; new plugins should import `plugin-sdk/run-command` directly |
|
||||
| `plugin-sdk/mattermost` | Deprecated Mattermost compatibility facade for older third-party channel packages; new plugins should import generic SDK subpaths directly |
|
||||
@@ -302,9 +311,9 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
<Accordion title="Capability and testing subpaths">
|
||||
| Subpath | Key exports |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
|
||||
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers including `saveRemoteMedia`, `saveResponseMedia`, `readRemoteMediaBuffer`, and deprecated `fetchRemoteMedia`; prefer store helpers before buffer reads when a URL should become OpenClaw media |
|
||||
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
|
||||
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
|
||||
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` |
|
||||
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
|
||||
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
|
||||
| `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
|
||||
@@ -361,10 +370,18 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reserved bundled-helper subpaths">
|
||||
There are currently no reserved bundled-helper SDK subpaths. Owner-specific
|
||||
helpers live inside the owning plugin package, while reusable host contracts
|
||||
use generic SDK subpaths such as `plugin-sdk/gateway-runtime`,
|
||||
`plugin-sdk/security-runtime`, and `plugin-sdk/plugin-config-runtime`.
|
||||
Reserved bundled-helper SDK subpaths are narrow owner-specific surfaces for
|
||||
bundled plugin code. They are tracked in the SDK inventory so package
|
||||
builds and aliasing stay deterministic, but they are not general plugin
|
||||
authoring APIs. New reusable host contracts should use generic SDK subpaths
|
||||
such as `plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, and
|
||||
`plugin-sdk/plugin-config-runtime`.
|
||||
|
||||
| Subpath | Owner and purpose |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/codex-mcp-projection` | Bundled Codex plugin helper for projecting user MCP server config into Codex app-server thread config |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ model as `provider/model`.
|
||||
- [xAI](/providers/xai)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
## Additional bundled provider variants
|
||||
## Additional provider variants
|
||||
|
||||
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `anthropic-vertex` - install `@openclaw/anthropic-vertex-provider` for implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ SGLang serves open-weight models via an OpenAI-compatible HTTP API. OpenClaw con
|
||||
| Streaming usage | Yes (`supportsStreamingUsage: true`) |
|
||||
| Pricing | Marked external-free (`modelPricing.external: false`) |
|
||||
|
||||
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY` and you do not define an explicit `models.providers.sglang` entry — see [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
|
||||
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY`. Use `sglang/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom SGLang base URL. See [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -71,8 +71,10 @@ define `models.providers.sglang`, OpenClaw will query:
|
||||
and convert the returned IDs into model entries.
|
||||
|
||||
<Note>
|
||||
If you set `models.providers.sglang` explicitly, auto-discovery is skipped and
|
||||
you must define models manually.
|
||||
If you set `models.providers.sglang` explicitly, OpenClaw uses your declared
|
||||
models by default. Add `"sglang/*": {}` to `agents.defaults.models` when you
|
||||
want OpenClaw to query that configured provider's `/models` endpoint and include
|
||||
all advertised SGLang models.
|
||||
</Note>
|
||||
|
||||
## Explicit configuration (manual models)
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "vLLM"
|
||||
|
||||
vLLM can serve open-source (and some custom) models via an **OpenAI-compatible** HTTP API. OpenClaw connects to vLLM using the `openai-completions` API.
|
||||
|
||||
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth) and you do not define an explicit `models.providers.vllm` entry.
|
||||
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth). Use `vllm/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom vLLM base URL.
|
||||
|
||||
OpenClaw treats `vllm` as a local OpenAI-compatible provider that supports
|
||||
streamed usage accounting, so status/context token counts can update from
|
||||
@@ -72,7 +72,7 @@ GET http://127.0.0.1:8000/v1/models
|
||||
and converts the returned IDs into model entries.
|
||||
|
||||
<Note>
|
||||
If you set `models.providers.vllm` explicitly, auto-discovery is skipped and you must define models manually.
|
||||
If you set `models.providers.vllm` explicitly, OpenClaw uses your declared models by default. Add `"vllm/*": {}` to `agents.defaults.models` when you want OpenClaw to query that configured provider's `/models` endpoint and include all advertised vLLM models.
|
||||
</Note>
|
||||
|
||||
## Explicit configuration (manual models)
|
||||
@@ -111,6 +111,21 @@ Use explicit config when:
|
||||
}
|
||||
```
|
||||
|
||||
To keep this provider dynamic without manually listing every model, add a provider
|
||||
wildcard to the visible model catalog:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"vllm/*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -331,7 +346,7 @@ Use explicit config when:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="No models discovered">
|
||||
Auto-discovery requires `VLLM_API_KEY` to be set **and** no explicit `models.providers.vllm` config entry. If you have defined the provider manually, OpenClaw skips discovery and uses only your declared models.
|
||||
Auto-discovery requires `VLLM_API_KEY` to be set. If you have defined `models.providers.vllm`, OpenClaw uses only your declared models unless `agents.defaults.models` includes `"vllm/*": {}`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tools render as raw text">
|
||||
|
||||
@@ -77,7 +77,7 @@ Onboarding starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
|
||||
In interactive token mode, choose default plaintext token storage or opt into SecretRef.
|
||||
Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
|
||||
4. **Channels** — built-in and bundled chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
|
||||
4. **Channels** — built-in and official plugin chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
|
||||
5. **Daemon** — Installs a LaunchAgent (macOS), systemd user unit (Linux/WSL2), or native Windows Scheduled Task with per-user Startup-folder fallback.
|
||||
If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist the resolved token into supervisor service environment metadata.
|
||||
If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
|
||||
|
||||
@@ -73,6 +73,7 @@ Notes:
|
||||
## Sending + delivery
|
||||
|
||||
- Messages are sent to the Gateway; delivery to providers is off by default.
|
||||
- The TUI is an internal source surface like WebChat, not a generic outbound channel. Harnesses that require `tools.message` for visible replies can satisfy the active TUI turn with a targetless `message.send`; explicit provider delivery still uses normal configured channels and never falls back to `lastChannel`.
|
||||
- Turn delivery on:
|
||||
- `/deliver on`
|
||||
- or the Settings panel
|
||||
|
||||
@@ -51,6 +51,7 @@ WebChat has two separate data paths:
|
||||
|
||||
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, Pi persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
|
||||
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
|
||||
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"openclawVersion": "2026.5.12-beta.8",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.95.2",
|
||||
@@ -15,6 +18,21 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.6"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1045.0",
|
||||
@@ -17,6 +20,21 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.6"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.0",
|
||||
@@ -15,6 +18,22 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/anthropic-vertex-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.6"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f
|
||||
|
||||
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
|
||||
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
|
||||
type AnthropicVertexClientOptions = ConstructorParameters<typeof AnthropicVertexSdk>[0];
|
||||
type AnthropicVertexClientOptions = {
|
||||
baseURL?: string;
|
||||
projectId?: string;
|
||||
region: string;
|
||||
};
|
||||
|
||||
export type AnthropicVertexStreamDeps = {
|
||||
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,10 +20,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
BROWSER_REQUEST_GATEWAY_SCOPE,
|
||||
} from "./src/browser-gateway-contract.js";
|
||||
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
|
||||
const BROWSER_CLI_DESCRIPTOR = {
|
||||
@@ -107,13 +111,13 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
{ commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] },
|
||||
);
|
||||
api.registerGatewayMethod(
|
||||
"browser.request",
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
async (opts) => {
|
||||
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
|
||||
return await handleBrowserGatewayRequest(opts);
|
||||
},
|
||||
{
|
||||
scope: "operator.admin",
|
||||
scope: BROWSER_REQUEST_GATEWAY_SCOPE,
|
||||
},
|
||||
);
|
||||
api.registerService(createLazyBrowserPluginService());
|
||||
|
||||
3
extensions/browser/src/browser-gateway-contract.ts
Normal file
3
extensions/browser/src/browser-gateway-contract.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const BROWSER_REQUEST_GATEWAY_METHOD = "browser.request" as const;
|
||||
export const BROWSER_REQUEST_GATEWAY_SCOPE = "operator.admin" as const;
|
||||
export const BROWSER_REQUEST_GATEWAY_SCOPES = [BROWSER_REQUEST_GATEWAY_SCOPE] as const;
|
||||
34
extensions/browser/src/cli/browser-cli-shared.test.ts
Normal file
34
extensions/browser/src/cli/browser-cli-shared.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { callGatewayFromCli } from "./core-api.js";
|
||||
|
||||
type CallGatewayFromCliArgs = Parameters<typeof callGatewayFromCli>;
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
callGatewayFromCli: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("./core-api.js", () => ({
|
||||
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
|
||||
}));
|
||||
|
||||
const { callBrowserRequest } = await import("./browser-cli-shared.js");
|
||||
|
||||
describe("callBrowserRequest", () => {
|
||||
beforeEach(() => {
|
||||
gatewayMocks.callGatewayFromCli.mockClear();
|
||||
});
|
||||
|
||||
it("requests the browser.request admin scope explicitly", async () => {
|
||||
await callBrowserRequest(
|
||||
{ json: true },
|
||||
{ method: "GET", path: "/status", query: { profile: "openclaw" } },
|
||||
{ progress: true },
|
||||
);
|
||||
|
||||
const call = gatewayMocks.callGatewayFromCli.mock.calls[0] as unknown as
|
||||
| CallGatewayFromCliArgs
|
||||
| undefined;
|
||||
const extra = call?.[3];
|
||||
expect(extra).toEqual({ progress: true, scopes: ["operator.admin"] });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
BROWSER_REQUEST_GATEWAY_SCOPES,
|
||||
} from "../browser-gateway-contract.js";
|
||||
import { callGatewayFromCli, type GatewayRpcOpts } from "./core-api.js";
|
||||
|
||||
export type BrowserParentOpts = GatewayRpcOpts & {
|
||||
@@ -44,7 +48,7 @@ export async function callBrowserRequest<T>(
|
||||
: undefined;
|
||||
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
|
||||
const payload = await callGatewayFromCli(
|
||||
"browser.request",
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
{ ...opts, timeout },
|
||||
{
|
||||
method: params.method,
|
||||
@@ -53,7 +57,7 @@ export async function callBrowserRequest<T>(
|
||||
body: params.body,
|
||||
timeoutMs: resolvedTimeout,
|
||||
},
|
||||
{ progress: extra?.progress },
|
||||
{ progress: extra?.progress, scopes: [...BROWSER_REQUEST_GATEWAY_SCOPES] },
|
||||
);
|
||||
if (payload === undefined) {
|
||||
throw new Error("Unexpected browser.request response");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1 +1 @@
|
||||
6f42494c638de9f72ce783550b4a7c62912c26d47293641e588625afa06db370
|
||||
2041ea91d5bbadd4fca2a4c9473c5647ae5fcdedaf57bd91ccbcf8ac285cb360
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cerebras provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,10 +27,10 @@
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
bridgeCodexAppServerStartOptions,
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerAuthProfileId,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerNativeHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
@@ -172,7 +173,7 @@ async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe("bridgeCodexAppServerStartOptions", () => {
|
||||
it("sets agent-owned CODEX_HOME and HOME for local app-server launches", async () => {
|
||||
it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions();
|
||||
try {
|
||||
@@ -188,17 +189,40 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: nativeHome,
|
||||
},
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
await expectPathMissing(nativeHome);
|
||||
expect(startOptions.env).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves inherited HOME when clearEnv asks to clear app-server isolation vars", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions({
|
||||
clearEnv: ["CODEX_HOME", "HOME", "FOO"],
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO"],
|
||||
});
|
||||
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const codexHome = path.join(agentDir, "custom-codex-home");
|
||||
@@ -260,7 +284,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
env: {
|
||||
EXISTING: "1",
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
@@ -298,7 +321,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
@@ -331,7 +353,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
@@ -364,7 +385,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -550,6 +570,25 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves native app-server auth untouched when auth bridging is disabled", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ requiresOpenaiAuth: true }));
|
||||
try {
|
||||
vi.stubEnv("OPENAI_API_KEY", "env-api-key");
|
||||
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
authProfileId: null,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ type: "apiKey" }));
|
||||
@@ -613,6 +652,65 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects an oauthRef-backed Codex profile for app-server login", () => {
|
||||
expect(
|
||||
resolveCodexAppServerAuthProfileId({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: Date.now() + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("openai-codex:default");
|
||||
});
|
||||
|
||||
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
access: "refreshed-ref-backed-access-token",
|
||||
refresh: "refreshed-ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed-refreshed",
|
||||
});
|
||||
try {
|
||||
upsertAuthProfile({
|
||||
agentDir,
|
||||
profileId: "openai-codex:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "ref-backed-access-token",
|
||||
refresh: "ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed",
|
||||
email: "codex@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
|
||||
accessToken: "refreshed-ref-backed-access-token",
|
||||
chatgptAccountId: "account-ref-backed-refreshed",
|
||||
chatgptPlanType: null,
|
||||
});
|
||||
|
||||
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("ref-backed-refresh-token");
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const agentDir = path.join(root, "agent");
|
||||
|
||||
@@ -35,14 +35,14 @@ const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
|
||||
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
|
||||
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
|
||||
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
|
||||
const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
|
||||
const CODEX_APP_SERVER_HOME_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
|
||||
|
||||
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
|
||||
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): Promise<CodexAppServerStartOptions> {
|
||||
if (params.startOptions.transport !== "stdio") {
|
||||
@@ -52,6 +52,9 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
params.startOptions,
|
||||
params.agentDir,
|
||||
);
|
||||
if (params.authProfileId === null) {
|
||||
return isolatedStartOptions;
|
||||
}
|
||||
const store = ensureCodexAppServerAuthProfileStore({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
@@ -259,18 +262,20 @@ async function withAgentCodexHomeEnvironment(
|
||||
: resolveCodexAppServerHomeDir(agentDir);
|
||||
const nativeHome = startOptions.env?.[HOME_ENV_VAR]?.trim()
|
||||
? startOptions.env[HOME_ENV_VAR]
|
||||
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
: undefined;
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.mkdir(nativeHome, { recursive: true });
|
||||
if (nativeHome) {
|
||||
await fs.mkdir(nativeHome, { recursive: true });
|
||||
}
|
||||
const nextStartOptions: CodexAppServerStartOptions = {
|
||||
...startOptions,
|
||||
env: {
|
||||
...startOptions.env,
|
||||
[CODEX_HOME_ENV_VAR]: codexHome,
|
||||
[HOME_ENV_VAR]: nativeHome,
|
||||
...(nativeHome ? { [HOME_ENV_VAR]: nativeHome } : {}),
|
||||
},
|
||||
};
|
||||
const clearEnv = withoutClearedCodexIsolationEnv(startOptions.clearEnv);
|
||||
const clearEnv = withoutClearedCodexHomeEnv(startOptions.clearEnv);
|
||||
if (clearEnv) {
|
||||
nextStartOptions.clearEnv = clearEnv;
|
||||
} else {
|
||||
@@ -279,11 +284,11 @@ async function withAgentCodexHomeEnvironment(
|
||||
return nextStartOptions;
|
||||
}
|
||||
|
||||
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
|
||||
function withoutClearedCodexHomeEnv(clearEnv: string[] | undefined): string[] | undefined {
|
||||
if (!clearEnv) {
|
||||
return undefined;
|
||||
}
|
||||
const reserved = new Set(CODEX_APP_SERVER_ISOLATION_ENV_VARS);
|
||||
const reserved = new Set(CODEX_APP_SERVER_HOME_ENV_VARS);
|
||||
const filtered = clearEnv.filter((envVar) => !reserved.has(envVar.trim().toUpperCase()));
|
||||
return filtered.length === clearEnv.length ? clearEnv : filtered;
|
||||
}
|
||||
@@ -291,10 +296,13 @@ function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string
|
||||
export async function applyCodexAppServerAuthProfile(params: {
|
||||
client: CodexAppServerClient;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): Promise<void> {
|
||||
if (params.authProfileId === null) {
|
||||
return;
|
||||
}
|
||||
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
|
||||
@@ -126,6 +126,42 @@ describe("CodexAppServerClient", () => {
|
||||
await expect(request).rejects.toHaveProperty("message", "Method not found");
|
||||
});
|
||||
|
||||
it("surfaces relogin details from Codex app-server RPC errors", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
const request = harness.client.request("thread/start", {});
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: "failed to load configuration",
|
||||
data: {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(request).rejects.toHaveProperty(
|
||||
"message",
|
||||
"failed to load configuration: Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
);
|
||||
await expect(request).rejects.toHaveProperty("data", {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects timed-out requests and ignores late responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
|
||||
@@ -42,13 +42,39 @@ export class CodexAppServerRpcError extends Error {
|
||||
readonly data?: JsonValue;
|
||||
|
||||
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
|
||||
super(error.message || `${method} failed`);
|
||||
super(formatCodexAppServerRpcErrorMessage(error, method));
|
||||
this.name = "CodexAppServerRpcError";
|
||||
this.code = error.code;
|
||||
this.data = error.data;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodexAppServerRpcErrorMessage(
|
||||
error: { message: string; data?: JsonValue },
|
||||
method: string,
|
||||
): string {
|
||||
const message = error.message || `${method} failed`;
|
||||
const detail = readCodexAppServerRpcReloginDetail(error.data);
|
||||
return detail && !message.includes(detail) ? `${message}: ${detail}` : message;
|
||||
}
|
||||
|
||||
function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string | undefined {
|
||||
const record = isJsonObject(data) ? data : undefined;
|
||||
const nested = isJsonObject(record?.error) ? record.error : record;
|
||||
if (!nested) {
|
||||
return undefined;
|
||||
}
|
||||
const isRelogin =
|
||||
nested.action === "relogin" ||
|
||||
(nested.reason === "cloudRequirements" && nested.errorCode === "Auth");
|
||||
const detail = typeof nested.detail === "string" ? nested.detail.trim() : "";
|
||||
return isRelogin && detail ? detail : undefined;
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function isCodexAppServerConnectionClosedError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
|
||||
@@ -320,6 +320,37 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records internal UI source replies separately from outbound messaging evidence", async () => {
|
||||
const toolResult = textToolResult("Sent to current chat.", {
|
||||
status: "ok",
|
||||
deliveryStatus: "sent",
|
||||
sourceReplySink: "internal-ui",
|
||||
sourceReply: {
|
||||
text: "visible reply",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", toolResult);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "<think>private</think>visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent to current chat."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(bridge.telemetry.messagingToolSentTexts).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentMediaUrls).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSourceReplyPayloads).toEqual([
|
||||
{
|
||||
text: "visible reply",
|
||||
mediaUrl: "/tmp/reply.png",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type AnyAgentTool,
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexDynamicToolsLoading } from "./config.js";
|
||||
@@ -47,6 +48,7 @@ export type CodexDynamicToolBridge = {
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[];
|
||||
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||
toolMediaUrls: string[];
|
||||
toolAudioAsVoice: boolean;
|
||||
@@ -77,6 +79,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
toolMediaUrls: [],
|
||||
toolAudioAsVoice: false,
|
||||
};
|
||||
@@ -279,6 +282,11 @@ function collectToolTelemetry(params: {
|
||||
return;
|
||||
}
|
||||
params.telemetry.didSendViaMessagingTool = true;
|
||||
const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details);
|
||||
if (sourceReplyPayload) {
|
||||
params.telemetry.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
|
||||
return;
|
||||
}
|
||||
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
|
||||
if (text) {
|
||||
params.telemetry.messagingToolSentTexts.push(text);
|
||||
@@ -296,6 +304,41 @@ function collectToolTelemetry(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function extractInternalSourceReplyPayload(
|
||||
details: unknown,
|
||||
): MessagingToolSourceReplyPayload | undefined {
|
||||
if (!isRecord(details) || details.sourceReplySink !== "internal-ui") {
|
||||
return undefined;
|
||||
}
|
||||
const rawPayload = details.sourceReply;
|
||||
if (!isRecord(rawPayload)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = readFirstString(rawPayload, ["text", "message"]);
|
||||
const mediaUrls = collectMediaUrls(rawPayload);
|
||||
const mediaUrl =
|
||||
typeof rawPayload.mediaUrl === "string" && rawPayload.mediaUrl.trim()
|
||||
? rawPayload.mediaUrl.trim()
|
||||
: mediaUrls[0];
|
||||
const payload: MessagingToolSourceReplyPayload = {
|
||||
...(text ? { text } : {}),
|
||||
...(mediaUrl ? { mediaUrl } : {}),
|
||||
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
|
||||
...(rawPayload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
...(isRecord(rawPayload.presentation)
|
||||
? { presentation: rawPayload.presentation as never }
|
||||
: {}),
|
||||
...(isRecord(rawPayload.interactive) ? { interactive: rawPayload.interactive as never } : {}),
|
||||
...(isRecord(rawPayload.channelData) ? { channelData: rawPayload.channelData } : {}),
|
||||
...(typeof details.idempotencyKey === "string" && details.idempotencyKey.trim()
|
||||
? { idempotencyKey: details.idempotencyKey.trim() }
|
||||
: {}),
|
||||
};
|
||||
return text || mediaUrls.length > 0 || payload.presentation || payload.interactive
|
||||
? payload
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type EmbeddedRunAttemptResult,
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolProgressDetailMode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -46,6 +47,7 @@ export type CodexAppServerToolTelemetry = {
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
|
||||
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||
toolMediaUrls?: string[];
|
||||
toolAudioAsVoice?: boolean;
|
||||
@@ -320,6 +322,7 @@ export class CodexAppServerEventProjector {
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
messagingToolSourceReplyPayloads: toolTelemetry.messagingToolSourceReplyPayloads ?? [],
|
||||
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
|
||||
toolMediaUrls: this.buildToolMediaUrls(toolTelemetry),
|
||||
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
|
||||
|
||||
74
extensions/codex/src/app-server/plugin-app-cache-key.ts
Normal file
74
extensions/codex/src/app-server/plugin-app-cache-key.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
buildCodexAppInventoryCacheKey,
|
||||
type CodexAppInventoryCacheKeyInput,
|
||||
} from "./app-inventory-cache.js";
|
||||
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
|
||||
import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
export type CodexPluginAppCacheKeyParams = Omit<
|
||||
CodexAppInventoryCacheKeyInput,
|
||||
"codexHome" | "endpoint"
|
||||
> & {
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">;
|
||||
agentDir?: string;
|
||||
};
|
||||
|
||||
export function buildCodexPluginAppCacheKey(params: CodexPluginAppCacheKeyParams): string {
|
||||
return buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir),
|
||||
endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer),
|
||||
authProfileId: params.authProfileId,
|
||||
accountId: params.accountId,
|
||||
envApiKeyFingerprint: params.envApiKeyFingerprint,
|
||||
appServerVersion: params.appServerVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCodexPluginAppCacheEndpoint(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
transport: appServer.start.transport,
|
||||
command: appServer.start.command,
|
||||
args: appServer.start.args,
|
||||
url: appServer.start.url ?? null,
|
||||
credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCodexPluginAppCacheCodexHome(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
|
||||
agentDir?: string,
|
||||
): string | undefined {
|
||||
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
|
||||
if (configuredCodexHome) {
|
||||
return configuredCodexHome;
|
||||
}
|
||||
return appServer.start.transport === "stdio" && agentDir
|
||||
? resolveCodexAppServerHomeDir(agentDir)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function fingerprintCodexPluginAppCacheCredentials(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
): string | null {
|
||||
const authToken = startOptions.authToken ?? "";
|
||||
const headers = Object.entries(startOptions.headers)
|
||||
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right));
|
||||
if (!authToken && headers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
hash.update("openclaw:codex:plugin-app-cache-credentials:v1");
|
||||
hash.update("\0");
|
||||
hash.update(authToken);
|
||||
for (const [key, value] of headers) {
|
||||
hash.update("\0");
|
||||
hash.update(key);
|
||||
hash.update("\0");
|
||||
hash.update(value);
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
|
||||
requestParams: CodexAppServerRequestParams<M>;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<CodexAppServerRequestResult<M>>;
|
||||
@@ -26,7 +27,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<T>;
|
||||
@@ -35,7 +37,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
isolated?: boolean;
|
||||
}): Promise<T> {
|
||||
@@ -48,13 +51,19 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
try {
|
||||
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
client.close();
|
||||
// Wait for the child to actually exit (with a SIGKILL fallback) so
|
||||
// the parent process doesn't hang on an orphaned codex app-server.
|
||||
// The stdio bin shim does not always propagate stdin EOF to the
|
||||
// underlying codex binary, so the unref'd close() path can leave
|
||||
// the child running and keep the parent's event loop alive.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
@@ -373,6 +374,70 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("retries a resumed context-engine thread on a fresh Codex thread after early context overflow", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
},
|
||||
});
|
||||
const contextEngine = createContextEngine();
|
||||
const harness = createStartedThreadHarness(async (method, requestParams) => {
|
||||
const request = requireRecord(requestParams, `${method} params`);
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-old");
|
||||
}
|
||||
if (method === "turn/start" && request.threadId === "thread-old") {
|
||||
throw new Error("Codex ran out of room in the model's context window");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
if (method === "turn/start" && request.threadId === "thread-fresh") {
|
||||
return turnStartResult("turn-fresh");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 400_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]),
|
||||
);
|
||||
await harness.notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-fresh",
|
||||
turnId: "turn-fresh",
|
||||
turn: {
|
||||
id: "turn-fresh",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await run;
|
||||
|
||||
expect(result.assistantTexts).toContain("fresh answer");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-fresh");
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
});
|
||||
|
||||
it("keeps current-turn context at the front of the Codex context-engine prompt", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -25,20 +25,18 @@ function queueActiveRunMessageForTest(
|
||||
return queueAgentHarnessMessage(...args);
|
||||
}
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import {
|
||||
buildCodexAppInventoryCacheKey,
|
||||
defaultCodexAppInventoryCache,
|
||||
} from "./app-inventory-cache.js";
|
||||
import {
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
resolveCodexAppServerHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
createCodexDynamicToolBridge,
|
||||
} from "./dynamic-tools.js";
|
||||
import * as elicitationBridge from "./elicitation-bridge.js";
|
||||
import {
|
||||
buildCodexPluginAppCacheKey,
|
||||
resolveCodexPluginAppCacheEndpoint,
|
||||
} from "./plugin-app-cache-key.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
|
||||
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
|
||||
@@ -645,6 +643,109 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes MCP server config through to Codex thread/start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
config: {
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
expect((startRequest?.[1] as { config?: unknown } | undefined)?.config).toMatchObject({
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprint: "mcp-v2",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
|
||||
});
|
||||
|
||||
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBeUndefined();
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not expose OpenClaw Tool Search controls through Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -683,6 +784,47 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes auth profiles into Codex dynamic tool construction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:api-key-backup": {
|
||||
provider: "openai",
|
||||
type: "api_key",
|
||||
key: "not-a-real-key",
|
||||
},
|
||||
},
|
||||
} satisfies EmbeddedRunAttemptParams["authProfileStore"];
|
||||
params.disableTools = false;
|
||||
params.authProfileStore = authProfileStore;
|
||||
|
||||
const factoryOptions: unknown[] = [];
|
||||
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await __testing.buildDynamicTools({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
sandboxSessionKey: params.sessionKey!,
|
||||
sandbox: null as never,
|
||||
runAbortController: new AbortController(),
|
||||
sessionAgentId: "main",
|
||||
pluginConfig: {},
|
||||
onYieldDetected: () => undefined,
|
||||
});
|
||||
|
||||
expect(factoryOptions).toHaveLength(1);
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
authProfileStore,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
|
||||
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));
|
||||
|
||||
@@ -1371,6 +1513,98 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(warnData?.lastActivityReason).toBe("request:item/tool/call:response");
|
||||
});
|
||||
|
||||
it("keeps the post-tool completion watchdog armed across dynamic tool completion bookkeeping", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
__testing.setCodexAppServerClientFactoryForTests(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
turnTerminalIdleTimeoutMs: 200,
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), { interval: 1 });
|
||||
|
||||
const toolResult = (await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
})) as { success?: boolean };
|
||||
expect(toolResult.success).toBe(false);
|
||||
await notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "dynamicToolCall",
|
||||
id: "call-1",
|
||||
tool: "message",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
expect(
|
||||
warn.mock.calls.some(
|
||||
([message]) => message === "codex app-server turn idle timed out waiting for completion",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
warn.mock.calls.some(
|
||||
([message]) =>
|
||||
message === "codex app-server turn idle timed out waiting for terminal event",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps waiting when Codex emits a raw assistant item after a dynamic tool response", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
@@ -3685,9 +3919,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
}),
|
||||
request: async () => ({
|
||||
data: [
|
||||
@@ -3887,9 +4121,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
accountId: "account-work",
|
||||
}),
|
||||
@@ -4028,9 +4262,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexAppServerHomeDir(agentDir),
|
||||
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
key: buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
envApiKeyFingerprint: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
baseEnv: { CODEX_API_KEY: "old-codex-env-key" },
|
||||
@@ -4331,6 +4565,180 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread for legacy context-engine sidecars without metadata", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
} as never;
|
||||
params.contextTokenBudget = 400_000;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"contextTokenBudget":400000');
|
||||
});
|
||||
|
||||
it("resumes a Codex thread when context-engine sidecar metadata is compatible", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const contextEngine = {
|
||||
schemaVersion: 1 as const,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
};
|
||||
await writeExistingBinding(sessionFile, workspaceDir, {
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine,
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
} as never;
|
||||
params.contextTokenBudget = 400_000;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
expect(binding.lifecycle).toEqual({ action: "resumed" });
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when context-engine sidecar metadata is no longer active", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, {
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
},
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.contextEngine).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when context-engine policy metadata changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, {
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","engineVersion":"1.0.0","ownsCompaction":true,"turnMaintenanceMode":"foreground","citationsMode":"inline","contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
},
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = {
|
||||
info: {
|
||||
id: "lossless-claw",
|
||||
name: "Lossless Claw",
|
||||
version: "1.0.1",
|
||||
ownsCompaction: true,
|
||||
turnMaintenanceMode: "foreground",
|
||||
},
|
||||
assemble: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
} as never;
|
||||
params.config = { memory: { citations: "inline" } } as never;
|
||||
params.contextTokenBudget = 400_000;
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"engineVersion":"1.0.1"');
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
|
||||
'"turnMaintenanceMode":"foreground"',
|
||||
);
|
||||
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -5198,7 +5606,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("keys plugin app inventory by websocket credentials without exposing them", () => {
|
||||
const first = __testing.resolveCodexPluginAppCacheEndpoint({
|
||||
const first = resolveCodexPluginAppCacheEndpoint({
|
||||
start: {
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
@@ -5207,13 +5615,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
authToken: "token-first",
|
||||
headers: { Authorization: "Bearer first" },
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
const second = __testing.resolveCodexPluginAppCacheEndpoint({
|
||||
const second = resolveCodexPluginAppCacheEndpoint({
|
||||
start: {
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
@@ -5222,11 +5625,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
authToken: "token-second",
|
||||
headers: { Authorization: "Bearer second" },
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 5,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatErrorMessage,
|
||||
isActiveHarnessContextEngine,
|
||||
isSubagentSessionKey,
|
||||
loadCodexBundleMcpThreadConfig,
|
||||
normalizeAgentRuntimeTools,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
@@ -41,16 +42,12 @@ import {
|
||||
import { markAuthProfileBlockedUntil, resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
buildCodexAppInventoryCacheKey,
|
||||
defaultCodexAppInventoryCache,
|
||||
} from "./app-inventory-cache.js";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
import {
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerAuthProfileId,
|
||||
resolveCodexAppServerAuthProfileIdForAgent,
|
||||
} from "./auth-bridge.js";
|
||||
@@ -87,9 +84,11 @@ import {
|
||||
buildCodexNativeHookRelayConfig,
|
||||
CODEX_NATIVE_HOOK_RELAY_EVENTS,
|
||||
} from "./native-hook-relay.js";
|
||||
import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
buildCodexPluginThreadConfig,
|
||||
buildCodexPluginThreadConfigInputFingerprint,
|
||||
mergeCodexThreadConfigs,
|
||||
shouldBuildCodexPluginThreadConfig,
|
||||
} from "./plugin-thread-config.js";
|
||||
import {
|
||||
@@ -114,7 +113,11 @@ import {
|
||||
resolveCodexUsageLimitResetAtMs,
|
||||
shouldRefreshCodexRateLimitsForUsageLimitMessage,
|
||||
} from "./rate-limits.js";
|
||||
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
|
||||
import {
|
||||
@@ -123,6 +126,7 @@ import {
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
startOrResumeThread,
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
} from "./thread-lifecycle.js";
|
||||
import {
|
||||
inferCodexDynamicToolMeta,
|
||||
@@ -391,50 +395,6 @@ function toCodexTextInput(text: string): CodexUserInput {
|
||||
return { type: "text", text, text_elements: [] };
|
||||
}
|
||||
|
||||
function resolveCodexPluginAppCacheEndpoint(appServer: CodexAppServerRuntimeOptions): string {
|
||||
return JSON.stringify({
|
||||
transport: appServer.start.transport,
|
||||
command: appServer.start.command,
|
||||
args: appServer.start.args,
|
||||
url: appServer.start.url ?? null,
|
||||
credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start),
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprintCodexPluginAppCacheCredentials(
|
||||
startOptions: CodexAppServerRuntimeOptions["start"],
|
||||
): string | null {
|
||||
const authToken = startOptions.authToken ?? "";
|
||||
const headers = Object.entries(startOptions.headers)
|
||||
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right));
|
||||
if (!authToken && headers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
hash.update("openclaw:codex:plugin-app-cache-credentials:v1");
|
||||
hash.update("\0");
|
||||
hash.update(authToken);
|
||||
for (const [key, value] of headers) {
|
||||
hash.update("\0");
|
||||
hash.update(key);
|
||||
hash.update("\0");
|
||||
hash.update(value);
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
function resolveCodexPluginAppCacheCodexHome(
|
||||
appServer: CodexAppServerRuntimeOptions,
|
||||
agentDir: string,
|
||||
): string | undefined {
|
||||
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
|
||||
if (configuredCodexHome) {
|
||||
return configuredCodexHome;
|
||||
}
|
||||
return appServer.start.transport === "stdio" ? resolveCodexAppServerHomeDir(agentDir) : undefined;
|
||||
}
|
||||
|
||||
function restrictCodexAppServerSandboxForOpenClawSandbox(
|
||||
appServer: CodexAppServerRuntimeOptions,
|
||||
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>,
|
||||
@@ -539,6 +499,16 @@ export async function runCodexAppServerAttempt(
|
||||
: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cfg: params.config,
|
||||
toolsEnabled: supportsModelTools(params.model),
|
||||
disableTools: params.disableTools,
|
||||
toolsAllow: params.toolsAllow,
|
||||
});
|
||||
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
|
||||
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
@@ -697,10 +667,13 @@ export async function runCodexAppServerAttempt(
|
||||
tools: toolBridge.specs,
|
||||
});
|
||||
let client: CodexAppServerClient;
|
||||
let thread: CodexAppServerThreadBinding;
|
||||
let thread: CodexAppServerThreadLifecycleBinding;
|
||||
let trajectoryEndRecorded = false;
|
||||
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
|
||||
let startupClientForCleanup: CodexAppServerClient | undefined;
|
||||
let restartContextEngineCodexThread:
|
||||
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
|
||||
| undefined;
|
||||
const startupTimeoutMs = resolveCodexStartupTimeoutMs({
|
||||
timeoutMs: params.timeoutMs,
|
||||
timeoutFloorMs: options.startupTimeoutFloorMs,
|
||||
@@ -732,11 +705,14 @@ export async function runCodexAppServerAttempt(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const threadConfig = nativeHookRelayConfig;
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
|
||||
);
|
||||
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
|
||||
const pluginAppCacheKey = buildCodexAppInventoryCacheKey({
|
||||
codexHome: resolveCodexPluginAppCacheCodexHome(appServer, agentDir),
|
||||
endpoint: resolveCodexPluginAppCacheEndpoint(appServer),
|
||||
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir,
|
||||
authProfileId: startupAuthProfileId,
|
||||
accountId: startupAuthAccountCacheKey,
|
||||
envApiKeyFingerprint: startupEnvApiKeyCacheKey,
|
||||
@@ -783,7 +759,7 @@ export async function runCodexAppServerAttempt(
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
const startupThread = await startOrResumeThread({
|
||||
const threadLifecycleParams = {
|
||||
client: startupClient,
|
||||
params: runtimeParams,
|
||||
cwd: effectiveWorkspace,
|
||||
@@ -791,6 +767,8 @@ export async function runCodexAppServerAttempt(
|
||||
appServer: pluginAppServer,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
config: threadConfig,
|
||||
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
|
||||
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
|
||||
pluginThreadConfig: pluginThreadConfigEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -809,7 +787,9 @@ export async function runCodexAppServerAttempt(
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} satisfies Parameters<typeof startOrResumeThread>[0];
|
||||
restartContextEngineCodexThread = () => startOrResumeThread(threadLifecycleParams);
|
||||
const startupThread = await startOrResumeThread(threadLifecycleParams);
|
||||
return { client: startupClient, thread: startupThread };
|
||||
};
|
||||
for (
|
||||
@@ -923,6 +903,7 @@ export async function runCodexAppServerAttempt(
|
||||
let turnCompletionLastActivityReason = "startup";
|
||||
let turnCompletionLastActivityDetails: Record<string, unknown> | undefined;
|
||||
let activeAppServerTurnRequests = 0;
|
||||
const activeOpenClawDynamicToolCallIds = new Set<string>();
|
||||
const activeTurnItemIds = new Set<string>();
|
||||
|
||||
const clearTurnCompletionIdleTimer = () => {
|
||||
@@ -1268,12 +1249,16 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionIdleWatchArmed &&
|
||||
!turnCompletionIdleWatchPinnedByTerminalError &&
|
||||
notification.method !== "turn/completed" &&
|
||||
isCurrentTurnNotification
|
||||
isCurrentTurnNotification &&
|
||||
!isTrackedOpenClawDynamicToolCompletionNotification(
|
||||
notification,
|
||||
activeOpenClawDynamicToolCallIds,
|
||||
)
|
||||
) {
|
||||
// The short completion-idle watchdog only guards the blind gap after
|
||||
// OpenClaw hands a turn-scoped request result back to Codex. Once Codex
|
||||
// sends another current-turn notification, the app-server is alive again;
|
||||
// the longer terminal watchdog remains the stuck-turn backstop.
|
||||
// OpenClaw hands a turn-scoped request result back to Codex. Bookkeeping
|
||||
// that closes the just-served OpenClaw dynamic tool item is still part of
|
||||
// that handoff, so keep the short watchdog armed for that notification.
|
||||
disarmTurnCompletionIdleWatch();
|
||||
}
|
||||
// Determine terminal-turn status before invoking the projector so a throw
|
||||
@@ -1369,6 +1354,7 @@ export async function runCodexAppServerAttempt(
|
||||
return undefined;
|
||||
}
|
||||
armCompletionWatchOnResponse = true;
|
||||
activeOpenClawDynamicToolCallIds.add(call.callId);
|
||||
trajectoryRecorder?.recordEvent("tool.call", {
|
||||
threadId: call.threadId,
|
||||
turnId: call.turnId,
|
||||
@@ -1471,17 +1457,9 @@ export async function runCodexAppServerAttempt(
|
||||
},
|
||||
];
|
||||
|
||||
let turn: CodexTurnStartResponse;
|
||||
try {
|
||||
runAgentHarnessLlmInputHook({
|
||||
event: llmInputEvent,
|
||||
ctx: hookContext,
|
||||
});
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "turn_starting", threadId: thread.threadId },
|
||||
});
|
||||
turn = assertCodexTurnStartResponse(
|
||||
let turn: CodexTurnStartResponse | undefined;
|
||||
const startCodexTurn = async (): Promise<CodexTurnStartResponse> =>
|
||||
assertCodexTurnStartResponse(
|
||||
await client.request(
|
||||
"turn/start",
|
||||
buildTurnStartParams(params, {
|
||||
@@ -1493,78 +1471,121 @@ export async function runCodexAppServerAttempt(
|
||||
{ timeoutMs: params.timeoutMs, signal: runAbortController.signal },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const usageLimitError = await formatCodexTurnStartUsageLimitError({
|
||||
client,
|
||||
error,
|
||||
pendingNotifications,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
try {
|
||||
runAgentHarnessLlmInputHook({
|
||||
event: llmInputEvent,
|
||||
ctx: hookContext,
|
||||
});
|
||||
const turnStartErrorMessage = usageLimitError?.message ?? formatErrorMessage(error);
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
|
||||
data: { phase: "turn_starting", threadId: thread.threadId },
|
||||
});
|
||||
trajectoryRecorder?.recordEvent("session.ended", {
|
||||
status: "error",
|
||||
threadId: thread.threadId,
|
||||
timedOut,
|
||||
aborted: runAbortController.signal.aborted,
|
||||
promptError: turnStartErrorMessage,
|
||||
});
|
||||
trajectoryEndRecorded = true;
|
||||
runAgentHarnessLlmOutputHook({
|
||||
event: {
|
||||
turn = await startCodexTurn();
|
||||
} catch (error) {
|
||||
let turnStartError = error;
|
||||
if (
|
||||
shouldRetryContextEngineTurnOnFreshCodexThread({
|
||||
error: turnStartError,
|
||||
contextEngineActive: Boolean(activeContextEngine),
|
||||
thread,
|
||||
}) &&
|
||||
restartContextEngineCodexThread
|
||||
) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server context-engine turn overflowed on resume; retrying with fresh thread",
|
||||
{
|
||||
threadId: thread.threadId,
|
||||
error: formatErrorMessage(turnStartError),
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
thread = await restartContextEngineCodexThread();
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "thread_ready_retry", threadId: thread.threadId },
|
||||
});
|
||||
try {
|
||||
turn = await startCodexTurn();
|
||||
} catch (retryError) {
|
||||
turnStartError = retryError;
|
||||
}
|
||||
}
|
||||
if (turn === undefined) {
|
||||
const usageLimitError = await formatCodexTurnStartUsageLimitError({
|
||||
client,
|
||||
error: turnStartError,
|
||||
pendingNotifications,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
const turnStartErrorMessage = usageLimitError?.message ?? formatErrorMessage(turnStartError);
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
|
||||
});
|
||||
trajectoryRecorder?.recordEvent("session.ended", {
|
||||
status: "error",
|
||||
threadId: thread.threadId,
|
||||
timedOut,
|
||||
aborted: runAbortController.signal.aborted,
|
||||
promptError: turnStartErrorMessage,
|
||||
});
|
||||
trajectoryEndRecorded = true;
|
||||
runAgentHarnessLlmOutputHook({
|
||||
event: {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
provider: params.provider,
|
||||
model: params.modelId,
|
||||
resolvedRef:
|
||||
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
|
||||
...(params.runtimePlan?.observability.harnessId
|
||||
? { harnessId: params.runtimePlan.observability.harnessId }
|
||||
: {}),
|
||||
assistantTexts: [],
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: {
|
||||
messages: turnStartFailureMessages,
|
||||
success: false,
|
||||
error: turnStartErrorMessage,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
nativeHookRelay?.unregister();
|
||||
await runAgentCleanupStep({
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
provider: params.provider,
|
||||
model: params.modelId,
|
||||
resolvedRef:
|
||||
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
|
||||
...(params.runtimePlan?.observability.harnessId
|
||||
? { harnessId: params.runtimePlan.observability.harnessId }
|
||||
: {}),
|
||||
assistantTexts: [],
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: {
|
||||
messages: turnStartFailureMessages,
|
||||
success: false,
|
||||
error: turnStartErrorMessage,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
},
|
||||
ctx: hookContext,
|
||||
});
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
nativeHookRelay?.unregister();
|
||||
await runAgentCleanupStep({
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
step: "codex-trajectory-flush-startup-failure",
|
||||
log: embeddedAgentLog,
|
||||
cleanup: async () => {
|
||||
await trajectoryRecorder?.flush();
|
||||
},
|
||||
});
|
||||
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
|
||||
if (usageLimitError) {
|
||||
await markCodexAuthProfileBlockedFromRateLimits({
|
||||
params,
|
||||
authProfileId: startupAuthProfileId,
|
||||
rateLimits: usageLimitError.rateLimitsForProfile,
|
||||
});
|
||||
return buildCodexTurnStartFailureResult({
|
||||
params,
|
||||
message: usageLimitError.message,
|
||||
messagesSnapshot: turnStartFailureMessages,
|
||||
systemPromptReport,
|
||||
step: "codex-trajectory-flush-startup-failure",
|
||||
log: embeddedAgentLog,
|
||||
cleanup: async () => {
|
||||
await trajectoryRecorder?.flush();
|
||||
},
|
||||
});
|
||||
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
|
||||
if (usageLimitError) {
|
||||
await markCodexAuthProfileBlockedFromRateLimits({
|
||||
params,
|
||||
authProfileId: startupAuthProfileId,
|
||||
rateLimits: usageLimitError.rateLimitsForProfile,
|
||||
});
|
||||
return buildCodexTurnStartFailureResult({
|
||||
params,
|
||||
message: usageLimitError.message,
|
||||
messagesSnapshot: turnStartFailureMessages,
|
||||
systemPromptReport,
|
||||
});
|
||||
}
|
||||
throw turnStartError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!turn) {
|
||||
throw new Error("codex app-server turn/start failed without an error");
|
||||
}
|
||||
turnId = turn.turn.id;
|
||||
const activeTurnId = turn.turn.id;
|
||||
@@ -1881,6 +1902,7 @@ function buildCodexTurnStartFailureResult(params: {
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
messagingToolSourceReplyPayloads: [],
|
||||
cloudCodeAssistFormatError: false,
|
||||
replayMetadata: {
|
||||
hadPotentialSideEffects: false,
|
||||
@@ -2229,6 +2251,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
resolvedWorkspace: input.resolvedWorkspace,
|
||||
}),
|
||||
config: params.config,
|
||||
authProfileStore: params.authProfileStore,
|
||||
abortSignal: input.runAbortController.signal,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
@@ -2670,6 +2693,22 @@ function readNotificationItemId(notification: CodexServerNotification): string |
|
||||
);
|
||||
}
|
||||
|
||||
function isTrackedOpenClawDynamicToolCompletionNotification(
|
||||
notification: CodexServerNotification,
|
||||
activeOpenClawDynamicToolCallIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (notification.method !== "item/completed" || !isJsonObject(notification.params)) {
|
||||
return false;
|
||||
}
|
||||
const itemId = readNotificationItemId(notification);
|
||||
if (!itemId || !activeOpenClawDynamicToolCallIds.has(itemId)) {
|
||||
return false;
|
||||
}
|
||||
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
|
||||
const itemType = item ? readString(item, "type") : undefined;
|
||||
return itemType === undefined || itemType === "dynamicToolCall";
|
||||
}
|
||||
|
||||
function readRawAssistantTextPreview(item: JsonObject): string | undefined {
|
||||
if (readString(item, "role") !== "assistant" || !Array.isArray(item.content)) {
|
||||
return undefined;
|
||||
@@ -3082,6 +3121,28 @@ function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function shouldRetryContextEngineTurnOnFreshCodexThread(params: {
|
||||
error: unknown;
|
||||
contextEngineActive: boolean;
|
||||
thread: CodexAppServerThreadLifecycleBinding;
|
||||
}): boolean {
|
||||
if (!params.contextEngineActive || params.thread.lifecycle.action !== "resumed") {
|
||||
return false;
|
||||
}
|
||||
return isCodexContextWindowError(params.error);
|
||||
}
|
||||
|
||||
function isCodexContextWindowError(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error);
|
||||
return (
|
||||
/ran out of room in the model'?s context window/iu.test(message) ||
|
||||
/context window/iu.test(message) ||
|
||||
/context length/iu.test(message) ||
|
||||
/maximum context/iu.test(message) ||
|
||||
/too many tokens/iu.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function readCodexNotificationItem(params: JsonValue | undefined): CodexThreadItem | undefined {
|
||||
if (!isJsonObject(params) || !isJsonObject(params.item)) {
|
||||
return undefined;
|
||||
@@ -3155,7 +3216,6 @@ export const __testing = {
|
||||
filterToolsForVisionInputs,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
resolveDynamicToolCallTimeoutMs,
|
||||
resolveCodexPluginAppCacheEndpoint,
|
||||
restrictCodexAppServerSandboxForOpenClawSandbox,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldForceMessageTool,
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("codex app-server session binding", () => {
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "tools-v1",
|
||||
userMcpServersFingerprint: "user-mcp-v1",
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
@@ -70,6 +71,7 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.model).toBe("gpt-5.4-codex");
|
||||
expect(binding?.modelProvider).toBe("openai");
|
||||
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
|
||||
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
|
||||
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));
|
||||
expect(bindingStat.isFile()).toBe(true);
|
||||
});
|
||||
@@ -102,6 +104,27 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips context-engine binding metadata", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-123",
|
||||
cwd: tempDir,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "lossless-policy-1",
|
||||
},
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
|
||||
expect(binding?.contextEngine).toEqual({
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "lossless-policy-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects old plugin app policy entries that duplicate the app id", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -40,13 +40,22 @@ export type CodexAppServerThreadBinding = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
pluginAppsFingerprint?: string;
|
||||
pluginAppsInputFingerprint?: string;
|
||||
pluginAppPolicyContext?: PluginAppPolicyContext;
|
||||
contextEngine?: CodexAppServerContextEngineBinding;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CodexAppServerContextEngineBinding = {
|
||||
schemaVersion: 1;
|
||||
engineId: string;
|
||||
policyFingerprint: string;
|
||||
};
|
||||
|
||||
export function resolveCodexAppServerBindingPath(sessionFile: string): string {
|
||||
return `${sessionFile}.codex-app-server.json`;
|
||||
}
|
||||
@@ -92,6 +101,12 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
: undefined,
|
||||
userMcpServersFingerprint:
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
: undefined,
|
||||
mcpServersFingerprint:
|
||||
typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined,
|
||||
pluginAppsFingerprint:
|
||||
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
|
||||
pluginAppsInputFingerprint:
|
||||
@@ -99,6 +114,7 @@ export async function readCodexAppServerBinding(
|
||||
? parsed.pluginAppsInputFingerprint
|
||||
: undefined,
|
||||
pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext),
|
||||
contextEngine: readContextEngineBinding(parsed.contextEngine),
|
||||
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
|
||||
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
@@ -135,9 +151,12 @@ export async function writeCodexAppServerBinding(
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: binding.contextEngine,
|
||||
createdAt: binding.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -147,6 +166,25 @@ export async function writeCodexAppServerBinding(
|
||||
);
|
||||
}
|
||||
|
||||
function readContextEngineBinding(value: unknown): CodexAppServerContextEngineBinding | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
typeof record.engineId !== "string" ||
|
||||
typeof record.policyFingerprint !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
engineId: record.engineId,
|
||||
policyFingerprint: record.policyFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
|
||||
@@ -39,6 +39,7 @@ let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerMod
|
||||
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
|
||||
async function sendInitializeResult(
|
||||
@@ -63,6 +64,7 @@ describe("shared Codex app-server client", () => {
|
||||
clearSharedCodexAppServerClient,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
createIsolatedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} = await import("./shared-client.js"));
|
||||
});
|
||||
@@ -151,6 +153,39 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.authProfileId).toBe("openai-codex:work");
|
||||
});
|
||||
|
||||
it("skips target auth resolution when native source auth is requested", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const config = { auth: { order: { "openai-codex": ["openai-codex:target"] } } };
|
||||
|
||||
const clientPromise = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
config,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
const [bridgeCall] = mocks.bridgeCodexAppServerStartOptions.mock.calls[0] ?? [];
|
||||
expect(bridgeCall).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
authProfileId: null,
|
||||
config,
|
||||
}),
|
||||
);
|
||||
const [applyCall] = mocks.applyCodexAppServerAuthProfile.mock.calls[0] ?? [];
|
||||
expect(applyCall).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
authProfileId: null,
|
||||
config,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the configured implicit auth profile before sharing a client", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
@@ -32,29 +32,34 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
export async function getSharedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: options?.authProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
authProfileId,
|
||||
agentDir,
|
||||
agentDir: usesNativeAuth ? undefined : agentDir,
|
||||
});
|
||||
if (state.key && state.key !== key) {
|
||||
clearSharedCodexAppServerClient();
|
||||
@@ -71,7 +76,7 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
});
|
||||
@@ -100,23 +105,28 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
export async function createIsolatedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: options?.authProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
@@ -126,7 +136,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
isActiveHarnessContextEngine,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
|
||||
import {
|
||||
CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY,
|
||||
renderCodexPromptOverlay,
|
||||
@@ -9,6 +11,10 @@ import {
|
||||
import { isModernCodexModel } from "../../provider.js";
|
||||
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
|
||||
import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
} from "./context-engine-projection.js";
|
||||
import {
|
||||
isCodexPluginThreadBindingStale,
|
||||
mergeCodexThreadConfigs,
|
||||
@@ -34,9 +40,19 @@ import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
type CodexAppServerAuthProfileLookup,
|
||||
type CodexAppServerContextEngineBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
export type CodexAppServerThreadLifecycle = {
|
||||
action: "started" | "resumed";
|
||||
rotatedContextEngineBinding?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding & {
|
||||
lifecycle: CodexAppServerThreadLifecycle;
|
||||
};
|
||||
|
||||
export type CodexPluginThreadConfigProvider = {
|
||||
enabled: boolean;
|
||||
inputFingerprint?: string;
|
||||
@@ -57,16 +73,58 @@ export async function startOrResumeThread(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
mcpServersFingerprint?: string;
|
||||
mcpServersFingerprintEvaluated?: boolean;
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
}): Promise<CodexAppServerThreadBinding> {
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
const contextEngineBinding = buildContextEngineBinding(params.params);
|
||||
const userMcpServersConfigPatch = buildCodexUserMcpServersThreadConfigPatch(params.params.config);
|
||||
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
let preserveExistingBinding = false;
|
||||
let rotatedContextEngineBinding = false;
|
||||
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
|
||||
if (binding?.threadId && (binding.contextEngine || contextEngineBinding)) {
|
||||
if (
|
||||
!contextEngineBinding ||
|
||||
!isContextEngineBindingCompatible(binding.contextEngine, contextEngineBinding)
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server context-engine binding changed; starting a new thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
engineId: contextEngineBinding?.engineId,
|
||||
previousEngineId: binding.contextEngine?.engineId,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
rotatedContextEngineBinding = true;
|
||||
}
|
||||
}
|
||||
if (binding?.threadId && binding.userMcpServersFingerprint !== userMcpServersFingerprint) {
|
||||
embeddedAgentLog.debug("codex app-server user MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
let pluginBindingStale = isCodexPluginThreadBindingStale({
|
||||
codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false,
|
||||
@@ -101,6 +159,17 @@ export async function startOrResumeThread(params: {
|
||||
binding = undefined;
|
||||
}
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
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.
|
||||
@@ -134,6 +203,7 @@ export async function startOrResumeThread(params: {
|
||||
} else {
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const resumeConfig = mergeCodexThreadConfigs(params.config, userMcpServersConfigPatch);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
@@ -142,7 +212,7 @@ export async function startOrResumeThread(params: {
|
||||
authProfileId,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: params.config,
|
||||
config: resumeConfig,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -154,6 +224,10 @@ export async function startOrResumeThread(params: {
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: binding.mcpServersFingerprint;
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
@@ -163,9 +237,12 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
createdAt: binding.createdAt,
|
||||
},
|
||||
{
|
||||
@@ -182,9 +259,13 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
lifecycle: { action: "resumed" },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isCodexAppServerConnectionClosedError(error)) {
|
||||
@@ -201,7 +282,11 @@ export async function startOrResumeThread(params: {
|
||||
const pluginThreadConfig = params.pluginThreadConfig?.enabled
|
||||
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
|
||||
: undefined;
|
||||
const config = mergeCodexThreadConfigs(params.config, pluginThreadConfig?.configPatch);
|
||||
const config = mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
);
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await params.client.request(
|
||||
"thread/start",
|
||||
@@ -222,6 +307,8 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
});
|
||||
const createdAt = new Date().toISOString();
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
|
||||
if (!preserveExistingBinding) {
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
@@ -232,9 +319,12 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
@@ -253,14 +343,87 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lifecycle: {
|
||||
action: "started",
|
||||
...(rotatedContextEngineBinding ? { rotatedContextEngineBinding } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildContextEngineBinding(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
): CodexAppServerContextEngineBinding | undefined {
|
||||
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
const engineId = contextEngine?.info?.id?.trim();
|
||||
if (!contextEngine || !engineId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
engineId,
|
||||
policyFingerprint: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
engineId,
|
||||
engineVersion: contextEngine.info.version,
|
||||
ownsCompaction: contextEngine.info.ownsCompaction === true,
|
||||
turnMaintenanceMode: contextEngine.info.turnMaintenanceMode,
|
||||
citationsMode: resolveContextEngineCitationsMode(params.config),
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
|
||||
config: params.config,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function isContextEngineBindingCompatible(
|
||||
previous: CodexAppServerContextEngineBinding | undefined,
|
||||
next: CodexAppServerContextEngineBinding,
|
||||
): boolean {
|
||||
return (
|
||||
previous?.schemaVersion === next.schemaVersion &&
|
||||
previous.engineId === next.engineId &&
|
||||
previous.policyFingerprint === next.policyFingerprint
|
||||
);
|
||||
}
|
||||
|
||||
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
|
||||
const rootConfig = isUnknownRecord(config) ? config : undefined;
|
||||
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
|
||||
const citations = memoryConfig?.citations;
|
||||
return isJsonConfigValue(citations) ? citations : undefined;
|
||||
}
|
||||
|
||||
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isJsonConfigValue(value: unknown): value is JsonValue {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isJsonConfigValue);
|
||||
}
|
||||
return isUnknownRecord(value) && Object.values(value).every(isJsonConfigValue);
|
||||
}
|
||||
|
||||
function shouldRecheckRecoverablePluginBinding(params: {
|
||||
binding: CodexAppServerThreadBinding;
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
@@ -420,6 +583,12 @@ function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprintUserMcpServersConfigPatch(
|
||||
configPatch: JsonObject | undefined,
|
||||
): string | undefined {
|
||||
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
|
||||
}
|
||||
|
||||
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
|
||||
if (!isJsonObject(tool)) {
|
||||
return stabilizeJsonValue(tool);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp",
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/tmp",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function threadResumeResult(threadId = "thread-existing"): Record<string, unknown> {
|
||||
return threadStartResult(threadId);
|
||||
}
|
||||
|
||||
function createAppServerOptions(): CodexAppServerRuntimeOptions {
|
||||
return {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 60_000,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
} as unknown as CodexAppServerRuntimeOptions;
|
||||
}
|
||||
|
||||
function createParams(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
configOverrides?: EmbeddedRunAttemptParams["config"],
|
||||
): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4-codex",
|
||||
thinkLevel: "medium",
|
||||
disableTools: true,
|
||||
timeoutMs: 5_000,
|
||||
authStorage: {} as never,
|
||||
authProfileStore: { version: 1, profiles: {} },
|
||||
modelRegistry: {} as never,
|
||||
config: configOverrides,
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
describe("startOrResumeThread — user mcp.servers projection (regression: #80814)", () => {
|
||||
let tempDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-80814-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("projects cfg.mcp.servers into the thread/start config patch under mcp_servers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, {
|
||||
mcp: {
|
||||
servers: {
|
||||
outlook: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/outlook-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"]),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
|
||||
expect(startParams?.config?.mcp_servers).toBeDefined();
|
||||
expect(startParams.config!.mcp_servers).toMatchObject({
|
||||
outlook: { command: "node", args: ["/opt/outlook-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits mcp_servers from the start config when cfg has no user MCP servers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
|
||||
expect(startParams?.config?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts a new thread when an existing binding lacks the matching user MCP fingerprint", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-restarted");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, {
|
||||
mcp: {
|
||||
servers: {
|
||||
notes: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/notes-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"]),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(request.mock.calls.some(([method]) => method === "thread/resume")).toBe(false);
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as {
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(startParams?.config?.mcp_servers).toBeDefined();
|
||||
expect(startParams.config!.mcp_servers).toMatchObject({
|
||||
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const config = {
|
||||
mcp: {
|
||||
servers: {
|
||||
notes: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/notes-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"];
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-with-user-mcp");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadResumeResult("thread-with-user-mcp");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, config),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
request.mockClear();
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, config),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const resumeCall = request.mock.calls.find(([method]) => method === "thread/resume");
|
||||
const resumeParams = resumeCall?.[1] as {
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(resumeCall).toBeDefined();
|
||||
expect(resumeParams?.config?.mcp_servers).toMatchObject({
|
||||
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
// Keep this in sync with the Codex CLI live-test package pin.
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.130.0";
|
||||
|
||||
@@ -21,14 +21,22 @@ import type {
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
|
||||
import {
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerAuthProfileIdForAgent,
|
||||
resolveCodexAppServerEnvApiKeyCacheKey,
|
||||
} from "../app-server/auth-bridge.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
} from "../app-server/config.js";
|
||||
import {
|
||||
ensureCodexPluginActivation,
|
||||
type CodexPluginActivationResult,
|
||||
} from "../app-server/plugin-activation.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
@@ -40,6 +48,7 @@ import {
|
||||
readCodexPluginMigrationConfigEntry,
|
||||
type CodexPluginMigrationConfigEntry,
|
||||
} from "./plan.js";
|
||||
import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
|
||||
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
|
||||
@@ -104,6 +113,8 @@ async function applyCodexPluginInstallItem(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const appCacheKey = await buildTargetCodexPluginAppCacheKey(ctx);
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: policy,
|
||||
installEvenIfActive: true,
|
||||
@@ -112,10 +123,14 @@ async function applyCodexPluginInstallItem(
|
||||
method,
|
||||
requestParams,
|
||||
timeoutMs: 60_000,
|
||||
startOptions: appServer.start,
|
||||
agentDir: resolveCodexMigrationTargets(ctx).agentDir,
|
||||
config: ctx.config,
|
||||
isolated: true,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey,
|
||||
});
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
const baseDetails = {
|
||||
...item.details,
|
||||
code: result.reason,
|
||||
@@ -162,6 +177,38 @@ async function applyCodexPluginInstallItem(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetCodexAppServer(ctx: MigrationProviderContext) {
|
||||
return resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: readCodexPluginConfig(ctx.config),
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTargetCodexPluginAppCacheKey(ctx: MigrationProviderContext): Promise<string> {
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
agentDir: targets.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const accountId = await resolveCodexAppServerAuthAccountCacheKey({
|
||||
authProfileId,
|
||||
agentDir: targets.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const envApiKeyFingerprint = authProfileId
|
||||
? undefined
|
||||
: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
return buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
agentDir: targets.agentDir,
|
||||
authProfileId,
|
||||
accountId,
|
||||
envApiKeyFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCodexPluginConfigItem(
|
||||
ctx: MigrationProviderContext,
|
||||
item: MigrationItem,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import { exists, sanitizeName } from "./helpers.js";
|
||||
import {
|
||||
codexPluginMigrationSubscriptionWarning,
|
||||
discoverCodexSource,
|
||||
hasCodexSource,
|
||||
type CodexPluginSource,
|
||||
@@ -33,6 +34,7 @@ const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [
|
||||
"codexPlugins",
|
||||
] as const;
|
||||
const MIGRATION_REASON_PLUGIN_EXISTS = "plugin exists";
|
||||
const CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED = "not_run";
|
||||
|
||||
export type CodexPluginMigrationConfigEntry = {
|
||||
configKey: string;
|
||||
@@ -40,6 +42,13 @@ export type CodexPluginMigrationConfigEntry = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type CodexPluginMigrationBlockSkipDetails = {
|
||||
pluginName: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
apps?: NonNullable<CodexPluginSource["migrationBlock"]>["apps"];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
if ((counts.get(base) ?? 0) <= 1) {
|
||||
@@ -173,6 +182,9 @@ function buildPluginItems(
|
||||
pluginName: plugin.pluginName,
|
||||
sourceInstalled: plugin.installed === true,
|
||||
sourceEnabled: plugin.enabled === true,
|
||||
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
|
||||
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -180,6 +192,29 @@ function buildPluginItems(
|
||||
}
|
||||
|
||||
manualIndex += 1;
|
||||
if (plugin.migrationBlock && plugin.pluginName) {
|
||||
const details: CodexPluginMigrationBlockSkipDetails = {
|
||||
pluginName: plugin.pluginName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
...(plugin.migrationBlock.apps ? { apps: plugin.migrationBlock.apps } : {}),
|
||||
...(plugin.migrationBlock.error ? { error: plugin.migrationBlock.error } : {}),
|
||||
};
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
source: plugin.source,
|
||||
status: "skipped",
|
||||
reason: plugin.migrationBlock.code,
|
||||
message:
|
||||
plugin.message ??
|
||||
`Codex native plugin "${plugin.name}" was found but not activated automatically.`,
|
||||
details: { ...details },
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
items.push(
|
||||
createMigrationManualItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
|
||||
@@ -195,6 +230,10 @@ function buildPluginItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
function shouldVerifyPluginApps(ctx: MigrationProviderContext): boolean {
|
||||
return ctx.providerOptions?.verifyPluginApps === true;
|
||||
}
|
||||
|
||||
export function readCodexPluginMigrationConfigEntry(
|
||||
item: MigrationItem,
|
||||
enabled: boolean,
|
||||
@@ -345,13 +384,17 @@ function buildPluginConfigItem(
|
||||
export async function buildCodexMigrationPlan(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<MigrationPlan> {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const source = await discoverCodexSource({
|
||||
input: ctx.source,
|
||||
evaluatePluginMigrationEligibility: true,
|
||||
verifyPluginApps: shouldVerifyPluginApps(ctx),
|
||||
});
|
||||
if (!hasCodexSource(source)) {
|
||||
throw new Error(
|
||||
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
);
|
||||
}
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(
|
||||
...(await buildSkillItems({
|
||||
@@ -386,16 +429,31 @@ export async function buildCodexMigrationPlan(
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting migration targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.length > 0
|
||||
...(source.plugins.some((plugin) => plugin.migratable)
|
||||
? [
|
||||
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.some(
|
||||
(plugin) => plugin.migratable && plugin.apps && plugin.apps.length > 0,
|
||||
) && !shouldVerifyPluginApps(ctx)
|
||||
? [
|
||||
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.some((plugin) => plugin.sourceKind === "cache")
|
||||
? ["Codex cached plugin bundles remain manual-review only."]
|
||||
: []),
|
||||
...(source.pluginDiscoveryError
|
||||
? [
|
||||
`Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`,
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.some(
|
||||
(plugin) => plugin.migrationBlock?.code === "codex_subscription_required",
|
||||
)
|
||||
? [codexPluginMigrationSubscriptionWarning()]
|
||||
: []),
|
||||
...(source.archivePaths.length > 0
|
||||
? [
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
|
||||
@@ -3,8 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
|
||||
const appServerRequest = vi.hoisted(() => vi.fn());
|
||||
@@ -38,6 +40,7 @@ function makeContext(params: {
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
reportDir?: string;
|
||||
config?: MigrationProviderContext["config"];
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
@@ -56,6 +59,7 @@ function makeContext(params: {
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
@@ -69,6 +73,14 @@ function findItem(items: readonly { id?: string }[], id: string) {
|
||||
return item as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function findItemByReason(items: readonly { reason?: string }[], reason: string) {
|
||||
const item = items.find((entry) => entry.reason === reason);
|
||||
if (!item) {
|
||||
throw new Error(`Expected migration item reason ${reason}`);
|
||||
}
|
||||
return item as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
||||
if (!record || typeof record !== "object") {
|
||||
throw new Error("Expected record");
|
||||
@@ -122,9 +134,28 @@ async function createCodexFixture(): Promise<{
|
||||
return { root, homeDir, codexHome, stateDir, workspaceDir };
|
||||
}
|
||||
|
||||
function sourceAppCacheKey(fixture: { codexHome: string }): string {
|
||||
return buildCodexPluginAppCacheKey({
|
||||
appServer: {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
appServerRequest.mockReset();
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -145,6 +176,7 @@ describe("buildCodexMigrationProvider", () => {
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -185,9 +217,15 @@ describe("buildCodexMigrationProvider", () => {
|
||||
|
||||
it("plans source-installed curated plugins without installing during dry-run", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockResolvedValueOnce(
|
||||
pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]),
|
||||
);
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
@@ -195,14 +233,23 @@ describe("buildCodexMigrationProvider", () => {
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(1);
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(mockCallArg(appServerRequest), {
|
||||
method: "plugin/list",
|
||||
requestParams: { cwds: [] },
|
||||
});
|
||||
expectRecordFields((mockCallArg(appServerRequest) as { startOptions?: unknown }).startOptions, {
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
appServerRequest.mock.calls.some(
|
||||
([arg]) => (arg as { method?: string }).method === "plugin/install",
|
||||
@@ -226,6 +273,588 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips source-installed plugins whose owned apps are inaccessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [
|
||||
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
expectRecordFields(requestParams, { forceRefetch: true });
|
||||
return appsList([
|
||||
appInfo("asdk_app_readwise", {
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "app_inaccessible");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
reason: "app_inaccessible",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "readwise",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "asdk_app_readwise",
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
needsAuth: false,
|
||||
},
|
||||
]);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("plans app-backed plugins without source app/list by default", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expectRecordFields(findItem(plan.items, "config:codex-plugins"), {
|
||||
kind: "config",
|
||||
action: "merge",
|
||||
status: "planned",
|
||||
});
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Codex app-backed plugins were planned without source app accessibility verification",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and skips app-backed plugins when source Codex account is not ChatGPT subscription auth", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return {
|
||||
account: { type: "apiKey" },
|
||||
requiresOpenaiAuth: true,
|
||||
} satisfies CodexGetAccountResponse;
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "codex_subscription_required");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
reason: "codex_subscription_required",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "gmail",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "app-gmail",
|
||||
name: "Gmail",
|
||||
needsAuth: true,
|
||||
},
|
||||
]);
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Codex app-backed plugin migration requires the Codex app-server source account",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(plan.warnings).not.toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("planned for native activation")]),
|
||||
);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns and skips app-backed plugins when source Codex account is missing", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return {
|
||||
account: null,
|
||||
requiresOpenaiAuth: true,
|
||||
} satisfies CodexGetAccountResponse;
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
expectRecordFields(findItemByReason(plan.items, "codex_subscription_required"), {
|
||||
reason: "codex_subscription_required",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls through to app inventory when source account read fails and app verification is requested", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
throw new Error("account unavailable");
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("app-gmail")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips app-backed plugins by default when source account read fails", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
throw new Error("account unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
const manualItem = findItemByReason(plan.items, "codex_account_unavailable");
|
||||
expectRecordFields(manualItem, {
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
reason: "codex_account_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expectRecordFields(manualItem.details, { error: "account unavailable" });
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads source plugin readiness with native source auth instead of target agent auth", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar", [
|
||||
pluginApp("app-google-calendar", { name: "Google Calendar", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("app-google-calendar")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: fixture.workspaceDir,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:target"],
|
||||
},
|
||||
},
|
||||
} as MigrationProviderContext["config"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(appServerRequest).toHaveBeenCalledTimes(4);
|
||||
for (const [arg] of appServerRequest.mock.calls) {
|
||||
expect(arg).toEqual(
|
||||
expect.objectContaining({
|
||||
authProfileId: null,
|
||||
isolated: true,
|
||||
startOptions: expect.objectContaining({
|
||||
env: {
|
||||
CODEX_HOME: fixture.codexHome,
|
||||
HOME: path.dirname(fixture.codexHome),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(arg).not.toHaveProperty("agentDir");
|
||||
expect(arg).not.toHaveProperty("config");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports inaccessible before missing when multiple owned apps are blocked", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [
|
||||
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
|
||||
pluginApp("asdk_app_reader", { name: "Reader", needsAuth: false }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([
|
||||
appInfo("asdk_app_readwise", {
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const manualItem = findItemByReason(plan.items, "app_inaccessible");
|
||||
expectRecordFields(manualItem, {
|
||||
reason: "app_inaccessible",
|
||||
status: "skipped",
|
||||
});
|
||||
const details = expectRecordFields(manualItem.details, {
|
||||
pluginName: "readwise",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
expect(details).not.toHaveProperty("code");
|
||||
expect(details.apps).toEqual([
|
||||
{
|
||||
id: "asdk_app_reader",
|
||||
name: "Reader",
|
||||
needsAuth: false,
|
||||
},
|
||||
{
|
||||
id: "asdk_app_readwise",
|
||||
name: "Readwise",
|
||||
isAccessible: false,
|
||||
isEnabled: true,
|
||||
needsAuth: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("force-refreshes source app inventory once for app-backed plugins sharing a cache key", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: sourceAppCacheKey(fixture),
|
||||
request: async () => appsList([appInfo("app-google-calendar", { isAccessible: false })]),
|
||||
});
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([
|
||||
pluginSummary("google-calendar", { installed: true, enabled: true }),
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
const pluginName = (requestParams as v2.PluginReadParams).pluginName;
|
||||
return pluginRead(pluginName, [pluginApp(`app-${pluginName}`)]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
expectRecordFields(requestParams, { forceRefetch: true });
|
||||
return appsList([appInfo("app-google-calendar"), appInfo("app-gmail")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(plan.items, "plugin:google-calendar"), { status: "planned" });
|
||||
expectRecordFields(findItem(plan.items, "plugin:gmail"), { status: "planned" });
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for disabled plugins and plugin/read failures", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(
|
||||
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([
|
||||
pluginSummary("readwise", { installed: true, enabled: false }),
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
expectRecordFields(requestParams, { pluginName: "gmail" });
|
||||
throw new Error("detail unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItemByReason(plan.items, "plugin_disabled"), {
|
||||
reason: "plugin_disabled",
|
||||
status: "skipped",
|
||||
});
|
||||
expectRecordFields(findItemByReason(plan.items, "plugin_read_unavailable"), {
|
||||
reason: "plugin_read_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when app inventory refresh fails for app-backed plugins", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("readwise", [pluginApp("asdk_app_readwise", { name: "Readwise" })]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
throw new Error("app inventory unavailable");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItemByReason(plan.items, "app_inventory_unavailable"), {
|
||||
reason: "app_inventory_unavailable",
|
||||
status: "skipped",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats auth-required source apps as ready when app inventory says they are accessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("reader", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("reader", [
|
||||
pluginApp("ready-app", { name: "Ready App", needsAuth: false }),
|
||||
pluginApp("auth-app", { name: "Auth App", needsAuth: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "account/read") {
|
||||
return chatGptAccount();
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([appInfo("ready-app"), appInfo("auth-app")]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
verifyPluginApps: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pluginItem = findItem(plan.items, "plugin:reader");
|
||||
expectRecordFields(pluginItem, {
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
});
|
||||
expectRecordFields(pluginItem.details, {
|
||||
configKey: "reader",
|
||||
pluginName: "reader",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies planned skills and archives native config during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const reportDir = path.join(fixture.root, "report");
|
||||
@@ -275,6 +904,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -287,6 +919,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -375,6 +1010,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
pluginSummary("gmail", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
@@ -415,6 +1053,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -427,6 +1068,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -449,6 +1093,11 @@ describe("buildCodexMigrationProvider", () => {
|
||||
|
||||
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const sourceKey = sourceAppCacheKey(fixture);
|
||||
await defaultCodexAppInventoryCache.refreshNow({
|
||||
key: sourceKey,
|
||||
request: async () => appsList([appInfo("source-only-app")]),
|
||||
});
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -476,6 +1125,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -488,6 +1140,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -504,6 +1159,14 @@ describe("buildCodexMigrationProvider", () => {
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
|
||||
const sourceCacheRead = defaultCodexAppInventoryCache.read({
|
||||
key: sourceKey,
|
||||
request: async () => {
|
||||
throw new Error("source app cache was cleared");
|
||||
},
|
||||
});
|
||||
expect(sourceCacheRead.state).toBe("fresh");
|
||||
expect(sourceCacheRead.snapshot?.apps.map((app) => app.id)).toEqual(["source-only-app"]);
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
@@ -545,6 +1208,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
@@ -557,6 +1223,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -596,6 +1265,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return {
|
||||
authPolicy: "ON_USE",
|
||||
@@ -619,6 +1291,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
@@ -671,6 +1346,9 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
throw new Error("install failed");
|
||||
}
|
||||
@@ -777,6 +1455,61 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRead(pluginName: string, apps: v2.AppSummary[] = []): v2.PluginReadResponse {
|
||||
return {
|
||||
plugin: {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
marketplacePath: "/marketplaces/openai-curated",
|
||||
summary: pluginSummary(pluginName, { installed: true, enabled: true }),
|
||||
description: null,
|
||||
skills: [],
|
||||
apps,
|
||||
mcpServers: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginApp(id: string, overrides: Partial<v2.AppSummary> = {}): v2.AppSummary {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: null,
|
||||
installUrl: null,
|
||||
needsAuth: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appInfo(id: string, overrides: Partial<v2.AppInfo> = {}): v2.AppInfo {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
distributionChannel: null,
|
||||
branding: null,
|
||||
appMetadata: null,
|
||||
labels: null,
|
||||
installUrl: null,
|
||||
isAccessible: true,
|
||||
isEnabled: true,
|
||||
pluginDisplayNames: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function appsList(apps: v2.AppInfo[]): v2.AppsListResponse {
|
||||
return { data: apps, nextCursor: null };
|
||||
}
|
||||
|
||||
function chatGptAccount(): CodexGetAccountResponse {
|
||||
return {
|
||||
account: { type: "chatgpt", email: "codex@example.test", planType: "plus" },
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -18,7 +18,9 @@ export function buildCodexMigrationProvider(
|
||||
description:
|
||||
"Inventory and promote Codex CLI skills while keeping Codex native plugins and hooks explicit.",
|
||||
async detect(ctx) {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const source = await discoverCodexSource({
|
||||
input: ctx.source,
|
||||
});
|
||||
const found = hasCodexSource(source);
|
||||
return {
|
||||
found,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
defaultCodexAppInventoryCache,
|
||||
type CodexAppInventoryRequest,
|
||||
} from "../app-server/app-inventory-cache.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import type { CodexAppServerStartOptions } from "../app-server/config.js";
|
||||
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
|
||||
import {
|
||||
pluginReadParams,
|
||||
type CodexPluginMarketplaceRef,
|
||||
} from "../app-server/plugin-inventory.js";
|
||||
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import {
|
||||
exists,
|
||||
@@ -32,9 +42,35 @@ export type CodexPluginSource = {
|
||||
pluginName?: string;
|
||||
installed?: boolean;
|
||||
enabled?: boolean;
|
||||
apps?: CodexPluginMigrationAppFact[];
|
||||
migrationBlock?: CodexPluginMigrationBlock;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type CodexPluginMigrationBlockCode =
|
||||
| "plugin_disabled"
|
||||
| "codex_subscription_required"
|
||||
| "codex_account_unavailable"
|
||||
| "plugin_read_unavailable"
|
||||
| "app_inventory_unavailable"
|
||||
| "app_inaccessible"
|
||||
| "app_disabled"
|
||||
| "app_missing";
|
||||
|
||||
export type CodexPluginMigrationAppFact = {
|
||||
id: string;
|
||||
name: string;
|
||||
needsAuth?: boolean;
|
||||
isAccessible?: boolean;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type CodexPluginMigrationBlock = {
|
||||
code: CodexPluginMigrationBlockCode;
|
||||
apps?: CodexPluginMigrationAppFact[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CodexArchiveSource = {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -56,6 +92,26 @@ type CodexSource = {
|
||||
archivePaths: CodexArchiveSource[];
|
||||
};
|
||||
|
||||
type CodexSourceDiscoveryOptions = {
|
||||
input?: string;
|
||||
evaluatePluginMigrationEligibility?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
};
|
||||
|
||||
type SourceAppServerRequestOptions = {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
};
|
||||
|
||||
type PluginReadResult =
|
||||
| {
|
||||
ok: true;
|
||||
detail: v2.PluginDetail;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function defaultCodexHome(): string {
|
||||
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
|
||||
}
|
||||
@@ -137,27 +193,19 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
|
||||
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
async function discoverInstalledCuratedPlugins(
|
||||
codexHome: string,
|
||||
options: CodexSourceDiscoveryOptions = {},
|
||||
): Promise<{
|
||||
plugins: CodexPluginSource[];
|
||||
error?: string;
|
||||
}> {
|
||||
const startOptions = sourceCodexAppServerStartOptions(codexHome);
|
||||
const requestOptions = { startOptions };
|
||||
try {
|
||||
const response = await requestCodexAppServerJson<v2.PluginListResponse>({
|
||||
const response = await requestSourceCodexAppServerJson<v2.PluginListResponse>(requestOptions, {
|
||||
method: "plugin/list",
|
||||
requestParams: { cwds: [] } satisfies v2.PluginListParams,
|
||||
timeoutMs: 60_000,
|
||||
isolated: true,
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "config",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: path.dirname(codexHome),
|
||||
},
|
||||
},
|
||||
});
|
||||
const marketplace = response.marketplaces.find(
|
||||
(entry) => entry.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
@@ -170,25 +218,21 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
}
|
||||
const plugins = marketplace.plugins
|
||||
.filter((plugin) => plugin.installed)
|
||||
.map((plugin): CodexPluginSource | undefined => {
|
||||
const pluginName = pluginNameFromSummary(plugin);
|
||||
if (!pluginName) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: plugin.name,
|
||||
pluginName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`,
|
||||
sourceKind: "app-server",
|
||||
migratable: true,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
};
|
||||
})
|
||||
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined)
|
||||
.toSorted((a, b) => (a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name));
|
||||
return { plugins };
|
||||
.map((plugin) => buildInstalledPluginSource(plugin))
|
||||
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined);
|
||||
const withEligibility =
|
||||
options.evaluatePluginMigrationEligibility === true
|
||||
? await withPluginMigrationEligibility({
|
||||
plugins,
|
||||
marketplace: marketplaceRef(marketplace),
|
||||
requestOptions,
|
||||
verifyPluginApps: options.verifyPluginApps === true,
|
||||
})
|
||||
: plugins;
|
||||
const sorted = withEligibility.toSorted((a, b) =>
|
||||
(a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name),
|
||||
);
|
||||
return { plugins: sorted };
|
||||
} catch (error) {
|
||||
return {
|
||||
plugins: [],
|
||||
@@ -197,6 +241,308 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCodexAppServerStartOptions(codexHome: string): CodexAppServerStartOptions {
|
||||
return {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
commandSource: "managed",
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: path.dirname(codexHome),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSourceCodexAppServerJson<T>(
|
||||
options: SourceAppServerRequestOptions,
|
||||
params: {
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
},
|
||||
): Promise<T> {
|
||||
return await requestCodexAppServerJson<T>({
|
||||
method: params.method,
|
||||
requestParams: params.requestParams,
|
||||
timeoutMs: 60_000,
|
||||
startOptions: options.startOptions,
|
||||
authProfileId: null,
|
||||
isolated: true,
|
||||
});
|
||||
}
|
||||
|
||||
function buildInstalledPluginSource(plugin: v2.PluginSummary): CodexPluginSource | undefined {
|
||||
const pluginName = pluginNameFromSummary(plugin);
|
||||
if (!pluginName) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: plugin.name,
|
||||
pluginName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`,
|
||||
sourceKind: "app-server",
|
||||
migratable: true,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
|
||||
return {
|
||||
name: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
...(marketplace.path ? { path: marketplace.path } : {}),
|
||||
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function withPluginMigrationEligibility(params: {
|
||||
plugins: CodexPluginSource[];
|
||||
marketplace: CodexPluginMarketplaceRef;
|
||||
requestOptions: SourceAppServerRequestOptions;
|
||||
verifyPluginApps: boolean;
|
||||
}): Promise<CodexPluginSource[]> {
|
||||
const pending: Array<{ plugin: CodexPluginSource; apps: CodexPluginMigrationAppFact[] }> = [];
|
||||
const evaluated: CodexPluginSource[] = [];
|
||||
|
||||
for (const plugin of params.plugins) {
|
||||
if (plugin.enabled !== true) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "plugin_disabled" },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" is installed in Codex but disabled; enable it in Codex before migrating it to OpenClaw.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = await readPluginDetail(params.requestOptions, params.marketplace, plugin);
|
||||
if (!detail.ok) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "plugin_read_unavailable", error: detail.error },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" detail could not be read: ${detail.error}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (detail.detail.apps.length === 0) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const apps = detail.detail.apps
|
||||
.map(sourcePluginAppFact)
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
pending.push({ plugin, apps });
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
let sourceAccount: Awaited<ReturnType<typeof readSourceCodexAccount>> | undefined;
|
||||
try {
|
||||
sourceAccount = await readSourceCodexAccount(params.requestOptions);
|
||||
} catch (error) {
|
||||
if (!params.verifyPluginApps) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "codex_account_unavailable", apps, error: message },
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but the source Codex app-server account could not be read: ${message}`,
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
}
|
||||
if (sourceAccount && sourceAccount !== "chatgpt") {
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: "codex_subscription_required", apps },
|
||||
message: codexSubscriptionRequiredMessage(plugin),
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
if (!params.verifyPluginApps) {
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
apps,
|
||||
migratable: true,
|
||||
});
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
for (const { plugin, apps } of pending) {
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: {
|
||||
code: "app_inventory_unavailable",
|
||||
apps,
|
||||
error: message,
|
||||
},
|
||||
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
if (!snapshot) {
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
const appInfoById = new Map(snapshot.apps.map((app) => [app.id, app] as const));
|
||||
for (const { plugin, apps: declaredApps } of pending) {
|
||||
const apps = declaredApps
|
||||
.map((app) => sourcePluginAppFactWithInventory(app, appInfoById.get(app.id)))
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
const blockCode = migrationBlockCodeForApps(apps);
|
||||
if (!blockCode) {
|
||||
evaluated.push({ ...plugin, apps, migratable: true });
|
||||
continue;
|
||||
}
|
||||
evaluated.push({
|
||||
...plugin,
|
||||
migratable: false,
|
||||
migrationBlock: { code: blockCode, apps },
|
||||
message: appInventoryBlockMessage(plugin, apps, blockCode),
|
||||
});
|
||||
}
|
||||
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
async function readSourceCodexAccount(
|
||||
options: SourceAppServerRequestOptions,
|
||||
): Promise<"chatgpt" | "non_chatgpt" | "missing"> {
|
||||
const response = await requestSourceCodexAppServerJson<CodexGetAccountResponse>(options, {
|
||||
method: "account/read",
|
||||
requestParams: { refreshToken: false },
|
||||
});
|
||||
if (
|
||||
!response.account ||
|
||||
typeof response.account !== "object" ||
|
||||
Array.isArray(response.account)
|
||||
) {
|
||||
return "missing";
|
||||
}
|
||||
const type = response.account.type;
|
||||
return type === "chatgpt" ? "chatgpt" : "non_chatgpt";
|
||||
}
|
||||
|
||||
async function readPluginDetail(
|
||||
options: SourceAppServerRequestOptions,
|
||||
marketplace: CodexPluginMarketplaceRef,
|
||||
plugin: CodexPluginSource,
|
||||
): Promise<PluginReadResult> {
|
||||
try {
|
||||
const response = await requestSourceCodexAppServerJson<v2.PluginReadResponse>(options, {
|
||||
method: "plugin/read",
|
||||
requestParams: pluginReadParams(marketplace, plugin.pluginName ?? plugin.name),
|
||||
});
|
||||
return { ok: true, detail: response.plugin };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSourceAppInventory(
|
||||
options: SourceAppServerRequestOptions,
|
||||
): Promise<Awaited<ReturnType<typeof defaultCodexAppInventoryCache.refreshNow>>> {
|
||||
const key = buildCodexPluginAppCacheKey({
|
||||
appServer: { start: options.startOptions },
|
||||
});
|
||||
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
||||
await requestSourceCodexAppServerJson<v2.AppsListResponse>(options, {
|
||||
method,
|
||||
requestParams,
|
||||
});
|
||||
return await defaultCodexAppInventoryCache.refreshNow({
|
||||
key,
|
||||
request,
|
||||
forceRefetch: true,
|
||||
});
|
||||
}
|
||||
|
||||
function sourcePluginAppFact(app: v2.AppSummary): CodexPluginMigrationAppFact {
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
needsAuth: app.needsAuth,
|
||||
};
|
||||
}
|
||||
|
||||
function sourcePluginAppFactWithInventory(
|
||||
app: CodexPluginMigrationAppFact,
|
||||
info: v2.AppInfo | undefined,
|
||||
): CodexPluginMigrationAppFact {
|
||||
if (!info) {
|
||||
return app;
|
||||
}
|
||||
return {
|
||||
...app,
|
||||
isAccessible: info.isAccessible,
|
||||
isEnabled: info.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function migrationBlockCodeForApps(
|
||||
apps: readonly CodexPluginMigrationAppFact[],
|
||||
): CodexPluginMigrationBlockCode | undefined {
|
||||
if (apps.some((app) => app.isAccessible === false)) {
|
||||
return "app_inaccessible";
|
||||
}
|
||||
if (apps.some((app) => app.isEnabled === false)) {
|
||||
return "app_disabled";
|
||||
}
|
||||
if (apps.some((app) => app.isAccessible === undefined || app.isEnabled === undefined)) {
|
||||
return "app_missing";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appInventoryBlockMessage(
|
||||
plugin: CodexPluginSource,
|
||||
apps: readonly CodexPluginMigrationAppFact[],
|
||||
code: CodexPluginMigrationBlockCode,
|
||||
): string {
|
||||
const status =
|
||||
code === "app_inaccessible" ? "inaccessible" : code === "app_disabled" ? "disabled" : "missing";
|
||||
const blocking =
|
||||
apps.find((app) =>
|
||||
code === "app_inaccessible"
|
||||
? app.isAccessible === false
|
||||
: code === "app_disabled"
|
||||
? app.isEnabled === false
|
||||
: app.isAccessible === undefined || app.isEnabled === undefined,
|
||||
) ?? apps[0];
|
||||
const appLabel = blocking ? ` app "${blocking.name}"` : " an owned app";
|
||||
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns${appLabel} but the source app inventory reports it is ${status}; authenticate or enable the app in Codex before migrating it to OpenClaw.`;
|
||||
}
|
||||
|
||||
export function codexPluginMigrationSubscriptionWarning(): string {
|
||||
return "Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.";
|
||||
}
|
||||
|
||||
function codexSubscriptionRequiredMessage(plugin: CodexPluginSource): string {
|
||||
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but ${codexPluginMigrationSubscriptionWarning()}`;
|
||||
}
|
||||
|
||||
function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
|
||||
const candidates = [summary.id, summary.name];
|
||||
for (const candidate of candidates) {
|
||||
@@ -216,8 +562,14 @@ function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
|
||||
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
|
||||
export async function discoverCodexSource(
|
||||
inputOrOptions?: string | CodexSourceDiscoveryOptions,
|
||||
): Promise<CodexSource> {
|
||||
const options =
|
||||
typeof inputOrOptions === "string" || inputOrOptions === undefined
|
||||
? { input: inputOrOptions }
|
||||
: inputOrOptions;
|
||||
const codexHome = resolveHomePath(options.input?.trim() || defaultCodexHome());
|
||||
const codexSkillsDir = path.join(codexHome, "skills");
|
||||
const agentsSkillsDir = personalAgentsSkillsDir();
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
@@ -231,7 +583,7 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
root: agentsSkillsDir,
|
||||
sourceLabel: "personal AgentSkill",
|
||||
});
|
||||
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome);
|
||||
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome, options);
|
||||
const sourcePluginNames = new Set(
|
||||
sourcePluginDiscovery.plugins.flatMap((plugin) =>
|
||||
plugin.pluginName ? [plugin.pluginName] : [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepInfra provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw diagnostics Prometheus exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -31,10 +31,10 @@
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"openclawVersion": "2026.5.12-beta.8",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,7 +21,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.12-beta.1"
|
||||
"openclaw": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -65,10 +65,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
"pluginApi": ">=2026.5.12-beta.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1"
|
||||
"openclawVersion": "2026.5.12-beta.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/document-extract-plugin",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"version": "2026.5.12-beta.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw local document extraction plugin",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user