Compare commits

..

4 Commits

1232 changed files with 35986 additions and 50138 deletions

View File

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

View File

@@ -52,11 +52,7 @@ function ghGraphQL(query, options = {}) {
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
result.stderr ||
result.stdout ||
`gh exited with status ${result.status}`
).trim();
const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim();
fail(`${message}: ${details}`);
}
if (Array.isArray(result?.errors) && result.errors.length > 0) {
@@ -77,7 +73,9 @@ function formatGraphQLAfterClause(cursor) {
}
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null;
return (
nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null
);
}
function fetchDiscussionReplyPage(commentNodeId, cursor) {
@@ -171,13 +169,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
while (!reply && hasMoreReplies) {
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
failOnGraphQLFailure(
replyPage,
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`);
const replies = replyPage?.data?.node?.replies;
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
@@ -195,7 +189,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
}
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : "";
const replyToClause = replyToNodeId
? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"`
: "";
const result = ghGraphQL(
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
);
@@ -265,10 +261,7 @@ function cmdFetchContent(locationJson) {
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
const { discussionId, comment } = fetchDiscussionComment(
discussionNumber,
discussionCommentDbId,
);
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,

View File

@@ -212,7 +212,7 @@ jobs:
{
check_name: "checks-fast-contracts-protocol",
runtime: "node",
task: "contracts",
task: "contracts-protocol",
},
]
: [],
@@ -408,52 +408,10 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Ensure secrets base commit (PR fast path)
if: github.event_name == 'pull_request'
@@ -509,52 +467,10 @@ jobs:
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -573,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
@@ -582,72 +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
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
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run protocol check
run: pnpm protocol:check
checks-node-extensions-shard:
permissions:
contents: read
@@ -661,52 +513,10 @@ jobs:
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_extensions_matrix) }}
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -755,52 +565,10 @@ jobs:
- name: Checkout
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
@@ -909,10 +677,9 @@ jobs:
-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
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target"
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA"
echo "checkout attempt ${attempt}/2 succeeded"
}
@@ -1031,52 +798,10 @@ jobs:
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -1100,52 +825,10 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -1161,72 +844,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
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -1234,96 +865,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:
@@ -1335,52 +1063,10 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -1432,52 +1118,10 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
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
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -1732,15 +1376,6 @@ jobs:
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: brew install xcodegen swiftlint swiftformat
- name: Detect Swift toolchain cache key
id: swift-toolchain
run: |
set -euo pipefail
xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/ */ /g; s/ $//')"
swift_version="$(swift --version | head -n 1)"
toolchain_key="$(printf '%s\n%s\n' "$xcode_version" "$swift_version" | shasum -a 256 | awk '{print $1}')"
echo "key=$toolchain_key" >> "$GITHUB_OUTPUT"
- name: Cache SwiftPM
uses: actions/cache@v5
with:
@@ -1749,24 +1384,10 @@ jobs:
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Cache Swift build directory
uses: actions/cache@v5
with:
path: apps/macos/.build
key: ${{ runner.os }}-swift-build-v1-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved', 'apps/shared/OpenClawKit/Package.swift', 'Swabble/Package.swift') }}
restore-keys: |
${{ runner.os }}-swift-build-v1-${{ steps.swift-toolchain.outputs.key }}-
- name: Patch mlx-audio-swift manifest
run: |
set -euo pipefail
if [ ! -f apps/macos/.build/checkouts/mlx-audio-swift/Package.swift ]; then
swift package resolve --package-path apps/macos >/dev/null
fi
if [ ! -f apps/macos/.build/checkouts/mlx-audio-swift/Package.swift ]; then
echo "mlx-audio-swift checkout missing after swift package resolve" >&2
exit 1
fi
swift package resolve --package-path apps/macos >/dev/null
chmod u+w apps/macos/.build/checkouts/mlx-audio-swift/Package.swift
python <<'PY'
from pathlib import Path
@@ -1802,10 +1423,7 @@ jobs:
run: |
set -euo pipefail
for attempt in 1 2 3; do
# The macOS lane validates the desktop app build; the CLI product is
# intentionally left to its own narrower surfaces instead of making
# this lane rebuild the whole package graph.
if swift build --package-path apps/macos --product OpenClaw --configuration release; then
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"

View File

