Compare commits

..

1 Commits

Author SHA1 Message Date
Tak Hoffman
36a7c93389 feat(workspace): add safe workspace reset command 2026-04-17 16:48:52 -05:00
1134 changed files with 20712 additions and 40506 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

@@ -50,13 +50,13 @@
- Keep files under ~700 LOC - extract helpers when larger
- Colocated tests: `*.test.ts` next to source files
- Run `pnpm check` before commits (lint + format)
- Run `pnpm tsgo` for production type checking, or `pnpm tsgo:all` for production plus test types
- Run `pnpm tsgo` for type checking
## Stack & Commands
- **Package manager**: pnpm (`pnpm install`)
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
- **Type-check**: `pnpm tsgo` (production), `pnpm tsgo:all` (production plus tests)
- **Type-check**: `pnpm tsgo`
- **Lint/format**: `pnpm check`
- **Tests**: `pnpm test`
- **Build**: `pnpm build`

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'
@@ -1031,52 +799,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 +826,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 +845,20 @@ jobs:
- name: Strict TS build smoke
run: pnpm build:strict-smoke
check-additional-shard:
check-additional:
permissions:
contents: read
name: ${{ matrix.check_name }}
name: "check-additional"
needs: [preflight]
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- check_name: check-additional-boundaries
group: boundaries
- check_name: check-additional-extension-surfaces
group: extension-surfaces
- check_name: check-additional-runtime-topology
group: runtime-topology
steps:
- name: Checkout
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 +866,193 @@ jobs:
install-bun: "false"
use-sticky-disk: "false"
- name: Run additional check shard
- name: Run plugin extension boundary guard
id: plugin_extension_boundary
continue-on-error: true
run: pnpm run lint:plugins:no-extension-imports
- name: Run no-random-messaging guard
id: no_random_messaging
continue-on-error: true
run: pnpm run lint:tmp:no-random-messaging
- name: Run channel-agnostic boundary guard
id: channel_agnostic_boundaries
continue-on-error: true
run: pnpm run lint:tmp:channel-agnostic-boundaries
- name: Run no-raw-channel-fetch guard
id: no_raw_channel_fetch
continue-on-error: true
run: pnpm run lint:tmp:no-raw-channel-fetch
- name: Run ingress owner guard
id: ingress_owner
continue-on-error: true
run: pnpm run lint:agent:ingress-owner
- name: Run no-register-http-handler guard
id: no_register_http_handler
continue-on-error: true
run: pnpm run lint:plugins:no-register-http-handler
- name: Run no-monolithic plugin-sdk entry import guard
id: no_monolithic_plugin_sdk_entry_imports
continue-on-error: true
run: pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
- name: Run no-extension-src-imports guard
id: no_extension_src_imports
continue-on-error: true
run: pnpm run lint:plugins:no-extension-src-imports
- name: Run no-extension-test-core-imports guard
id: no_extension_test_core_imports
continue-on-error: true
run: pnpm run lint:plugins:no-extension-test-core-imports
- name: Run plugin-sdk subpaths exported guard
id: plugin_sdk_subpaths_exported
continue-on-error: true
run: pnpm run lint:plugins:plugin-sdk-subpaths-exported
- name: Run web search provider boundary guard
id: web_search_provider_boundary
continue-on-error: true
run: pnpm run lint:web-search-provider-boundaries
- name: Run web fetch provider boundary guard
id: web_fetch_provider_boundary
continue-on-error: true
run: pnpm run lint:web-fetch-provider-boundaries
- name: Run extension src boundary guard
id: extension_src_outside_plugin_sdk_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
- name: Run extension plugin-sdk-internal guard
id: extension_plugin_sdk_internal_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-plugin-sdk-internal
- name: Run extension relative-outside-package guard
id: extension_relative_outside_package_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-relative-outside-package
- name: Run extension channel lint
id: extension_channel_lint
continue-on-error: true
run: pnpm run lint:extensions:channels
- name: Run bundled extension lint
id: extension_bundled_lint
continue-on-error: true
run: pnpm run lint:extensions:bundled
- name: Run extension package boundary TypeScript check
id: extension_package_boundary_tsc
continue-on-error: true
env:
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
shell: bash
run: |
set -euo pipefail
run: pnpm run test:extensions:package-boundary
failures=0
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
run: pnpm lint:ui:no-raw-window-open
run_check() {
local label="$1"
shift
- name: Check control UI locale sync
id: control_ui_i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
continue-on-error: true
run: pnpm ui:i18n:check
echo "::group::${label}"
if "$@"; then
echo "[ok] ${label}"
else
echo "::error title=${label} failed::${label} failed"
failures=1
fi
echo "::endgroup::"
}
- name: Run gateway watch regression harness
id: gateway_watch_regression
continue-on-error: true
run: pnpm test:gateway:watch-regression
case "$ADDITIONAL_CHECK_GROUP" in
boundaries)
run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports
run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging
run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries
run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch
run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner
run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler
run_check "lint:plugins:no-monolithic-plugin-sdk-entry-imports" pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
run_check "lint:plugins:no-extension-src-imports" pnpm run lint:plugins:no-extension-src-imports
run_check "lint:plugins:no-extension-test-core-imports" pnpm run lint:plugins:no-extension-test-core-imports
run_check "lint:plugins:plugin-sdk-subpaths-exported" pnpm run lint:plugins:plugin-sdk-subpaths-exported
run_check "web-search-provider-boundary" pnpm run lint:web-search-provider-boundaries
run_check "web-fetch-provider-boundary" pnpm run lint:web-fetch-provider-boundaries
run_check "extension-src-outside-plugin-sdk-boundary" pnpm run lint:extensions:no-src-outside-plugin-sdk
run_check "extension-plugin-sdk-internal-boundary" pnpm run lint:extensions:no-plugin-sdk-internal
run_check "extension-relative-outside-package-boundary" pnpm run lint:extensions:no-relative-outside-package
run_check "lint:ui:no-raw-window-open" pnpm lint:ui:no-raw-window-open
;;
extension-surfaces)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
run_check "test:extensions:package-boundary" pnpm run test:extensions:package-boundary
;;
runtime-topology)
if [ "$RUN_CONTROL_UI_I18N" = "true" ]; then
run_check "ui:i18n:check" pnpm ui:i18n:check
fi
run_check "gateway-watch-regression" pnpm test:gateway:watch-regression
run_check "check:import-cycles" pnpm check:import-cycles
run_check "check:madge-import-cycles" pnpm check:madge-import-cycles
;;
*)
echo "Unsupported additional check group: $ADDITIONAL_CHECK_GROUP" >&2
exit 1
;;
esac
- name: Run import cycle guard
id: import_cycles
continue-on-error: true
run: pnpm check:import-cycles
exit "$failures"
- name: Run madge import cycle guard
id: madge_import_cycles
continue-on-error: true
run: pnpm check:madge-import-cycles
- name: Upload gateway watch regression artifacts
if: always() && matrix.group == 'runtime-topology'
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard]
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Verify additional check shards
- name: Fail if any additional check failed
if: always()
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
NO_RANDOM_MESSAGING_OUTCOME: ${{ steps.no_random_messaging.outcome }}
CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME: ${{ steps.channel_agnostic_boundaries.outcome }}
NO_RAW_CHANNEL_FETCH_OUTCOME: ${{ steps.no_raw_channel_fetch.outcome }}
INGRESS_OWNER_OUTCOME: ${{ steps.ingress_owner.outcome }}
NO_REGISTER_HTTP_HANDLER_OUTCOME: ${{ steps.no_register_http_handler.outcome }}
NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME: ${{ steps.no_monolithic_plugin_sdk_entry_imports.outcome }}
NO_EXTENSION_SRC_IMPORTS_OUTCOME: ${{ steps.no_extension_src_imports.outcome }}
NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_fetch_provider_boundary.outcome }}
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
IMPORT_CYCLES_OUTCOME: ${{ steps.import_cycles.outcome }}
MADGE_IMPORT_CYCLES_OUTCOME: ${{ steps.madge_import_cycles.outcome }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
failures=0
for result in \
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
"lint:tmp:no-random-messaging|$NO_RANDOM_MESSAGING_OUTCOME" \
"lint:tmp:channel-agnostic-boundaries|$CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME" \
"lint:tmp:no-raw-channel-fetch|$NO_RAW_CHANNEL_FETCH_OUTCOME" \
"lint:agent:ingress-owner|$INGRESS_OWNER_OUTCOME" \
"lint:plugins:no-register-http-handler|$NO_REGISTER_HTTP_HANDLER_OUTCOME" \
"lint:plugins:no-monolithic-plugin-sdk-entry-imports|$NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME" \
"lint:plugins:no-extension-src-imports|$NO_EXTENSION_SRC_IMPORTS_OUTCOME" \
"lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
"lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
"web-fetch-provider-boundary|$WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME" \
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
"check:import-cycles|$IMPORT_CYCLES_OUTCOME" \
"check:madge-import-cycles|$MADGE_IMPORT_CYCLES_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"
if [ "$outcome" != "success" ]; then
echo "::error title=${name} failed::${name} outcome: ${outcome}"
failures=1
fi
done
exit "$failures"
build-smoke:
permissions:
@@ -1335,52 +1064,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 +1119,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 +1377,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 +1385,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 +1424,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

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

@@ -12,21 +12,16 @@
"eslint/no-await-in-loop": "off",
"eslint/no-new": "error",
"eslint/no-shadow": "off",
"eslint/no-warning-comments": "error",
"eslint/no-unmodified-loop-condition": "error",
"eslint-plugin-unicorn/prefer-set-size": "error",
"oxc/no-accumulating-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/consistent-return": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-conversion": "error",
"typescript/no-unnecessary-type-parameters": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-useless-default-assignment": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/prefer-set-size": "error",
"unicorn/require-post-message-target-origin": "error"
@@ -52,12 +47,6 @@
"**/node_modules/**"
],
"overrides": [
{
"files": ["src/security/**"],
"rules": {
"eslint/no-warning-comments": "off"
}
},
{
"files": [
"**/*.test.ts",

View File

@@ -127,16 +127,7 @@
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
- Type-check/build: `pnpm build`
- TypeScript checks are split by architecture boundary:
- `pnpm tsgo` / `pnpm tsgo:core`: core production graph (`src/`, `ui/`, `packages/`; no `extensions/`).
- `pnpm tsgo:core:test`: core colocated tests.
- `pnpm tsgo:extensions`: bundled extension production graph.
- `pnpm tsgo:extensions:test`: bundled extension colocated tests.
- `pnpm tsgo:all`: every TypeScript graph above; this is what `pnpm check` runs.
- `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`.
- Narrow aliases remain for local loops: `pnpm tsgo:test:src`, `pnpm tsgo:test:ui`, `pnpm tsgo:test:packages`.
- Do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes for repo type checking. Use `tsgo` graphs. `tsc` is allowed only when emitting declaration/package-boundary compatibility artifacts that `tsgo` does not replace.
- Boundary rule: core must not know extension implementation details. Extensions hook into core through manifests, registries, capabilities, and public `openclaw/plugin-sdk/*` contracts. If you find core production code naming a specific extension, or a core test that is really testing extension-owned behavior, call it out and prefer moving coverage/logic to the owning extension or a generic contract test.
- 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)
@@ -155,7 +146,7 @@
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
- Plugin SDK API drift uses `pnpm plugin-sdk:api:gen` / `pnpm plugin-sdk:api:check`.
- If you change config schema/help or the public Plugin SDK surface, run the matching gen command and commit the updated `.sha256` hash file. Keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
- When a `tsgo` graph fails, triage by coherent surface instead of by raw error count: rerun the failing graph, group failures by package/module/type contract, open the source-of-truth type or export file first, fix the root mismatch, then rerun the failing graph before widening into downstream consumers. Check `origin/main` before doing broad cleanup because some apparent type debt is already fixed upstream.
- When `pnpm tsgo` fails, triage by coherent surface instead of by raw error count: rerun the gate, group failures by package/module/type contract, open the source-of-truth type or export file first, fix the root mismatch, then rerun `pnpm tsgo` before widening into downstream consumers. Check `origin/main` before doing broad cleanup because some apparent type debt is already fixed upstream.
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
- Verification modes for work on `main`:
- Default mode: `main` is relatively stable. Count pre-commit hook coverage when it already verified the current tree, avoid rerunning the exact same checks just for ceremony, and prefer keeping CI/main green before landing.

View File

@@ -6,80 +6,25 @@ Docs: https://docs.openclaw.ai
### Changes
### Fixes
- Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras.
- Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9.
## 2026.4.18
### Changes
- Anthropic/models: add Claude Opus 4.7 `xhigh` reasoning effort support and keep it separate from adaptive thinking.
- Control UI/settings: overhaul the settings and slash-command experience with faster presets, quick-create flows, and refreshed command discovery. (#67819) Thanks @BunsDev.
- 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
- Codex/gateway: fix gateway crashes when the codex-acp subprocess terminates abruptly; pending requests now shut down gracefully instead of propagating an uncaught EPIPE through the gateway daemon and connected channels. Fixes #67886. (#67947) Thanks @openperf.
- 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.
- Agents/bootstrap: dedupe repeated bootstrap-truncation warnings so startup logs stay actionable. (#67906) Thanks @rubencu.
- 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 and preserve scopes for reused device tokens on successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810, #68039) Thanks @BunsDev.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.
- WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699.
- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699.
- Plugin SDK: preserve `secret-input-runtime` function exports in published builds so provider plugins can read SecretRef-backed setup inputs.
- 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.
- Bundled plugins/install: keep staged bundled plugin runtime imports resolving through the packaged Plugin SDK while omitting checkout-only aliases from the dist inventory, so published installs do not fail on repo-local paths.
- 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.
- Memory-core: preserve stored vector dimensions during read-only recovery so memory indexes do not lose vector metadata while repairing read-only state.
- 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.
- 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.
- 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.
- Control UI/settings: reset scroll position when switching settings pages and align details headers. (#68150) Thanks @BunsDev.
- 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.
- Gateway/web: allow same-origin microphone access in the Permissions-Policy header so browser voice capture can work from the Control UI and webchat origin. (#68368)
- 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.
- Agents/Google: strip `thinkingBudget=0` for the thinking-required `gemini-2.5-pro` model in embedded-runner and native Google payloads, so requests no longer fail with `Budget 0 is invalid. This model only works in thinking mode.` and the API uses its default thinking behavior instead. (#68607) Thanks @josmithiii.
- Slack/threads: log failed thread starter and history fetches at verbose level while preserving best-effort fallback behavior, so missing Slack thread context is diagnosable without interrupting inbound handling. (#68594) Thanks @martingarramon.
- Gateway/restart: keep stale-gateway cleanup from terminating the current process's parent or ancestors, so plugin sidecars like WeChat no longer kill the active gateway and trigger an infinite supervisor restart loop. Fixes #68451. (#68517) Thanks @openperf.
- Gateway/auth: reject gateway auth credentials that match published example placeholders at startup and secret reload, and keep cloud install snippets from publishing copy-paste gateway/keyring secrets. (#68404) Thanks @coygeek.
- CLI/update: preserve macOS restart helper launchctl failures in the update restart log without letting log setup block the restart path. (#68492) Thanks @hclsys.
- Slack/threads: keep file-only root messages as starter context so first thread replies can still hydrate starter media. (#68594) Thanks @martingarramon.
- Google/Antigravity: resolve forward-compatible Gemini 3.1 Pro custom-tools and Flash variants from the bundled Google plugin templates, so `google-antigravity/gemini-3.1-pro-preview-customtools` no longer falls through to an unknown-model error. Fixes #35512.
## 2026.4.15
@@ -187,10 +132,6 @@ Docs: https://docs.openclaw.ai
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
- Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf
- Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably.
- OpenRouter/streaming: treat `reasoning_details.response.output_text` and `reasoning_details.response.text` as visible assistant output on OpenRouter-compatible completions streams, while keeping `reasoning.text` hidden and refusing to surface ambiguous bare `text` items by default so visible replies, thinking blocks, and tool calls can coexist in the same chunk. (#67410) Thanks @neeravmakwana.
- Models/OpenRouter aliases: resolve `openrouter:auto` to the canonical `openrouter/auto` model and map `openrouter:free` to the first configured concrete `openrouter/...:free` model instead of mis-resolving these compatibility aliases under the default provider. (#57066) Thanks @sumiisiaran.
- OpenRouter/Arcee: canonicalize stale OpenRouter `https://openrouter.ai/v1` base URLs during provider config normalization and runtime model/transport resolution, so fresh `models.json` writes and previously discovered rows self-heal back to `https://openrouter.ai/api/v1` instead of breaking OpenRouter-routed requests. (#67295) Thanks @achalkov.
## 2026.4.14

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026041890
versionName = "2026.4.18"
versionCode = 2026041690
versionName = "2026.4.16"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,6 +1,6 @@
# OpenClaw iOS Changelog
## 2026.4.18 - 2026-04-18
## 2026.4.16 - 2026-04-17
Maintenance update for the current OpenClaw release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.18
OPENCLAW_MARKETING_VERSION = 2026.4.18
OPENCLAW_IOS_VERSION = 2026.4.16
OPENCLAW_MARKETING_VERSION = 2026.4.16
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.18"
"version": "2026.4.16"
}

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

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

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.18</string>
<string>2026.4.16</string>
<key>CFBundleVersion</key>
<string>2026041890</string>
<string>2026041690</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

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

@@ -1,4 +1,4 @@
5ce9d439f8cf84fc9d20c93436fea6492bdab0e84e9e51867648343e0375b670 config-baseline.json
10b7c57a6198526b846471e1bcda6e361c1f3db2e3b1cd24abd8bac11db56e16 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 @@
0b0cf2ecc30501bb6381671e3704570f405655b026a0b8b6437c3a5677450b9b plugin-sdk-api-baseline.json
cb72d7b5f73005280854654b51501ec82f5a2f23b7ccb915b63c6354300559d5 plugin-sdk-api-baseline.jsonl
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl

View File

@@ -59,14 +59,6 @@
"source": "Feishu",
"target": "Feishu"
},
{
"source": "WeChat",
"target": "微信"
},
{
"source": "Weixin",
"target": "微信"
},
{
"source": "Mattermost",
"target": "Mattermost"

View File

@@ -34,7 +34,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (bundled plugin).
- [Voice Call](/plugins/voice-call) — Telephony via Plivo or Twilio (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
- [WeChat](/channels/wechat) — Tencent iLink Bot plugin via QR login; private chats only (external plugin).
- [WeChat](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) — Tencent iLink Bot plugin via QR login; private chats only.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (bundled plugin).

View File

@@ -1,168 +0,0 @@
---
summary: "WeChat channel setup through the external openclaw-weixin plugin"
read_when:
- You want to connect OpenClaw to WeChat or Weixin
- You are installing or troubleshooting the openclaw-weixin channel plugin
- You need to understand how external channel plugins run beside the Gateway
title: "WeChat"
---
# WeChat
OpenClaw connects to WeChat through Tencent's external
`@tencent-weixin/openclaw-weixin` channel plugin.
Status: external plugin. Direct chats and media are supported. Group chats are not
advertised by the current plugin capability metadata.
## Naming
- **WeChat** is the user-facing name in these docs.
- **Weixin** is the name used by Tencent's package and by the plugin id.
- `openclaw-weixin` is the OpenClaw channel id.
- `@tencent-weixin/openclaw-weixin` is the npm package.
Use `openclaw-weixin` in CLI commands and config paths.
## How it works
The WeChat code does not live in the OpenClaw core repo. OpenClaw provides the
generic channel plugin contract, and the external plugin provides the
WeChat-specific runtime:
1. `openclaw plugins install` installs `@tencent-weixin/openclaw-weixin`.
2. The Gateway discovers the plugin manifest and loads the plugin entrypoint.
3. The plugin registers channel id `openclaw-weixin`.
4. `openclaw channels login --channel openclaw-weixin` starts QR login.
5. The plugin stores account credentials under the OpenClaw state directory.
6. When the Gateway starts, the plugin starts its Weixin monitor for each
configured account.
7. Inbound WeChat messages are normalized through the channel contract, routed to
the selected OpenClaw agent, and sent back through the plugin outbound path.
That separation matters: OpenClaw core should stay channel-agnostic. WeChat login,
Tencent iLink API calls, media upload/download, context tokens, and account
monitoring are owned by the external plugin.
## Install
Quick install:
```bash
npx -y @tencent-weixin/openclaw-weixin-cli install
```
Manual install:
```bash
openclaw plugins install "@tencent-weixin/openclaw-weixin"
openclaw config set plugins.entries.openclaw-weixin.enabled true
```
Restart the Gateway after install:
```bash
openclaw gateway restart
```
## Login
Run QR login on the same machine that runs the Gateway:
```bash
openclaw channels login --channel openclaw-weixin
```
Scan the QR code with WeChat on your phone and confirm the login. The plugin saves
the account token locally after a successful scan.
To add another WeChat account, run the same login command again. For multiple
accounts, isolate direct-message sessions by account, channel, and sender:
```bash
openclaw config set session.dmScope per-account-channel-peer
```
## Access control
Direct messages use the normal OpenClaw pairing and allowlist model for channel
plugins.
Approve new senders:
```bash
openclaw pairing list openclaw-weixin
openclaw pairing approve openclaw-weixin <CODE>
```
For the full access-control model, see [Pairing](/channels/pairing).
## Compatibility
The plugin checks the host OpenClaw version at startup.
| Plugin line | OpenClaw version | npm tag |
| ----------- | ----------------------- | -------- |
| `2.x` | `>=2026.3.22` | `latest` |
| `1.x` | `>=2026.1.0 <2026.3.22` | `legacy` |
If the plugin reports that your OpenClaw version is too old, either update
OpenClaw or install the legacy plugin line:
```bash
openclaw plugins install @tencent-weixin/openclaw-weixin@legacy
```
## Sidecar process
The WeChat plugin can run helper work beside the Gateway while it monitors the
Tencent iLink API. In issue #68451, that helper path exposed a bug in OpenClaw's
generic stale-Gateway cleanup: a child process could try to clean up the parent
Gateway process, causing restart loops under process managers such as systemd.
Current OpenClaw startup cleanup excludes the current process and its ancestors,
so a channel helper must not kill the Gateway that launched it. This fix is
generic; it is not a WeChat-specific path in core.
## Troubleshooting
Check install and status:
```bash
openclaw plugins list
openclaw channels status --probe
openclaw --version
```
If the channel shows as installed but does not connect, confirm that the plugin is
enabled and restart:
```bash
openclaw config set plugins.entries.openclaw-weixin.enabled true
openclaw gateway restart
```
If the Gateway restarts repeatedly after enabling WeChat, update both OpenClaw and
the plugin:
```bash
npm view @tencent-weixin/openclaw-weixin version
openclaw plugins install "@tencent-weixin/openclaw-weixin" --force
openclaw gateway restart
```
Temporary disable:
```bash
openclaw config set plugins.entries.openclaw-weixin.enabled false
openclaw gateway restart
```
## Related docs
- Channel overview: [Chat Channels](/channels)
- Pairing: [Pairing](/channels/pairing)
- Channel routing: [Channel Routing](/channels/channel-routing)
- Plugin architecture: [Plugin Architecture](/plugins/architecture)
- Channel plugin SDK: [Channel Plugin SDK](/plugins/sdk-channel-plugins)
- External package: [@tencent-weixin/openclaw-weixin](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin)

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

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

@@ -61,14 +61,14 @@ node --import tsx scripts/repro/tsx-name-repro.ts
## Workarounds
- Use Bun for dev scripts (current temporary revert).
- Use `tsgo` for repo type checking, then run the built output:
- Use Node + tsc watch, then run compiled output:
```bash
pnpm tsgo
node openclaw.mjs status
pnpm exec tsc --watch --preserveWatchOutput
node --watch openclaw.mjs status
```
- Historical note: `tsc` was used here while debugging this Node/tsx issue, but repo type-check lanes now use `tsgo`.
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node openclaw.mjs status` works on Node 25.
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25specific.

View File

@@ -456,14 +456,6 @@
"source": "/channels/grammy",
"destination": "/channels/telegram"
},
{
"source": "/channels/openclaw-weixin",
"destination": "/channels/wechat"
},
{
"source": "/channels/weixin",
"destination": "/channels/wechat"
},
{
"source": "/group-messages",
"destination": "/channels/group-messages"
@@ -1036,7 +1028,6 @@
"channels/telegram",
"channels/tlon",
"channels/twitch",
"channels/wechat",
"channels/whatsapp",
"channels/zalo",
"channels/zalouser"

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

@@ -213,21 +213,18 @@ For the generic Docker flow, see [Docker](/install/docker).
```bash
OPENCLAW_IMAGE=openclaw:latest
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_TOKEN=change-me-now
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_CONFIG_DIR=/home/$USER/.openclaw
OPENCLAW_WORKSPACE_DIR=/home/$USER/.openclaw/workspace
GOG_KEYRING_PASSWORD=
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Generate strong secrets:
```bash
openssl rand -hex 32

View File

@@ -134,21 +134,18 @@ For the generic Docker flow, see [Docker](/install/docker).
```bash
OPENCLAW_IMAGE=openclaw:latest
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_TOKEN=change-me-now
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_CONFIG_DIR=/root/.openclaw
OPENCLAW_WORKSPACE_DIR=/root/.openclaw/workspace
GOG_KEYRING_PASSWORD=
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Generate strong secrets:
```bash
openssl rand -hex 32

View File

@@ -96,7 +96,6 @@ Those belong in your plugin code and `package.json`.
"modelPrefixes": ["router-"]
},
"cliBackends": ["openrouter-cli"],
"syntheticAuthRefs": ["openrouter-cli"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
@@ -154,7 +153,6 @@ Those belong in your plugin code and `package.json`.
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
@@ -601,10 +599,6 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `providerAuthAliases` lets provider variants reuse another provider's auth
env vars, auth profiles, config-backed auth, and API-key onboarding choice
without hardcoding that relationship in core.
- `syntheticAuthRefs` is the cheap metadata path for provider-owned synthetic
auth hooks that must be visible to cold model discovery before the runtime
registry exists. Only list refs whose runtime provider or CLI backend actually
implements `resolveSyntheticAuth`.
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
prompts, and similar channel surfaces that should not boot plugin runtime
just to inspect env names.

View File

@@ -128,25 +128,20 @@ Choose your preferred auth method and follow the setup steps.
## Capabilities
| Capability | Supported |
| ---------------------- | ----------------------------- |
| Chat completions | Yes |
| Image generation | Yes |
| Music generation | Yes |
| Text-to-speech | Yes |
| Image understanding | Yes |
| Audio transcription | Yes |
| Video understanding | Yes |
| Web search (Grounding) | Yes |
| Thinking/reasoning | Yes (Gemini 2.5+ / Gemini 3+) |
| Gemma 4 models | Yes |
| Capability | Supported |
| ---------------------- | ----------------- |
| Chat completions | Yes |
| Image generation | Yes |
| Music generation | Yes |
| Text-to-speech | Yes |
| Image understanding | Yes |
| Audio transcription | Yes |
| Video understanding | Yes |
| Web search (Grounding) | Yes |
| Thinking/reasoning | Yes (Gemini 3.1+) |
| Gemma 4 models | Yes |
<Tip>
Gemini 3 models use `thinkingLevel` rather than `thinkingBudget`. OpenClaw maps
Gemini 3, Gemini 3.1, and `gemini-*-latest` alias reasoning controls to
`thinkingLevel` so default/low-latency runs do not send disabled
`thinkingBudget` values.
Gemma 4 models (for example `gemma-4-26b-a4b-it`) support thinking mode. OpenClaw
rewrites `thinkingBudget` to a supported Google `thinkingLevel` for Gemma 4.
Setting thinking to `off` preserves thinking disabled instead of mapping to

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.4.18",
"version": "2026.4.16",
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",

View File

@@ -1,13 +1,13 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1032.0",
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
"@aws-sdk/credential-provider-node": "3.972.32"
"@aws-sdk/client-bedrock": "3.1028.0",
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
"@aws-sdk/credential-provider-node": "3.972.30"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Anthropic Vertex provider plugin",
"type": "module",

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

@@ -6,7 +6,6 @@
"modelPrefixes": ["claude-"]
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

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

@@ -164,48 +164,4 @@ describe("arcee provider plugin", () => {
} as never),
).toBeUndefined();
});
it("canonicalizes stale OpenRouter /v1 config and transport metadata", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeConfig?.({
provider: "arcee",
providerConfig: {
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1/",
models: [],
},
} as never),
).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
});
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
baseUrl: "https://openrouter.ai/api/v1",
});
expect(
provider.normalizeTransport?.({
provider: "arcee",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
} as never),
).toEqual({
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
});
});

View File

@@ -5,6 +5,7 @@ import {
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
applyArceeConfig,
applyArceeOpenRouterConfig,
@@ -14,7 +15,7 @@ import {
import {
buildArceeProvider,
buildArceeOpenRouterProvider,
normalizeArceeOpenRouterBaseUrl,
isArceeOpenRouterBaseUrl,
toArceeOpenRouterModelId,
} from "./provider-catalog.js";
@@ -69,6 +70,13 @@ function buildArceeAuthMethods() {
];
}
function readConfiguredArceeCatalogEntries(config: OpenClawConfig | undefined) {
return readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
});
}
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (directKey) {
@@ -86,18 +94,12 @@ async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>(
model: T,
): T | undefined {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(model.baseUrl);
if (!normalizedBaseUrl) {
return undefined;
}
const normalizedId = toArceeOpenRouterModelId(model.id);
if (normalizedId === model.id && normalizedBaseUrl === model.baseUrl) {
if (!isArceeOpenRouterBaseUrl(model.baseUrl)) {
return undefined;
}
return {
...model,
id: normalizedId,
baseUrl: normalizedBaseUrl,
id: toArceeOpenRouterModelId(model.id),
};
}
@@ -115,27 +117,8 @@ export default definePluginEntry({
catalog: {
run: resolveArceeCatalog,
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
}),
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
augmentModelCatalog: ({ config }) => readConfiguredArceeCatalogEntries(config),
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
normalizeTransport: ({ api, baseUrl }) => {
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
});
},

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",

View File

@@ -2,25 +2,13 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-sha
import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./models.js";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1";
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeArceeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return undefined;
}
if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) {
return OPENROUTER_BASE_URL;
}
return undefined;
}
export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean {
return normalizeArceeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
export function toArceeOpenRouterModelId(modelId: string): string {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.4.18",
"version": "2026.4.16",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
@@ -8,7 +8,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.18"
"openclaw": ">=2026.4.16"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -43,10 +43,10 @@
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.18"
"pluginApi": ">=2026.4.16"
},
"build": {
"openclawVersion": "2026.4.18"
"openclawVersion": "2026.4.16"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",

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,69 +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";
type BraveWebSearchRuntime = typeof import("./brave-web-search-provider.runtime.js");
let braveWebSearchRuntimePromise: Promise<BraveWebSearchRuntime> | undefined;
function loadBraveWebSearchRuntime(): Promise<BraveWebSearchRuntime> {
braveWebSearchRuntimePromise ??= import("./brave-web-search-provider.runtime.js");
return braveWebSearchRuntimePromise;
}
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)) {
@@ -78,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,
@@ -104,24 +94,19 @@ 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 loadBraveWebSearchRuntime();
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js");
return await executeBraveSearch(args, searchConfig);
},
};
@@ -139,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(
@@ -156,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,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -32,10 +32,11 @@ function createExtensionFallbackBrowserHarness(options?: {
const pages = (options?.urls ?? [undefined]).map(
(url) =>
Object.assign(
{ on: pageOn, context: () => context },
url ? { url: () => url } : {},
) as unknown as import("playwright-core").Page,
({
on: pageOn,
context: () => context,
...(url ? { url: () => url } : {}),
}) as unknown as import("playwright-core").Page,
);
(context as unknown as { pages: () => unknown[] }).pages = () => pages;

View File

@@ -10,7 +10,7 @@ import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
return {
@@ -24,99 +24,93 @@ export function registerBrowserAgentActDownloadRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post(
"/wait/download",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
const timeoutMs = toNumber(body.timeoutMs);
app.post("/wait/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
const timeoutMs = toNumber(body.timeoutMs);
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath: string | undefined;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!resolvedDownloadPath) {
return;
}
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
}),
);
app.post(
"/download",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
}
const pw = await requirePwAi(res, "download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
}
const pw = await requirePwAi(res, "wait for download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
let downloadPath: string | undefined;
if (out.trim()) {
const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!downloadPath) {
if (!resolvedDownloadPath) {
return;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
}),
);
downloadPath = resolvedDownloadPath;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.waitForDownloadViaPlaywright({
...requestBase,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
app.post("/download", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
}
const pw = await requirePwAi(res, "download");
if (!pw) {
return;
}
await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
const downloadPath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_DOWNLOAD_DIR,
requestedPath: out,
scopeLabel: "downloads directory",
});
if (!downloadPath) {
return;
}
const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
const result = await pw.downloadViaPlaywright({
...requestBase,
ref,
path: downloadPath,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
},
});
});
}

View File

@@ -10,136 +10,124 @@ import {
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import {
asyncBrowserRoute,
jsonError,
toBoolean,
toNumber,
toStringArray,
toStringOrEmpty,
} from "./utils.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentActHookRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post(
"/hooks/file-chooser",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
app.post("/hooks/file-chooser", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
if (resolvedPaths.length !== 1) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? "",
});
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
return res.json({ ok: true });
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
if (resolvedPaths.length !== 1) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
}
const uid = inputRef || ref;
if (!uid) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
}
await uploadChromeMcpFile({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid,
filePath: resolvedPaths[0] ?? "",
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
return;
}
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
return;
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
await pw.setInputFilesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths: resolvedPaths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl,
targetId: tab.targetId,
inputRef,
element,
paths: resolvedPaths,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
ref,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
ref,
});
}
}
res.json({ ok: true });
},
});
}),
);
}
res.json({ ok: true });
},
});
});
app.post(
"/hooks/dialog",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
app.post("/hooks/dialog", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `() => {
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (timeoutMs) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
}
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `() => {
const state = (window.__openclawDialogHook ??= {});
if (!state.originals) {
state.originals = {
@@ -178,23 +166,22 @@ export function registerBrowserAgentActHookRoutes(
};
return true;
}`,
});
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true });
},
});
}),
);
return res.json({ ok: true });
}
const pw = await requirePwAi(res, "dialog hook");
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl,
targetId: tab.targetId,
accept,
promptText,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true });
},
});
});
}

