mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 15:32:40 +08:00
Compare commits
1 Commits
ci-latency
...
codex/work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a7c93389 |
@@ -127,7 +127,7 @@ The `fetch-content` output for `discussion_comment` includes `comment_node_id` a
|
||||
The recreated comment should follow this format:
|
||||
|
||||
```
|
||||
> **Note:** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
|
||||
> **Note from maintainer (@<LOGIN>):** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
|
||||
|
||||
---
|
||||
|
||||
|
||||
79
.github/actions/checkout-ci-fast/action.yml
vendored
79
.github/actions/checkout-ci-fast/action.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Fast CI checkout
|
||||
description: >
|
||||
Perform a shallow detached checkout with timeout/retry behavior tuned for the
|
||||
Linux CI lanes that have seen intermittent actions/checkout stalls.
|
||||
inputs:
|
||||
repo:
|
||||
description: Repository in owner/name form.
|
||||
required: true
|
||||
sha:
|
||||
description: Commit SHA to fetch and checkout.
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token used for authenticated fetches.
|
||||
required: true
|
||||
fetch-depth:
|
||||
description: Depth to fetch from origin.
|
||||
required: false
|
||||
default: "1"
|
||||
fetch-timeout-seconds:
|
||||
description: Per-attempt fetch timeout in seconds.
|
||||
required: false
|
||||
default: "30"
|
||||
attempts:
|
||||
description: Number of checkout attempts before failing.
|
||||
required: false
|
||||
default: "2"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fast checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ inputs.repo }}
|
||||
CHECKOUT_SHA: ${{ inputs.sha }}
|
||||
CHECKOUT_TOKEN: ${{ inputs.token }}
|
||||
CHECKOUT_FETCH_DEPTH: ${{ inputs.fetch-depth }}
|
||||
CHECKOUT_FETCH_TIMEOUT_SECONDS: ${{ inputs.fetch-timeout-seconds }}
|
||||
CHECKOUT_ATTEMPTS: ${{ inputs.attempts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM "${CHECKOUT_FETCH_TIMEOUT_SECONDS}s" git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth="${CHECKOUT_FETCH_DEPTH}" origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/${CHECKOUT_ATTEMPTS} succeeded"
|
||||
}
|
||||
|
||||
for attempt in $(seq 1 "$CHECKOUT_ATTEMPTS"); do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/${CHECKOUT_ATTEMPTS} failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after ${CHECKOUT_ATTEMPTS} attempts" >&2
|
||||
exit 1
|
||||
402
.github/workflows/ci.yml
vendored
402
.github/workflows/ci.yml
vendored
@@ -212,7 +212,7 @@ jobs:
|
||||
{
|
||||
check_name: "checks-fast-contracts-protocol",
|
||||
runtime: "node",
|
||||
task: "contracts",
|
||||
task: "contracts-protocol",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -408,11 +408,10 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure secrets base commit (PR fast path)
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -468,11 +467,10 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -491,8 +489,10 @@ jobs:
|
||||
bundled)
|
||||
pnpm test:bundled
|
||||
;;
|
||||
contracts)
|
||||
contracts|contracts-protocol)
|
||||
pnpm build
|
||||
pnpm test:contracts
|
||||
pnpm protocol:check
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks-fast task: $TASK" >&2
|
||||
@@ -500,31 +500,6 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-fast-protocol:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "checks-fast-protocol"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run protocol check
|
||||
run: pnpm protocol:check
|
||||
|
||||
checks-node-extensions-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -538,11 +513,10 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_extensions_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -591,11 +565,10 @@ jobs:
|
||||
|
||||
- name: Checkout
|
||||
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
|
||||
@@ -675,11 +648,52 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_test_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/2 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/2 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 2 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -785,11 +799,10 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -813,11 +826,10 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -833,31 +845,20 @@ jobs:
|
||||
- name: Strict TS build smoke
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
check-additional-shard:
|
||||
check-additional:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
name: "check-additional"
|
||||
needs: [preflight]
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-additional-boundaries
|
||||
group: boundaries
|
||||
- check_name: check-additional-extension-surfaces
|
||||
group: extension-surfaces
|
||||
- check_name: check-additional-runtime-topology
|
||||
group: runtime-topology
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -865,96 +866,193 @@ jobs:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run additional check shard
|
||||
- name: Run plugin extension boundary guard
|
||||
id: plugin_extension_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-extension-imports
|
||||
|
||||
- name: Run no-random-messaging guard
|
||||
id: no_random_messaging
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:tmp:no-random-messaging
|
||||
|
||||
- name: Run channel-agnostic boundary guard
|
||||
id: channel_agnostic_boundaries
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:tmp:channel-agnostic-boundaries
|
||||
|
||||
- name: Run no-raw-channel-fetch guard
|
||||
id: no_raw_channel_fetch
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:tmp:no-raw-channel-fetch
|
||||
|
||||
- name: Run ingress owner guard
|
||||
id: ingress_owner
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:agent:ingress-owner
|
||||
|
||||
- name: Run no-register-http-handler guard
|
||||
id: no_register_http_handler
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-register-http-handler
|
||||
|
||||
- name: Run no-monolithic plugin-sdk entry import guard
|
||||
id: no_monolithic_plugin_sdk_entry_imports
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
|
||||
|
||||
- name: Run no-extension-src-imports guard
|
||||
id: no_extension_src_imports
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-extension-src-imports
|
||||
|
||||
- name: Run no-extension-test-core-imports guard
|
||||
id: no_extension_test_core_imports
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-extension-test-core-imports
|
||||
|
||||
- name: Run plugin-sdk subpaths exported guard
|
||||
id: plugin_sdk_subpaths_exported
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:plugin-sdk-subpaths-exported
|
||||
|
||||
- name: Run web search provider boundary guard
|
||||
id: web_search_provider_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-search-provider-boundaries
|
||||
|
||||
- name: Run web fetch provider boundary guard
|
||||
id: web_fetch_provider_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-fetch-provider-boundaries
|
||||
|
||||
- name: Run extension src boundary guard
|
||||
id: extension_src_outside_plugin_sdk_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||
|
||||
- name: Run extension plugin-sdk-internal guard
|
||||
id: extension_plugin_sdk_internal_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
||||
|
||||
- name: Run extension relative-outside-package guard
|
||||
id: extension_relative_outside_package_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-relative-outside-package
|
||||
|
||||
- name: Run extension channel lint
|
||||
id: extension_channel_lint
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:channels
|
||||
|
||||
- name: Run bundled extension lint
|
||||
id: extension_bundled_lint
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:bundled
|
||||
|
||||
- name: Run extension package boundary TypeScript check
|
||||
id: extension_package_boundary_tsc
|
||||
continue-on-error: true
|
||||
env:
|
||||
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
|
||||
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
failures=0
|
||||
- name: Enforce safe external URL opening policy
|
||||
id: no_raw_window_open
|
||||
continue-on-error: true
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
run_check() {
|
||||
local label="$1"
|
||||
shift
|
||||
- name: Check control UI locale sync
|
||||
id: control_ui_i18n
|
||||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||||
continue-on-error: true
|
||||
run: pnpm ui:i18n:check
|
||||
|
||||
echo "::group::${label}"
|
||||
if "$@"; then
|
||||
echo "[ok] ${label}"
|
||||
else
|
||||
echo "::error title=${label} failed::${label} failed"
|
||||
failures=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
}
|
||||
- name: Run gateway watch regression harness
|
||||
id: gateway_watch_regression
|
||||
continue-on-error: true
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
case "$ADDITIONAL_CHECK_GROUP" in
|
||||
boundaries)
|
||||
run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports
|
||||
run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging
|
||||
run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries
|
||||
run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch
|
||||
run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner
|
||||
run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler
|
||||
run_check "lint:plugins:no-monolithic-plugin-sdk-entry-imports" pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
|
||||
run_check "lint:plugins:no-extension-src-imports" pnpm run lint:plugins:no-extension-src-imports
|
||||
run_check "lint:plugins:no-extension-test-core-imports" pnpm run lint:plugins:no-extension-test-core-imports
|
||||
run_check "lint:plugins:plugin-sdk-subpaths-exported" pnpm run lint:plugins:plugin-sdk-subpaths-exported
|
||||
run_check "web-search-provider-boundary" pnpm run lint:web-search-provider-boundaries
|
||||
run_check "web-fetch-provider-boundary" pnpm run lint:web-fetch-provider-boundaries
|
||||
run_check "extension-src-outside-plugin-sdk-boundary" pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||
run_check "extension-plugin-sdk-internal-boundary" pnpm run lint:extensions:no-plugin-sdk-internal
|
||||
run_check "extension-relative-outside-package-boundary" pnpm run lint:extensions:no-relative-outside-package
|
||||
run_check "lint:ui:no-raw-window-open" pnpm lint:ui:no-raw-window-open
|
||||
;;
|
||||
extension-surfaces)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
|
||||
run_check "test:extensions:package-boundary" pnpm run test:extensions:package-boundary
|
||||
;;
|
||||
runtime-topology)
|
||||
if [ "$RUN_CONTROL_UI_I18N" = "true" ]; then
|
||||
run_check "ui:i18n:check" pnpm ui:i18n:check
|
||||
fi
|
||||
run_check "gateway-watch-regression" pnpm test:gateway:watch-regression
|
||||
run_check "check:import-cycles" pnpm check:import-cycles
|
||||
run_check "check:madge-import-cycles" pnpm check:madge-import-cycles
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported additional check group: $ADDITIONAL_CHECK_GROUP" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Run import cycle guard
|
||||
id: import_cycles
|
||||
continue-on-error: true
|
||||
run: pnpm check:import-cycles
|
||||
|
||||
exit "$failures"
|
||||
- name: Run madge import cycle guard
|
||||
id: madge_import_cycles
|
||||
continue-on-error: true
|
||||
run: pnpm check:madge-import-cycles
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always() && matrix.group == 'runtime-topology'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
check-additional:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-additional"
|
||||
needs: [preflight, check-additional-shard]
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify additional check shards
|
||||
- name: Fail if any additional check failed
|
||||
if: always()
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
|
||||
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
|
||||
NO_RANDOM_MESSAGING_OUTCOME: ${{ steps.no_random_messaging.outcome }}
|
||||
CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME: ${{ steps.channel_agnostic_boundaries.outcome }}
|
||||
NO_RAW_CHANNEL_FETCH_OUTCOME: ${{ steps.no_raw_channel_fetch.outcome }}
|
||||
INGRESS_OWNER_OUTCOME: ${{ steps.ingress_owner.outcome }}
|
||||
NO_REGISTER_HTTP_HANDLER_OUTCOME: ${{ steps.no_register_http_handler.outcome }}
|
||||
NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME: ${{ steps.no_monolithic_plugin_sdk_entry_imports.outcome }}
|
||||
NO_EXTENSION_SRC_IMPORTS_OUTCOME: ${{ steps.no_extension_src_imports.outcome }}
|
||||
NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
|
||||
PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
|
||||
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
|
||||
WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_fetch_provider_boundary.outcome }}
|
||||
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
|
||||
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
|
||||
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
|
||||
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
IMPORT_CYCLES_OUTCOME: ${{ steps.import_cycles.outcome }}
|
||||
MADGE_IMPORT_CYCLES_OUTCOME: ${{ steps.madge_import_cycles.outcome }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Additional check shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
failures=0
|
||||
for result in \
|
||||
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
|
||||
"lint:tmp:no-random-messaging|$NO_RANDOM_MESSAGING_OUTCOME" \
|
||||
"lint:tmp:channel-agnostic-boundaries|$CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME" \
|
||||
"lint:tmp:no-raw-channel-fetch|$NO_RAW_CHANNEL_FETCH_OUTCOME" \
|
||||
"lint:agent:ingress-owner|$INGRESS_OWNER_OUTCOME" \
|
||||
"lint:plugins:no-register-http-handler|$NO_REGISTER_HTTP_HANDLER_OUTCOME" \
|
||||
"lint:plugins:no-monolithic-plugin-sdk-entry-imports|$NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME" \
|
||||
"lint:plugins:no-extension-src-imports|$NO_EXTENSION_SRC_IMPORTS_OUTCOME" \
|
||||
"lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
|
||||
"lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
|
||||
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"web-fetch-provider-boundary|$WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
|
||||
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
|
||||
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
|
||||
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
|
||||
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
|
||||
"check:import-cycles|$IMPORT_CYCLES_OUTCOME" \
|
||||
"check:madge-import-cycles|$MADGE_IMPORT_CYCLES_OUTCOME"; do
|
||||
name="${result%%|*}"
|
||||
outcome="${result#*|}"
|
||||
if [ "$outcome" != "success" ]; then
|
||||
echo "::error title=${name} failed::${name} outcome: ${outcome}"
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failures"
|
||||
|
||||
build-smoke:
|
||||
permissions:
|
||||
@@ -966,11 +1064,10 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -1022,11 +1119,10 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: ./.github/actions/checkout-ci-fast
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repo: ${{ github.repository }}
|
||||
sha: ${{ github.sha }}
|
||||
token: ${{ github.token }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
@@ -140,8 +140,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
|
||||
|
||||
- name: Commit and push locale updates
|
||||
env:
|
||||
|
||||
20
.github/workflows/docker-release.yml
vendored
20
.github/workflows/docker-release.yml
vendored
@@ -362,36 +362,28 @@ jobs:
|
||||
|
||||
- name: Create and push default manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.value }}
|
||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${TAGS}"
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
${{ needs.build-amd64.outputs.digest }} \
|
||||
${{ needs.build-arm64.outputs.digest }}
|
||||
|
||||
- name: Create and push slim manifest
|
||||
shell: bash
|
||||
env:
|
||||
SLIM_TAGS: ${{ steps.tags.outputs.slim }}
|
||||
AMD64_SLIM_DIGEST: ${{ needs.build-amd64.outputs.slim-digest }}
|
||||
ARM64_SLIM_DIGEST: ${{ needs.build-arm64.outputs.slim-digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${SLIM_TAGS}"
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.slim }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_SLIM_DIGEST}" \
|
||||
"${ARM64_SLIM_DIGEST}"
|
||||
${{ needs.build-amd64.outputs.slim-digest }} \
|
||||
${{ needs.build-arm64.outputs.slim-digest }}
|
||||
|
||||
@@ -144,7 +144,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -152,63 +151,7 @@ env:
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
validate_selected_ref:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_sha: ${{ steps.validate.outputs.selected_sha }}
|
||||
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
selected_sha="$(git rev-parse HEAD)"
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
else
|
||||
pr_head_count="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
|
||||
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
|
||||
)"
|
||||
if [[ "$pr_head_count" != "0" ]]; then
|
||||
trusted_reason="open-pr-head"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Validated ref: \`${INPUT_REF}\`"
|
||||
echo "Resolved SHA: \`$selected_sha\`"
|
||||
echo "Trust reason: \`$trusted_reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
@@ -221,7 +164,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -248,7 +191,6 @@ jobs:
|
||||
run: pnpm test:live:cache
|
||||
|
||||
validate_repo_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 90
|
||||
@@ -258,7 +200,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -276,7 +218,6 @@ jobs:
|
||||
run: pnpm test:e2e
|
||||
|
||||
validate_special_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e || inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -304,7 +245,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -352,7 +293,6 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -456,7 +396,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -510,7 +450,6 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -599,7 +538,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -623,39 +562,9 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
|
||||
# The CLI backend Docker lane should exercise the same staged
|
||||
# Codex auth path Peter uses locally so MCP cron creation and
|
||||
# multimodal probes stay covered in CI. Replace the staged
|
||||
# config.toml with a minimal CI-safe config so the repo stays
|
||||
# trusted for MCP/tool use without inheriting maintainer-local
|
||||
# provider/profile overrides that do not exist inside CI.
|
||||
# Codex's workspace-write sandbox relies on user namespaces that
|
||||
# this Docker lane does not provide, so run Codex unsandboxed
|
||||
# inside the already-isolated container to keep MCP cron/tool
|
||||
# execution representative instead of failing on nested sandbox
|
||||
# setup.
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-codex-harness-docker)
|
||||
# Keep CI on the API-key path for now. The staged Codex auth secret
|
||||
# is currently stale, but the wrapper still supports codex-auth for
|
||||
# local maintainer reruns without changing Peter's flow.
|
||||
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-acp-bind-docker)
|
||||
if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
|
||||
else
|
||||
# The hydrated Gemini settings file only selects Gemini CLI auth
|
||||
# mode. CI still needs a usable Gemini or Google API key before
|
||||
# ACP bind can initialize a Gemini session.
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
3
.github/workflows/openclaw-npm-release.yml
vendored
3
.github/workflows/openclaw-npm-release.yml
vendored
@@ -397,10 +397,9 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
|
||||
PUBLISH_TARBALL_PATH: ${{ steps.publish_tarball.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_target="${PUBLISH_TARBALL_PATH}"
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
|
||||
54
.github/workflows/openclaw-release-checks.yml
vendored
54
.github/workflows/openclaw-release-checks.yml
vendored
@@ -130,19 +130,12 @@ jobs:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
|
||||
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
|
||||
secrets: inherit
|
||||
|
||||
live_and_e2e_release_checks:
|
||||
needs: [resolve_target]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
@@ -150,47 +143,4 @@ jobs:
|
||||
include_release_path_suites: true
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: openclaw-scheduled-live-checks-${{ github.ref }}
|
||||
@@ -20,7 +19,6 @@ jobs:
|
||||
live_and_openwebui_checks:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
@@ -28,47 +26,4 @@ jobs:
|
||||
include_release_path_suites: false
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
secrets: inherit
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -11,10 +11,8 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
|
||||
- WhatsApp/multi-account: centralize named-account inbound policy, isolate per-account group activation and scoped session keys, preserve legacy activation backfill, and keep `accounts.default` shared defaults aligned across runtime, setup, and compat migration paths. Thanks @mcaxtr.
|
||||
- Cron/delivery: clean up isolated sessions after direct deliveries when `deleteAfterRun` is enabled, covering structured and threaded branches that previously bypassed cleanup. (#67807) Thanks @MonkeyLeeT.
|
||||
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
|
||||
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
|
||||
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
|
||||
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.
|
||||
- WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699.
|
||||
- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699.
|
||||
@@ -27,33 +25,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
|
||||
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
|
||||
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
|
||||
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
|
||||
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
|
||||
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
|
||||
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
|
||||
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
|
||||
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
|
||||
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
|
||||
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
|
||||
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
|
||||
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
|
||||
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
|
||||
- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198)
|
||||
- Telegram/streaming: fence same-session stale preview and finalization work after aborts so Telegram no longer replays an older reply or flushes a hidden short preview after the abort confirmation lands. (#68100) Thanks @rubencu.
|
||||
- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc.
|
||||
- Config/redact: add `browser.cdpUrl` and `browser.profiles.*.cdpUrl` to sensitive URL config paths so embedded credentials (query tokens and HTTP Basic auth) are properly redacted in `config.get` API responses and availability error messages. (#67679) Thanks @Ziy1-Tan.
|
||||
- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699.
|
||||
- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent.
|
||||
- Matrix: honor `channels.matrix.network.dangerouslyAllowPrivateNetwork` when creating clients for private-network homeservers. (#68332) Thanks @kagura-agent.
|
||||
- Cron/message tool: keep cron-owned runs with `delivery.mode: "none"` on the normal message-tool path so they can still send explicit messages, create threads, and route conditionally when no runner-owned delivery target is active. (#68482) Thanks @obviyus.
|
||||
- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25.
|
||||
- Config/$schema: preserve root-authored `$schema` during partial config rewrites without injecting include-only schema URLs into the root config. (#47322) Thanks @EfeDurmaz16.
|
||||
- Agents/CLI delivery: run the same reply-media path normalizer the auto-reply flow uses before shipping `openclaw agent --deliver` payloads, so relative `MEDIA:./out/photo.png` tokens resolve against the agent workspace instead of being rejected downstream with `LocalMediaAccessError: Local media path is not under an allowed directory`. Thanks @frankekn.
|
||||
|
||||
## 2026.4.15
|
||||
|
||||
@@ -161,7 +132,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
|
||||
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
|
||||
- Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf
|
||||
- Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ import Foundation
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
|
||||
private static let helperName = "openclaw"
|
||||
static let strictHostKeyCheckingSSHOptions = [
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
]
|
||||
static let updateHostKeysSSHOptions = [
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
@@ -403,7 +397,9 @@ enum CommandResolver {
|
||||
"""
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = self.sshArguments(
|
||||
target: parsed,
|
||||
identity: settings.identity,
|
||||
|
||||
@@ -22,21 +22,7 @@ enum ExecApprovalCommandDisplaySanitizer {
|
||||
}
|
||||
|
||||
private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool {
|
||||
let category = scalar.properties.generalCategory
|
||||
if category == .control
|
||||
|| category == .format
|
||||
|| category == .lineSeparator
|
||||
|| category == .paragraphSeparator
|
||||
{
|
||||
return true
|
||||
}
|
||||
// Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so
|
||||
// attackers cannot spoof token boundaries in the approval UI with spaces that render
|
||||
// like a plain space but are handled differently by shells/parsers.
|
||||
if category == .spaceSeparator, scalar.value != 0x20 {
|
||||
return true
|
||||
}
|
||||
return self.invisibleCodePoints.contains(scalar.value)
|
||||
scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value)
|
||||
}
|
||||
|
||||
private static func escape(_ scalar: UnicodeScalar) -> String {
|
||||
|
||||
@@ -483,7 +483,8 @@ final class NodePairingApprovalPrompter {
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@ enum RemoteGatewayProbe {
|
||||
let options = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
identity: identity,
|
||||
|
||||
@@ -73,12 +73,14 @@ final class RemotePortTunnel {
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
]
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
|
||||
@@ -164,9 +164,6 @@ import Testing
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
|
||||
#expect(cmd.contains("UpdateHostKeys=yes"))
|
||||
#expect(cmd.contains("-i"))
|
||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||
if let script = cmd.last {
|
||||
|
||||
@@ -9,37 +9,4 @@ struct ExecApprovalCommandDisplaySanitizerTests {
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가")
|
||||
}
|
||||
|
||||
@Test func `escapes control characters used to spoof line breaks`() {
|
||||
let input = "echo safe\n\rcurl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"echo safe\\u{A}\\u{D}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes Unicode line and paragraph separators`() {
|
||||
let lineInput = "echo ok\u{2028}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) ==
|
||||
"echo ok\\u{2028}curl https://example.test")
|
||||
let paragraphInput = "echo ok\u{2029}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) ==
|
||||
"echo ok\\u{2029}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() {
|
||||
let nbspInput = "echo ok\u{00A0}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl")
|
||||
let narrowNbspInput = "echo ok\u{202F}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl")
|
||||
let ideographicSpaceInput = "echo ok\u{3000}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) ==
|
||||
"echo ok\\u{3000}curl")
|
||||
let asciiSpaceInput = "echo ok curl"
|
||||
#expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
17d43e3c5dd6ac09fa6c7954e6923d2e0fba767483bac0a2b257fb7ec736f8a4 config-baseline.json
|
||||
fdb7867bbc18792d3645ea36c31b64425d6f19c0b19a7460564f67eb97c0a71e config-baseline.core.json
|
||||
3c87ac2fc4c234348eb88812d1904724d7492890498f101d953bc761da8fdead config-baseline.json
|
||||
eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json
|
||||
99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb config-baseline.channel.json
|
||||
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json
|
||||
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
d08f0e793e66192fdbc377183ce0d94adcbec53cf334522bce8c0c457b90b0a8 plugin-sdk-api-baseline.json
|
||||
924f20b350a9f1997e95b3d7249cbb6720c9576c63e6c0c15cca0164734fd93d plugin-sdk-api-baseline.jsonl
|
||||
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
|
||||
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
|
||||
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
|
||||
the session and continues. Large bootstrap files are truncated when injected;
|
||||
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and
|
||||
`agents.defaults.bootstrapTotalMaxChars` (default: 60000).
|
||||
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
|
||||
`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
|
||||
`openclaw setup` can recreate missing defaults without overwriting existing
|
||||
files.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and what’s in your workspace.
|
||||
```
|
||||
🧠 Context breakdown
|
||||
Workspace: <workspaceDir>
|
||||
Bootstrap max/file: 12,000 chars
|
||||
Bootstrap max/file: 20,000 chars
|
||||
Sandbox: mode=non-main sandboxed=false
|
||||
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
|
||||
|
||||
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (first-run only)
|
||||
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
|
||||
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`).
|
||||
|
||||
|
||||
@@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction.
|
||||
> as a one-shot startup-context block for that first turn.
|
||||
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
|
||||
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
|
||||
(default: 60000). Missing files inject a short missing-file marker. When truncation
|
||||
(default: 150000). Missing files inject a short missing-file marker. When truncation
|
||||
occurs, OpenClaw can inject a warning block in Project Context; control this with
|
||||
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
|
||||
default: `once`).
|
||||
|
||||
@@ -955,21 +955,21 @@ Controls when workspace bootstrap files are injected into the system prompt. Def
|
||||
|
||||
### `agents.defaults.bootstrapMaxChars`
|
||||
|
||||
Max characters per workspace bootstrap file before truncation. Default: `12000`.
|
||||
Max characters per workspace bootstrap file before truncation. Default: `20000`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapMaxChars: 12000 } },
|
||||
agents: { defaults: { bootstrapMaxChars: 20000 } },
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.bootstrapTotalMaxChars`
|
||||
|
||||
Max total characters injected across all workspace bootstrap files. Default: `60000`.
|
||||
Max total characters injected across all workspace bootstrap files. Default: `150000`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapTotalMaxChars: 60000 } },
|
||||
agents: { defaults: { bootstrapTotalMaxChars: 150000 } },
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
|
||||
export { buildAnthropicProvider } from "./register.runtime.js";
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
export function createAnthropicProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: ["claude-cli"],
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "cli",
|
||||
kind: "custom",
|
||||
label: "Claude CLI",
|
||||
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "anthropic-cli",
|
||||
choiceLabel: "Anthropic Claude CLI",
|
||||
choiceHint: "Reuse a local Claude CLI login on this host",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "setup-token",
|
||||
kind: "token",
|
||||
label: "Anthropic setup-token",
|
||||
hint: "Manual bearer token path",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "setup-token",
|
||||
choiceLabel: "Anthropic setup-token",
|
||||
choiceHint: "Manual token path",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key + token",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
hint: "Direct Anthropic API key",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "apiKey",
|
||||
choiceLabel: "Anthropic API key",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
upsertAuthProfile,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
cloneFirstTemplateModel,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
@@ -398,10 +395,11 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
return {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
@@ -507,11 +505,6 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
store: ctx.store,
|
||||
profileId: ctx.profileId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider(buildAnthropicProvider());
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
|
||||
import { __testing } from "../test-api.js";
|
||||
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
const braveManifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
|
||||
@@ -3,59 +3,25 @@ import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createBraveSchema,
|
||||
mapBraveLlmContextResults,
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveConfig,
|
||||
resolveBraveMode,
|
||||
} from "./brave-web-search-provider.shared.js";
|
||||
|
||||
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
|
||||
const BraveSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
country: {
|
||||
type: "string",
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
},
|
||||
language: {
|
||||
type: "string",
|
||||
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
},
|
||||
freshness: {
|
||||
type: "string",
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
},
|
||||
date_after: {
|
||||
type: "string",
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
},
|
||||
date_before: {
|
||||
type: "string",
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
},
|
||||
search_lang: {
|
||||
type: "string",
|
||||
description:
|
||||
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
||||
},
|
||||
ui_lang: {
|
||||
type: "string",
|
||||
description:
|
||||
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
type ConfigInput = Parameters<
|
||||
NonNullable<WebSearchProviderPlugin["getConfiguredCredentialValue"]>
|
||||
>[0];
|
||||
type ConfigTarget = Parameters<
|
||||
NonNullable<WebSearchProviderPlugin["setConfiguredCredentialValue"]>
|
||||
>[0];
|
||||
|
||||
function resolveProviderWebSearchPluginConfig(
|
||||
config: unknown,
|
||||
config: ConfigInput,
|
||||
pluginId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!isRecord(config)) {
|
||||
@@ -68,6 +34,40 @@ function resolveProviderWebSearchPluginConfig(
|
||||
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
||||
}
|
||||
|
||||
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const current = target[key];
|
||||
if (isRecord(current)) {
|
||||
return current;
|
||||
}
|
||||
const next: Record<string, unknown> = {};
|
||||
target[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function setProviderWebSearchPluginConfigValue(
|
||||
configTarget: ConfigTarget,
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const plugins = ensureObject(configTarget as Record<string, unknown>, "plugins");
|
||||
const entries = ensureObject(plugins, "entries");
|
||||
const entry = ensureObject(entries, pluginId);
|
||||
if (entry.enabled === undefined) {
|
||||
entry.enabled = true;
|
||||
}
|
||||
const config = ensureObject(entry, "config");
|
||||
const webSearch = ensureObject(config, "webSearch");
|
||||
webSearch[key] = value;
|
||||
}
|
||||
|
||||
function setTopLevelCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
value: unknown,
|
||||
): void {
|
||||
searchConfigTarget.apiKey = value;
|
||||
}
|
||||
|
||||
function mergeScopedSearchConfig(
|
||||
searchConfig: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
@@ -94,22 +94,17 @@ function mergeScopedSearchConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
|
||||
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
|
||||
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function createBraveToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
const braveMode = resolveBraveMode(searchConfig);
|
||||
const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig));
|
||||
|
||||
return {
|
||||
description:
|
||||
braveMode === "llm-context"
|
||||
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
|
||||
parameters: BraveSearchSchema,
|
||||
parameters: createBraveSchema(),
|
||||
execute: async (args) => {
|
||||
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js");
|
||||
return await executeBraveSearch(args, searchConfig);
|
||||
@@ -129,12 +124,15 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
docsUrl: "https://docs.openclaw.ai/brave-search",
|
||||
autoDetectOrder: 10,
|
||||
credentialPath: BRAVE_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: BRAVE_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "brave" },
|
||||
}),
|
||||
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
||||
setCredentialValue: setTopLevelCredentialValue,
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createBraveToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
@@ -146,3 +144,10 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
import {
|
||||
mapBraveLlmContextResults,
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
} from "./src/brave-web-search-provider.shared.js";
|
||||
|
||||
export const __testing = {
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
export { __testing } from "./src/brave-web-search-provider.js";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
|
||||
resolveCdpReachabilityTimeouts,
|
||||
} from "./cdp-timeouts.js";
|
||||
import { redactCdpUrl } from "./cdp.helpers.js";
|
||||
import {
|
||||
closeChromeMcpSession,
|
||||
ensureChromeMcpAvailable,
|
||||
@@ -60,7 +59,6 @@ export function createProfileAvailability({
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
}: AvailabilityDeps): AvailabilityOps {
|
||||
const redactedProfileCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
@@ -212,7 +210,7 @@ export function createProfileAvailability({
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${redactedProfileCdpUrl}.`
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
|
||||
} from "./cdp-timeouts.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { BrowserProfileUnavailableError } from "./errors.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js";
|
||||
|
||||
@@ -176,39 +175,4 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redacts credentials in remote CDP availability errors", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
|
||||
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
|
||||
|
||||
const state = makeBrowserServerState({
|
||||
profile: {
|
||||
name: "remote",
|
||||
cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123",
|
||||
cdpHost: "browserless.example.com",
|
||||
cdpIsLoopback: false,
|
||||
cdpPort: 443,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
attachOnly: false,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
defaultProfile: "remote",
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const profile = ctx.forProfile("remote");
|
||||
|
||||
isChromeReachable.mockResolvedValue(false);
|
||||
|
||||
const promise = profile.ensureBrowserAvailable();
|
||||
await expect(promise).rejects.toThrow(BrowserProfileUnavailableError);
|
||||
await expect(promise).rejects.toThrow(
|
||||
'Remote CDP for profile "remote" is not reachable at https://browserless.example.com/?token=***.',
|
||||
);
|
||||
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./src/directory-config.js";
|
||||
@@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
createResolvedDirectoryEntriesLister,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-config-runtime";
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
|
||||
|
||||
function resolveDiscordDirectoryConfigAccount(
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
clearPluginCommands,
|
||||
executePluginCommand,
|
||||
matchPluginCommand,
|
||||
registerPluginCommand,
|
||||
} from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createTestRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "../../../../test/helpers/plugins/plugin-registry.js";
|
||||
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
||||
import {
|
||||
createMockCommandInteraction,
|
||||
type MockCommandInteraction,
|
||||
} from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.manager.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
|
||||
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
|
||||
@@ -30,6 +22,33 @@ const runtimeModuleMocks = vi.hoisted(() => ({
|
||||
resolveDirectStatusReplyForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
||||
"openclaw/plugin-sdk/plugin-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
|
||||
executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||
"openclaw/plugin-sdk/reply-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
|
||||
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
|
||||
}));
|
||||
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
@@ -251,16 +270,13 @@ async function expectPairCommandReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
commandName: string;
|
||||
interaction: MockCommandInteraction;
|
||||
expectedRegisteredName?: string;
|
||||
}) {
|
||||
const command = await createPluginCommand({
|
||||
cfg: params.cfg,
|
||||
name: params.commandName,
|
||||
});
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
|
||||
const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({
|
||||
text: "paired:now",
|
||||
});
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
Object.assign(params.interaction, {
|
||||
options: {
|
||||
@@ -272,12 +288,6 @@ async function expectPairCommandReply(params: {
|
||||
);
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: expect.objectContaining({ name: params.expectedRegisteredName ?? "pair" }),
|
||||
args: "now",
|
||||
}),
|
||||
);
|
||||
expect(params.interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "paired:now" }),
|
||||
);
|
||||
@@ -328,28 +338,21 @@ describe("Discord native plugin command dispatch", () => {
|
||||
await import("./native-command.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
clearPluginCommands();
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
discordNativeCommandTesting.setMatchPluginCommand(matchPluginCommand);
|
||||
discordNativeCommandTesting.setExecutePluginCommand(executePluginCommand);
|
||||
discordNativeCommandTesting.setDispatchReplyWithDispatcher(dispatchReplyWithDispatcher);
|
||||
discordNativeCommandTesting.setResolveDirectStatusReplyForSession(
|
||||
resolveDirectStatusReplyForSession,
|
||||
);
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(
|
||||
resolveDiscordNativeInteractionRouteState,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
clearPluginCommands();
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
const actualPluginRuntime = await vi.importActual<
|
||||
typeof import("openclaw/plugin-sdk/plugin-runtime")
|
||||
>("openclaw/plugin-sdk/plugin-runtime");
|
||||
runtimeModuleMocks.matchPluginCommand.mockReset();
|
||||
runtimeModuleMocks.matchPluginCommand.mockImplementation(matchPluginCommand);
|
||||
runtimeModuleMocks.matchPluginCommand.mockImplementation(
|
||||
actualPluginRuntime.matchPluginCommand,
|
||||
);
|
||||
runtimeModuleMocks.executePluginCommand.mockReset();
|
||||
runtimeModuleMocks.executePluginCommand.mockImplementation(executePluginCommand);
|
||||
runtimeModuleMocks.executePluginCommand.mockImplementation(
|
||||
actualPluginRuntime.executePluginCommand,
|
||||
);
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset();
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
|
||||
counts: {
|
||||
@@ -369,10 +372,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand,
|
||||
);
|
||||
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof dispatchReplyWithDispatcher,
|
||||
);
|
||||
discordNativeCommandTesting.setResolveDirectStatusReplyForSession(
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession as typeof resolveDirectStatusReplyForSession,
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
|
||||
);
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) =>
|
||||
createUnboundRouteState({
|
||||
@@ -436,6 +436,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
description: "Pair",
|
||||
acceptsArgs: true,
|
||||
};
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: "234567890123456789",
|
||||
@@ -454,7 +455,6 @@ describe("Discord native plugin command dispatch", () => {
|
||||
handler: async ({ args }) => ({ text: `open:${args ?? ""}` }),
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
|
||||
const executeSpy = runtimeModuleMocks.executePluginCommand;
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
|
||||
|
||||
@@ -94,7 +94,6 @@ const DISCORD_COMMAND_DESCRIPTION_MAX = 100;
|
||||
let matchPluginCommandImpl = pluginRuntime.matchPluginCommand;
|
||||
let executePluginCommandImpl = pluginRuntime.executePluginCommand;
|
||||
let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher;
|
||||
let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession;
|
||||
let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState;
|
||||
|
||||
export const __testing = {
|
||||
@@ -119,13 +118,6 @@ export const __testing = {
|
||||
dispatchReplyWithDispatcherImpl = next;
|
||||
return previous;
|
||||
},
|
||||
setResolveDirectStatusReplyForSession(
|
||||
next: typeof resolveDirectStatusReplyForSession,
|
||||
): typeof resolveDirectStatusReplyForSession {
|
||||
const previous = resolveDirectStatusReplyForSessionImpl;
|
||||
resolveDirectStatusReplyForSessionImpl = next;
|
||||
return previous;
|
||||
},
|
||||
setResolveDiscordNativeInteractionRouteState(
|
||||
next: typeof resolveDiscordNativeInteractionRouteState,
|
||||
): typeof resolveDiscordNativeInteractionRouteState {
|
||||
@@ -629,19 +621,6 @@ async function safeDiscordInteractionCall<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandDefinition {
|
||||
return {
|
||||
key: command.name,
|
||||
nativeName: command.name,
|
||||
description: command.description,
|
||||
textAliases: [],
|
||||
acceptsArgs: command.acceptsArgs,
|
||||
args: command.args,
|
||||
argsParsing: "none",
|
||||
scope: "native",
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordNativeCommand(params: {
|
||||
command: NativeCommandSpec;
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
@@ -660,13 +639,18 @@ export function createDiscordNativeCommand(params: {
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
} = params;
|
||||
const fallbackCommandDefinition = createNativeCommandDefinition(command);
|
||||
const commandDefinition =
|
||||
matchPluginCommandImpl(`/${command.name}`) !== null
|
||||
? fallbackCommandDefinition
|
||||
: (findCommandByNativeName(command.name, "discord", {
|
||||
includeBundledChannelFallback: false,
|
||||
}) ?? fallbackCommandDefinition);
|
||||
findCommandByNativeName(command.name, "discord") ??
|
||||
({
|
||||
key: command.name,
|
||||
nativeName: command.name,
|
||||
description: command.description,
|
||||
textAliases: [],
|
||||
acceptsArgs: command.acceptsArgs,
|
||||
args: command.args,
|
||||
argsParsing: "none",
|
||||
scope: "native",
|
||||
} satisfies ChatCommandDefinition);
|
||||
const argDefinitions = commandDefinition.args ?? command.args;
|
||||
const commandOptions = buildDiscordCommandOptions({
|
||||
command: commandDefinition,
|
||||
@@ -1146,7 +1130,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
if (!suppressReplies && commandName === "status") {
|
||||
const statusReply = await resolveDirectStatusReplyForSessionImpl({
|
||||
const statusReply = await resolveDirectStatusReplyForSession({
|
||||
cfg,
|
||||
sessionKey: commandTargetSessionKey?.trim() || sessionKey,
|
||||
channel: "discord",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
GatewayIntents,
|
||||
@@ -221,9 +221,6 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
|
||||
vi.stubGlobal("fetch", globalFetchMock);
|
||||
vi.useRealTimers();
|
||||
baseRegisterClientSpy.mockClear();
|
||||
@@ -239,11 +236,6 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
resetLastAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses safe gateway metadata lookup without proxy", async () => {
|
||||
const runtime = createRuntime();
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
|
||||
@@ -18,34 +18,14 @@ import {
|
||||
normalizeOptionalStringifiedId,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { DiscordComponentMessageSpec } from "./components.js";
|
||||
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
import { sendDiscordComponentMessage } from "./send.components.js";
|
||||
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
|
||||
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
|
||||
|
||||
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
|
||||
|
||||
type DiscordSendRuntime = typeof import("./send.js");
|
||||
type DiscordSendFn = DiscordSendRuntime["sendMessageDiscord"];
|
||||
type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage;
|
||||
|
||||
let discordSendRuntimePromise: Promise<DiscordSendRuntime> | undefined;
|
||||
let discordComponentSendPromise: Promise<DiscordComponentSendFn> | undefined;
|
||||
|
||||
async function loadDiscordSendRuntime(): Promise<DiscordSendRuntime> {
|
||||
discordSendRuntimePromise ??= import("./send.js");
|
||||
return await discordSendRuntimePromise;
|
||||
}
|
||||
|
||||
async function sendDiscordComponentMessageLazy(
|
||||
...args: Parameters<DiscordComponentSendFn>
|
||||
): ReturnType<DiscordComponentSendFn> {
|
||||
discordComponentSendPromise ??= import("./send.components.js").then(
|
||||
(module) => module.sendDiscordComponentMessage,
|
||||
);
|
||||
return await (
|
||||
await discordComponentSendPromise
|
||||
)(...args);
|
||||
}
|
||||
|
||||
function hasApprovalChannelData(payload: { channelData?: unknown }): boolean {
|
||||
const channelData = payload.channelData;
|
||||
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
|
||||
@@ -113,7 +93,6 @@ async function maybeSendDiscordWebhookText(params: {
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
const { getThreadBindingManager } = await import("./monitor/thread-bindings.js");
|
||||
const manager = getThreadBindingManager(params.accountId ?? undefined);
|
||||
if (!manager) {
|
||||
return null;
|
||||
@@ -126,7 +105,6 @@ async function maybeSendDiscordWebhookText(params: {
|
||||
identity: params.identity,
|
||||
binding,
|
||||
});
|
||||
const { sendWebhookMessageDiscord } = await loadDiscordSendRuntime();
|
||||
const result = await sendWebhookMessageDiscord(params.text, {
|
||||
webhookId: binding.webhookId,
|
||||
webhookToken: binding.webhookToken,
|
||||
@@ -156,12 +134,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
| { components?: DiscordComponentMessageSpec }
|
||||
| undefined;
|
||||
const rawComponentSpec =
|
||||
discordData?.components ??
|
||||
(payload.interactive
|
||||
? (await import("./shared-interactive.js")).buildDiscordInteractiveComponents(
|
||||
payload.interactive,
|
||||
)
|
||||
: undefined);
|
||||
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
|
||||
const componentSpec = rawComponentSpec
|
||||
? rawComponentSpec.text
|
||||
? rawComponentSpec
|
||||
@@ -181,8 +154,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
});
|
||||
}
|
||||
const send =
|
||||
resolveOutboundSendDep<DiscordSendFn>(ctx.deps, "discord") ??
|
||||
(await loadDiscordSendRuntime()).sendMessageDiscord;
|
||||
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
|
||||
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
|
||||
const mediaUrls = resolvePayloadMediaUrls(payload);
|
||||
const result = await sendPayloadMediaSequenceOrFallback({
|
||||
@@ -190,7 +162,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrls,
|
||||
fallbackResult: { messageId: "", channelId: target },
|
||||
sendNoMedia: async () =>
|
||||
await sendDiscordComponentMessageLazy(target, componentSpec, {
|
||||
await sendDiscordComponentMessage(target, componentSpec, {
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
@@ -198,7 +170,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
}),
|
||||
send: async ({ text, mediaUrl, isFirst }) => {
|
||||
if (isFirst) {
|
||||
return await sendDiscordComponentMessageLazy(target, componentSpec, {
|
||||
return await sendDiscordComponentMessage(target, componentSpec, {
|
||||
mediaUrl,
|
||||
mediaAccess: ctx.mediaAccess,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
@@ -241,8 +213,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
}
|
||||
}
|
||||
const send =
|
||||
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
|
||||
(await loadDiscordSendRuntime()).sendMessageDiscord;
|
||||
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
|
||||
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
|
||||
verbose: false,
|
||||
replyTo: replyToId ?? undefined,
|
||||
@@ -265,8 +236,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
silent,
|
||||
}) => {
|
||||
const send =
|
||||
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
|
||||
(await loadDiscordSendRuntime()).sendMessageDiscord;
|
||||
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
|
||||
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
@@ -279,9 +249,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
});
|
||||
},
|
||||
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) =>
|
||||
await (
|
||||
await loadDiscordSendRuntime()
|
||||
).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
|
||||
await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
cfg,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiscordPluginBase } from "./shared.js";
|
||||
|
||||
describe("createDiscordPluginBase", () => {
|
||||
it("owns Discord native command name overrides", () => {
|
||||
const plugin = createDiscordPluginBase({ setup: {} as never });
|
||||
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "tts",
|
||||
defaultName: "tts",
|
||||
}),
|
||||
).toBe("voice");
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "status",
|
||||
defaultName: "status",
|
||||
}),
|
||||
).toBe("status");
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,37 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
enablePluginInConfig,
|
||||
getScopedCredentialValue,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
setScopedCredentialValue,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { runDuckDuckGoSearch } from "./ddg-client.js";
|
||||
|
||||
const DuckDuckGoSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
|
||||
},
|
||||
safeSearch: {
|
||||
type: "string",
|
||||
description: "SafeSearch level: strict, moderate, or off.",
|
||||
},
|
||||
const DuckDuckGoSearchSchema = Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
}),
|
||||
),
|
||||
region: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
|
||||
}),
|
||||
),
|
||||
safeSearch: Type.Optional(
|
||||
Type.String({
|
||||
description: "SafeSearch level: strict, moderate, or off.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
@@ -37,21 +45,17 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 100,
|
||||
credentialPath: "",
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: "",
|
||||
searchCredential: { type: "scoped", scopeId: "duckduckgo" },
|
||||
selectionPluginId: "duckduckgo",
|
||||
}),
|
||||
inactiveSecretPaths: [],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "duckduckgo"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "duckduckgo", value),
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "duckduckgo").config,
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.",
|
||||
parameters: DuckDuckGoSearchSchema,
|
||||
execute: async (args) => {
|
||||
const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([
|
||||
import("./ddg-client.js"),
|
||||
import("openclaw/plugin-sdk/provider-web-search"),
|
||||
]);
|
||||
return await runDuckDuckGoSearch({
|
||||
execute: async (args) =>
|
||||
await runDuckDuckGoSearch({
|
||||
config: ctx.config,
|
||||
query: readStringParam(args, "query", { required: true }),
|
||||
count: readNumberParam(args, "count", { integer: true }),
|
||||
@@ -61,8 +65,7 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
| "moderate"
|
||||
| "off"
|
||||
| undefined,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
type SearchConfigRecord,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
|
||||
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
|
||||
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
|
||||
const EXA_MAX_SEARCH_COUNT = 100;
|
||||
|
||||
type ExaConfig = {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
|
||||
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];
|
||||
|
||||
type ExaTextContentsOption = boolean | { maxCharacters?: number };
|
||||
type ExaHighlightsContentsOption =
|
||||
| boolean
|
||||
| {
|
||||
maxCharacters?: number;
|
||||
query?: string;
|
||||
numSentences?: number;
|
||||
highlightsPerUrl?: number;
|
||||
};
|
||||
type ExaSummaryContentsOption = boolean | { query?: string };
|
||||
|
||||
type ExaContentsArgs = {
|
||||
highlights?: ExaHighlightsContentsOption;
|
||||
text?: ExaTextContentsOption;
|
||||
summary?: ExaSummaryContentsOption;
|
||||
};
|
||||
|
||||
type ExaSearchResult = {
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
publishedDate?: unknown;
|
||||
highlights?: unknown;
|
||||
highlightScores?: unknown;
|
||||
summary?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
type ExaSearchResponse = {
|
||||
results?: unknown;
|
||||
};
|
||||
|
||||
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
|
||||
const trimmed = normalizeOptionalLowercaseString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
|
||||
? (trimmed as ExaFreshness)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
|
||||
const exa = searchConfig?.exa;
|
||||
return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
|
||||
}
|
||||
|
||||
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
|
||||
readProviderEnvValue(["EXA_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExaDescription(result: ExaSearchResult): string {
|
||||
const highlights = result.highlights;
|
||||
if (Array.isArray(highlights)) {
|
||||
const highlightText = highlights
|
||||
.map((entry) => normalizeOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join("\n");
|
||||
if (highlightText) {
|
||||
return highlightText;
|
||||
}
|
||||
}
|
||||
const summary = normalizeOptionalString(result.summary);
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
return normalizeOptionalString(result.text) ?? "";
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function invalidContentsPayload(message: string) {
|
||||
return {
|
||||
error: "invalid_contents",
|
||||
message,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExaSearchCount(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function parseExaContents(
|
||||
rawContents: unknown,
|
||||
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
|
||||
if (rawContents === undefined) {
|
||||
return { value: undefined };
|
||||
}
|
||||
if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
|
||||
return invalidContentsPayload(
|
||||
"contents must be an object with optional text, highlights, and summary fields.",
|
||||
);
|
||||
}
|
||||
|
||||
const raw = rawContents as Record<string, unknown>;
|
||||
const allowedKeys = new Set(["text", "highlights", "summary"]);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return invalidContentsPayload(
|
||||
`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parsed: ExaContentsArgs = {};
|
||||
|
||||
const parseText = (
|
||||
value: unknown,
|
||||
): ExaTextContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.text must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key !== "maxCharacters") {
|
||||
return invalidContentsPayload(
|
||||
`contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
|
||||
return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
|
||||
}
|
||||
return parsePositiveInteger(obj.maxCharacters)
|
||||
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
|
||||
: {};
|
||||
};
|
||||
|
||||
const parseHighlights = (
|
||||
value: unknown,
|
||||
): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.highlights must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.has(key)) {
|
||||
return invalidContentsPayload(
|
||||
`contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
|
||||
return invalidContentsPayload(
|
||||
"contents.highlights.maxCharacters must be a positive integer.",
|
||||
);
|
||||
}
|
||||
if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
|
||||
return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
|
||||
}
|
||||
if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
|
||||
return invalidContentsPayload(
|
||||
"contents.highlights.highlightsPerUrl must be a positive integer.",
|
||||
);
|
||||
}
|
||||
if ("query" in obj && typeof obj.query !== "string") {
|
||||
return invalidContentsPayload("contents.highlights.query must be a string.");
|
||||
}
|
||||
return {
|
||||
...(parsePositiveInteger(obj.maxCharacters)
|
||||
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
|
||||
: {}),
|
||||
...(typeof obj.query === "string" ? { query: obj.query } : {}),
|
||||
...(parsePositiveInteger(obj.numSentences)
|
||||
? { numSentences: parsePositiveInteger(obj.numSentences) }
|
||||
: {}),
|
||||
...(parsePositiveInteger(obj.highlightsPerUrl)
|
||||
? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const parseSummary = (
|
||||
value: unknown,
|
||||
): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.summary must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key !== "query") {
|
||||
return invalidContentsPayload(
|
||||
`contents.summary has unknown field "${key}". Only "query" is allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("query" in obj && typeof obj.query !== "string") {
|
||||
return invalidContentsPayload("contents.summary.query must be a string.");
|
||||
}
|
||||
return typeof obj.query === "string" ? { query: obj.query } : {};
|
||||
};
|
||||
|
||||
if ("text" in raw) {
|
||||
const parsedText = parseText(raw.text);
|
||||
if (isErrorPayload(parsedText)) {
|
||||
return parsedText;
|
||||
}
|
||||
parsed.text = parsedText;
|
||||
}
|
||||
if ("highlights" in raw) {
|
||||
const parsedHighlights = parseHighlights(raw.highlights);
|
||||
if (isErrorPayload(parsedHighlights)) {
|
||||
return parsedHighlights;
|
||||
}
|
||||
parsed.highlights = parsedHighlights;
|
||||
}
|
||||
if ("summary" in raw) {
|
||||
const parsedSummary = parseSummary(raw.summary);
|
||||
if (isErrorPayload(parsedSummary)) {
|
||||
return parsedSummary;
|
||||
}
|
||||
parsed.summary = parsedSummary;
|
||||
}
|
||||
|
||||
return { value: parsed };
|
||||
}
|
||||
|
||||
function normalizeExaResults(payload: unknown): ExaSearchResult[] {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return [];
|
||||
}
|
||||
const results = (payload as ExaSearchResponse).results;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results.filter((entry): entry is ExaSearchResult =>
|
||||
Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFreshnessStartDate(freshness: ExaFreshness): string {
|
||||
const now = new Date();
|
||||
if (freshness === "day") {
|
||||
now.setUTCDate(now.getUTCDate() - 1);
|
||||
return now.toISOString();
|
||||
}
|
||||
if (freshness === "week") {
|
||||
now.setUTCDate(now.getUTCDate() - 7);
|
||||
return now.toISOString();
|
||||
}
|
||||
if (freshness === "month") {
|
||||
const currentDay = now.getUTCDate();
|
||||
now.setUTCDate(1);
|
||||
now.setUTCMonth(now.getUTCMonth() - 1);
|
||||
const lastDayOfTargetMonth = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
|
||||
return now.toISOString();
|
||||
}
|
||||
now.setUTCFullYear(now.getUTCFullYear() - 1);
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
async function runExaSearch(params: {
|
||||
apiKey: string;
|
||||
query: string;
|
||||
count: number;
|
||||
freshness?: ExaFreshness;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
type: ExaSearchType;
|
||||
contents?: ExaContentsArgs;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<ExaSearchResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
numResults: params.count,
|
||||
type: params.type,
|
||||
contents: params.contents ?? { highlights: true },
|
||||
};
|
||||
|
||||
if (params.dateAfter) {
|
||||
body.startPublishedDate = params.dateAfter;
|
||||
} else if (params.freshness) {
|
||||
body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
|
||||
}
|
||||
if (params.dateBefore) {
|
||||
body.endPublishedDate = params.dateBefore;
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: EXA_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": params.apiKey,
|
||||
"x-exa-integration": "openclaw",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
try {
|
||||
return normalizeExaResults(await res.json());
|
||||
} catch (error) {
|
||||
throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function missingExaKeyPayload() {
|
||||
return {
|
||||
error: "missing_exa_api_key",
|
||||
message:
|
||||
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeExaWebSearchProviderTool(
|
||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchConfig = mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"exa",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
|
||||
) as SearchConfigRecord | undefined;
|
||||
const params = args;
|
||||
const exaConfig = resolveExaConfig(searchConfig);
|
||||
const apiKey = resolveExaApiKey(exaConfig);
|
||||
if (!apiKey) {
|
||||
return missingExaKeyPayload();
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const rawType = readStringParam(params, "type");
|
||||
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
|
||||
? (rawType as ExaSearchType)
|
||||
: "auto";
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
const freshness = normalizeExaFreshness(rawFreshness);
|
||||
if (rawFreshness && !freshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: 'freshness must be one of "day", "week", "month", or "year".',
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawDateAfter = readStringParam(params, "date_after");
|
||||
const rawDateBefore = readStringParam(params, "date_before");
|
||||
if (freshness && (rawDateAfter || rawDateBefore)) {
|
||||
return {
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const parsedDateRange = parseIsoDateRange({
|
||||
rawDateAfter,
|
||||
rawDateBefore,
|
||||
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
|
||||
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
|
||||
invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
|
||||
});
|
||||
if ("error" in parsedDateRange) {
|
||||
return parsedDateRange;
|
||||
}
|
||||
const { dateAfter, dateBefore } = parsedDateRange;
|
||||
|
||||
const parsedContents = parseExaContents(params.contents);
|
||||
if (isErrorPayload(parsedContents)) {
|
||||
return parsedContents;
|
||||
}
|
||||
const contents =
|
||||
parsedContents.value && Object.keys(parsedContents.value).length > 0
|
||||
? parsedContents.value
|
||||
: undefined;
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"exa",
|
||||
type,
|
||||
query,
|
||||
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
|
||||
contents?.text ? JSON.stringify(contents.text) : undefined,
|
||||
contents?.summary ? JSON.stringify(contents.summary) : undefined,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const results = await runExaSearch({
|
||||
apiKey,
|
||||
query,
|
||||
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
type,
|
||||
contents,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
|
||||
const payload = {
|
||||
query,
|
||||
provider: "exa",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "exa",
|
||||
wrapped: true,
|
||||
},
|
||||
results: results.map((entry) => {
|
||||
const title = typeof entry.title === "string" ? entry.title : "";
|
||||
const url = typeof entry.url === "string" ? entry.url : "";
|
||||
const description = resolveExaDescription(entry);
|
||||
const summary = normalizeOptionalString(entry.summary) ?? "";
|
||||
const highlightScores = Array.isArray(entry.highlightScores)
|
||||
? entry.highlightScores.filter(
|
||||
(score): score is number => typeof score === "number" && Number.isFinite(score),
|
||||
)
|
||||
: [];
|
||||
const published =
|
||||
typeof entry.publishedDate === "string" && entry.publishedDate
|
||||
? entry.publishedDate
|
||||
: undefined;
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
|
||||
...(highlightScores.length > 0 ? { highlightScores } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeExaResults,
|
||||
normalizeExaFreshness,
|
||||
parseExaContents,
|
||||
resolveExaApiKey,
|
||||
resolveExaConfig,
|
||||
resolveExaDescription,
|
||||
resolveExaSearchCount,
|
||||
resolveFreshnessStartDate,
|
||||
} as const;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "../test-api.js";
|
||||
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
|
||||
import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js";
|
||||
|
||||
describe("exa web search provider", () => {
|
||||
it("exposes the expected metadata and selection wiring", () => {
|
||||
@@ -17,31 +15,6 @@ describe("exa web search provider", () => {
|
||||
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the lightweight contract surface aligned with provider metadata", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const contractProvider = createContractExaWebSearchProvider();
|
||||
if (!contractProvider.applySelectionConfig) {
|
||||
throw new Error("Expected contract applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = contractProvider.applySelectionConfig({});
|
||||
|
||||
expect(contractProvider).toMatchObject({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
onboardingScopes: provider.onboardingScopes,
|
||||
credentialLabel: provider.credentialLabel,
|
||||
envVars: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
docsUrl: provider.docsUrl,
|
||||
autoDetectOrder: provider.autoDetectOrder,
|
||||
credentialPath: provider.credentialPath,
|
||||
});
|
||||
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
|
||||
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers scoped configured api keys over environment fallbacks", () => {
|
||||
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
|
||||
});
|
||||
|
||||
@@ -1,61 +1,594 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
enablePluginInConfig,
|
||||
getScopedCredentialValue,
|
||||
mergeScopedSearchConfig,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
setScopedCredentialValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
type WebSearchProviderToolDefinition,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey";
|
||||
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
|
||||
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
|
||||
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
|
||||
const EXA_MAX_SEARCH_COUNT = 100;
|
||||
|
||||
const ExaSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-100, subject to Exa search-type limits).",
|
||||
minimum: 1,
|
||||
maximum: EXA_MAX_SEARCH_COUNT,
|
||||
},
|
||||
freshness: {
|
||||
type ExaConfig = {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
|
||||
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];
|
||||
|
||||
type ExaTextContentsOption = boolean | { maxCharacters?: number };
|
||||
type ExaHighlightsContentsOption =
|
||||
| boolean
|
||||
| {
|
||||
maxCharacters?: number;
|
||||
query?: string;
|
||||
numSentences?: number;
|
||||
highlightsPerUrl?: number;
|
||||
};
|
||||
type ExaSummaryContentsOption = boolean | { query?: string };
|
||||
|
||||
type ExaContentsArgs = {
|
||||
highlights?: ExaHighlightsContentsOption;
|
||||
text?: ExaTextContentsOption;
|
||||
summary?: ExaSummaryContentsOption;
|
||||
};
|
||||
|
||||
type ExaSearchResult = {
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
publishedDate?: unknown;
|
||||
highlights?: unknown;
|
||||
highlightScores?: unknown;
|
||||
summary?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
type ExaSearchResponse = {
|
||||
results?: unknown;
|
||||
};
|
||||
|
||||
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
|
||||
const trimmed = normalizeOptionalLowercaseString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
|
||||
? (trimmed as ExaFreshness)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function optionalStringEnum<T extends readonly string[]>(values: T, description: string) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...EXA_FRESHNESS_VALUES],
|
||||
description: 'Filter by time: "day", "week", "month", or "year".',
|
||||
},
|
||||
date_after: {
|
||||
type: "string",
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
},
|
||||
date_before: {
|
||||
type: "string",
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
enum: [...EXA_SEARCH_TYPES],
|
||||
description:
|
||||
'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
|
||||
},
|
||||
contents: {
|
||||
type: "object",
|
||||
properties: {
|
||||
highlights: {
|
||||
description:
|
||||
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
|
||||
},
|
||||
text: {
|
||||
description: "Text config: true, or an object with maxCharacters.",
|
||||
},
|
||||
summary: {
|
||||
description: "Summary config: true, or an object with query.",
|
||||
enum: [...values],
|
||||
description,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
|
||||
const exa = searchConfig?.exa;
|
||||
return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
|
||||
}
|
||||
|
||||
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
|
||||
readProviderEnvValue(["EXA_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExaDescription(result: ExaSearchResult): string {
|
||||
const highlights = result.highlights;
|
||||
if (Array.isArray(highlights)) {
|
||||
const highlightText = highlights
|
||||
.map((entry) => normalizeOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join("\n");
|
||||
if (highlightText) {
|
||||
return highlightText;
|
||||
}
|
||||
}
|
||||
const summary = normalizeOptionalString(result.summary);
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
return normalizeOptionalString(result.text) ?? "";
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function invalidContentsPayload(message: string) {
|
||||
return {
|
||||
error: "invalid_contents",
|
||||
message,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExaSearchCount(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function parseExaContents(
|
||||
rawContents: unknown,
|
||||
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
|
||||
if (rawContents === undefined) {
|
||||
return { value: undefined };
|
||||
}
|
||||
if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
|
||||
return invalidContentsPayload(
|
||||
"contents must be an object with optional text, highlights, and summary fields.",
|
||||
);
|
||||
}
|
||||
|
||||
const raw = rawContents as Record<string, unknown>;
|
||||
const allowedKeys = new Set(["text", "highlights", "summary"]);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return invalidContentsPayload(
|
||||
`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parsed: ExaContentsArgs = {};
|
||||
|
||||
const parseText = (
|
||||
value: unknown,
|
||||
): ExaTextContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.text must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key !== "maxCharacters") {
|
||||
return invalidContentsPayload(
|
||||
`contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
|
||||
return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
|
||||
}
|
||||
return parsePositiveInteger(obj.maxCharacters)
|
||||
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
|
||||
: {};
|
||||
};
|
||||
|
||||
const parseHighlights = (
|
||||
value: unknown,
|
||||
): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.highlights must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.has(key)) {
|
||||
return invalidContentsPayload(
|
||||
`contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
|
||||
return invalidContentsPayload(
|
||||
"contents.highlights.maxCharacters must be a positive integer.",
|
||||
);
|
||||
}
|
||||
if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
|
||||
return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
|
||||
}
|
||||
if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
|
||||
return invalidContentsPayload(
|
||||
"contents.highlights.highlightsPerUrl must be a positive integer.",
|
||||
);
|
||||
}
|
||||
if ("query" in obj && typeof obj.query !== "string") {
|
||||
return invalidContentsPayload("contents.highlights.query must be a string.");
|
||||
}
|
||||
return {
|
||||
...(parsePositiveInteger(obj.maxCharacters)
|
||||
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
|
||||
: {}),
|
||||
...(typeof obj.query === "string" ? { query: obj.query } : {}),
|
||||
...(parsePositiveInteger(obj.numSentences)
|
||||
? { numSentences: parsePositiveInteger(obj.numSentences) }
|
||||
: {}),
|
||||
...(parsePositiveInteger(obj.highlightsPerUrl)
|
||||
? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const parseSummary = (
|
||||
value: unknown,
|
||||
): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return invalidContentsPayload("contents.summary must be a boolean or an object.");
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key !== "query") {
|
||||
return invalidContentsPayload(
|
||||
`contents.summary has unknown field "${key}". Only "query" is allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("query" in obj && typeof obj.query !== "string") {
|
||||
return invalidContentsPayload("contents.summary.query must be a string.");
|
||||
}
|
||||
return typeof obj.query === "string" ? { query: obj.query } : {};
|
||||
};
|
||||
|
||||
if ("text" in raw) {
|
||||
const parsedText = parseText(raw.text);
|
||||
if (isErrorPayload(parsedText)) {
|
||||
return parsedText;
|
||||
}
|
||||
parsed.text = parsedText;
|
||||
}
|
||||
if ("highlights" in raw) {
|
||||
const parsedHighlights = parseHighlights(raw.highlights);
|
||||
if (isErrorPayload(parsedHighlights)) {
|
||||
return parsedHighlights;
|
||||
}
|
||||
parsed.highlights = parsedHighlights;
|
||||
}
|
||||
if ("summary" in raw) {
|
||||
const parsedSummary = parseSummary(raw.summary);
|
||||
if (isErrorPayload(parsedSummary)) {
|
||||
return parsedSummary;
|
||||
}
|
||||
parsed.summary = parsedSummary;
|
||||
}
|
||||
|
||||
return { value: parsed };
|
||||
}
|
||||
|
||||
function normalizeExaResults(payload: unknown): ExaSearchResult[] {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return [];
|
||||
}
|
||||
const results = (payload as ExaSearchResponse).results;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results.filter((entry): entry is ExaSearchResult =>
|
||||
Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFreshnessStartDate(freshness: ExaFreshness): string {
|
||||
const now = new Date();
|
||||
if (freshness === "day") {
|
||||
now.setUTCDate(now.getUTCDate() - 1);
|
||||
return now.toISOString();
|
||||
}
|
||||
if (freshness === "week") {
|
||||
now.setUTCDate(now.getUTCDate() - 7);
|
||||
return now.toISOString();
|
||||
}
|
||||
if (freshness === "month") {
|
||||
const currentDay = now.getUTCDate();
|
||||
now.setUTCDate(1);
|
||||
now.setUTCMonth(now.getUTCMonth() - 1);
|
||||
const lastDayOfTargetMonth = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
|
||||
return now.toISOString();
|
||||
}
|
||||
now.setUTCFullYear(now.getUTCFullYear() - 1);
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
async function runExaSearch(params: {
|
||||
apiKey: string;
|
||||
query: string;
|
||||
count: number;
|
||||
freshness?: ExaFreshness;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
type: ExaSearchType;
|
||||
contents?: ExaContentsArgs;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<ExaSearchResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
numResults: params.count,
|
||||
type: params.type,
|
||||
contents: params.contents ?? { highlights: true },
|
||||
};
|
||||
|
||||
if (params.dateAfter) {
|
||||
body.startPublishedDate = params.dateAfter;
|
||||
} else if (params.freshness) {
|
||||
body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
|
||||
}
|
||||
if (params.dateBefore) {
|
||||
body.endPublishedDate = params.dateBefore;
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: EXA_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": params.apiKey,
|
||||
"x-exa-integration": "openclaw",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
try {
|
||||
return normalizeExaResults(await res.json());
|
||||
} catch (error) {
|
||||
throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createExaSchema() {
|
||||
return Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-100, subject to Exa search-type limits).",
|
||||
minimum: 1,
|
||||
maximum: EXA_MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
freshness: optionalStringEnum(
|
||||
EXA_FRESHNESS_VALUES,
|
||||
'Filter by time: "day", "week", "month", or "year".',
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
type: optionalStringEnum(
|
||||
EXA_SEARCH_TYPES,
|
||||
'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
|
||||
),
|
||||
contents: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
highlights: Type.Optional(
|
||||
Type.Unsafe<ExaHighlightsContentsOption>({
|
||||
description:
|
||||
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
|
||||
}),
|
||||
),
|
||||
text: Type.Optional(
|
||||
Type.Unsafe<ExaTextContentsOption>({
|
||||
description: "Text config: true, or an object with maxCharacters.",
|
||||
}),
|
||||
),
|
||||
summary: Type.Optional(
|
||||
Type.Unsafe<ExaSummaryContentsOption>({
|
||||
description: "Summary config: true, or an object with query.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function missingExaKeyPayload() {
|
||||
return {
|
||||
error: "missing_exa_api_key",
|
||||
message:
|
||||
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function createExaToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
|
||||
parameters: createExaSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args;
|
||||
const exaConfig = resolveExaConfig(searchConfig);
|
||||
const apiKey = resolveExaApiKey(exaConfig);
|
||||
if (!apiKey) {
|
||||
return missingExaKeyPayload();
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const rawType = readStringParam(params, "type");
|
||||
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
|
||||
? (rawType as ExaSearchType)
|
||||
: "auto";
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
const freshness = normalizeExaFreshness(rawFreshness);
|
||||
if (rawFreshness && !freshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: 'freshness must be one of "day", "week", "month", or "year".',
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawDateAfter = readStringParam(params, "date_after");
|
||||
const rawDateBefore = readStringParam(params, "date_before");
|
||||
if (freshness && (rawDateAfter || rawDateBefore)) {
|
||||
return {
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const parsedDateRange = parseIsoDateRange({
|
||||
rawDateAfter,
|
||||
rawDateBefore,
|
||||
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
|
||||
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
|
||||
invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
|
||||
});
|
||||
if ("error" in parsedDateRange) {
|
||||
return parsedDateRange;
|
||||
}
|
||||
const { dateAfter, dateBefore } = parsedDateRange;
|
||||
|
||||
const parsedContents = parseExaContents(params.contents);
|
||||
if (isErrorPayload(parsedContents)) {
|
||||
return parsedContents;
|
||||
}
|
||||
const contents =
|
||||
parsedContents.value && Object.keys(parsedContents.value).length > 0
|
||||
? parsedContents.value
|
||||
: undefined;
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"exa",
|
||||
type,
|
||||
query,
|
||||
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
|
||||
contents?.text ? JSON.stringify(contents.text) : undefined,
|
||||
contents?.summary ? JSON.stringify(contents.summary) : undefined,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const results = await runExaSearch({
|
||||
apiKey,
|
||||
query,
|
||||
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
type,
|
||||
contents,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
|
||||
const payload = {
|
||||
query,
|
||||
provider: "exa",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "exa",
|
||||
wrapped: true,
|
||||
},
|
||||
results: results.map((entry) => {
|
||||
const title = typeof entry.title === "string" ? entry.title : "";
|
||||
const url = typeof entry.url === "string" ? entry.url : "";
|
||||
const description = resolveExaDescription(entry);
|
||||
const summary = normalizeOptionalString(entry.summary) ?? "";
|
||||
const highlightScores = Array.isArray(entry.highlightScores)
|
||||
? entry.highlightScores.filter(
|
||||
(score): score is number => typeof score === "number" && Number.isFinite(score),
|
||||
)
|
||||
: [];
|
||||
const published =
|
||||
typeof entry.publishedDate === "string" && entry.publishedDate
|
||||
? entry.publishedDate
|
||||
: undefined;
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
|
||||
...(highlightScores.length > 0 ? { highlightScores } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
@@ -69,22 +602,35 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://exa.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 65,
|
||||
credentialPath: EXA_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: EXA_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "exa" },
|
||||
configuredCredential: { pluginId: "exa" },
|
||||
selectionPluginId: "exa",
|
||||
}),
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
|
||||
parameters: ExaSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { executeExaWebSearchProviderTool } =
|
||||
await import("./exa-web-search-provider.runtime.js");
|
||||
return await executeExaWebSearchProviderTool(ctx, args);
|
||||
},
|
||||
}),
|
||||
credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "exa", value),
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value);
|
||||
},
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config,
|
||||
createTool: (ctx) =>
|
||||
createExaToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"exa",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeExaResults,
|
||||
normalizeExaFreshness,
|
||||
parseExaContents,
|
||||
resolveExaApiKey,
|
||||
resolveExaConfig,
|
||||
resolveExaDescription,
|
||||
resolveExaSearchCount,
|
||||
resolveFreshnessStartDate,
|
||||
} as const;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { __testing } from "./src/exa-web-search-provider.runtime.js";
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.exa.config.webSearch.apiKey";
|
||||
|
||||
return {
|
||||
id: "exa",
|
||||
label: "Exa Search",
|
||||
hint: "Neural + keyword search with date filters and content extraction",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Exa API key",
|
||||
envVars: ["EXA_API_KEY"],
|
||||
placeholder: "exa-...",
|
||||
signupUrl: "https://exa.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 65,
|
||||
credentialPath,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath,
|
||||
searchCredential: { type: "scoped", scopeId: "exa" },
|
||||
configuredCredential: { pluginId: "exa" },
|
||||
selectionPluginId: "exa",
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
|
||||
export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildFalImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { createFalProvider } from "./provider-registration.js";
|
||||
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
|
||||
import { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
const PROVIDER_ID = "fal";
|
||||
@@ -10,7 +11,36 @@ export default definePluginEntry({
|
||||
name: "fal Provider",
|
||||
description: "Bundled fal image and video generation provider",
|
||||
register(api) {
|
||||
api.registerProvider(createFalProvider());
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "fal",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["FAL_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "fal API key",
|
||||
hint: "Image and video generation API key",
|
||||
optionKey: "falApiKey",
|
||||
flagName: "--fal-api-key",
|
||||
envVar: "FAL_KEY",
|
||||
promptMessage: "Enter fal API key",
|
||||
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
|
||||
expectedProviders: ["fal"],
|
||||
applyConfig: (cfg) => applyFalConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image and video generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image and video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildFalVideoGenerationProvider());
|
||||
},
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const PROVIDER_ID = "fal";
|
||||
const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
|
||||
|
||||
export function createFalProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: "fal",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["FAL_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "fal API key",
|
||||
hint: "Image and video generation API key",
|
||||
run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }),
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image and video generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image and video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
|
||||
|
||||
const PROVIDER_ID = "fal";
|
||||
|
||||
export function createFalProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: "fal",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["FAL_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "fal API key",
|
||||
hint: "Image and video generation API key",
|
||||
optionKey: "falApiKey",
|
||||
flagName: "--fal-api-key",
|
||||
envVar: "FAL_KEY",
|
||||
promptMessage: "Enter fal API key",
|
||||
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
|
||||
expectedProviders: ["fal"],
|
||||
applyConfig: (cfg) => applyFalConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image and video generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image and video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -25,14 +25,9 @@ vi.mock("./bot.js", () => ({
|
||||
handleFeishuMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendCardFeishu: sendCardFeishuMock,
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
@@ -94,13 +89,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createFeishuClientMock.mockReset().mockReturnValue({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(handleFeishuMessage)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined as never);
|
||||
@@ -366,142 +354,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9b",
|
||||
chatId: "oc_dm_chat_123",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_id: "oc_dm_chat_123",
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses resolved DM chat type when building approval cards without stored context", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9c",
|
||||
chatId: "oc_dm_chat_234",
|
||||
actionValue: createFeishuCardInteractionEnvelope({
|
||||
k: "meta",
|
||||
a: FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
m: {
|
||||
command: "/new",
|
||||
prompt: "Start a fresh session?",
|
||||
},
|
||||
c: {
|
||||
u: "u123",
|
||||
h: "oc_dm_chat_234",
|
||||
e: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
|
||||
|
||||
expect(sendCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
card: expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
elements: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tag: "action",
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: expect.objectContaining({
|
||||
c: expect.objectContaining({
|
||||
t: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to p2p when Feishu chat API returns an error", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9d",
|
||||
chatId: "oc_unknown_chat_456",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to p2p when Feishu chat API throws", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockRejectedValue(new Error("network failure")),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9e",
|
||||
chatId: "oc_broken_chat_789",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops duplicate structured callback tokens", async () => {
|
||||
const event = createStructuredQuickActionEvent({
|
||||
token: "tok10",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FEISHU_APPROVAL_CONFIRM_ACTION,
|
||||
FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
} from "./card-ux-approval.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
|
||||
export type FeishuCardActionEvent = {
|
||||
@@ -105,7 +104,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string
|
||||
function buildSyntheticMessageEvent(
|
||||
event: FeishuCardActionEvent,
|
||||
content: string,
|
||||
chatType: "p2p" | "group",
|
||||
chatType?: "p2p" | "group",
|
||||
): FeishuMessageEvent {
|
||||
return {
|
||||
sender: {
|
||||
@@ -118,7 +117,7 @@ function buildSyntheticMessageEvent(
|
||||
message: {
|
||||
message_id: `card-action-${event.token}`,
|
||||
chat_id: event.context.chat_id || event.operator.open_id,
|
||||
chat_type: chatType,
|
||||
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: content }),
|
||||
},
|
||||
@@ -137,127 +136,20 @@ async function dispatchSyntheticCommand(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
command: string;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
botOpenId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
accountId?: string;
|
||||
chatType?: "p2p" | "group";
|
||||
}): Promise<void> {
|
||||
const resolvedChatType = await resolveCardActionChatType({
|
||||
event: params.event,
|
||||
account: params.account,
|
||||
chatType: params.chatType,
|
||||
log: params.runtime?.log ?? console.log,
|
||||
});
|
||||
await handleFeishuMessage({
|
||||
cfg: params.cfg,
|
||||
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
|
||||
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
|
||||
botOpenId: params.botOpenId,
|
||||
runtime: params.runtime,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
// Feishu's im.chat.get returns two fields:
|
||||
// chat_mode: conversation type — "p2p" | "group" | "topic"
|
||||
// chat_type: privacy classification — "private" | "public"
|
||||
// We check chat_mode first because it directly indicates conversation type.
|
||||
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
|
||||
// policy) — a private group chat misclassified as p2p is safer than the
|
||||
// reverse. "topic" and "public" are treated as group semantics.
|
||||
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
|
||||
if (value === "group" || value === "topic" || value === "public") {
|
||||
return "group";
|
||||
}
|
||||
if (value === "p2p" || value === "private") {
|
||||
return "p2p";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
|
||||
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
|
||||
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
|
||||
|
||||
function pruneChatTypeCache(now: number): void {
|
||||
for (const [key, entry] of resolvedChatTypeCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
|
||||
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
|
||||
const iter = resolvedChatTypeCache.keys();
|
||||
for (let i = 0; i < excess; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLogValue(v: string): string {
|
||||
return v.replace(/[\r\n]/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
async function resolveCardActionChatType(params: {
|
||||
event: FeishuCardActionEvent;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
chatType?: "p2p" | "group";
|
||||
log: (message: string) => void;
|
||||
}): Promise<"p2p" | "group"> {
|
||||
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
|
||||
if (explicitChatType) {
|
||||
return explicitChatType;
|
||||
}
|
||||
|
||||
const chatId = params.event.context.chat_id?.trim();
|
||||
if (!chatId) {
|
||||
return "p2p";
|
||||
}
|
||||
|
||||
const cacheKey = `${params.account.accountId}:${chatId}`;
|
||||
const now = Date.now();
|
||||
pruneChatTypeCache(now);
|
||||
const cached = resolvedChatTypeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await createFeishuClient(params.account).im.chat.get({
|
||||
path: { chat_id: chatId },
|
||||
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
|
||||
if (response.code === 0) {
|
||||
const resolvedChatType =
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_type);
|
||||
if (resolvedChatType) {
|
||||
resolvedChatTypeCache.set(cacheKey, {
|
||||
value: resolvedChatType,
|
||||
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
|
||||
});
|
||||
return resolvedChatType;
|
||||
}
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
|
||||
);
|
||||
} else {
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
|
||||
);
|
||||
}
|
||||
|
||||
return "p2p";
|
||||
}
|
||||
|
||||
async function sendInvalidInteractionNotice(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
@@ -354,12 +246,7 @@ export async function handleFeishuCardAction(params: {
|
||||
prompt,
|
||||
sessionKey: envelope.c?.s,
|
||||
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
|
||||
chatType: await resolveCardActionChatType({
|
||||
event,
|
||||
account,
|
||||
chatType: envelope.c?.t,
|
||||
log,
|
||||
}),
|
||||
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
|
||||
}),
|
||||
accountId,
|
||||
@@ -395,11 +282,10 @@ export async function handleFeishuCardAction(params: {
|
||||
cfg,
|
||||
event,
|
||||
command,
|
||||
account,
|
||||
botOpenId: params.botOpenId,
|
||||
runtime,
|
||||
accountId,
|
||||
chatType: envelope.c?.t,
|
||||
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
});
|
||||
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
|
||||
return;
|
||||
@@ -425,7 +311,6 @@ export async function handleFeishuCardAction(params: {
|
||||
cfg,
|
||||
event,
|
||||
command: content,
|
||||
account,
|
||||
botOpenId: params.botOpenId,
|
||||
runtime,
|
||||
accountId,
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
enablePluginInConfig,
|
||||
getScopedCredentialValue,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
setScopedCredentialValue,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { runFirecrawlSearch } from "./firecrawl-client.js";
|
||||
|
||||
const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey";
|
||||
const GenericFirecrawlSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
const GenericFirecrawlSearchSchema = Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
}),
|
||||
),
|
||||
},
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
@@ -30,25 +35,27 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
|
||||
autoDetectOrder: 60,
|
||||
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "firecrawl" },
|
||||
configuredCredential: { pluginId: "firecrawl" },
|
||||
selectionPluginId: "firecrawl",
|
||||
}),
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "firecrawl", value),
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
|
||||
},
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.",
|
||||
parameters: GenericFirecrawlSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { runFirecrawlSearch } = await import("./firecrawl-client.js");
|
||||
return await runFirecrawlSearch({
|
||||
execute: async (args) =>
|
||||
await runFirecrawlSearch({
|
||||
cfg: ctx.config,
|
||||
query: typeof args.query === "string" ? args.query : "",
|
||||
count: typeof args.count === "number" ? args.count : undefined,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
resolveProviderHttpRequestConfig,
|
||||
type ProviderRequestTransportOverrides,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
|
||||
export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js";
|
||||
import {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
@@ -21,8 +24,6 @@ export {
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig,
|
||||
shouldNormalizeGoogleProviderConfig,
|
||||
} from "./provider-policy.js";
|
||||
export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
||||
export { buildGoogleProvider } from "./provider-registration.js";
|
||||
|
||||
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
|
||||
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
|
||||
@@ -87,3 +88,27 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
|
||||
transport: params.transport,
|
||||
});
|
||||
}
|
||||
|
||||
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
|
||||
|
||||
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
|
||||
next: OpenClawConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = cfg.agents?.defaults?.model as unknown;
|
||||
const currentPrimary =
|
||||
typeof current === "string"
|
||||
? current.trim() || undefined
|
||||
: current &&
|
||||
typeof current === "object" &&
|
||||
typeof (current as { primary?: unknown }).primary === "string"
|
||||
? ((current as { primary: string }).primary || "").trim() || undefined
|
||||
: undefined;
|
||||
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
return {
|
||||
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
|
||||
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
|
||||
@@ -30,8 +29,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
|
||||
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
|
||||
}
|
||||
|
||||
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
return {
|
||||
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
@@ -129,9 +128,5 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
};
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider(buildGoogleGeminiCliProvider());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
|
||||
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
|
||||
|
||||
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
|
||||
next: OpenClawConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = cfg.agents?.defaults?.model as unknown;
|
||||
const currentPrimary =
|
||||
typeof current === "string"
|
||||
? current.trim() || undefined
|
||||
: current &&
|
||||
typeof current === "object" &&
|
||||
typeof (current as { primary?: unknown }).primary === "string"
|
||||
? ((current as { primary: string }).primary || "").trim() || undefined
|
||||
: undefined;
|
||||
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
return {
|
||||
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
export function createGoogleProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "google",
|
||||
label: "Google AI Studio",
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: ["google-antigravity", "google-vertex"],
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Google Gemini API key",
|
||||
hint: "AI Studio / Gemini API key",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "gemini-api-key",
|
||||
choiceLabel: "Google Gemini API key",
|
||||
groupId: "google",
|
||||
groupLabel: "Google",
|
||||
groupHint: "Gemini API key + OAuth",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "google-gemini-cli",
|
||||
label: "Gemini CLI OAuth",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["gemini-cli"],
|
||||
envVars: [
|
||||
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
|
||||
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
kind: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
run: noopAuth,
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "google-gemini-cli",
|
||||
choiceLabel: "Gemini CLI OAuth",
|
||||
choiceHint: "Google OAuth with project-aware token payload",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeGoogleModelId } from "./model-id.js";
|
||||
import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js";
|
||||
import {
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
applyGoogleGeminiModelDefault,
|
||||
normalizeGoogleProviderConfig,
|
||||
normalizeGoogleModelId,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
} from "./api.js";
|
||||
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
|
||||
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
|
||||
import {
|
||||
normalizeGoogleProviderConfig,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
} from "./provider-policy.js";
|
||||
|
||||
export function buildGoogleProvider(): ProviderPlugin {
|
||||
return {
|
||||
export function registerGoogleProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "google",
|
||||
label: "Google AI Studio",
|
||||
docsPath: "/providers/models",
|
||||
@@ -50,9 +50,5 @@ export function buildGoogleProvider(): ProviderPlugin {
|
||||
}),
|
||||
...GOOGLE_GEMINI_PROVIDER_HOOKS,
|
||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGoogleProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider(buildGoogleProvider());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
buildUnsupportedSearchFilterResponse,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveCitationRedirectUrl,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
type SearchConfigRecord,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
|
||||
import {
|
||||
resolveGeminiConfig,
|
||||
resolveGeminiModel,
|
||||
type GeminiConfig,
|
||||
} from "./gemini-web-search-provider.shared.js";
|
||||
|
||||
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
|
||||
|
||||
type GeminiGroundingResponse = {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
|
||||
readProviderEnvValue(["GEMINI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
async function runGeminiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
|
||||
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": params.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: params.query }] }],
|
||||
tools: [{ google_search: {} }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const safeDetail = ((await res.text()) || res.statusText).replace(
|
||||
/key=[^&\s]+/giu,
|
||||
"key=***",
|
||||
);
|
||||
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
|
||||
}
|
||||
|
||||
let data: GeminiGroundingResponse;
|
||||
try {
|
||||
data = (await res.json()) as GeminiGroundingResponse;
|
||||
} catch (error) {
|
||||
const safeError = String(error).replace(/key=[^&\s]+/giu, "key=***");
|
||||
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error });
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const rawMessage = data.error.message || data.error.status || "unknown";
|
||||
throw new Error(
|
||||
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/giu, "key=***")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = data.candidates?.[0];
|
||||
const content =
|
||||
candidate?.content?.parts
|
||||
?.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n") ?? "No response";
|
||||
const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? [])
|
||||
.filter((chunk) => chunk.web?.uri)
|
||||
.map((chunk) => ({
|
||||
url: chunk.web!.uri!,
|
||||
title: chunk.web?.title || undefined,
|
||||
}));
|
||||
|
||||
const citations: Array<{ url: string; title?: string }> = [];
|
||||
for (let index = 0; index < rawCitations.length; index += 10) {
|
||||
const batch = rawCitations.slice(index, index + 10);
|
||||
const resolved = await Promise.all(
|
||||
batch.map(async (citation) => ({
|
||||
...citation,
|
||||
url: await resolveCitationRedirectUrl(citation.url),
|
||||
})),
|
||||
);
|
||||
citations.push(...resolved);
|
||||
}
|
||||
|
||||
return { content, citations };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeGeminiSearch(
|
||||
args: Record<string, unknown>,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini");
|
||||
if (unsupportedResponse) {
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const geminiConfig = resolveGeminiConfig(searchConfig);
|
||||
const apiKey = resolveGeminiRuntimeApiKey(geminiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_gemini_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
const model = resolveGeminiModel(geminiConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"gemini",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runGeminiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "gemini",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "gemini",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
|
||||
|
||||
export type GeminiConfig = {
|
||||
apiKey?: unknown;
|
||||
model?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function trimToUndefined(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function resolveGeminiConfig(searchConfig?: Record<string, unknown>): GeminiConfig {
|
||||
const gemini = searchConfig?.gemini;
|
||||
return isRecord(gemini) ? gemini : {};
|
||||
}
|
||||
|
||||
export function resolveGeminiApiKey(
|
||||
gemini?: GeminiConfig,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string | undefined {
|
||||
return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY);
|
||||
}
|
||||
|
||||
export function resolveGeminiModel(gemini?: GeminiConfig): string {
|
||||
return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL;
|
||||
}
|
||||
@@ -1,42 +1,244 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
buildSearchCacheKey,
|
||||
buildUnsupportedSearchFilterResponse,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
getScopedCredentialValue,
|
||||
MAX_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveCitationRedirectUrl,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
setScopedCredentialValue,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js";
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
|
||||
|
||||
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
|
||||
const GEMINI_TOOL_PARAMETERS = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
|
||||
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
|
||||
|
||||
type GeminiConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type GeminiGroundingResponse = {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
|
||||
const gemini = searchConfig?.gemini;
|
||||
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
||||
? (gemini as GeminiConfig)
|
||||
: {};
|
||||
}
|
||||
|
||||
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
|
||||
readProviderEnvValue(["GEMINI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeminiModel(gemini?: GeminiConfig): string {
|
||||
const model = normalizeOptionalString(gemini?.model) ?? "";
|
||||
return model || DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
async function runGeminiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
|
||||
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": params.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: params.query }] }],
|
||||
tools: [{ google_search: {} }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
country: { type: "string", description: "Not supported by Gemini." },
|
||||
language: { type: "string", description: "Not supported by Gemini." },
|
||||
freshness: { type: "string", description: "Not supported by Gemini." },
|
||||
date_after: { type: "string", description: "Not supported by Gemini." },
|
||||
date_before: { type: "string", description: "Not supported by Gemini." },
|
||||
},
|
||||
required: ["query"],
|
||||
} satisfies Record<string, unknown>;
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const safeDetail = ((await res.text()) || res.statusText).replace(
|
||||
/key=[^&\s]+/gi,
|
||||
"key=***",
|
||||
);
|
||||
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
|
||||
}
|
||||
|
||||
let data: GeminiGroundingResponse;
|
||||
try {
|
||||
data = (await res.json()) as GeminiGroundingResponse;
|
||||
} catch (error) {
|
||||
const safeError = String(error).replace(/key=[^&\s]+/gi, "key=***");
|
||||
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error });
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const rawMessage = data.error.message || data.error.status || "unknown";
|
||||
throw new Error(
|
||||
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/gi, "key=***")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = data.candidates?.[0];
|
||||
const content =
|
||||
candidate?.content?.parts
|
||||
?.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n") ?? "No response";
|
||||
const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? [])
|
||||
.filter((chunk) => chunk.web?.uri)
|
||||
.map((chunk) => ({
|
||||
url: chunk.web!.uri!,
|
||||
title: chunk.web?.title || undefined,
|
||||
}));
|
||||
|
||||
const citations: Array<{ url: string; title?: string }> = [];
|
||||
for (let index = 0; index < rawCitations.length; index += 10) {
|
||||
const batch = rawCitations.slice(index, index + 10);
|
||||
const resolved = await Promise.all(
|
||||
batch.map(async (citation) => ({
|
||||
...citation,
|
||||
url: await resolveCitationRedirectUrl(citation.url),
|
||||
})),
|
||||
);
|
||||
citations.push(...resolved);
|
||||
}
|
||||
|
||||
return { content, citations };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGeminiSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createGeminiToolDefinition(
|
||||
searchConfig?: Record<string, unknown>,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
|
||||
parameters: GEMINI_TOOL_PARAMETERS,
|
||||
parameters: createGeminiSchema(),
|
||||
execute: async (args) => {
|
||||
const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js");
|
||||
return await executeGeminiSearch(args, searchConfig);
|
||||
const params = args;
|
||||
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini");
|
||||
if (unsupportedResponse) {
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const geminiConfig = resolveGeminiConfig(searchConfig);
|
||||
const apiKey = resolveGeminiApiKey(geminiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_gemini_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveGeminiModel(geminiConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"gemini",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runGeminiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "gemini",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "gemini",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,19 +255,23 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath: GEMINI_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: GEMINI_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "gemini" },
|
||||
configuredCredential: { pluginId: "google" },
|
||||
}),
|
||||
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "gemini", value),
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createGeminiToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
"gemini",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
|
||||
),
|
||||
) as SearchConfigRecord | undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1,28 @@
|
||||
export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
|
||||
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.google.config.webSearch.apiKey";
|
||||
|
||||
return {
|
||||
id: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Requires Google Gemini API key · Google Search grounding",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Google Gemini API key",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath,
|
||||
searchCredential: { type: "scoped", scopeId: "gemini" },
|
||||
configuredCredential: { pluginId: "google" },
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
@@ -84,20 +83,13 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
|
||||
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
|
||||
return cachedCerts.certs;
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: CHAT_CERTS_URL,
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${response.status})`);
|
||||
}
|
||||
const certs = (await response.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
} finally {
|
||||
await release();
|
||||
const res = await fetch(CHAT_CERTS_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${res.status})`);
|
||||
}
|
||||
const certs = (await res.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
}
|
||||
|
||||
export type GoogleChatAudienceType = "app-url" | "project-number";
|
||||
|
||||
@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
release: async () => {},
|
||||
})),
|
||||
verifySignedJwtWithCertsAsync: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
@@ -29,7 +28,6 @@ vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: function GoogleAuth() {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -295,34 +293,4 @@ describe("verifyGoogleChatRequest", () => {
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
|
||||
release,
|
||||
});
|
||||
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
|
||||
"token",
|
||||
{ "kid-1": "cert-body" },
|
||||
"123456789",
|
||||
["chat@system.gserviceaccount.com"],
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export {
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./media-contract-api.js";
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
__testing as imessageConversationBindingTesting,
|
||||
createIMessageConversationBindingManager,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
listConfiguredAccountIds,
|
||||
resolveMergedAccountConfig,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
resolveMatrixAuth,
|
||||
setMatrixAuthClientDepsForTest,
|
||||
} from "./client/config.js";
|
||||
import * as credentialsReadModule from "./credentials-read.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const saveBackfilledMatrixDeviceIdMock = vi.hoisted(() => vi.fn(async () => "saved"));
|
||||
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const repairCurrentTokenStorageMetaDeviceIdMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
@@ -33,10 +39,18 @@ vi.mock("./client/storage.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client/config-secret-input.runtime.js", () => ({
|
||||
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
||||
}));
|
||||
const {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
setMatrixAuthClientDepsForTest,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} = await import("./client/config.js");
|
||||
|
||||
let credentialsReadModule: typeof import("./credentials-read.js") | undefined;
|
||||
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
|
||||
const matrixDoRequestMock = vi.fn();
|
||||
|
||||
@@ -46,17 +60,721 @@ class MockMatrixClient {
|
||||
}
|
||||
}
|
||||
|
||||
function requireCredentialsReadModule(): typeof import("./credentials-read.js") {
|
||||
if (!credentialsReadModule) {
|
||||
throw new Error("credentials-read test module not initialized");
|
||||
}
|
||||
return credentialsReadModule;
|
||||
}
|
||||
|
||||
function resolveDefaultMatrixAuthContext(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
|
||||
) {
|
||||
return resolveMatrixAuthContext({ cfg, env });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
describe("Matrix auth/config live surfaces", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveDefaultMatrixAuthContext(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("does not throw when accessToken uses a non-env SecretRef", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(
|
||||
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toMatchObject({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass",
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from partial top-level auth defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7890",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
proxy: "http://127.0.0.1:7891",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7891",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBe("http://localhost.localdomain:8008");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
beforeAll(async () => {
|
||||
credentialsReadModule = await import("./credentials-read.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
const readModule = requireCredentialsReadModule();
|
||||
vi.mocked(readModule.loadMatrixCredentials).mockReset();
|
||||
vi.mocked(readModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(readModule.credentialsMatchConfig).mockReset();
|
||||
vi.mocked(readModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
saveMatrixCredentialsMock.mockReset();
|
||||
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
|
||||
touchMatrixCredentialsMock.mockReset();
|
||||
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
|
||||
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
|
||||
ensureMatrixSdkLoggingConfiguredMock.mockReset();
|
||||
matrixDoRequestMock.mockReset();
|
||||
setMatrixAuthClientDepsForTest({
|
||||
@@ -155,14 +873,14 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses cached matching credentials when access token is not configured", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -190,14 +908,14 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -242,13 +960,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -333,8 +1051,8 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses named-account password auth instead of inheriting the base access token", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(false);
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
access_token: "ops-token",
|
||||
user_id: "@ops:example.org",
|
||||
@@ -602,7 +1320,7 @@ describe("resolveMatrixAuth", () => {
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(requireCredentialsReadModule().loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-new",
|
||||
@@ -659,51 +1377,52 @@ describe("resolveMatrixAuth", () => {
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
|
||||
it("resolves file-backed accessToken SecretRefs during Matrix auth", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-secret-ref-"));
|
||||
const secretPath = path.join(tempDir, "token.txt");
|
||||
await fs.writeFile(secretPath, "file-token\n", "utf8");
|
||||
await fs.chmod(secretPath, 0o600);
|
||||
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token.txt",
|
||||
mode: "singleValue",
|
||||
try {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: secretPath,
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(resolveConfiguredSecretInputStringMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
value: { source: "file", provider: "matrix-file", id: "value" },
|
||||
path: "channels.matrix.accessToken",
|
||||
}),
|
||||
);
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "resolved-token",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "file-token",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
|
||||
@@ -752,13 +1471,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./config.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
function resolveDefaultMatrixAuthContext(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
|
||||
) {
|
||||
return resolveMatrixAuthContext({ cfg, env });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
describe("Matrix auth/config live surfaces", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveDefaultMatrixAuthContext(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("does not throw when accessToken uses a non-env SecretRef", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(
|
||||
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toMatchObject({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass",
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from partial top-level auth defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7890",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
proxy: "http://127.0.0.1:7891",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7891",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBe("http://localhost.localdomain:8008");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
@@ -351,15 +351,6 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ref = coerceSecretRef(configured.value, params.cfg.secrets?.defaults);
|
||||
if (!ref) {
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: configured.value,
|
||||
path: configured.path,
|
||||
defaults: params.cfg.secrets?.defaults,
|
||||
});
|
||||
}
|
||||
|
||||
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
|
||||
const resolved = await resolveConfiguredSecretInputString({
|
||||
config: params.cfg,
|
||||
@@ -372,9 +363,13 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
|
||||
return resolved.value;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
|
||||
);
|
||||
if (coerceSecretRef(configured.value, params.cfg.secrets?.defaults)) {
|
||||
throw new Error(
|
||||
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readMatrixBaseConfigField(
|
||||
|
||||
@@ -86,61 +86,6 @@ describe("createMatrixClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ssrfPolicy from allowPrivateNetwork when no explicit policy is provided", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith(
|
||||
"https://matrix.example.org",
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit ssrfPolicy over allowPrivateNetwork", async () => {
|
||||
const explicitPolicy = { allowPrivateNetwork: true, customField: "test" };
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
allowPrivateNetwork: false,
|
||||
ssrfPolicy: explicitPolicy as never,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith(
|
||||
"https://matrix.example.org",
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
ssrfPolicy: explicitPolicy,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves ssrfPolicy undefined when allowPrivateNetwork is falsy and no explicit policy", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith(
|
||||
"https://matrix.example.org",
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
ssrfPolicy: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips persistent storage wiring when persistence is disabled", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
type SsrFPolicy,
|
||||
} from "../../runtime-api.js";
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
|
||||
import {
|
||||
@@ -98,8 +95,7 @@ export async function createMatrixClient(params: {
|
||||
idbSnapshotPath: storagePaths?.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
autoBootstrapCrypto: params.autoBootstrapCrypto,
|
||||
ssrfPolicy:
|
||||
params.ssrfPolicy ?? ssrfPolicyFromDangerouslyAllowPrivateNetwork(params.allowPrivateNetwork),
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import {
|
||||
claimCurrentTokenStorageState,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadRuntimeApiExportTypesViaJiti } from "../../../../../test/helpers/plugins/jiti-runtime-api.ts";
|
||||
import type { MatrixRoomInfo } from "./room-info.js";
|
||||
|
||||
type DirectRoomTrackerOptions = {
|
||||
@@ -947,3 +949,26 @@ describe("monitorMatrixProvider", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix plugin registration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the matrix runtime api through Jiti", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
|
||||
expect(
|
||||
loadRuntimeApiExportTypesViaJiti({
|
||||
modulePath: runtimeApiPath,
|
||||
exportNames: [
|
||||
"requiresExplicitMatrixDefaultAccount",
|
||||
"resolveMatrixDefaultOrOnlyAccountId",
|
||||
],
|
||||
realPluginSdkSpecifiers: [],
|
||||
}),
|
||||
).toEqual({
|
||||
requiresExplicitMatrixDefaultAccount: "function",
|
||||
resolveMatrixDefaultOrOnlyAccountId: "function",
|
||||
});
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../../../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
|
||||
import type { MatrixRoomKeyBackupRestoreResult } from "../sdk.js";
|
||||
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
|
||||
|
||||
|
||||
@@ -15,10 +15,8 @@ export {
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
} from "openclaw/plugin-sdk/channel-reply-options-runtime";
|
||||
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { formatLocationText, toLocationContext } from "openclaw/plugin-sdk/channel-location";
|
||||
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-logging";
|
||||
|
||||
@@ -18,21 +18,6 @@ function requestUrl(input: RequestInfo | URL | undefined): string {
|
||||
return input.url;
|
||||
}
|
||||
|
||||
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
|
||||
function clearTestUndiciRuntimeDepsOverride(): void {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
}
|
||||
|
||||
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
@@ -239,13 +224,11 @@ describe("MatrixClient request hardening", () => {
|
||||
lastCreateClientOpts = null;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
clearTestUndiciRuntimeDepsOverride();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
clearTestUndiciRuntimeDepsOverride();
|
||||
});
|
||||
|
||||
it("blocks absolute endpoints unless explicitly allowed", async () => {
|
||||
@@ -255,7 +238,7 @@ describe("MatrixClient request hardening", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
|
||||
@@ -282,7 +265,7 @@ describe("MatrixClient request hardening", () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(payload, { status: 200 }),
|
||||
);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -313,7 +296,7 @@ describe("MatrixClient request hardening", () => {
|
||||
}
|
||||
return new Response(payload, { status: 200 });
|
||||
});
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -492,7 +475,7 @@ describe("MatrixClient request hardening", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -523,7 +506,7 @@ describe("MatrixClient request hardening", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -548,7 +531,7 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
localTimeoutMs: 25,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
isMockedFetch,
|
||||
} from "openclaw/plugin-sdk/runtime-fetch";
|
||||
import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
@@ -13,8 +10,7 @@ import {
|
||||
export {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
isMockedFetch,
|
||||
fetchWithRuntimeDispatcher,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type PinnedDispatcherPolicy,
|
||||
type SsrFPolicy,
|
||||
|
||||
@@ -8,15 +8,6 @@ function clearTestUndiciRuntimeDepsOverride(): void {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
}
|
||||
|
||||
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("performMatrixRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -28,7 +19,8 @@ describe("performMatrixRequest", () => {
|
||||
});
|
||||
|
||||
it("rejects oversized raw responses before buffering the whole body", async () => {
|
||||
stubRuntimeFetch(
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response("too-big", {
|
||||
@@ -63,7 +55,8 @@ describe("performMatrixRequest", () => {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
stubRuntimeFetch(
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
@@ -94,7 +87,8 @@ describe("performMatrixRequest", () => {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
});
|
||||
stubRuntimeFetch(
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
@@ -141,7 +135,12 @@ describe("performMatrixRequest", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
stubRuntimeFetch(runtimeFetch);
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: runtimeFetch,
|
||||
};
|
||||
|
||||
const result = await performMatrixRequest({
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
buildTimeoutAbortSignal,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type SsrFPolicy,
|
||||
fetchWithRuntimeDispatcher,
|
||||
type PinnedDispatcherPolicy,
|
||||
} from "./transport-runtime-api.js";
|
||||
|
||||
@@ -89,6 +89,13 @@ function buildBufferedResponse(params: {
|
||||
return response;
|
||||
}
|
||||
|
||||
function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return false;
|
||||
}
|
||||
return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object";
|
||||
}
|
||||
|
||||
async function fetchWithMatrixDispatcher(params: {
|
||||
url: string;
|
||||
init: MatrixDispatcherRequestInit;
|
||||
@@ -97,7 +104,10 @@ async function fetchWithMatrixDispatcher(params: {
|
||||
// fetches must stay fail-closed unless a retry path can preserve the
|
||||
// validated pinned-address binding. Route dispatcher-attached requests
|
||||
// through undici runtime fetch so the pinned dispatcher is preserved.
|
||||
return await fetchWithRuntimeDispatcherOrMockedGlobal(params.url, params.init);
|
||||
if (params.init.dispatcher && !isMockedFetch(globalThis.fetch)) {
|
||||
return await fetchWithRuntimeDispatcher(params.url, params.init);
|
||||
}
|
||||
return await fetch(params.url, params.init);
|
||||
}
|
||||
|
||||
async function fetchWithMatrixGuardedRedirects(params: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
} from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
|
||||
export type MatrixThreadBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
|
||||
@@ -311,17 +311,15 @@ describe("matrix thread bindings", () => {
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
|
||||
expect(JSON.parse(persistedRaw)).toMatchObject({
|
||||
version: 1,
|
||||
bindings: [],
|
||||
});
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
await vi.waitFor(async () => {
|
||||
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
|
||||
expect(JSON.parse(persistedRaw)).toMatchObject({
|
||||
version: 1,
|
||||
bindings: [],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -355,22 +353,23 @@ describe("matrix thread bindings", () => {
|
||||
|
||||
renameMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(
|
||||
logVerboseMessage.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" &&
|
||||
message.includes("failed auto-unbinding expired bindings"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
|
||||
);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
logVerboseMessage.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" &&
|
||||
message.includes("failed auto-unbinding expired bindings"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
@@ -641,12 +640,9 @@ describe("matrix thread bindings", () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(originalLastActivityAt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -665,12 +661,9 @@ describe("matrix thread bindings", () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const bindingsPath = resolveBindingsFilePath();
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
resolveThreadBindingFarewellText,
|
||||
type SessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
} from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
|
||||
import type { MatrixAuth } from "./client/types.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WizardPrompter } from "../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -11,11 +10,11 @@ vi.mock("./resolve-targets.js", () => ({
|
||||
resolveMatrixTargets: resolveMatrixTargetsMock,
|
||||
}));
|
||||
|
||||
let promptMatrixAllowFrom: typeof import("./onboarding.js").__testing.promptMatrixAllowFrom;
|
||||
let runMatrixAddAccountAllowlistConfigure: typeof import("./onboarding.test-harness.js").runMatrixAddAccountAllowlistConfigure;
|
||||
|
||||
describe("matrix onboarding account-scoped resolution", () => {
|
||||
beforeAll(async () => {
|
||||
({ promptMatrixAllowFrom } = (await import("./onboarding.js")).__testing);
|
||||
({ runMatrixAddAccountAllowlistConfigure } = await import("./onboarding.test-harness.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,11 +27,7 @@ describe("matrix onboarding account-scoped resolution", () => {
|
||||
});
|
||||
|
||||
it("passes accountId into Matrix allowlist target resolution during onboarding", async () => {
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
text: vi.fn(async () => "Alice"),
|
||||
} as unknown as WizardPrompter;
|
||||
const result = await promptMatrixAllowFrom({
|
||||
const result = await runMatrixAddAccountAllowlistConfigure({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -41,19 +36,15 @@ describe("matrix onboarding account-scoped resolution", () => {
|
||||
homeserver: "https://matrix.main.example.org",
|
||||
accessToken: "main-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
prompter,
|
||||
accountId: "ops",
|
||||
allowFromInput: "Alice",
|
||||
roomsAllowlistInput: "",
|
||||
});
|
||||
|
||||
expect(result.channels?.matrix?.accounts?.ops?.dm?.allowFrom).toEqual(["@alice:example.org"]);
|
||||
expect(result).not.toBe("skip");
|
||||
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
accountId: "ops",
|
||||
|
||||
@@ -769,7 +769,3 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
promptMatrixAllowFrom,
|
||||
};
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
const wizardGroup = {
|
||||
groupId: "minimax",
|
||||
groupLabel: "MiniMax",
|
||||
groupHint: "M2.7 (recommended)",
|
||||
} as const;
|
||||
|
||||
export function createMinimaxProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
hookAliases: ["minimax-cn"],
|
||||
docsPath: "/providers/minimax",
|
||||
envVars: ["MINIMAX_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "api-global",
|
||||
kind: "api_key",
|
||||
label: "MiniMax API key (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-global-api",
|
||||
choiceLabel: "MiniMax API key (Global)",
|
||||
choiceHint: "Global endpoint - api.minimax.io",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-cn",
|
||||
kind: "api_key",
|
||||
label: "MiniMax API key (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-cn-api",
|
||||
choiceLabel: "MiniMax API key (CN)",
|
||||
choiceHint: "CN endpoint - api.minimaxi.com",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createMinimaxPortalProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "minimax-portal",
|
||||
label: "MiniMax",
|
||||
hookAliases: ["minimax-portal-cn"],
|
||||
docsPath: "/providers/minimax",
|
||||
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
kind: "device_code",
|
||||
label: "MiniMax OAuth (Global)",
|
||||
hint: "Global endpoint - api.minimax.io",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-global-oauth",
|
||||
choiceLabel: "MiniMax OAuth (Global)",
|
||||
choiceHint: "Global endpoint - api.minimax.io",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oauth-cn",
|
||||
kind: "device_code",
|
||||
label: "MiniMax OAuth (CN)",
|
||||
hint: "CN endpoint - api.minimaxi.com",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "minimax-cn-oauth",
|
||||
choiceLabel: "MiniMax OAuth (CN)",
|
||||
choiceHint: "CN endpoint - api.minimaxi.com",
|
||||
...wizardGroup,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
buildSearchCacheKey,
|
||||
formatCliCommand,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
type SearchConfigRecord,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search";
|
||||
const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search";
|
||||
const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
|
||||
|
||||
type MiniMaxSearchResult = {
|
||||
title?: string;
|
||||
link?: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
type MiniMaxRelatedSearch = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
type MiniMaxSearchResponse = {
|
||||
organic?: MiniMaxSearchResult[];
|
||||
related_searches?: MiniMaxRelatedSearch[];
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
|
||||
readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function isMiniMaxCnHost(value: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).hostname.endsWith("minimaxi.com");
|
||||
} catch {
|
||||
return trimmed.includes("minimaxi.com");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMiniMaxRegion(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Record<string, unknown>,
|
||||
): "cn" | "global" {
|
||||
// 1. Explicit region in search config takes priority
|
||||
const minimax =
|
||||
typeof searchConfig?.minimax === "object" &&
|
||||
searchConfig.minimax !== null &&
|
||||
!Array.isArray(searchConfig.minimax)
|
||||
? (searchConfig.minimax as Record<string, unknown>)
|
||||
: undefined;
|
||||
const configuredRegion =
|
||||
typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined;
|
||||
if (configuredRegion) {
|
||||
return configuredRegion === "cn" ? "cn" : "global";
|
||||
}
|
||||
|
||||
// 2. Infer from the shared MiniMax host override.
|
||||
if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) {
|
||||
return "cn";
|
||||
}
|
||||
|
||||
// 3. Infer from model provider base URL (set by CN onboarding)
|
||||
const models = config?.models as Record<string, unknown> | undefined;
|
||||
const providers = models?.providers as Record<string, unknown> | undefined;
|
||||
const minimaxProvider = providers?.minimax as Record<string, unknown> | undefined;
|
||||
const portalProvider = providers?.["minimax-portal"] as Record<string, unknown> | undefined;
|
||||
const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : "";
|
||||
const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : "";
|
||||
if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) {
|
||||
return "cn";
|
||||
}
|
||||
|
||||
return "global";
|
||||
}
|
||||
|
||||
function resolveMiniMaxEndpoint(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Record<string, unknown>,
|
||||
): string {
|
||||
return resolveMiniMaxRegion(searchConfig, config) === "cn"
|
||||
? MINIMAX_SEARCH_ENDPOINT_CN
|
||||
: MINIMAX_SEARCH_ENDPOINT_GLOBAL;
|
||||
}
|
||||
|
||||
async function runMiniMaxSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{
|
||||
results: Array<Record<string, unknown>>;
|
||||
relatedSearches?: string[];
|
||||
}> {
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: params.endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ q: params.query }),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as MiniMaxSearchResponse;
|
||||
|
||||
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
|
||||
throw new Error(
|
||||
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const organic = Array.isArray(data.organic) ? data.organic : [];
|
||||
const results = organic.slice(0, params.count).map((entry) => {
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.link ?? "";
|
||||
const snippet = entry.snippet ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: snippet ? wrapWebContent(snippet, "web_search") : "",
|
||||
published: entry.date || undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const relatedSearches = Array.isArray(data.related_searches)
|
||||
? data.related_searches
|
||||
.map((r) => r.query)
|
||||
.filter((q): q is string => typeof q === "string" && q.length > 0)
|
||||
.map((q) => wrapWebContent(q, "web_search"))
|
||||
: undefined;
|
||||
|
||||
return { results, relatedSearches };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function missingMiniMaxKeyPayload() {
|
||||
return {
|
||||
error: "missing_minimax_api_key",
|
||||
message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeMiniMaxWebSearchProviderTool(
|
||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchConfig = mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"minimax",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "minimax"),
|
||||
{ mirrorApiKeyToTopLevel: true },
|
||||
) as SearchConfigRecord | undefined;
|
||||
const config = ctx.config;
|
||||
const apiKey = resolveMiniMaxApiKey(searchConfig);
|
||||
if (!apiKey) {
|
||||
return missingMiniMaxKeyPayload();
|
||||
}
|
||||
|
||||
const params = args;
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
|
||||
const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT);
|
||||
const endpoint = resolveMiniMaxEndpoint(searchConfig, config);
|
||||
|
||||
const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||
|
||||
const { results, relatedSearches } = await runMiniMaxSearch({
|
||||
query,
|
||||
count: resolvedCount,
|
||||
apiKey,
|
||||
endpoint,
|
||||
timeoutSeconds,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
provider: "minimax",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "minimax",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
|
||||
if (relatedSearches && relatedSearches.length > 0) {
|
||||
payload.relatedSearches = relatedSearches;
|
||||
}
|
||||
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
|
||||
MINIMAX_SEARCH_ENDPOINT_CN,
|
||||
resolveMiniMaxApiKey,
|
||||
resolveMiniMaxEndpoint,
|
||||
resolveMiniMaxRegion,
|
||||
} as const;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { minimaxWebSearchTesting } from "../test-api.js";
|
||||
import { __testing } from "./minimax-web-search-provider.js";
|
||||
|
||||
const {
|
||||
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
resolveMiniMaxApiKey,
|
||||
resolveMiniMaxEndpoint,
|
||||
resolveMiniMaxRegion,
|
||||
} = minimaxWebSearchTesting;
|
||||
} = __testing;
|
||||
|
||||
describe("minimax web search provider", () => {
|
||||
const originalApiHost = process.env.MINIMAX_API_HOST;
|
||||
|
||||
@@ -1,23 +1,275 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
buildSearchCacheKey,
|
||||
formatCliCommand,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
setTopLevelCredentialValue,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
type WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey";
|
||||
const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search";
|
||||
const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search";
|
||||
const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
|
||||
|
||||
const MiniMaxSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
type MiniMaxSearchResult = {
|
||||
title?: string;
|
||||
link?: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
type MiniMaxRelatedSearch = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
type MiniMaxSearchResponse = {
|
||||
organic?: MiniMaxSearchResult[];
|
||||
related_searches?: MiniMaxRelatedSearch[];
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
|
||||
readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function isMiniMaxCnHost(value: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).hostname.endsWith("minimaxi.com");
|
||||
} catch {
|
||||
return trimmed.includes("minimaxi.com");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMiniMaxRegion(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Record<string, unknown>,
|
||||
): "cn" | "global" {
|
||||
// 1. Explicit region in search config takes priority
|
||||
const minimax =
|
||||
typeof searchConfig?.minimax === "object" &&
|
||||
searchConfig.minimax !== null &&
|
||||
!Array.isArray(searchConfig.minimax)
|
||||
? (searchConfig.minimax as Record<string, unknown>)
|
||||
: undefined;
|
||||
const configuredRegion =
|
||||
typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined;
|
||||
if (configuredRegion) {
|
||||
return configuredRegion === "cn" ? "cn" : "global";
|
||||
}
|
||||
|
||||
// 2. Infer from the shared MiniMax host override.
|
||||
if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) {
|
||||
return "cn";
|
||||
}
|
||||
|
||||
// 3. Infer from model provider base URL (set by CN onboarding)
|
||||
const models = config?.models as Record<string, unknown> | undefined;
|
||||
const providers = models?.providers as Record<string, unknown> | undefined;
|
||||
const minimaxProvider = providers?.minimax as Record<string, unknown> | undefined;
|
||||
const portalProvider = providers?.["minimax-portal"] as Record<string, unknown> | undefined;
|
||||
const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : "";
|
||||
const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : "";
|
||||
if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) {
|
||||
return "cn";
|
||||
}
|
||||
|
||||
return "global";
|
||||
}
|
||||
|
||||
function resolveMiniMaxEndpoint(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Record<string, unknown>,
|
||||
): string {
|
||||
return resolveMiniMaxRegion(searchConfig, config) === "cn"
|
||||
? MINIMAX_SEARCH_ENDPOINT_CN
|
||||
: MINIMAX_SEARCH_ENDPOINT_GLOBAL;
|
||||
}
|
||||
|
||||
async function runMiniMaxSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{
|
||||
results: Array<Record<string, unknown>>;
|
||||
relatedSearches?: string[];
|
||||
}> {
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: params.endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ q: params.query }),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as MiniMaxSearchResponse;
|
||||
|
||||
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
|
||||
throw new Error(
|
||||
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const organic = Array.isArray(data.organic) ? data.organic : [];
|
||||
const results = organic.slice(0, params.count).map((entry) => {
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.link ?? "";
|
||||
const snippet = entry.snippet ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: snippet ? wrapWebContent(snippet, "web_search") : "",
|
||||
published: entry.date || undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const relatedSearches = Array.isArray(data.related_searches)
|
||||
? data.related_searches
|
||||
.map((r) => r.query)
|
||||
.filter((q): q is string => typeof q === "string" && q.length > 0)
|
||||
.map((q) => wrapWebContent(q, "web_search"))
|
||||
: undefined;
|
||||
|
||||
return { results, relatedSearches };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const MiniMaxSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function missingMiniMaxKeyPayload() {
|
||||
return {
|
||||
error: "missing_minimax_api_key",
|
||||
message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function createMiniMaxToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Record<string, unknown>,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
|
||||
parameters: MiniMaxSearchSchema,
|
||||
execute: async (args) => {
|
||||
const apiKey = resolveMiniMaxApiKey(searchConfig);
|
||||
if (!apiKey) {
|
||||
return missingMiniMaxKeyPayload();
|
||||
}
|
||||
|
||||
const params = args;
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
|
||||
const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT);
|
||||
const endpoint = resolveMiniMaxEndpoint(searchConfig, config);
|
||||
|
||||
const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||
|
||||
const { results, relatedSearches } = await runMiniMaxSearch({
|
||||
query,
|
||||
count: resolvedCount,
|
||||
apiKey,
|
||||
endpoint,
|
||||
timeoutSeconds,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
provider: "minimax",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "minimax",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
|
||||
if (relatedSearches && relatedSearches.length > 0) {
|
||||
payload.relatedSearches = relatedSearches;
|
||||
}
|
||||
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
|
||||
MINIMAX_SEARCH_ENDPOINT_CN,
|
||||
resolveMiniMaxApiKey,
|
||||
resolveMiniMaxEndpoint,
|
||||
resolveMiniMaxRegion,
|
||||
} as const;
|
||||
|
||||
export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
@@ -30,21 +282,24 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://platform.minimax.io/user-center/basic-information/interface-key",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/minimax-search",
|
||||
autoDetectOrder: 15,
|
||||
credentialPath: MINIMAX_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: MINIMAX_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "minimax" },
|
||||
}),
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
|
||||
parameters: MiniMaxSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { executeMiniMaxWebSearchProviderTool } =
|
||||
await import("./minimax-web-search-provider.runtime.js");
|
||||
return await executeMiniMaxWebSearchProviderTool(ctx, args);
|
||||
},
|
||||
}),
|
||||
credentialPath: "plugins.entries.minimax.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.minimax.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
||||
setCredentialValue: setTopLevelCredentialValue,
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "minimax")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "minimax", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createMiniMaxToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
"minimax",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "minimax"),
|
||||
{ mirrorApiKeyToTopLevel: true },
|
||||
) as SearchConfigRecord | undefined,
|
||||
ctx.config as Record<string, unknown> | undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ export {
|
||||
minimaxMediaUnderstandingProvider,
|
||||
minimaxPortalMediaUnderstandingProvider,
|
||||
} from "./media-understanding-provider.js";
|
||||
export { __testing as minimaxWebSearchTesting } from "./src/minimax-web-search-provider.runtime.js";
|
||||
export { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
export function createMoonshotProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "moonshot",
|
||||
label: "Moonshot",
|
||||
docsPath: "/providers/moonshot",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Kimi API key (.ai)",
|
||||
hint: "Kimi K2.5 + Kimi",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
groupLabel: "Moonshot AI (Kimi K2.5)",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-key-cn",
|
||||
kind: "api_key",
|
||||
label: "Kimi API key (.cn)",
|
||||
hint: "Kimi K2.5 + Kimi",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
groupLabel: "Moonshot AI (Kimi K2.5)",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
buildUnsupportedSearchFilterResponse,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderSetupContext,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
isNativeMoonshotBaseUrl,
|
||||
MOONSHOT_BASE_URL,
|
||||
MOONSHOT_CN_BASE_URL,
|
||||
MOONSHOT_DEFAULT_MODEL_ID,
|
||||
} from "../provider-catalog.js";
|
||||
|
||||
const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL;
|
||||
const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
|
||||
/** Models that require explicit thinking disablement for web search.
|
||||
* Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded
|
||||
* because they default to thinking-enabled and disabling it would defeat their
|
||||
* purpose; they are also unlikely to be used for web search. */
|
||||
const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]);
|
||||
const KIMI_WEB_SEARCH_TOOL = {
|
||||
type: "builtin_function",
|
||||
function: { name: "$web_search" },
|
||||
} as const;
|
||||
|
||||
type KimiConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type KimiToolCall = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type KimiMessage = {
|
||||
role?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: KimiToolCall[];
|
||||
};
|
||||
|
||||
type KimiSearchResponse = {
|
||||
choices?: Array<{
|
||||
finish_reason?: string;
|
||||
message?: KimiMessage;
|
||||
}>;
|
||||
search_results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
|
||||
const kimi = searchConfig?.kimi;
|
||||
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
|
||||
}
|
||||
|
||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
|
||||
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiModel(kimi?: KimiConfig): string {
|
||||
const model = normalizeOptionalString(kimi?.model) ?? "";
|
||||
return model || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string {
|
||||
const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? "";
|
||||
if (explicitBaseUrl) {
|
||||
return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl;
|
||||
if (typeof moonshotBaseUrl === "string") {
|
||||
const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim());
|
||||
if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) {
|
||||
return normalizedMoonshotBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
|
||||
const content = message?.content?.trim();
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
const reasoning = message?.reasoning_content?.trim();
|
||||
return reasoning || undefined;
|
||||
}
|
||||
|
||||
function extractKimiCitations(data: KimiSearchResponse): string[] {
|
||||
const citations = (data.search_results ?? [])
|
||||
.map((entry) => entry.url?.trim())
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (!rawArguments) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments) as {
|
||||
search_results?: Array<{ url?: string }>;
|
||||
url?: string;
|
||||
};
|
||||
const parsedUrl = normalizeOptionalString(parsed.url);
|
||||
if (parsedUrl) {
|
||||
citations.push(parsedUrl);
|
||||
}
|
||||
for (const result of parsed.search_results ?? []) {
|
||||
const resultUrl = normalizeOptionalString(result.url);
|
||||
if (resultUrl) {
|
||||
citations.push(resultUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed tool arguments
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return rawArguments;
|
||||
}
|
||||
|
||||
async function runKimiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: string[] }> {
|
||||
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
|
||||
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
|
||||
const collectedCitations = new Set<string>();
|
||||
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const next = await withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}),
|
||||
messages,
|
||||
tools: [KIMI_WEB_SEARCH_TOOL],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (
|
||||
res,
|
||||
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as KimiSearchResponse;
|
||||
for (const citation of extractKimiCitations(data)) {
|
||||
collectedCitations.add(citation);
|
||||
}
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
const text = extractKimiMessageText(message);
|
||||
const toolCalls = message?.tool_calls ?? [];
|
||||
|
||||
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message?.content ?? "",
|
||||
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
|
||||
let pushed = false;
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolCallId = toolCall.id?.trim();
|
||||
const toolCallName = toolCall.function?.name?.trim();
|
||||
const toolContent = extractKimiToolResultContent(toolCall);
|
||||
if (!toolCallId || !toolCallName || !toolContent) {
|
||||
continue;
|
||||
}
|
||||
pushed = true;
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCallId,
|
||||
name: toolCallName,
|
||||
content: toolContent,
|
||||
});
|
||||
}
|
||||
if (!pushed) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
return { done: false };
|
||||
},
|
||||
);
|
||||
|
||||
if (next.done) {
|
||||
return { content: next.content, citations: next.citations };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: "Search completed but no final answer was produced.",
|
||||
citations: [...collectedCitations],
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeKimiWebSearchProviderTool(
|
||||
ctx: { config?: OpenClawConfig; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchConfig = mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"kimi",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
|
||||
) as SearchConfigRecord | undefined;
|
||||
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "kimi");
|
||||
if (unsupportedResponse) {
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const kimiConfig = resolveKimiConfig(searchConfig);
|
||||
const apiKey = resolveKimiApiKey(kimiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_kimi_api_key",
|
||||
message:
|
||||
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
const model = resolveKimiModel(kimiConfig);
|
||||
const baseUrl = resolveKimiBaseUrl(kimiConfig, ctx.config);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"kimi",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
baseUrl,
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runKimiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "kimi",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "kimi",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function runKimiSearchProviderSetup(
|
||||
ctx: WebSearchProviderSetupContext,
|
||||
): Promise<WebSearchProviderSetupContext["config"]> {
|
||||
const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
|
||||
const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? "";
|
||||
// Normalize trailing slashes so initialValue matches canonical option values.
|
||||
const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, "");
|
||||
const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? "";
|
||||
|
||||
// Region selection (baseUrl)
|
||||
const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl);
|
||||
const regionOptions: Array<{ value: string; label: string; hint?: string }> = [];
|
||||
if (isCustomBaseUrl) {
|
||||
regionOptions.push({
|
||||
value: normalizedBaseUrl,
|
||||
label: `Keep current (${normalizedBaseUrl})`,
|
||||
hint: "custom endpoint",
|
||||
});
|
||||
}
|
||||
regionOptions.push(
|
||||
{
|
||||
value: MOONSHOT_BASE_URL,
|
||||
label: "Moonshot API key (.ai)",
|
||||
hint: "api.moonshot.ai",
|
||||
},
|
||||
{
|
||||
value: MOONSHOT_CN_BASE_URL,
|
||||
label: "Moonshot API key (.cn)",
|
||||
hint: "api.moonshot.cn",
|
||||
},
|
||||
);
|
||||
|
||||
const regionChoice = await ctx.prompter.select<string>({
|
||||
message: "Kimi API region",
|
||||
options: regionOptions,
|
||||
initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL,
|
||||
});
|
||||
const baseUrl = regionChoice;
|
||||
|
||||
// Model selection
|
||||
const currentModelLabel = existingModel
|
||||
? `Keep current (moonshot/${existingModel})`
|
||||
: `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`;
|
||||
const modelChoice = await ctx.prompter.select<string>({
|
||||
message: "Kimi web search model",
|
||||
options: [
|
||||
{
|
||||
value: "__keep__",
|
||||
label: currentModelLabel,
|
||||
},
|
||||
{
|
||||
value: "__custom__",
|
||||
label: "Enter model manually",
|
||||
},
|
||||
{
|
||||
value: DEFAULT_KIMI_SEARCH_MODEL,
|
||||
label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`,
|
||||
},
|
||||
],
|
||||
initialValue: "__keep__",
|
||||
});
|
||||
|
||||
let model: string;
|
||||
if (modelChoice === "__keep__") {
|
||||
model = existingModel || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
} else if (modelChoice === "__custom__") {
|
||||
const customModel = await ctx.prompter.text({
|
||||
message: "Kimi model name",
|
||||
initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL,
|
||||
placeholder: DEFAULT_KIMI_SEARCH_MODEL,
|
||||
});
|
||||
model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
} else {
|
||||
model = modelChoice;
|
||||
}
|
||||
|
||||
// Write baseUrl and model into plugins.entries.moonshot.config.webSearch
|
||||
const next = { ...ctx.config };
|
||||
setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl);
|
||||
setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model);
|
||||
return next;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveKimiApiKey,
|
||||
resolveKimiModel,
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
extractKimiToolResultContent,
|
||||
} as const;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { withEnv } from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "../test-api.js";
|
||||
import { __testing } from "./kimi-web-search-provider.js";
|
||||
|
||||
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
|
||||
|
||||
|
||||
@@ -1,33 +1,437 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
buildSearchCacheKey,
|
||||
buildUnsupportedSearchFilterResponse,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
getScopedCredentialValue,
|
||||
MAX_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
setScopedCredentialValue,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderSetupContext,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
type WebSearchProviderToolDefinition,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
isNativeMoonshotBaseUrl,
|
||||
MOONSHOT_BASE_URL,
|
||||
MOONSHOT_CN_BASE_URL,
|
||||
MOONSHOT_DEFAULT_MODEL_ID,
|
||||
} from "../provider-catalog.js";
|
||||
|
||||
const KIMI_CREDENTIAL_PATH = "plugins.entries.moonshot.config.webSearch.apiKey";
|
||||
const KimiSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL;
|
||||
const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
|
||||
/** Models that require explicit thinking disablement for web search.
|
||||
* Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded
|
||||
* because they default to thinking-enabled and disabling it would defeat their
|
||||
* purpose; they are also unlikely to be used for web search. */
|
||||
const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]);
|
||||
const KIMI_WEB_SEARCH_TOOL = {
|
||||
type: "builtin_function",
|
||||
function: { name: "$web_search" },
|
||||
} as const;
|
||||
|
||||
type KimiConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type KimiToolCall = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type KimiMessage = {
|
||||
role?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: KimiToolCall[];
|
||||
};
|
||||
|
||||
type KimiSearchResponse = {
|
||||
choices?: Array<{
|
||||
finish_reason?: string;
|
||||
message?: KimiMessage;
|
||||
}>;
|
||||
search_results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
|
||||
const kimi = searchConfig?.kimi;
|
||||
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
|
||||
}
|
||||
|
||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
|
||||
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiModel(kimi?: KimiConfig): string {
|
||||
const model = normalizeOptionalString(kimi?.model) ?? "";
|
||||
return model || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string {
|
||||
const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? "";
|
||||
if (explicitBaseUrl) {
|
||||
return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl;
|
||||
if (typeof moonshotBaseUrl === "string") {
|
||||
const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim());
|
||||
if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) {
|
||||
return normalizedMoonshotBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
|
||||
const content = message?.content?.trim();
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
const reasoning = message?.reasoning_content?.trim();
|
||||
return reasoning || undefined;
|
||||
}
|
||||
|
||||
function extractKimiCitations(data: KimiSearchResponse): string[] {
|
||||
const citations = (data.search_results ?? [])
|
||||
.map((entry) => entry.url?.trim())
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (!rawArguments) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments) as {
|
||||
search_results?: Array<{ url?: string }>;
|
||||
url?: string;
|
||||
};
|
||||
const parsedUrl = normalizeOptionalString(parsed.url);
|
||||
if (parsedUrl) {
|
||||
citations.push(parsedUrl);
|
||||
}
|
||||
for (const result of parsed.search_results ?? []) {
|
||||
const resultUrl = normalizeOptionalString(result.url);
|
||||
if (resultUrl) {
|
||||
citations.push(resultUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed tool arguments
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return rawArguments;
|
||||
}
|
||||
|
||||
async function runKimiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: string[] }> {
|
||||
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
|
||||
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
|
||||
const collectedCitations = new Set<string>();
|
||||
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const next = await withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}),
|
||||
messages,
|
||||
tools: [KIMI_WEB_SEARCH_TOOL],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (
|
||||
res,
|
||||
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as KimiSearchResponse;
|
||||
for (const citation of extractKimiCitations(data)) {
|
||||
collectedCitations.add(citation);
|
||||
}
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
const text = extractKimiMessageText(message);
|
||||
const toolCalls = message?.tool_calls ?? [];
|
||||
|
||||
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message?.content ?? "",
|
||||
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
|
||||
let pushed = false;
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolCallId = toolCall.id?.trim();
|
||||
const toolCallName = toolCall.function?.name?.trim();
|
||||
const toolContent = extractKimiToolResultContent(toolCall);
|
||||
if (!toolCallId || !toolCallName || !toolContent) {
|
||||
continue;
|
||||
}
|
||||
pushed = true;
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCallId,
|
||||
name: toolCallName,
|
||||
content: toolContent,
|
||||
});
|
||||
}
|
||||
if (!pushed) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
return { done: false };
|
||||
},
|
||||
);
|
||||
|
||||
if (next.done) {
|
||||
return { content: next.content, citations: next.citations };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: "Search completed but no final answer was produced.",
|
||||
citations: [...collectedCitations],
|
||||
};
|
||||
}
|
||||
|
||||
function createKimiSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
language: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
||||
});
|
||||
}
|
||||
|
||||
function createKimiToolDefinition(
|
||||
searchConfig: SearchConfigRecord | undefined,
|
||||
openClawConfig: OpenClawConfig | undefined,
|
||||
): WebSearchProviderToolDefinition {
|
||||
return {
|
||||
description:
|
||||
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
|
||||
parameters: createKimiSchema(),
|
||||
execute: async (args) => {
|
||||
const params = args;
|
||||
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi");
|
||||
if (unsupportedResponse) {
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const kimiConfig = resolveKimiConfig(searchConfig);
|
||||
const apiKey = resolveKimiApiKey(kimiConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_kimi_api_key",
|
||||
message:
|
||||
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveKimiModel(kimiConfig);
|
||||
const baseUrl = resolveKimiBaseUrl(kimiConfig, openClawConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"kimi",
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
baseUrl,
|
||||
model,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runKimiSearch({
|
||||
query,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "kimi",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "kimi",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
country: { type: "string", description: "Not supported by Kimi." },
|
||||
language: { type: "string", description: "Not supported by Kimi." },
|
||||
freshness: { type: "string", description: "Not supported by Kimi." },
|
||||
date_after: { type: "string", description: "Not supported by Kimi." },
|
||||
date_before: { type: "string", description: "Not supported by Kimi." },
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
async function runKimiSearchProviderSetup(
|
||||
ctx: WebSearchProviderSetupContext,
|
||||
): Promise<WebSearchProviderSetupContext["config"]> {
|
||||
const runtime = await import("./kimi-web-search-provider.runtime.js");
|
||||
return await runtime.runKimiSearchProviderSetup(ctx);
|
||||
const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
|
||||
const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? "";
|
||||
// Normalize trailing slashes so initialValue matches canonical option values.
|
||||
const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, "");
|
||||
const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? "";
|
||||
|
||||
// Region selection (baseUrl)
|
||||
const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl);
|
||||
const regionOptions: Array<{ value: string; label: string; hint?: string }> = [];
|
||||
if (isCustomBaseUrl) {
|
||||
regionOptions.push({
|
||||
value: normalizedBaseUrl,
|
||||
label: `Keep current (${normalizedBaseUrl})`,
|
||||
hint: "custom endpoint",
|
||||
});
|
||||
}
|
||||
regionOptions.push(
|
||||
{
|
||||
value: MOONSHOT_BASE_URL,
|
||||
label: "Moonshot API key (.ai)",
|
||||
hint: "api.moonshot.ai",
|
||||
},
|
||||
{
|
||||
value: MOONSHOT_CN_BASE_URL,
|
||||
label: "Moonshot API key (.cn)",
|
||||
hint: "api.moonshot.cn",
|
||||
},
|
||||
);
|
||||
|
||||
const regionChoice = await ctx.prompter.select<string>({
|
||||
message: "Kimi API region",
|
||||
options: regionOptions,
|
||||
initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL,
|
||||
});
|
||||
const baseUrl = regionChoice;
|
||||
|
||||
// Model selection
|
||||
const currentModelLabel = existingModel
|
||||
? `Keep current (moonshot/${existingModel})`
|
||||
: `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`;
|
||||
const modelChoice = await ctx.prompter.select<string>({
|
||||
message: "Kimi web search model",
|
||||
options: [
|
||||
{
|
||||
value: "__keep__",
|
||||
label: currentModelLabel,
|
||||
},
|
||||
{
|
||||
value: "__custom__",
|
||||
label: "Enter model manually",
|
||||
},
|
||||
{
|
||||
value: DEFAULT_KIMI_SEARCH_MODEL,
|
||||
label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`,
|
||||
},
|
||||
],
|
||||
initialValue: "__keep__",
|
||||
});
|
||||
|
||||
let model: string;
|
||||
if (modelChoice === "__keep__") {
|
||||
model = existingModel || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
} else if (modelChoice === "__custom__") {
|
||||
const customModel = await ctx.prompter.text({
|
||||
message: "Kimi model name",
|
||||
initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL,
|
||||
placeholder: DEFAULT_KIMI_SEARCH_MODEL,
|
||||
});
|
||||
model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL;
|
||||
} else {
|
||||
model = modelChoice;
|
||||
}
|
||||
|
||||
// Write baseUrl and model into plugins.entries.moonshot.config.webSearch
|
||||
const next = { ...ctx.config };
|
||||
setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl);
|
||||
setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
@@ -42,22 +446,33 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 40,
|
||||
credentialPath: KIMI_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: KIMI_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "kimi" },
|
||||
configuredCredential: { pluginId: "moonshot" },
|
||||
}),
|
||||
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "kimi", value),
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
|
||||
},
|
||||
runSetup: runKimiSearchProviderSetup,
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
|
||||
parameters: KimiSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { executeKimiWebSearchProviderTool } =
|
||||
await import("./kimi-web-search-provider.runtime.js");
|
||||
return await executeKimiWebSearchProviderTool(ctx, args);
|
||||
},
|
||||
}),
|
||||
createTool: (ctx) =>
|
||||
createKimiToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||
"kimi",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
|
||||
) as SearchConfigRecord | undefined,
|
||||
ctx.config,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveKimiApiKey,
|
||||
resolveKimiModel,
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
extractKimiToolResultContent,
|
||||
} as const;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { __testing } from "./src/kimi-web-search-provider.runtime.js";
|
||||
export { __testing } from "./src/kimi-web-search-provider.js";
|
||||
export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
|
||||
@@ -10,7 +10,6 @@ export {
|
||||
OPENAI_DEFAULT_TTS_VOICE,
|
||||
} from "./default-models.js";
|
||||
export { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
||||
export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
|
||||
export { buildOpenAIProvider } from "./openai-provider.js";
|
||||
export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
|
||||
export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
|
||||
|
||||
@@ -87,116 +87,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
provider: "openai-codex",
|
||||
access: "local-access",
|
||||
refresh: "local-refresh",
|
||||
expires: Date.now() + 10 * 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("does not override explicit local non-oauth auth with Codex CLI bootstrap", () => {
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "api_key",
|
||||
provider: "openai-codex",
|
||||
key: "sk-local",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("allows Codex CLI bootstrap when the stored default is expired", () => {
|
||||
const accessToken = buildJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct_123",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "expired-local-access",
|
||||
refresh: "expired-local-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
credential: {
|
||||
access: accessToken,
|
||||
refresh: "refresh-token",
|
||||
accountId: "acct_123",
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses Codex CLI bootstrap when an expired local default belongs to a different account", () => {
|
||||
const accessToken = buildJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex-b@example.com",
|
||||
},
|
||||
});
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct_b",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "near-expiry-local-access",
|
||||
refresh: "near-expiry-local-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct_a",
|
||||
email: "codex-a@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -205,49 +96,6 @@ describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("allows the runtime-only Codex CLI profile when the stored default already matches", () => {
|
||||
const accessToken = buildJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct_123",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const firstParse = readOpenAICodexCliOAuthProfile({
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(firstParse).not.toBeNull();
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
credential: {
|
||||
access: accessToken,
|
||||
refresh: "refresh-token",
|
||||
accountId: "acct_123",
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null without logging when the Codex CLI auth file is missing", () => {
|
||||
const error = Object.assign(new Error("missing"), {
|
||||
code: "ENOENT",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
hasUsableOAuthCredential,
|
||||
resolveRequiredHomeDir,
|
||||
type AuthProfileStore,
|
||||
type OAuthCredential,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { AuthProfileStore, OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
resolveCodexAccessTokenExpiry,
|
||||
@@ -71,6 +67,7 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
|
||||
a.provider === b.provider &&
|
||||
a.access === b.access &&
|
||||
a.refresh === b.refresh &&
|
||||
a.expires === b.expires &&
|
||||
a.clientId === b.clientId &&
|
||||
a.email === b.email &&
|
||||
a.displayName === b.displayName &&
|
||||
@@ -80,40 +77,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAuthIdentityToken(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeAuthEmailToken(value: string | undefined): string | undefined {
|
||||
return normalizeAuthIdentityToken(value)?.toLowerCase();
|
||||
}
|
||||
|
||||
// Keep this overwrite guard aligned with the canonical OAuth identity-copy rule
|
||||
// in src/agents/auth-profiles/oauth.ts without widening the plugin SDK surface.
|
||||
function isSafeToReplaceStoredIdentity(
|
||||
existing: Pick<OAuthCredential, "accountId" | "email">,
|
||||
incoming: Pick<OAuthCredential, "accountId" | "email">,
|
||||
): boolean {
|
||||
const existingAccountId = normalizeAuthIdentityToken(existing.accountId);
|
||||
const incomingAccountId = normalizeAuthIdentityToken(incoming.accountId);
|
||||
const existingEmail = normalizeAuthEmailToken(existing.email);
|
||||
const incomingEmail = normalizeAuthEmailToken(incoming.email);
|
||||
|
||||
if (existingAccountId !== undefined && incomingAccountId !== undefined) {
|
||||
return existingAccountId === incomingAccountId;
|
||||
}
|
||||
if (existingEmail !== undefined && incomingEmail !== undefined) {
|
||||
return existingEmail === incomingEmail;
|
||||
}
|
||||
|
||||
const existingHasIdentity = existingAccountId !== undefined || existingEmail !== undefined;
|
||||
if (existingHasIdentity) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readOpenAICodexCliOAuthProfile(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
store: AuthProfileStore;
|
||||
@@ -142,28 +105,7 @@ export function readOpenAICodexCliOAuthProfile(params: {
|
||||
...(identity.profileName ? { displayName: identity.profileName } : {}),
|
||||
};
|
||||
const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
|
||||
const existingOAuth =
|
||||
existing?.type === "oauth" && existing.provider === PROVIDER_ID ? existing : undefined;
|
||||
if (existing && !existingOAuth) {
|
||||
log.debug("kept explicit local auth over Codex CLI bootstrap", {
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
localType: existing.type,
|
||||
localProvider: existing.provider,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
existingOAuth &&
|
||||
hasUsableOAuthCredential(existingOAuth) &&
|
||||
!oauthCredentialMatches(existingOAuth, credential)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
existingOAuth &&
|
||||
!oauthCredentialMatches(existingOAuth, credential) &&
|
||||
!isSafeToReplaceStoredIdentity(existingOAuth, credential)
|
||||
) {
|
||||
if (existing && (existing.type !== "oauth" || !oauthCredentialMatches(existing, credential))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -241,9 +241,8 @@ describeLive("openai plugin live", () => {
|
||||
});
|
||||
|
||||
const text = (transcription?.text ?? "").toLowerCase();
|
||||
const collapsedText = text.replace(/[\s-]+/g, "");
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(collapsedText).toContain("openclaw");
|
||||
expect(text).toContain("openclaw");
|
||||
expect(text).toMatch(/\bok\b/);
|
||||
}, 45_000);
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
export function createOpenAICodexProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "openai-codex",
|
||||
label: "OpenAI Codex",
|
||||
docsPath: "/providers/models",
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
kind: "oauth",
|
||||
label: "ChatGPT OAuth",
|
||||
hint: "Browser sign-in",
|
||||
run: noopAuth,
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "openai-codex",
|
||||
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
|
||||
choiceHint: "Browser sign-in",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenAIProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
hookAliases: ["azure-openai", "azure-openai-responses"],
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENAI_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "OpenAI API key",
|
||||
hint: "Direct OpenAI API key",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
groupId: "openai",
|
||||
groupLabel: "OpenAI",
|
||||
groupHint: "Codex OAuth + API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user