@@ -1,6 +1,12 @@
name: CodeQL
on:
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "**/*.mdx"
- "LICENSE"
workflow_dispatch:
schedule:
- cron: "0 6 * * *"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
# Repository Guidelines
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/telegram/src/index.ts:80`); never absolute paths or `~/...`.
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, infra in `src/infra`, media pipeline in `src/media`, web provider helpers in `src/web` and `src/plugins/web-*provider*.ts`).
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
@@ -17,8 +17,8 @@
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/channels`, `src/routing`, `src/web`
- Bundled plugin channels: `extensions/<channel>/` (for example Discord, Telegram, Slack, Matrix, Zalo, ZaloUser, Voice Call)
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
## Architecture Boundaries
@@ -73,7 +73,7 @@
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-surface-loader.ts`, `src/plugins/public-surface-runtime.ts`, `src/plugins/provider-public-artifacts.ts`, `src/plugins/web-provider-public-artifacts.ts`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
@@ -87,8 +87,6 @@
- `src/plugin-sdk/AGENTS.md` expands public SDK contract rules.
- `src/plugins/AGENTS.md` expands plugin loading, registry, and manifest rules.
- `src/gateway/protocol/AGENTS.md` expands typed Gateway protocol rules.
- `src/gateway/AGENTS.md` expands Gateway server hot-path and plugin artifact rules.
- `src/agents/AGENTS.md` expands agent test/import performance rules.
- `test/helpers/AGENTS.md` and `test/helpers/channels/AGENTS.md` expand shared test helper boundary rules.
- Plugin architecture direction:
- Keep a manifest-first control plane: discovery, validation, enablement, setup hints, and activation planning should stay metadata-driven by default.
@@ -119,8 +117,8 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks are installed by the package `prepare` script (`git config core.hooksPath git-hooks`). The hook formats/lints staged source files and runs `pnpm check` unless the staged change is docs-only or `FAST_COMMIT=1` is set.
- `FAST_COMMIT=1` skips the repo-wide `pnpm check` inside the pre-commit hook only. The hook still runs targeted formatting/linting for staged files and restages formatter changes. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
@@ -130,8 +128,8 @@
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to host-aware `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK_MODE=throttled` to force the lower-memory profile, `OPENCLAW_LOCAL_CHECK_MODE=full` to keep lock-only behavior, or `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format:check` (oxfmt --check)
- Format fix: `pnpm format` or `pnpm format:fix` (oxfmt --write)
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
- "gate" means a verification command or command set that must be green for the decision you are making.
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
@@ -139,8 +137,8 @@
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
- Formatting gate: the pre-commit hook runs targeted formatting on staged source files before `pnpm check`. If you want a repo-wide formatting-only preflight locally, run `pnpm format:check` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm check`; targeted formatting/linting still runs, so use that only when you are deliberately covering the touched surface some other way.
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
@@ -217,8 +215,6 @@
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
- Test performance guardrail: when replacing a slow integration test with helper-level coverage, extract the exact production composition into a named helper and test that helper. Do not trade coverage shape for speed without preserving the behavior proof somewhere cheaper.
- Test performance guardrail: for plugin-owned static descriptors used by core tests or cold paths, prefer lightweight public artifacts with full-runtime fallback over loading broad bundled plugin barrels.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
- Do not set test workers above 16; tried already.
@@ -254,8 +250,8 @@
## Security & Configuration Tips
- Channel/provider state lives under `~/.openclaw/credentials/`; rerun `openclaw channels login` if logged out. Model auth profiles live under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`; legacy OAuth import still reads `~/.openclaw/credentials/oauth.json`.
- Pi sessions live under `~/.openclaw/agents/<agentId>/sessions/` by default; `session.store` can override the session store path.
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
@@ -272,13 +268,13 @@
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway may run as an app-managed launchd job. Restart the gateway via the app or `openclaw gateway restart`; inspect with `openclaw gateway status --deep` or, for the default profile, `launchctl print gui/$UID/ai.openclaw.gateway`. Use `scripts/restart-mac.sh` when you need to rebuild/relaunch the local macOS app itself. The app LaunchAgent uses `ai.openclaw.mac`. **When debugging on macOS, start/stop the gateway via the app or gateway CLI, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/version.json` (source for generated iOS config and Fastlane metadata), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), and `docs/install/updating.md` (pinned npm version).
- "Bump version everywhere" means all version locations above, then run `pnpm ios:version:sync` for iOS generated outputs. Only touch appcast metadata when cutting a new macOS Sparkle release.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.

View File