View File

@@ -37,7 +37,7 @@ import {
} from "./agent.shared.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -333,355 +333,347 @@ export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post(
"/act",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
return jsonActError(res, 400, ACT_ERROR_CODES.kindRequired, "kind is required");
}
const kind: ActKind = kindRaw;
let action: BrowserActRequest;
try {
action = normalizeActRequest(body);
} catch (err) {
return jsonActError(res, 400, ACT_ERROR_CODES.invalidRequest, formatErrorMessage(err));
}
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) {
return jsonActError(
res,
400,
ACT_ERROR_CODES.selectorUnsupported,
SELECTOR_UNSUPPORTED_MESSAGE,
);
}
const earlyFn = action.kind === "wait" || action.kind === "evaluate" ? action.fn : "";
if (
(action.kind === "evaluate" || (action.kind === "wait" && earlyFn)) &&
!ctx.state().resolved.evaluateEnabled
) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.evaluateDisabled,
browserEvaluateDisabledMessage(action.kind === "evaluate" ? "evaluate" : "wait"),
);
}
await withRouteTabContext({
req,
app.post("/act", async (req, res) => {
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
return jsonActError(res, 400, ACT_ERROR_CODES.kindRequired, "kind is required");
}
const kind: ActKind = kindRaw;
let action: BrowserActRequest;
try {
action = normalizeActRequest(body);
} catch (err) {
return jsonActError(res, 400, ACT_ERROR_CODES.invalidRequest, formatErrorMessage(err));
}
const targetId = resolveTargetIdFromBody(body);
if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) {
return jsonActError(
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
if (action.targetId && action.targetId !== tab.targetId) {
400,
ACT_ERROR_CODES.selectorUnsupported,
SELECTOR_UNSUPPORTED_MESSAGE,
);
}
const earlyFn = action.kind === "wait" || action.kind === "evaluate" ? action.fn : "";
if (
(action.kind === "evaluate" || (action.kind === "wait" && earlyFn)) &&
!ctx.state().resolved.evaluateEnabled
) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.evaluateDisabled,
browserEvaluateDisabledMessage(action.kind === "evaluate" ? "evaluate" : "wait"),
);
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
if (action.targetId && action.targetId !== tab.targetId) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.targetIdMismatch,
"action targetId must match request targetId",
);
}
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds,
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) {
return jsonActError(
res,
403,
ACT_ERROR_CODES.targetIdMismatch,
"action targetId must match request targetId",
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
unsupportedMessage,
);
}
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds,
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) {
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
unsupportedMessage,
);
}
switch (action.kind) {
case "click":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
clickChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
doubleClick: action.doubleClick ?? false,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "type":
await runExistingSessionActionWithNavigationGuard({
execute: async () => {
await fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.text,
});
if (action.submit) {
await pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: "Enter",
});
}
},
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "press":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: action.key,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "hover":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
hoverChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "scrollIntoView":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref!],
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "drag":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
dragChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fromUid: action.startRef!,
toUid: action.endRef!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "select":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.values[0] ?? "",
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "fill":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpForm({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
value: String(field.value ?? ""),
})),
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "resize":
await resizeChromeMcpPage({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
width: action.width,
height: action.height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "wait":
await waitForExistingSessionCondition({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
case "evaluate": {
const result = await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: action.fn,
args: action.ref ? [action.ref] : undefined,
}),
guard: existingSessionNavigationGuard,
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result,
});
}
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
return res.json({ ok: true, targetId: tab.targetId });
case "batch":
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
EXISTING_SESSION_LIMITS.act.batch,
);
}
}
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) {
return;
}
if (action.kind === "batch") {
const targetIdError = validateBatchTargetIds(action.actions, tab.targetId);
if (targetIdError) {
return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, targetIdError);
}
}
const result = await pw.executeActViaPlaywright({
cdpUrl,
action,
targetId: tab.targetId,
evaluateEnabled,
ssrfPolicy,
signal: req.signal,
});
switch (action.kind) {
case "batch":
return res.json({ ok: true, targetId: tab.targetId, results: result.results ?? [] });
case "evaluate":
case "click":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
clickChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
doubleClick: action.doubleClick ?? false,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "type":
await runExistingSessionActionWithNavigationGuard({
execute: async () => {
await fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.text,
});
if (action.submit) {
await pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: "Enter",
});
}
},
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "press":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
pressChromeMcpKey({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
key: action.key,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "hover":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
hoverChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "scrollIntoView":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`,
args: [action.ref!],
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "drag":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
dragChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fromUid: action.startRef!,
toUid: action.endRef!,
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "select":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpElement({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: action.ref!,
value: action.values[0] ?? "",
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "fill":
await runExistingSessionActionWithNavigationGuard({
execute: () =>
fillChromeMcpForm({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
elements: action.fields.map((field) => ({
uid: field.ref,
value: String(field.value ?? ""),
})),
}),
guard: existingSessionNavigationGuard,
});
return res.json({ ok: true, targetId: tab.targetId });
case "resize":
await resizeChromeMcpPage({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
width: action.width,
height: action.height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
case "wait":
await waitForExistingSessionCondition({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
timeMs: action.timeMs,
text: action.text,
textGone: action.textGone,
selector: action.selector,
url: action.url,
loadState: action.loadState,
fn: action.fn,
timeoutMs: action.timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
case "evaluate": {
const result = await runExistingSessionActionWithNavigationGuard({
execute: () =>
evaluateChromeMcpScript({
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
fn: action.fn,
args: action.ref ? [action.ref] : undefined,
}),
guard: existingSessionNavigationGuard,
});
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result: result.result,
result,
});
case "click":
case "resize":
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
default:
}
case "close":
await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir);
return res.json({ ok: true, targetId: tab.targetId });
case "batch":
return jsonActError(
res,
501,
ACT_ERROR_CODES.unsupportedForExistingSession,
EXISTING_SESSION_LIMITS.act.batch,
);
}
},
});
}),
);
}
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) {
return;
}
if (action.kind === "batch") {
const targetIdError = validateBatchTargetIds(action.actions, tab.targetId);
if (targetIdError) {
return jsonActError(res, 403, ACT_ERROR_CODES.targetIdMismatch, targetIdError);
}
}
const result = await pw.executeActViaPlaywright({
cdpUrl,
action,
targetId: tab.targetId,
evaluateEnabled,
ssrfPolicy,
signal: req.signal,
});
switch (action.kind) {
case "batch":
return res.json({ ok: true, targetId: tab.targetId, results: result.results ?? [] });
case "evaluate":
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result: result.result,
});
case "click":
case "resize":
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
default:
return res.json({ ok: true, targetId: tab.targetId });
}
},
});
});
registerBrowserAgentActHookRoutes(app, ctx);
registerBrowserAgentActDownloadRoutes(app, ctx);
app.post(
"/response/body",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) {
return jsonError(res, 400, "url is required");
}
app.post("/response/body", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody);
}
const pw = await requirePwAi(res, "response body");
if (!pw) {
return;
}
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody);
}
const pw = await requirePwAi(res, "response body");
if (!pw) {
return;
}
const result = await pw.responseBodyViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
},
});
});
app.post("/highlight", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
},
});
}),
);
app.post(
"/highlight",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const ref = toStringOrEmpty(body.ref);
if (!ref) {
return jsonError(res, 400, "ref is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
await evaluateChromeMcpScript({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
args: [ref],
fn: `(el) => {
args: [ref],
fn: `(el) => {
if (!(el instanceof Element)) {
return false;
}
@@ -696,21 +688,20 @@ export function registerBrowserAgentActRoutes(
}, 2000);
return true;
}`,
});
return res.json({ ok: true, targetId: tab.targetId });
}
const pw = await requirePwAi(res, "highlight");
if (!pw) {
return;
}
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
return res.json({ ok: true, targetId: tab.targetId });
}
const pw = await requirePwAi(res, "highlight");
if (!pw) {
return;
}
await pw.highlightViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
}

