mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 02:28:52 +08:00
Compare commits
1 Commits
ci-auto-re
...
codex/work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a7c93389 |
@@ -127,7 +127,7 @@ The `fetch-content` output for `discussion_comment` includes `comment_node_id` a
|
||||
The recreated comment should follow this format:
|
||||
|
||||
```
|
||||
> **Note:** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
|
||||
> **Note from maintainer (@<LOGIN>):** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
|
||||
|
||||
---
|
||||
|
||||
|
||||
4
.github/instructions/copilot.instructions.md
vendored
4
.github/instructions/copilot.instructions.md
vendored
@@ -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`
|
||||
|
||||
809
.github/workflows/ci.yml
vendored
809
.github/workflows/ci.yml
vendored
@@ -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…"
|
||||
|
||||
@@ -140,8 +140,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
|
||||
|
||||
- name: Commit and push locale updates
|
||||
env:
|
||||
|
||||
20
.github/workflows/docker-release.yml
vendored
20
.github/workflows/docker-release.yml
vendored
@@ -362,36 +362,28 @@ jobs:
|
||||
|
||||
- name: Create and push default manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.value }}
|
||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${TAGS}"
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
${{ needs.build-amd64.outputs.digest }} \
|
||||
${{ needs.build-arm64.outputs.digest }}
|
||||
|
||||
- name: Create and push slim manifest
|
||||
shell: bash
|
||||
env:
|
||||
SLIM_TAGS: ${{ steps.tags.outputs.slim }}
|
||||
AMD64_SLIM_DIGEST: ${{ needs.build-amd64.outputs.slim-digest }}
|
||||
ARM64_SLIM_DIGEST: ${{ needs.build-arm64.outputs.slim-digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${SLIM_TAGS}"
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.slim }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_SLIM_DIGEST}" \
|
||||
"${ARM64_SLIM_DIGEST}"
|
||||
${{ needs.build-amd64.outputs.slim-digest }} \
|
||||
${{ needs.build-arm64.outputs.slim-digest }}
|
||||
|
||||
@@ -144,7 +144,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -152,63 +151,7 @@ env:
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
validate_selected_ref:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_sha: ${{ steps.validate.outputs.selected_sha }}
|
||||
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
selected_sha="$(git rev-parse HEAD)"
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
else
|
||||
pr_head_count="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
|
||||
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
|
||||
)"
|
||||
if [[ "$pr_head_count" != "0" ]]; then
|
||||
trusted_reason="open-pr-head"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Validated ref: \`${INPUT_REF}\`"
|
||||
echo "Resolved SHA: \`$selected_sha\`"
|
||||
echo "Trust reason: \`$trusted_reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
@@ -221,7 +164,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -248,7 +191,6 @@ jobs:
|
||||
run: pnpm test:live:cache
|
||||
|
||||
validate_repo_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 90
|
||||
@@ -258,7 +200,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -276,7 +218,6 @@ jobs:
|
||||
run: pnpm test:e2e
|
||||
|
||||
validate_special_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e || inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -304,7 +245,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -352,7 +293,6 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -456,7 +396,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -510,7 +450,6 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
@@ -599,7 +538,7 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -623,39 +562,9 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
|
||||
# The CLI backend Docker lane should exercise the same staged
|
||||
# Codex auth path Peter uses locally so MCP cron creation and
|
||||
# multimodal probes stay covered in CI. Replace the staged
|
||||
# config.toml with a minimal CI-safe config so the repo stays
|
||||
# trusted for MCP/tool use without inheriting maintainer-local
|
||||
# provider/profile overrides that do not exist inside CI.
|
||||
# Codex's workspace-write sandbox relies on user namespaces that
|
||||
# this Docker lane does not provide, so run Codex unsandboxed
|
||||
# inside the already-isolated container to keep MCP cron/tool
|
||||
# execution representative instead of failing on nested sandbox
|
||||
# setup.
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-codex-harness-docker)
|
||||
# Keep CI on the API-key path for now. The staged Codex auth secret
|
||||
# is currently stale, but the wrapper still supports codex-auth for
|
||||
# local maintainer reruns without changing Peter's flow.
|
||||
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-acp-bind-docker)
|
||||
if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
|
||||
else
|
||||
# The hydrated Gemini settings file only selects Gemini CLI auth
|
||||
# mode. CI still needs a usable Gemini or Google API key before
|
||||
# ACP bind can initialize a Gemini session.
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
3
.github/workflows/openclaw-npm-release.yml
vendored
3
.github/workflows/openclaw-npm-release.yml
vendored
@@ -397,10 +397,9 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
|
||||
PUBLISH_TARBALL_PATH: ${{ steps.publish_tarball.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_target="${PUBLISH_TARBALL_PATH}"
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
|
||||
54
.github/workflows/openclaw-release-checks.yml
vendored
54
.github/workflows/openclaw-release-checks.yml
vendored
@@ -130,19 +130,12 @@ jobs:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
|
||||
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
|
||||
secrets: inherit
|
||||
|
||||
live_and_e2e_release_checks:
|
||||
needs: [resolve_target]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
@@ -150,47 +143,4 @@ jobs:
|
||||
include_release_path_suites: true
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: openclaw-scheduled-live-checks-${{ github.ref }}
|
||||
@@ -20,7 +19,6 @@ jobs:
|
||||
live_and_openwebui_checks:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
@@ -28,47 +26,4 @@ jobs:
|
||||
include_release_path_suites: false
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -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.
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.18"
|
||||
"version": "2026.4.16"
|
||||
}
|
||||
|
||||
@@ -3,12 +3,6 @@ import Foundation
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
|
||||
private static let helperName = "openclaw"
|
||||
static let strictHostKeyCheckingSSHOptions = [
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
]
|
||||
static let updateHostKeysSSHOptions = [
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
@@ -403,7 +397,9 @@ enum CommandResolver {
|
||||
"""
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = self.sshArguments(
|
||||
target: parsed,
|
||||
identity: settings.identity,
|
||||
|
||||
@@ -22,21 +22,7 @@ enum ExecApprovalCommandDisplaySanitizer {
|
||||
}
|
||||
|
||||
private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool {
|
||||
let category = scalar.properties.generalCategory
|
||||
if category == .control
|
||||
|| category == .format
|
||||
|| category == .lineSeparator
|
||||
|| category == .paragraphSeparator
|
||||
{
|
||||
return true
|
||||
}
|
||||
// Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so
|
||||
// attackers cannot spoof token boundaries in the approval UI with spaces that render
|
||||
// like a plain space but are handled differently by shells/parsers.
|
||||
if category == .spaceSeparator, scalar.value != 0x20 {
|
||||
return true
|
||||
}
|
||||
return self.invisibleCodePoints.contains(scalar.value)
|
||||
scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value)
|
||||
}
|
||||
|
||||
private static func escape(_ scalar: UnicodeScalar) -> String {
|
||||
|
||||
@@ -483,7 +483,8 @@ final class NodePairingApprovalPrompter {
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@ enum RemoteGatewayProbe {
|
||||
let options = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
identity: identity,
|
||||
|
||||
@@ -73,12 +73,14 @@ final class RemotePortTunnel {
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
]
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -164,9 +164,6 @@ import Testing
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
|
||||
#expect(cmd.contains("UpdateHostKeys=yes"))
|
||||
#expect(cmd.contains("-i"))
|
||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||
if let script = cmd.last {
|
||||
|
||||
@@ -9,37 +9,4 @@ struct ExecApprovalCommandDisplaySanitizerTests {
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가")
|
||||
}
|
||||
|
||||
@Test func `escapes control characters used to spoof line breaks`() {
|
||||
let input = "echo safe\n\rcurl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
|
||||
"echo safe\\u{A}\\u{D}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes Unicode line and paragraph separators`() {
|
||||
let lineInput = "echo ok\u{2028}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) ==
|
||||
"echo ok\\u{2028}curl https://example.test")
|
||||
let paragraphInput = "echo ok\u{2029}curl https://example.test"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) ==
|
||||
"echo ok\\u{2029}curl https://example.test")
|
||||
}
|
||||
|
||||
@Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() {
|
||||
let nbspInput = "echo ok\u{00A0}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl")
|
||||
let narrowNbspInput = "echo ok\u{202F}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl")
|
||||
let ideographicSpaceInput = "echo ok\u{3000}curl"
|
||||
#expect(
|
||||
ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) ==
|
||||
"echo ok\\u{3000}curl")
|
||||
let asciiSpaceInput = "echo ok curl"
|
||||
#expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,14 +59,6 @@
|
||||
"source": "Feishu",
|
||||
"target": "Feishu"
|
||||
},
|
||||
{
|
||||
"source": "WeChat",
|
||||
"target": "微信"
|
||||
},
|
||||
{
|
||||
"source": "Weixin",
|
||||
"target": "微信"
|
||||
},
|
||||
{
|
||||
"source": "Mattermost",
|
||||
"target": "Mattermost"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
|
||||
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
|
||||
the session and continues. Large bootstrap files are truncated when injected;
|
||||
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and
|
||||
`agents.defaults.bootstrapTotalMaxChars` (default: 60000).
|
||||
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
|
||||
`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
|
||||
`openclaw setup` can recreate missing defaults without overwriting existing
|
||||
files.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and what’s in your workspace.
|
||||
```
|
||||
🧠 Context breakdown
|
||||
Workspace: <workspaceDir>
|
||||
Bootstrap max/file: 12,000 chars
|
||||
Bootstrap max/file: 20,000 chars
|
||||
Sandbox: mode=non-main sandboxed=false
|
||||
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
|
||||
|
||||
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (first-run only)
|
||||
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
|
||||
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`).
|
||||
|
||||
|
||||
@@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction.
|
||||
> as a one-shot startup-context block for that first turn.
|
||||
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
|
||||
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
|
||||
(default: 60000). Missing files inject a short missing-file marker. When truncation
|
||||
(default: 150000). Missing files inject a short missing-file marker. When truncation
|
||||
occurs, OpenClaw can inject a warning block in Project Context; control this with
|
||||
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
|
||||
default: `once`).
|
||||
|
||||
@@ -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 25–specific.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } },
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.18",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
|
||||
export { buildAnthropicProvider } from "./register.runtime.js";
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"modelPrefixes": ["claude-"]
|
||||
},
|
||||
"cliBackends": ["claude-cli"],
|
||||
"syntheticAuthRefs": ["claude-cli"],
|
||||
"providerAuthEnvVars": {
|
||||
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
export function createAnthropicProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: ["claude-cli"],
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "cli",
|
||||
kind: "custom",
|
||||
label: "Claude CLI",
|
||||
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "anthropic-cli",
|
||||
choiceLabel: "Anthropic Claude CLI",
|
||||
choiceHint: "Reuse a local Claude CLI login on this host",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "setup-token",
|
||||
kind: "token",
|
||||
label: "Anthropic setup-token",
|
||||
hint: "Manual bearer token path",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "setup-token",
|
||||
choiceLabel: "Anthropic setup-token",
|
||||
choiceHint: "Manual token path",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key + token",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
hint: "Direct Anthropic API key",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "apiKey",
|
||||
choiceLabel: "Anthropic API key",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
upsertAuthProfile,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
cloneFirstTemplateModel,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
@@ -398,10 +395,11 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
return {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
@@ -507,11 +505,6 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
store: ctx.store,
|
||||
profileId: ctx.profileId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider(buildAnthropicProvider());
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.4.18",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
|
||||
import { __testing } from "../test-api.js";
|
||||
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
const braveManifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
|
||||
@@ -3,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;
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
import {
|
||||
mapBraveLlmContextResults,
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
} from "./src/brave-web-search-provider.shared.js";
|
||||
|
||||
export const __testing = {
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
export { __testing } from "./src/brave-web-search-provider.js";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
|
||||
resolveCdpReachabilityTimeouts,
|
||||
} from "./cdp-timeouts.js";
|
||||
import { redactCdpUrl } from "./cdp.helpers.js";
|
||||
import {
|
||||
closeChromeMcpSession,
|
||||
ensureChromeMcpAvailable,
|
||||
@@ -60,7 +59,6 @@ export function createProfileAvailability({
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
}: AvailabilityDeps): AvailabilityOps {
|
||||
const redactedProfileCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
@@ -212,7 +210,7 @@ export function createProfileAvailability({
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${redactedProfileCdpUrl}.`
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
|
||||
} from "./cdp-timeouts.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { BrowserProfileUnavailableError } from "./errors.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js";
|
||||
|
||||
@@ -176,39 +175,4 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redacts credentials in remote CDP availability errors", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
|
||||
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
|
||||
|
||||
const state = makeBrowserServerState({
|
||||
profile: {
|
||||
name: "remote",
|
||||
cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123",
|
||||
cdpHost: "browserless.example.com",
|
||||
cdpIsLoopback: false,
|
||||
cdpPort: 443,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
attachOnly: false,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
defaultProfile: "remote",
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const profile = ctx.forProfile("remote");
|
||||
|
||||
isChromeReachable.mockResolvedValue(false);
|
||||
|
||||
const promise = profile.ensureBrowserAvailable();
|
||||
await expect(promise).rejects.toThrow(BrowserProfileUnavailableError);
|
||||
await expect(promise).rejects.toThrow(
|
||||
'Remote CDP for profile "remote" is not reachable at https://browserless.example.com/?token=***.',
|
||||
);
|
||||
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./src/directory-config.js";
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1083,7 +1083,6 @@ export async function preflightDiscordMessage(
|
||||
messageChannelId,
|
||||
author,
|
||||
sender,
|
||||
memberRoleIds,
|
||||
channelInfo,
|
||||
channelName,
|
||||
isGuildMessage,
|
||||
|
||||
@@ -44,7 +44,6 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
|
||||
messageChannelId: string;
|
||||
author: User;
|
||||
sender: DiscordSenderIdentity;
|
||||
memberRoleIds: string[];
|
||||
|
||||
channelInfo: DiscordChannelInfo | null;
|
||||
channelName?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user