@@ -4,56 +4,9 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- macOS/gateway: add `screen.snapshot` support for macOS app nodes, including runtime plumbing, default macOS allowlisting, and docs for monitor preview flows. (#67954) Thanks @BunsDev.
### 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.
- 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.
- Plugins/discovery: reuse bundled and global plugin discovery results across workspace cache misses so Windows multi-workspace startup stops redoing the shared synchronous scan. (#67940) Thanks @obviyus.
- Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus.
- Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar.
- Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu.
- Memory/sqlite-vec: emit the degraded sqlite-vec warning once per degraded episode instead of repeating it for every file write, while preserving the latch across safe-reindex rollback and resetting it when vector state is genuinely rebuilt. (#67898) Thanks @rubencu.
- Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus.
- 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
@@ -160,8 +113,6 @@ Docs: https://docs.openclaw.ai
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
- 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

View File

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

View File

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

View File

@@ -146,7 +146,6 @@ final class MacNodeModeCoordinator {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,

View File

@@ -63,8 +63,6 @@ actor MacNodeRuntime {
return try await self.handleCameraInvoke(req)
case OpenClawLocationCommand.get.rawValue:
return try await self.handleLocationInvoke(req)
case MacNodeScreenCommand.snapshot.rawValue:
return try await self.handleScreenSnapshotInvoke(req)
case MacNodeScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req)
case OpenClawSystemCommand.run.rawValue:
@@ -354,34 +352,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleScreenSnapshotInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(MacNodeScreenSnapshotParams.self, from: req.paramsJSON)) ??
MacNodeScreenSnapshotParams()
let services = await self.mainActorServices()
let capturedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let res = try await services.snapshotScreen(
screenIndex: params.screenIndex,
maxWidth: params.maxWidth,
quality: params.quality,
format: params.format)
struct ScreenSnapshotPayload: Encodable {
var format: String
var base64: String
var width: Int
var height: Int
var screenIndex: Int?
var capturedAtMs: Int64
}
let payload = try Self.encodePayload(ScreenSnapshotPayload(
format: res.format.rawValue,
base64: res.data.base64EncodedString(),
width: res.width,
height: res.height,
screenIndex: params.screenIndex,
capturedAtMs: capturedAtMs))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
if let cachedMainActorServices { return cachedMainActorServices }
let services = await self.makeMainActorServices()

View File

@@ -4,13 +4,6 @@ import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -28,24 +21,9 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenSnapshotter = ScreenSnapshotService()
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
try await self.screenSnapshotter.snapshot(
screenIndex: screenIndex,
maxWidth: maxWidth,
quality: quality,
format: format)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,

View File

@@ -1,18 +1,9 @@
import Foundation
import OpenClawKit
enum MacNodeScreenCommand: String, Codable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
struct MacNodeScreenSnapshotParams: Codable, Equatable {
var screenIndex: Int?
var maxWidth: Int?
var quality: Double?
var format: OpenClawScreenSnapshotFormat?
}
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?

View File

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

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
import AppKit
import Foundation
import OpenClawKit
@preconcurrency import ScreenCaptureKit
@MainActor
final class ScreenSnapshotService {
enum ScreenSnapshotError: LocalizedError {
case noDisplays
case invalidScreenIndex(Int)
case captureFailed(String)
case encodeFailed(String)
var errorDescription: String? {
switch self {
case .noDisplays:
"No displays available for screen snapshot"
case let .invalidScreenIndex(idx):
"Invalid screen index \(idx)"
case let .captureFailed(message):
message
case let .encodeFailed(message):
message
}
}
}
func snapshot(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
let format = format ?? .jpeg
let normalized = Self.normalize(maxWidth: maxWidth, quality: quality, format: format)
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
guard !displays.isEmpty else {
throw ScreenSnapshotError.noDisplays
}
let idx = screenIndex ?? 0
guard idx >= 0, idx < displays.count else {
throw ScreenSnapshotError.invalidScreenIndex(idx)
}
let display = displays[idx]
let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let targetSize = Self.targetSize(
width: display.width,
height: display.height,
maxWidth: normalized.maxWidth)
config.width = targetSize.width
config.height = targetSize.height
config.showsCursor = true
let cgImage: CGImage
do {
cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config)
} catch {
throw ScreenSnapshotError.captureFailed(error.localizedDescription)
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
let data: Data
switch format {
case .png:
guard let encoded = bitmap.representation(using: .png, properties: [:]) else {
throw ScreenSnapshotError.encodeFailed("png encode failed")
}
data = encoded
case .jpeg:
guard let encoded = bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: normalized.quality])
else {
throw ScreenSnapshotError.encodeFailed("jpeg encode failed")
}
data = encoded
}
return (data: data, format: format, width: cgImage.width, height: cgImage.height)
}
private static func normalize(
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat)
-> (maxWidth: Int, quality: Double)
{
let resolvedMaxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? (format == .png ? 900 : 1600)
let resolvedQuality = min(1.0, max(0.05, quality ?? 0.72))
return (maxWidth: resolvedMaxWidth, quality: resolvedQuality)
}
private static func targetSize(width: Int, height: Int, maxWidth: Int) -> (width: Int, height: Int) {
guard width > 0, height > 0, width > maxWidth else {
return (width: width, height: height)
}
let scale = Double(maxWidth) / Double(width)
let targetHeight = max(1, Int((Double(height) * scale).rounded()))
return (width: maxWidth, height: targetHeight)
}
}

View File

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

View File

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

View File

@@ -78,19 +78,6 @@ struct MacNodeRuntimeTests {
@Test func `handle invoke screen record uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
_ = screenIndex
_ = maxWidth
_ = quality
return (Data("snapshot".utf8), format ?? .jpeg, 640, 360)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -140,94 +127,6 @@ struct MacNodeRuntimeTests {
#expect(!payload.base64.isEmpty)
}
@Test func `handle invoke screen snapshot uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
var snapshotCalledAtMs: Int64?
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
self.snapshotCalledAtMs = Int64(Date().timeIntervalSince1970 * 1000)
#expect(screenIndex == 0)
#expect(maxWidth == 800)
#expect(quality == 0.5)
return (Data("ok".utf8), format ?? .jpeg, 800, 450)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
.authorizedAlways
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
.fullAccuracy
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
_ = desiredAccuracy
_ = maxAgeMs
_ = timeoutMs
return CLLocation(latitude: 0, longitude: 0)
}
}
let services = await MainActor.run { FakeMainActorServices() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let params = MacNodeScreenSnapshotParams(
screenIndex: 0,
maxWidth: 800,
quality: 0.5,
format: .jpeg)
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-screen-snapshot",
command: MacNodeScreenCommand.snapshot.rawValue,
paramsJSON: json))
#expect(response.ok == true)
let payloadJSON = try #require(response.payloadJSON)
struct Payload: Decodable {
var format: String
var base64: String
var width: Int
var height: Int
var capturedAtMs: Int64
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(payload.format == "jpeg")
#expect(payload.base64 == Data("ok".utf8).base64EncodedString())
#expect(payload.width == 800)
#expect(payload.height == 450)
#expect(payload.capturedAtMs > 0)
let snapshotCalledAtMs = await MainActor.run { services.snapshotCalledAtMs }
#expect(snapshotCalledAtMs != nil)
#expect(payload.capturedAtMs <= snapshotCalledAtMs!)
}
@Test func `handle invoke browser proxy uses injected request`() async {
let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in
#expect(paramsJSON?.contains("/tabs") == true)

View File

@@ -444,18 +444,34 @@ private struct ChatComposerTextView: NSViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
guard let composerTextView = textView as? ChatComposerNSTextView else {
preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView")
}
composerTextView.delegate = context.coordinator
let textView = ChatComposerNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
composerTextView.string = self.text
composerTextView.onSend = { [weak composerTextView] in
composerTextView?.window?.makeFirstResponder(nil)
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.string = self.text
textView.onSend = { [weak textView] in
textView?.window?.makeFirstResponder(nil)
self.onSend()
}
composerTextView.onPasteImageAttachment = self.onPasteImageAttachment
textView.onPasteImageAttachment = self.onPasteImageAttachment
let scroll = NSScrollView()
scroll.drawsBackground = false
@@ -506,34 +522,6 @@ private struct ChatComposerTextView: NSViewRepresentable {
}
}
enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults.
@MainActor
static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView()
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
textView.allowsUndo = true
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
return textView
}
}
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?