View File

@@ -11,153 +11,138 @@ import {
import { resolveWritableOutputPathOrRespond } from "./output-paths.js";
import { DEFAULT_TRACE_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, toBoolean, toStringOrEmpty } from "./utils.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentDebugRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get(
"/console",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
app.get("/console", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const level = typeof req.query.level === "string" ? req.query.level : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
run: async ({ cdpUrl, tab, pw }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString(level),
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "console messages",
run: async ({ cdpUrl, tab, pw }) => {
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString(level),
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
});
});
app.get(
"/errors",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
app.get("/errors", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "page errors",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "page errors",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.get(
"/requests",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
app.get("/requests", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "network requests",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: normalizeOptionalString(filter),
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "network requests",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: normalizeOptionalString(filter),
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post(
"/trace/start",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? undefined;
const snapshots = toBoolean(body.snapshots) ?? undefined;
const sources = toBoolean(body.sources) ?? undefined;
app.post("/trace/start", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const screenshots = toBoolean(body.screenshots) ?? undefined;
const snapshots = toBoolean(body.snapshots) ?? undefined;
const sources = toBoolean(body.sources) ?? undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
run: async ({ cdpUrl, tab, pw }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace start",
run: async ({ cdpUrl, tab, pw }) => {
await pw.traceStartViaPlaywright({
cdpUrl,
targetId: tab.targetId,
screenshots,
snapshots,
sources,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/trace/stop",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
app.post("/trace/stop", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const out = toStringOrEmpty(body.path) || "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
ensureRootDir: true,
});
if (!tracePath) {
return;
}
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath,
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath),
});
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
const tracePath = await resolveWritableOutputPathOrRespond({
res,
rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
ensureRootDir: true,
});
if (!tracePath) {
return;
}
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
path: tracePath,
});
res.json({
ok: true,
targetId: tab.targetId,
path: path.resolve(tracePath),
});
},
});
});
}

View File

@@ -40,7 +40,7 @@ import {
} from "./agent.snapshot.plan.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay";
@@ -177,205 +177,106 @@ export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post(
"/navigate",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
url,
});
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
return res.json({ ok: true, targetId: tab.targetId, ...result });
}
const pw = await requirePwAi(res, "navigate");
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs(),
});
res.json({ ok: true, targetId: currentTargetId, ...result });
},
});
}),
);
app.post(
"/pdf",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url,
});
},
});
}),
);
app.post(
"/screenshot",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) {
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
});
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
return;
}
let buffer: Buffer;
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
ref,
element,
fullPage,
type,
});
buffer = snap.buffer;
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
});
}
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
},
});
}),
);
app.get(
"/snapshot",
asyncBrowserRoute(async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
app.post("/navigate", async (req, res) => {
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) {
return jsonError(res, 400, "url is required");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const result = await navigateChromeMcpPage({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
url,
});
await assertBrowserNavigationResultAllowed({ url: result.url, ...ssrfPolicyOpts });
return res.json({ ok: true, targetId: tab.targetId, ...result });
}
const pw = await requirePwAi(res, "navigate");
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl,
targetId: tab.targetId,
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
const currentTargetId = await resolveTargetIdAfterNavigate({
oldTargetId: tab.targetId,
navigatedUrl: result.url,
listTabs: () => profileCtx.listTabs(),
});
res.json({ ok: true, targetId: currentTargetId, ...result });
},
});
});
app.post("/pdf", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "pdf",
run: async ({ cdpUrl, tab, pw }) => {
const pdf = await pw.pdfViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
await saveBrowserMediaResponse({
res,
buffer: pdf.buffer,
contentType: "application/pdf",
maxBytes: pdf.buffer.byteLength,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
app.post("/screenshot", async (req, res) => {
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
const ref = toStringOrEmpty(body.ref) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) {
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
await withRouteTabContext({
req,
res,
ctx,
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
@@ -383,128 +284,133 @@ export function registerBrowserAgentSnapshotRoutes(
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
uid: ref,
fullPage,
format: type,
});
if (plan.format === "aria") {
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
});
}
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
maxChars: plan.resolvedMaxChars,
});
if (plan.labels) {
const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
format: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...built,
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
}
}
return res.json({
ok: true,
format: "ai",
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
...built,
});
return;
}
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
let buffer: Buffer;
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
return;
}
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
ref,
element,
fullPage,
type,
});
buffer = snap.buffer;
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
});
}
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
if (String(err).toLowerCase().includes("_snapshotforai")) {
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
}
throw err;
});
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
await saveNormalizedScreenshotResponse({
res,
buffer,
type,
targetId: tab.targetId,
url: tab.url,
});
},
});
});
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
if (plan.format === "aria") {
return res.json({
ok: true,
format: "aria",
targetId: tab.targetId,
url: tab.url,
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
});
}
const built = buildAiSnapshotFromChromeMcpSnapshot({
root: snapshot,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
maxChars: plan.resolvedMaxChars,
});
if (plan.labels) {
const refs = Object.keys(built.refs);
const labelResult = await renderChromeMcpLabels({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
refs,
});
try {
const labeled = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
format: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
const normalized = await normalizeBrowserScreenshot(labeled, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
@@ -515,65 +421,147 @@ export function registerBrowserAgentSnapshotRoutes(
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: plan.format,
format: "ai",
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
labelsCount: labelResult.labels,
labelsSkipped: labelResult.skipped,
imagePath: path.resolve(saved.path),
imageType,
...snap,
imageType: normalized.contentType?.includes("jpeg") ? "jpeg" : "png",
...built,
});
} finally {
await clearChromeMcpOverlay({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
});
}
}
return res.json({
ok: true,
format: "ai",
targetId: tab.targetId,
url: tab.url,
...built,
});
}
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
if (String(err).toLowerCase().includes("_snapshotforai")) {
return await pw.snapshotRoleViaPlaywright(roleSnapshotArgs);
}
throw err;
});
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
refs: "refs" in snap ? snap.refs : {},
type: "png",
});
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
normalized.contentType ?? "image/png",
"browser",
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
);
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
imagePath: path.resolve(saved.path),
imageType,
...snap,
});
}
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,
...snap,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
}),
);
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
return;
}
return res.json({
ok: true,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,
});
} catch (err) {
handleRouteError(ctx, res, err);
}
});
}