View File

@@ -1,34 +1,9 @@
import Foundation
public enum OpenClawScreenCommand: String, Codable, Sendable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
public enum OpenClawScreenSnapshotFormat: String, Codable, Sendable {
case jpeg
case png
}
public struct OpenClawScreenSnapshotParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawScreenSnapshotFormat?
public init(
screenIndex: Int? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: OpenClawScreenSnapshotFormat? = nil)
{
self.screenIndex = screenIndex
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?

View File

@@ -1,15 +0,0 @@
#if os(macOS)
import AppKit
import Testing
@testable import OpenClawChatUI
@Suite
@MainActor
struct ChatComposerTextViewTests {
@Test func configuredComposerTextViewEnablesUndo() {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
#expect(textView.allowsUndo)
}
}
#endif

View File

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

View File

@@ -1,2 +1,2 @@
d08f0e793e66192fdbc377183ce0d94adcbec53cf334522bce8c0c457b90b0a8 plugin-sdk-api-baseline.json
924f20b350a9f1997e95b3d7249cbb6720c9576c63e6c0c15cca0164734fd93d plugin-sdk-api-baseline.jsonl
9683f324fae8f455f2b64d7e152a77009941e4c7558521bca2510d8bcf573af9 plugin-sdk-api-baseline.json
097bf226e4e857e9296d0851852a2963c6263d176c4c470452d9a8efd36988e5 plugin-sdk-api-baseline.jsonl

View File

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

View File

@@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and whats 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`).

View File

@@ -120,7 +120,7 @@ can write back through the mounted workspace.
Seed assets live in `qa/`:
- `qa/scenarios/index.md`
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
These are intentionally in git so the QA plan is visible to both humans and the
agent.
@@ -129,7 +129,6 @@ agent.
the source of truth for one test run and should define:
- scenario metadata
- optional category, capability, lane, and risk metadata
- docs and code refs
- optional plugin requirements
- optional gateway config patch
@@ -140,10 +139,6 @@ and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
Scenario files should be grouped by product capability rather than source tree
folder. Keep scenario IDs stable when files move; use `docsRefs` and `codeRefs`
for implementation traceability.
The baseline list should stay broad enough to cover:
- DM and channel chat

View File

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

View File

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

View File

@@ -89,21 +89,7 @@ Gateway → Client:
```
`server`, `features`, `snapshot`, and `policy` are all required by the schema
(`src/gateway/protocol/schema/frames.ts`). `canvasHostUrl` is optional. `auth`
reports the negotiated role/scopes when available, and includes `deviceToken`
when the gateway issues one.
When no device token is issued, `hello-ok.auth` can still report the negotiated
permissions:
```json
{
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
When a device token is issued, `hello-ok` also includes:

View File

@@ -213,7 +213,7 @@ The minimum adoption bar for a new channel is:
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command.
Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`.
Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
5. Author or adapt markdown scenarios under `qa/scenarios/`.
6. Use the generic scenario helpers for new scenarios.
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.

View File

@@ -55,7 +55,7 @@ The macOS app presents itself as a node. Common commands:
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.snapshot`, `screen.record`
- Screen: `screen.record`
- System: `system.run`, `system.notify`
The node reports a `permissions` map so agents can decide whats allowed.

View File

@@ -185,9 +185,7 @@ Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-mention-gating` for mention-policy decisions.
Use `openclaw/plugin-sdk/channel-inbound` only when you need the broader inbound
helper barrel.
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
@@ -257,11 +255,6 @@ bundled channel plugins that already depend on runtime injection:
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
If you only need `implicitMentionKindWhen` and
`resolveInboundMentionDecision`, import from
`openclaw/plugin-sdk/channel-mention-gating` to avoid loading unrelated inbound
runtime helpers.
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.

View File

@@ -318,7 +318,7 @@ Current bundled provider examples:
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |

View File

@@ -88,7 +88,6 @@ explicitly promotes one as public.
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Channel config schema types |
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
@@ -96,7 +95,6 @@ explicitly promotes one as public.
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers |
| `plugin-sdk/poll-runtime` | Narrow poll normalization helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy agent media payload builder |
| `plugin-sdk/conversation-runtime` | Conversation/thread binding, pairing, and configured-binding helpers |
@@ -110,10 +108,7 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-mention-gating` | Narrow mention-policy helpers without the broader inbound runtime surface |
| `plugin-sdk/channel-location` | Channel location context and formatting helpers |
| `plugin-sdk/channel-logging` | Channel logging helpers for inbound drops and typing/ack failures |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -171,7 +166,6 @@ explicitly promotes one as public.
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
@@ -193,7 +187,6 @@ explicitly promotes one as public.
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize helpers |
@@ -218,7 +211,6 @@ explicitly promotes one as public.
| `plugin-sdk/file-lock` | Re-entrant file-lock helpers |
| `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers |
| `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers |
| `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports |
| `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives |
| `plugin-sdk/boolean-param` | Loose boolean param reader |
| `plugin-sdk/dangerous-name-runtime` | Dangerous-name matching resolution helpers |
@@ -234,12 +226,6 @@ explicitly promotes one as public.
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag and event helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, shared error classification helpers, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, and pinned lookup helpers |
| `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | Session-store read helpers without broad config writes/maintenance imports |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
| `plugin-sdk/agent-runtime` | Agent dir/identity/workspace helpers |
@@ -278,7 +264,7 @@ explicitly promotes one as public.
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |

View File

@@ -18,7 +18,7 @@ The desired end state is a generic QA harness that loads powerful scenario defin
## Current State
Primary source of truth now lives in `qa/scenarios/index.md` plus one file per
scenario under `qa/scenarios/<theme>/*.md`.
scenario under `qa/scenarios/*.md`.
Implemented:
@@ -26,7 +26,7 @@ Implemented:
- canonical QA pack metadata
- operator identity
- kickoff mission
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
- one markdown file per scenario
- scenario metadata
- handler bindings
@@ -107,8 +107,8 @@ These categories matter because they drive DSL requirements. A flat list of prom
### Single source of truth
Use `qa/scenarios/index.md` plus `qa/scenarios/<theme>/*.md` as the authored
source of truth.
Use `qa/scenarios/index.md` plus `qa/scenarios/*.md` as the authored source of
truth.
The pack should stay:
@@ -363,7 +363,7 @@ Generated compatibility:
Done.
- added `qa/scenarios/index.md`
- split scenarios into `qa/scenarios/<theme>/*.md`
- split scenarios into `qa/scenarios/*.md`
- added parser for named markdown YAML pack content
- validated with zod
- switched consumers to the parsed pack

View File

@@ -82,10 +82,7 @@ html.dark .nav-tabs-underline {
border-radius: 8px;
background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent);
text-decoration: none;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.showcase-actions a:first-child {

View File

@@ -60,10 +60,6 @@ third-party plugins see.
- Do not rely on eager global registry seeding or import-time side effects to
make a plugin “available”. Plugin availability should come from manifest
ownership plus targeted activation.
- When core needs plugin-owned static data on a hot path, expose a lightweight
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
the full plugin so fast paths do not drift from runtime behavior.
## Expanding The Boundary

View File

@@ -119,7 +119,7 @@ describe("active-memory plugin", () => {
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(async () => {
@@ -425,7 +425,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
@@ -513,7 +513,7 @@ describe("active-memory plugin", () => {
searchMode: "inherit",
},
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -602,7 +602,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -630,7 +630,7 @@ describe("active-memory plugin", () => {
queryMode: "message",
promptStyle: "preference-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -675,7 +675,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
thinking: "medium",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -701,7 +701,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
promptAppend: "Prefer stable long-term preferences over one-off events.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -730,7 +730,7 @@ describe("active-memory plugin", () => {
promptOverride: "Custom memory prompt. Return NONE or one user fact.",
promptAppend: "Extra custom instruction.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -802,7 +802,7 @@ describe("active-memory plugin", () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
@@ -828,7 +828,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
@@ -851,7 +851,7 @@ describe("active-memory plugin", () => {
modelFallback: "google/gemini-3-flash",
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? custom fallback", messages: [] },
@@ -878,7 +878,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? built-in fallback", messages: [] },
@@ -1027,7 +1027,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
@@ -1073,7 +1073,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
@@ -1107,7 +1107,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 25));
return {
@@ -1145,7 +1145,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? log sanitization", messages: [] },
@@ -1179,7 +1179,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const hugeSession = `agent:main:${"x".repeat(500)}`;
await hooks.before_prompt_build(
@@ -1423,7 +1423,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1451,7 +1451,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1482,7 +1482,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1536,7 +1536,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1578,7 +1578,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1611,7 +1611,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1619,7 +1619,8 @@ describe("active-memory plugin", () => {
messages: [
{
role: "user",
content: "Active Memory: I really do want you to remember that I prefer aisle seats.",
content:
"Active Memory: I really do want you to remember that I prefer aisle seats.",
},
{
role: "user",
@@ -1673,7 +1674,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
@@ -1707,7 +1708,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
@@ -1757,7 +1758,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
@@ -1801,7 +1802,7 @@ describe("active-memory plugin", () => {
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1838,7 +1839,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1905,7 +1906,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(

View File

@@ -1527,9 +1527,7 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
}
const rawText = extractTextContent(typed.content);
const text =
role === "assistant"
? stripRecalledContextNoise(rawText)
: stripInjectedActiveMemoryPrefixOnly(rawText);
role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText);
if (!text) {
continue;
}

View File

@@ -1,37 +0,0 @@
import {
isMissingEmbeddingApiKeyError,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createBedrockEmbeddingProvider,
DEFAULT_BEDROCK_EMBEDDING_MODEL,
} from "./embedding-provider.js";
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "bedrock",
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "amazon-bedrock",
autoSelectPriority: 60,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
create: async (options) => {
const { provider, client } = await createBedrockEmbeddingProvider({
...options,
provider: "bedrock",
fallback: "none",
});
return {
provider,
runtime: {
id: "bedrock",
cacheKeyData: {
provider: "bedrock",
region: client.region,
model: client.model,
dimensions: client.dimensions,
},
},
};
},
};

View File

@@ -2,9 +2,6 @@
"id": "amazon-bedrock",
"enabledByDefault": true,
"providers": ["amazon-bedrock"],
"contracts": {
"memoryEmbeddingProviders": ["bedrock"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -5,9 +5,7 @@
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1028.0",
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
"@aws-sdk/credential-provider-node": "3.972.30"
"@aws-sdk/client-bedrock": "3.1028.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -14,7 +14,6 @@ import {
resolveBedrockConfigApiKey,
resolveImplicitBedrockProvider,
} from "./api.js";
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
type GuardrailConfig = {
guardrailIdentifier: string;
@@ -79,8 +78,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
const guardrail = pluginConfig.guardrail;
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);

View File

@@ -1,5 +1,4 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

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

View File

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

View File

@@ -2,7 +2,3 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";

View File

@@ -1,46 +0,0 @@
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
retryDelayMs?: number;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
): { targetId: string; shouldRetry: boolean } => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return { targetId: opts.oldTargetId, shouldRetry: false };
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return { targetId: byUrl[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return {
targetId: uniqueReplacement[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false,
};
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return { targetId: tabs[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
return { targetId: opts.oldTargetId, shouldRetry: true };
};
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;
}
} catch {
// Best-effort: fall back to pre-navigation targetId.
}
return currentTargetId;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
type Tab = { targetId: string; url: string };
@@ -8,6 +8,10 @@ function staticListTabs(tabs: Tab[]): () => Promise<Tab[]> {
}
describe("resolveTargetIdAfterNavigate", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("returns original targetId when old target still exists (no swap)", async () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
@@ -33,7 +37,6 @@ describe("resolveTargetIdAfterNavigate", () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
@@ -44,12 +47,12 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
vi.useFakeTimers();
let calls = 0;
const result = await resolveTargetIdAfterNavigate({
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://delayed.com",
retryDelayMs: 0,
listTabs: async () => {
calls++;
if (calls === 1) {
@@ -59,33 +62,50 @@ describe("resolveTargetIdAfterNavigate", () => {
},
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("delayed-999");
expect(calls).toBe(2);
vi.useRealTimers();
});
it("falls back to original targetId when no match found after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://no-match.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "unrelated-1", url: "https://unrelated.com" },
{ targetId: "unrelated-2", url: "https://unrelated2.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
it("falls back to single remaining tab when no URL match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://single-tab.com",
retryDelayMs: 0,
listTabs: staticListTabs([{ targetId: "only-tab", url: "https://some-other.com" }]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("only-tab");
vi.useRealTimers();
});
it("falls back to original targetId when listTabs throws", async () => {
@@ -100,16 +120,22 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -32,7 +32,6 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
@@ -173,6 +172,48 @@ async function saveBrowserMediaResponse(params: {
});
}
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
});
}
} catch {
// Best-effort: fall back to pre-navigation targetId
}
return currentTargetId;
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,

View File

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

View File

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

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
installAgentContractHooks,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
});

View File

@@ -1,577 +0,0 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
});

View File

@@ -348,6 +348,7 @@ async function loadBrowserServerModule(): Promise<BrowserServerModule> {
if (browserServerModule) {
return browserServerModule;
}
vi.resetModules();
browserServerModule = await import("../server.js");
return browserServerModule;
}
@@ -483,6 +484,7 @@ export async function startBrowserControlServerFromConfig() {
export async function stopBrowserControlServer(): Promise<void> {
const server = browserServerModule;
browserServerModule = null;
if (!server) {
return;
}

View File

@@ -0,0 +1,207 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
installBrowserControlServerHooks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
describe("browser control server", () => {
installBrowserControlServerHooks();
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -199,39 +199,6 @@ describe("CodexAppServerClient", () => {
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");

View File

@@ -74,13 +74,6 @@ export class CodexAppServerClient {
),
);
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
// before the "exit" event fires, so a pending writeMessage() produces an
// asynchronous error on stdin that would otherwise crash the gateway.
child.stdin.on?.("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
}
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
@@ -219,9 +212,6 @@ export class CodexAppServerClient {
}
private writeMessage(message: RpcRequest | RpcResponse): void {
if (this.closed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
@@ -310,9 +300,7 @@ export class CodexAppServerClient {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
}
private rejectPendingRequests(error: Error): void {

View File

@@ -4,7 +4,6 @@ export type CodexAppServerTransport = {
end?: () => unknown;
destroy?: () => unknown;
unref?: () => unknown;
on?: (event: "error", listener: (error: Error) => void) => unknown;
};
stdout: NodeJS.ReadableStream & {
destroy?: () => unknown;

View File

@@ -42,7 +42,7 @@ describeLive("comfy live", () => {
beforeAll(async () => {
cfg = withPluginsEnabled(loadConfig());
agentDir = resolveOpenClawAgentDir();
plugin.register(
await plugin.register(
createTestPluginApi({
config: cfg as never,
registerImageGenerationProvider(provider) {

View File

@@ -92,7 +92,7 @@ function registerPairCommand(params?: {
pluginConfig?: Record<string, unknown>;
}): OpenClawPluginCommandDefinition {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
void registerDevicePair.register(
createApi({
...params,
registerCommand: (nextCommand) => {

View File

@@ -1,5 +1,4 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,

View File

@@ -1,4 +0,0 @@
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { __testing } from "./src/exa-web-search-provider.runtime.js";

View File

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

View File

@@ -1 +1 @@
export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
const createGitHubCopilotEmbeddingProviderMock = vi.hoisted(() => vi.fn());
vi.mock("./auth.js", () => ({
resolveFirstGithubToken: resolveFirstGithubTokenMock,
@@ -18,6 +19,10 @@ vi.mock("openclaw/plugin-sdk/github-copilot-token", () => ({
resolveCopilotApiToken: resolveCopilotApiTokenMock,
}));
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
createGitHubCopilotEmbeddingProvider: createGitHubCopilotEmbeddingProviderMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
@@ -68,6 +73,15 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
source: "test",
baseUrl: TEST_BASE_URL,
});
createGitHubCopilotEmbeddingProviderMock.mockImplementation(async (client) => ({
provider: {
id: "github-copilot",
model: client.model,
embedQuery: async () => [0.1, 0.2, 0.3],
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
},
client,
}));
});
afterEach(() => {
@@ -75,6 +89,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
createGitHubCopilotEmbeddingProviderMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
@@ -98,8 +113,12 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "gh_test_token_123" }),
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: TEST_BASE_URL,
githubToken: "gh_test_token_123",
model: "text-embedding-3-small",
}),
);
});
@@ -198,12 +217,14 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
} as never);
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
githubToken: "gh_remote_token",
}),
);
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith({
baseUrl: "https://proxy.example/v1",
env: process.env,
fetchImpl: fetch,
githubToken: "gh_remote_token",
headers: { "X-Proxy-Token": "proxy" },
model: "text-embedding-3-small",
});
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
init: { headers: Record<string, string> };

View File

@@ -4,10 +4,7 @@ import {
resolveCopilotApiToken,
} from "openclaw/plugin-sdk/github-copilot-token";
import {
buildRemoteBaseUrlPolicy,
sanitizeAndNormalizeEmbedding,
withRemoteHttpResponse,
type MemoryEmbeddingProvider,
createGitHubCopilotEmbeddingProvider,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -47,15 +44,6 @@ type CopilotModelEntry = {
supported_endpoints?: unknown;
};
type GitHubCopilotEmbeddingClient = {
githubToken: string;
model: string;
baseUrl?: string;
headers?: Record<string, string>;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
};
function isCopilotSetupError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
@@ -159,126 +147,9 @@ function pickBestModel(available: string[], userModel?: string): string {
throw new Error("No embedding models available from GitHub Copilot");
}
function parseGitHubCopilotEmbeddingPayload(payload: unknown, expectedCount: number): number[][] {
if (!payload || typeof payload !== "object") {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const data = (payload as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const vectors = Array.from<number[] | undefined>({ length: expectedCount });
for (const entry of data) {
if (!entry || typeof entry !== "object") {
throw new Error("GitHub Copilot embeddings response contains an invalid entry");
}
const indexValue = (entry as { index?: unknown }).index;
const embedding = (entry as { embedding?: unknown }).embedding;
const index = typeof indexValue === "number" ? indexValue : Number.NaN;
if (!Number.isInteger(index)) {
throw new Error("GitHub Copilot embeddings response contains an invalid index");
}
if (index < 0 || index >= expectedCount) {
throw new Error("GitHub Copilot embeddings response contains an out-of-range index");
}
if (vectors[index] !== undefined) {
throw new Error("GitHub Copilot embeddings response contains duplicate indexes");
}
if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) {
throw new Error("GitHub Copilot embeddings response contains an invalid embedding");
}
vectors[index] = sanitizeAndNormalizeEmbedding(embedding);
}
for (let index = 0; index < expectedCount; index += 1) {
if (vectors[index] === undefined) {
throw new Error("GitHub Copilot embeddings response missing vectors for some inputs");
}
}
return vectors as number[][];
}
async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddingClient): Promise<{
baseUrl: string;
headers: Record<string, string>;
}> {
const token = await resolveCopilotApiToken({
githubToken: client.githubToken,
env: client.env,
fetchImpl: client.fetchImpl,
});
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
return {
baseUrl,
headers: {
...COPILOT_HEADERS_STATIC,
...client.headers,
Authorization: `Bearer ${token.token}`,
},
};
}
async function createGitHubCopilotEmbeddingProvider(
client: GitHubCopilotEmbeddingClient,
): Promise<{ provider: MemoryEmbeddingProvider; client: GitHubCopilotEmbeddingClient }> {
const initialSession = await resolveGitHubCopilotEmbeddingSession(client);
const embed = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) {
return [];
}
const session = await resolveGitHubCopilotEmbeddingSession(client);
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
return await withRemoteHttpResponse({
url,
fetchImpl: client.fetchImpl,
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
init: {
method: "POST",
headers: session.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
throw new Error("GitHub Copilot embeddings returned invalid JSON");
}
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
},
});
};
return {
provider: {
id: COPILOT_EMBEDDING_PROVIDER_ID,
model: client.model,
embedQuery: async (text) => {
const [vector] = await embed([text]);
return vector ?? [];
},
embedBatch: embed,
},
client: {
...client,
baseUrl: initialSession.baseUrl,
},
};
}
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: COPILOT_EMBEDDING_PROVIDER_ID,
transport: "remote",
authProviderId: COPILOT_EMBEDDING_PROVIDER_ID,
autoSelectPriority: 15,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: (err: unknown) => isCopilotSetupError(err),

View File

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

View File

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

View File

@@ -1,121 +0,0 @@
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import type {
VideoGenerationProvider,
VideoGenerationProviderConfiguredContext,
} from "openclaw/plugin-sdk/video-generation";
export const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview";
export const GOOGLE_PRO_MUSIC_MODEL = "lyria-3-pro-preview";
export const GOOGLE_MAX_INPUT_IMAGES = 10;
export const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
export const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
export const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
export const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function isGoogleProviderConfigured(
ctx: { agentDir?: string } | VideoGenerationProviderConfiguredContext,
): boolean {
return isProviderApiKeyConfigured({
provider: "google",
agentDir: ctx.agentDir,
});
}
export function createGoogleMusicGenerationProviderMetadata(): Omit<
MusicGenerationProvider,
"generateMusic"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_MUSIC_MODEL,
models: [DEFAULT_GOOGLE_MUSIC_MODEL, GOOGLE_PRO_MUSIC_MODEL],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxTracks: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: GOOGLE_MAX_INPUT_IMAGES,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
},
};
}
export function createGoogleVideoGenerationProviderMetadata(): Omit<
VideoGenerationProvider,
"generateVideo"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
models: [
DEFAULT_GOOGLE_VIDEO_MODEL,
"veo-3.1-generate-preview",
"veo-3.1-lite-generate-preview",
"veo-3.0-fast-generate-001",
"veo-3.0-generate-001",
"veo-2.0-generate-001",
],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
},
};
}

View File

@@ -1,23 +1,16 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import {
createGoogleMusicGenerationProviderMetadata,
createGoogleVideoGenerationProviderMetadata,
} from "./generation-provider-metadata.js";
import { geminiMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
import { registerGoogleProvider } from "./provider-registration.js";
import { buildGoogleSpeechProvider } from "./speech-provider.js";
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js";
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | null = null;
let googleMusicGenerationProviderPromise: Promise<MusicGenerationProvider> | null = null;
let googleVideoGenerationProviderPromise: Promise<VideoGenerationProvider> | null = null;
type GoogleMediaUnderstandingProvider = Required<
Pick<
@@ -44,24 +37,6 @@ async function loadGoogleMediaUnderstandingProvider(): Promise<MediaUnderstandin
return await googleMediaUnderstandingProviderPromise;
}
async function loadGoogleMusicGenerationProvider(): Promise<MusicGenerationProvider> {
if (!googleMusicGenerationProviderPromise) {
googleMusicGenerationProviderPromise = import("./music-generation-provider.js").then((mod) =>
mod.buildGoogleMusicGenerationProvider(),
);
}
return await googleMusicGenerationProviderPromise;
}
async function loadGoogleVideoGenerationProvider(): Promise<VideoGenerationProvider> {
if (!googleVideoGenerationProviderPromise) {
googleVideoGenerationProviderPromise = import("./video-generation-provider.js").then((mod) =>
mod.buildGoogleVideoGenerationProvider(),
);
}
return await googleVideoGenerationProviderPromise;
}
async function loadGoogleRequiredMediaUnderstandingProvider(): Promise<GoogleMediaUnderstandingProvider> {
const provider = await loadGoogleMediaUnderstandingProvider();
if (
@@ -128,22 +103,6 @@ function createLazyGoogleMediaUnderstandingProvider(): MediaUnderstandingProvide
};
}
function createLazyGoogleMusicGenerationProvider(): MusicGenerationProvider {
return {
...createGoogleMusicGenerationProviderMetadata(),
generateMusic: async (...args) =>
await (await loadGoogleMusicGenerationProvider()).generateMusic(...args),
};
}
function createLazyGoogleVideoGenerationProvider(): VideoGenerationProvider {
return {
...createGoogleVideoGenerationProviderMetadata(),
generateVideo: async (...args) =>
await (await loadGoogleVideoGenerationProvider()).generateVideo(...args),
};
}
export default definePluginEntry({
id: "google",
name: "Google Plugin",
@@ -152,12 +111,11 @@ export default definePluginEntry({
api.registerCliBackend(buildGoogleGeminiCliBackend());
registerGoogleGeminiCliProvider(api);
registerGoogleProvider(api);
api.registerMemoryEmbeddingProvider(geminiMemoryEmbeddingProviderAdapter);
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
api.registerMusicGenerationProvider(createLazyGoogleMusicGenerationProvider());
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
api.registerSpeechProvider(buildGoogleSpeechProvider());
api.registerVideoGenerationProvider(createLazyGoogleVideoGenerationProvider());
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
api.registerWebSearchProvider(createGeminiWebSearchProvider());
},
});

Some files were not shown because too many files have changed in this diff Show More