View File

@@ -7,7 +7,7 @@ import {
withPlaywrightRouteContext,
} from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
type StorageKind = "local" | "session";
@@ -68,427 +68,385 @@ export function registerBrowserAgentStorageRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get(
"/cookies",
asyncBrowserRoute(async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
app.get("/cookies", async (req, res) => {
const targetId = resolveTargetIdFromQuery(req.query);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.cookiesGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post(
"/cookies/set",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) {
return jsonError(res, 400, "cookie is required");
app.post("/cookies/set", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) {
return jsonError(res, 400, "cookie is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || undefined,
domain: toStringOrEmpty(cookie.domain) || undefined,
path: toStringOrEmpty(cookie.path) || undefined,
expires: toNumber(cookie.expires) ?? undefined,
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
? cookie.sameSite
: undefined,
},
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/cookies/clear", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.get("/storage/:kind", async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString(key),
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
});
app.post("/storage/:kind/set", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
const key = toStringOrEmpty(mutation.body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/storage/:kind/clear", async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/offline", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post("/set/headers", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) {
return jsonError(res, 400, "headers is required");
}
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") {
parsed[k] = v;
}
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
cookie: {
name: toStringOrEmpty(cookie.name),
value: toStringOrEmpty(cookie.value),
url: toStringOrEmpty(cookie.url) || undefined,
domain: toStringOrEmpty(cookie.domain) || undefined,
path: toStringOrEmpty(cookie.path) || undefined,
expires: toNumber(cookie.expires) ?? undefined,
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
? cookie.sameSite
: undefined,
},
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/cookies/clear",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
app.post("/set/credentials", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = readStringValue(body.password);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "cookies clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.cookiesClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.get(
"/storage/:kind",
asyncBrowserRoute(async (req, res) => {
const kind = parseStorageKind(toStringOrEmpty(req.params.kind));
if (!kind) {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = resolveTargetIdFromQuery(req.query);
const key = toStringOrEmpty(req.query.key);
app.post("/set/geolocation", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const latitude = toNumber(body.latitude);
const longitude = toNumber(body.longitude);
const accuracy = toNumber(body.accuracy) ?? undefined;
const origin = toStringOrEmpty(body.origin) || undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "storage get",
run: async ({ cdpUrl, tab, pw }) => {
const result = await pw.storageGetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString(key),
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
latitude,
longitude,
accuracy,
origin,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/storage/:kind/set",
asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
const key = toStringOrEmpty(mutation.body.key);
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof mutation.body.value === "string" ? mutation.body.value : "";
app.post("/set/media", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? schemeRaw
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage set",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageSetViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
key,
value,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/storage/:kind/clear",
asyncBrowserRoute(async (req, res) => {
const mutation = parseStorageMutationFromRequest(req, res);
if (!mutation) {
return;
}
app.post("/set/timezone", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId: mutation.parsed.targetId,
feature: "storage clear",
run: async ({ cdpUrl, tab, pw }) => {
await pw.storageClearViaPlaywright({
cdpUrl,
targetId: tab.targetId,
kind: mutation.parsed.kind,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/set/offline",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const offline = toBoolean(body.offline);
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
app.post("/set/locale", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) {
return jsonError(res, 400, "locale is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "offline",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setOfflineViaPlaywright({
cdpUrl,
targetId: tab.targetId,
offline,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
app.post(
"/set/headers",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) {
return jsonError(res, 400, "headers is required");
}
app.post("/set/device", async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) {
return jsonError(res, 400, "name is required");
}
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") {
parsed[k] = v;
}
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "headers",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl,
targetId: tab.targetId,
headers: parsed,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/credentials",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = readStringValue(body.password);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "http credentials",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setHttpCredentialsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
username,
password,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/geolocation",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const latitude = toNumber(body.latitude);
const longitude = toNumber(body.longitude);
const accuracy = toNumber(body.accuracy) ?? undefined;
const origin = toStringOrEmpty(body.origin) || undefined;
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "geolocation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setGeolocationViaPlaywright({
cdpUrl,
targetId: tab.targetId,
latitude,
longitude,
accuracy,
origin,
clear,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/media",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? schemeRaw
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "media emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.emulateMediaViaPlaywright({
cdpUrl,
targetId: tab.targetId,
colorScheme,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/timezone",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "timezone",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setTimezoneViaPlaywright({
cdpUrl,
targetId: tab.targetId,
timezoneId,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/locale",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const locale = toStringOrEmpty(body.locale);
if (!locale) {
return jsonError(res, 400, "locale is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "locale",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setLocaleViaPlaywright({
cdpUrl,
targetId: tab.targetId,
locale,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
app.post(
"/set/device",
asyncBrowserRoute(async (req, res) => {
const body = readBody(req);
const targetId = resolveTargetIdFromBody(body);
const name = toStringOrEmpty(body.name);
if (!name) {
return jsonError(res, 400, "name is required");
}
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
}),
);
await withPlaywrightRouteContext({
req,
res,
ctx,
targetId,
feature: "device emulation",
run: async ({ cdpUrl, tab, pw }) => {
await pw.setDeviceViaPlaywright({
cdpUrl,
targetId: tab.targetId,
name,
});
res.json({ ok: true, targetId: tab.targetId });
},
});
});
}

View File

@@ -6,7 +6,7 @@ import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
function handleBrowserRouteError(res: BrowserResponse, err: unknown) {
const mapped = toBrowserErrorResponse(err);
@@ -49,198 +49,177 @@ async function withProfilesServiceMutation(params: {
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get(
"/profiles",
asyncBrowserRoute(async (_req, res) => {
try {
const service = createBrowserProfilesService(ctx);
const profiles = await service.listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
}),
);
app.get("/profiles", async (_req, res) => {
try {
const service = createBrowserProfilesService(ctx);
const profiles = await service.listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Get status (profile-aware)
app.get(
"/",
asyncBrowserRoute(async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
return jsonError(res, 503, "browser server not started");
}
app.get("/", async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
} catch {
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const [cdpHttp, cdpReady] = await Promise.all([
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
detectError = String(err);
}
}),
);
res.json({
enabled: current.resolved.enabled,
profile: profileCtx.profile.name,
driver: profileCtx.profile.driver,
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
running: cdpReady,
cdpReady,
cdpHttp,
pid: capabilities.usesChromeMcp
? getChromeMcpPid(profileCtx.profile.name)
: (profileState?.running?.pid ?? null),
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
});
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
});
// Start browser (profile-aware)
app.post(
"/start",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
},
});
}),
);
app.post("/start", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
},
});
});
// Stop browser (profile-aware)
app.post(
"/stop",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
},
});
}),
);
app.post("/stop", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
},
});
});
// Reset profile (profile-aware)
app.post(
"/reset-profile",
asyncBrowserRoute(async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
},
});
}),
);
app.post("/reset-profile", async (req, res) => {
await withBasicProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
},
});
});
// Create a new profile
app.post(
"/profiles/create",
asyncBrowserRoute(async (req, res) => {
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
app.post("/profiles/create", async (req, res) => {
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
res,
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
res,
ctx,
run: async (service) =>
await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver:
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});
}),
);
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) =>
await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
userDataDir: userDataDir || undefined,
driver:
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});
});
// Delete a profile
app.delete(
"/profiles/:name",
asyncBrowserRoute(async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) {
return jsonError(res, 400, "profile name is required");
}
app.delete("/profiles/:name", async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) {
return jsonError(res, 400, "profile name is required");
}
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name),
});
}),
);
await withProfilesServiceMutation({
res,
ctx,
run: async (service) => await service.deleteProfile(name),
});
});
}

View File

@@ -6,44 +6,35 @@ let createBrowserRouteDispatcher: typeof import("./dispatcher.js").createBrowser
describe("browser route dispatcher (abort)", () => {
beforeAll(async () => {
vi.doMock("./index.js", () => {
const asyncRoute = <Req, Res>(
handler: (req: Req, res: Res) => void | Promise<void>,
): ((req: Req, res: Res) => void | Promise<void>) => {
return (req, res) => handler(req, res);
};
return {
registerBrowserRoutes(app: { get: (path: string, handler: unknown) => void }) {
app.get(
"/slow",
asyncRoute(
async (req: { signal?: AbortSignal }, res: { json: (body: unknown) => void }) => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
async (req: { signal?: AbortSignal }, res: { json: (body: unknown) => void }) => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
res.json({ ok: true });
},
),
});
res.json({ ok: true });
},
);
app.get(
"/echo/:id",
asyncRoute(
(
req: { params?: Record<string, string> },
res: { json: (body: unknown) => void },
) => {
res.json({ id: req.params?.id ?? null });
},
),
async (
req: { params?: Record<string, string> },
res: { json: (body: unknown) => void },
) => {
res.json({ id: req.params?.id ?? null });
},
);
},
};

View File

@@ -11,13 +11,7 @@ import {
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveTargetIdFromTabs } from "../target-id.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import {
asyncBrowserRoute,
getProfileContext,
jsonError,
toNumber,
toStringOrEmpty,
} from "./utils.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
function resolveTabsProfileContext(
req: BrowserRequest,
@@ -144,180 +138,165 @@ async function runTabTargetMutation(params: {
}
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get(
"/tabs",
asyncBrowserRoute(async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
app.get("/tabs", async (req, res) => {
await withTabsProfileRoute({
req,
res,
ctx,
run: async (profileCtx) => {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
res.json({ running: true, tabs });
},
});
});
app.post("/tabs/open", async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
res.json(tab);
},
});
});
app.post("/tabs/focus", async (req, res) => {
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
const tabs = await profileCtx.listTabs();
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(resolved.targetId);
},
});
});
app.delete("/tabs/:targetId", async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
},
});
});
app.post("/tabs/action", async (req, res) => {
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
res.json({ running: true, tabs });
},
});
}),
);
return res.json({ ok: true, tabs });
}
app.post(
"/tabs/open",
asyncBrowserRoute(async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) {
return jsonError(res, 400, "url is required");
}
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
await assertBrowserNavigationAllowed({
url,
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
res.json(tab);
},
});
}),
);
const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab });
}
app.post(
"/tabs/focus",
asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, (req.body as { targetId?: unknown })?.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
if (action === "close") {
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
const target = resolveIndexedTab(tabs, index);
if (!target) {
throw new BrowserTabNotFoundError();
}
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) {
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
url: target.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(resolved.targetId);
},
});
}),
);
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
app.delete(
"/tabs/:targetId",
asyncBrowserRoute(async (req, res) => {
const targetId = parseRequiredTargetId(res, req.params.targetId);
if (!targetId) {
return;
}
await runTabTargetMutation({
req,
res,
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.closeTab(id);
},
});
}),
);
app.post(
"/tabs/action",
asyncBrowserRoute(async (req, res) => {
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
await withTabsProfileRoute({
req,
res,
ctx,
mapTabError: true,
run: async (profileCtx) => {
if (action === "list") {
const reachable = await profileCtx.isReachable(300);
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
return res.json({ ok: true, tabs });
}
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank");
return res.json({ ok: true, tab });
}
if (action === "close") {
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: target.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
return jsonError(res, 400, "unknown tab action");
},
});
}),
);
return jsonError(res, 400, "unknown tab action");
},
});
});
}

View File

@@ -1,11 +1,7 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { parseBooleanValue } from "../../utils/boolean.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteHandler } from "./types.js";
export function asyncBrowserRoute(handler: BrowserRouteHandler): BrowserRouteHandler {
return (req, res) => handler(req, res);
}
import type { BrowserRequest, BrowserResponse } from "./types.js";
/**
* Extract profile name from query string or body and get profile context.

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

@@ -22,7 +22,6 @@ async function normalizeUploadPaths(paths: string[]): Promise<string[]> {
return result.paths;
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Browser request result type is shared between request and success formatter.
async function runBrowserPostAction<T>(params: {
parent: BrowserParentOpts;
profile: string | undefined;

View File

@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import {
@@ -64,6 +63,7 @@ export function requireRef(ref: string | undefined) {
}
async function readFile(path: string): Promise<string> {
const fs = await import("node:fs/promises");
return await fs.readFile(path, "utf8");
}

View File

@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
@@ -102,6 +101,7 @@ export function registerBrowserInspectCommands(
);
if (opts.out) {
const fs = await import("node:fs/promises");
if (result.format === "ai") {
await fs.writeFile(opts.out, result.snapshot, "utf8");
} else {

View File

@@ -126,7 +126,6 @@ async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile
return { path: filePath, base64: buffer.toString("base64"), mimeType };
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- CLI JSON params are typed by the invoked method.
function decodeParams<T>(raw?: string | null): T {
if (!raw) {
throw new Error("INVALID_REQUEST: paramsJSON required");

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",

View File

@@ -1,10 +1,10 @@
{
"name": "@openclaw/codex",
"version": "2026.4.18",
"version": "2026.4.16",
"description": "OpenClaw Codex harness and model provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.67.68",
"@mariozechner/pi-coding-agent": "0.66.1",
"ws": "^8.20.0",
"zod": "^4.3.6"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/comfy-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw ComfyUI provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepgram-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw Deepgram media-understanding provider",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.4.18",
"version": "2026.4.16",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
@@ -24,10 +24,10 @@
"./index.ts"
],
"compat": {
"pluginApi": ">=2026.4.18"
"pluginApi": ">=2026.4.16"
},
"build": {
"openclawVersion": "2026.4.18"
"openclawVersion": "2026.4.16"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.4.18",
"version": "2026.4.16",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
@@ -8,7 +8,7 @@
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.1.15",
"@pierre/diffs": "1.1.13",
"@pierre/theme": "0.0.29",
"@sinclair/typebox": "0.34.49",
"playwright-core": "1.59.1"

View File

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

View File

@@ -1,13 +1,13 @@
{
"name": "@openclaw/discord",
"version": "2026.4.18",
"version": "2026.4.16",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
"@buape/carbon": "0.16.0",
"@buape/carbon": "0.15.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"discord-api-types": "^0.38.47",
"discord-api-types": "^0.38.45",
"https-proxy-agent": "^9.0.0",
"opusscript": "^0.1.1"
},
@@ -16,7 +16,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.18"
"openclaw": ">=2026.4.16"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -52,10 +52,10 @@
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.18"
"pluginApi": ">=2026.4.16"
},
"build": {
"openclawVersion": "2026.4.18"
"openclawVersion": "2026.4.16"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -118,7 +118,7 @@ export async function handleDiscordMessagingAction(
: accountId
? { accountId }
: undefined;
const withReactionRuntimeOptions = (extra?: Record<string, unknown>) => ({
const withReactionRuntimeOptions = <T extends Record<string, unknown>>(extra?: T) => ({
...(reactionRuntimeOptions ?? cfgOptions),
...extra,
});

View File

@@ -106,12 +106,11 @@ export async function handleDiscordPresenceAction(
return jsonResult({
ok: true,
status,
activities: activities.map((a) =>
Object.assign(
{ type: a.type, name: a.name },
a.url ? { url: a.url } : {},
a.state ? { state: a.state } : {},
),
),
activities: activities.map((a) => ({
type: a.type,
name: a.name,
...(a.url ? { url: a.url } : {}),
...(a.state ? { state: a.state } : {}),
})),
});
}

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

@@ -311,7 +311,6 @@ export function createMentionRequiredGuildConfig(overrides?: Partial<Config>): C
}
export function captureNextDispatchCtx<
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe captured dispatch context shape.
T extends {
SessionKey?: string;
ParentSessionKey?: string;

View File

@@ -464,7 +464,6 @@ async function dispatchDiscordComponentEvent(params: {
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: interactionCtx.memberRoleIds,
GroupSystemPrompt: interactionCtx.isDirectMessage ? undefined : groupSystemPrompt,
GroupSpace: guildInfo?.id ?? guildInfo?.slug ?? interactionCtx.rawGuildId ?? undefined,
OwnerAllowFrom: ownerAllowFrom,

View File

@@ -6,8 +6,8 @@ type ReplyThreadingContext = {
ReplyThreading?: ReplyThreadingPolicy;
};
export function applyImplicitReplyBatchGate(
ctx: object,
export function applyImplicitReplyBatchGate<T extends object>(
ctx: T,
replyToMode: ReplyToMode,
isBatched: boolean,
) {
@@ -15,5 +15,5 @@ export function applyImplicitReplyBatchGate(
if (!replyThreading) {
return;
}
(ctx as ReplyThreadingContext).ReplyThreading = replyThreading;
(ctx as T & ReplyThreadingContext).ReplyThreading = replyThreading;
}

View File

@@ -1083,7 +1083,6 @@ export async function preflightDiscordMessage(
messageChannelId,
author,
sender,
memberRoleIds,
channelInfo,
channelName,
isGuildMessage,

View File

@@ -44,7 +44,6 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;
memberRoleIds: string[];
channelInfo: DiscordChannelInfo | null;
channelName?: string;

View File

@@ -144,7 +144,6 @@ export async function processDiscordMessage(
displayChannelSlug,
guildInfo,
guildSlug,
memberRoleIds,
channelConfig,
baseSessionKey,
boundSessionKey,
@@ -482,7 +481,6 @@ export async function processDiscordMessage(
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: memberRoleIds,
UntrustedContext: untrustedContext,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,

View File

@@ -50,7 +50,6 @@ describe("buildDiscordNativeCommandContext", () => {
interactionId: "interaction-1",
channelId: "chan-1",
threadParentId: "parent-1",
memberRoleIds: ["admin"],
guildName: "Ops",
channelTopic: "Production alerts only",
channelConfig: {
@@ -83,8 +82,6 @@ describe("buildDiscordNativeCommandContext", () => {
expect(ctx.ChatType).toBe("channel");
expect(ctx.ConversationLabel).toBe("chan-1");
expect(ctx.GroupSubject).toBe("Ops");
expect(ctx.GroupSpace).toBe("guild-1");
expect(ctx.MemberRoleIds).toEqual(["admin"]);
expect(ctx.GroupSystemPrompt).toBe("Use the runbook.");
expect(ctx.OwnerAllowFrom).toEqual(["user-1"]);
expect(ctx.MessageThreadId).toBe("chan-1");

View File

@@ -13,8 +13,6 @@ export type BuildDiscordNativeCommandContextParams = {
interactionId: string;
channelId: string;
threadParentId?: string;
memberRoleIds?: string[];
guildId?: string;
guildName?: string;
channelTopic?: string;
channelConfig?: DiscordChannelConfigResolved | null;
@@ -69,10 +67,6 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma
ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
GroupSubject: params.isGuild ? params.guildName : undefined,
GroupSpace: params.isGuild
? (params.guildInfo?.id ?? params.guildInfo?.slug ?? params.guildId)
: undefined,
MemberRoleIds: params.memberRoleIds,
GroupSystemPrompt: groupSystemPrompt,
UntrustedContext: untrustedContext,
OwnerAllowFrom: ownerAllowFrom,

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