Compare commits
92 Commits
codex/open
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc5e380f2 | |||
|
|
188ca4ba4a | ||
|
|
0a50621664 | ||
|
|
81f65e3308 | ||
|
|
fa9cc78add | ||
|
|
02808934c8 | ||
|
|
3c7de420d2 | ||
|
|
2333c3e5fd | ||
|
|
e22d0f1cbf | ||
| 311db31e03 | |||
|
|
c7b2f8ee3a | ||
|
|
b8f4ed6102 | ||
|
|
9a8dd2dfe1 | ||
|
|
6a405a00e5 | ||
|
|
28a7eb3343 | ||
|
|
40fc458072 | ||
|
|
861816738b | ||
| e6ffdf3177 | |||
|
|
d70722f815 | ||
|
|
a19456d8a8 | ||
|
|
01c2c2ed31 | ||
|
|
e4966473c4 | ||
|
|
58bbf890e0 | ||
|
|
217e2665ff | ||
|
|
a94488ffe5 | ||
|
|
e2f005537d | ||
|
|
a72122c731 | ||
|
|
bb7fbbf91c | ||
|
|
6f819a5db7 | ||
|
|
6fe87e8341 | ||
|
|
dcbd8fadcc | ||
| 5b718b8b8a | |||
|
|
3dd2359ca3 | ||
|
|
7277dd516f | ||
|
|
2b55f41865 | ||
|
|
4859396051 | ||
|
|
7135cfc415 | ||
|
|
807d49f22d | ||
|
|
a7f1773602 | ||
|
|
05ac76daae | ||
|
|
8fc9a614f2 | ||
|
|
169ec72783 | ||
|
|
0a0d04f3a7 | ||
|
|
0f096a588d | ||
|
|
72bc3c0319 | ||
|
|
cccd72686b | ||
|
|
e0dc9d5256 | ||
|
|
a5207472cf | ||
|
|
919addfd11 | ||
|
|
6db48ee738 | ||
|
|
49637e87ea | ||
|
|
c19631fd9c | ||
|
|
772f47a5fe | ||
|
|
f125b1f603 | ||
|
|
0b3ff0c94e | ||
|
|
a5717fd7b9 | ||
|
|
9de1e70687 | ||
|
|
9180e9258d | ||
|
|
86cd52bf21 | ||
|
|
80b6560649 | ||
|
|
9c91d0d6ee | ||
|
|
fa6e2aa996 | ||
|
|
ef5a0f02d4 | ||
|
|
51395b4b73 | ||
| 77cd9551fe | |||
|
|
0dd73a2c2e | ||
|
|
d7cf863fd5 | ||
|
|
3f4cee5783 | ||
|
|
eb62488dbc | ||
|
|
2ace2722de | ||
|
|
cf1fb831fe | ||
|
|
57ab5711e1 | ||
|
|
0e546ccd7b | ||
| e65fd7c3ac | |||
|
|
014998c61f | ||
|
|
a30ef9ccf0 | ||
|
|
098a446926 | ||
|
|
e6437d9be7 | ||
|
|
fc965b3ec4 | ||
|
|
1f43a989a0 | ||
|
|
d436729508 | ||
|
|
28cdc45bd5 | ||
|
|
014e7b4f99 | ||
|
|
55dec269be | ||
|
|
07d69b50f7 | ||
|
|
a3e14fb353 | ||
|
|
91d7138520 | ||
|
|
edcfdcdfef | ||
|
|
733ef26c58 | ||
|
|
5833378794 | ||
|
|
1874b28bc1 | ||
|
|
911f5709a3 |
129
.github/workflows/pipeline.yml
vendored
129
.github/workflows/pipeline.yml
vendored
@ -1,5 +1,9 @@
|
||||
name: Pipeline
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@ -17,7 +21,7 @@ on:
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
internal_service_token:
|
||||
ai_workspace_auth_token:
|
||||
description: "Optional ACP auth token for deploy"
|
||||
required: false
|
||||
default: ""
|
||||
@ -26,6 +30,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pipeline-${{ github.ref }}
|
||||
@ -35,9 +40,6 @@ defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
|
||||
|
||||
jobs:
|
||||
production_state:
|
||||
name: Production State
|
||||
@ -51,12 +53,40 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
continue-on-error: true
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN
|
||||
|
||||
- name: Export bridge auth token
|
||||
if: ${{ steps.vault.outcome == 'success' && steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN != '' }}
|
||||
run: echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Probe current production bridge
|
||||
id: production_state
|
||||
env:
|
||||
BRIDGE_SERVER_URL: https://xworkmate-bridge.svc.plus
|
||||
BRIDGE_AUTH_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" ]]; then
|
||||
echo "::notice title=Production state skipped::AI_WORKSPACE_AUTH_TOKEN is unavailable from Vault; continuing without production bridge metadata."
|
||||
{
|
||||
echo "production_image="
|
||||
echo "production_tag="
|
||||
echo "production_commit="
|
||||
echo "production_version="
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
echo "${key}=${value}" >> "$GITHUB_OUTPUT"
|
||||
done < <(bash ./scripts/github-actions/report-production-state.sh "${BRIDGE_SERVER_URL}")
|
||||
@ -106,6 +136,19 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge GHCR_TOKEN | GHCR_TOKEN
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
@ -118,7 +161,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ vars.GHCR_USERNAME || github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
password: ${{ steps.vault.outputs.GHCR_TOKEN || github.token }}
|
||||
|
||||
- name: Resolve service image ref
|
||||
id: service_ref
|
||||
@ -169,25 +212,55 @@ jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: build
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
if: ${{ github.event_name != 'pull_request' && github.event_name != 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run_apply: ${{ steps.deploy_meta.outputs.run_apply }}
|
||||
env:
|
||||
INTERNAL_SERVICE_TOKEN: ${{ github.event_name == 'workflow_dispatch' && inputs.internal_service_token || secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }}
|
||||
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
steps:
|
||||
- name: Checkout service repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: xworkmate-bridge
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN ;
|
||||
kv/data/github-actions/xworkmate-bridge WORKSPACE_REPO_TOKEN | WORKSPACE_REPO_TOKEN ;
|
||||
kv/data/github-actions/xworkmate-bridge SINGLE_NODE_VPS_SSH_PRIVATE_KEY | SINGLE_NODE_VPS_SSH_PRIVATE_KEY ;
|
||||
kv/data/github-actions/xworkmate-bridge SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 | SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 ;
|
||||
kv/data/github-actions/xworkmate-bridge SSH_KNOWN_HOSTS | SSH_KNOWN_HOSTS
|
||||
|
||||
- name: Export deploy secrets
|
||||
run: |
|
||||
{
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${{ inputs.ai_workspace_auth_token }}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN=${{ inputs.ai_workspace_auth_token }}"
|
||||
else
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}"
|
||||
fi
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate deploy secrets
|
||||
run: |
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN}" ]]; then
|
||||
echo "::error::AI_WORKSPACE_AUTH_TOKEN is empty. Provide it via the workflow_dispatch input, or ensure kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN is readable from Vault."
|
||||
exit 1
|
||||
fi
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN length=${#AI_WORKSPACE_AUTH_TOKEN}"
|
||||
|
||||
- name: Checkout playbooks repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: x-evor/playbooks
|
||||
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
|
||||
token: ${{ steps.vault.outputs.WORKSPACE_REPO_TOKEN || github.token }}
|
||||
path: playbooks
|
||||
|
||||
- name: Download build image ref artifact
|
||||
@ -211,6 +284,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install "ansible-core==2.18.3"
|
||||
ansible-galaxy collection install ansible.posix
|
||||
|
||||
- name: Resolve deployment settings
|
||||
id: deploy_meta
|
||||
@ -233,8 +307,9 @@ jobs:
|
||||
- name: Prepare runner SSH access
|
||||
working-directory: xworkmate-bridge
|
||||
env:
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 }}
|
||||
SSH_KNOWN_HOSTS: ${{ steps.vault.outputs.SSH_KNOWN_HOSTS }}
|
||||
run: bash ./scripts/github-actions/prepare-ssh.sh "${{ steps.deploy_meta.outputs.target_host }}" "${SSH_KNOWN_HOSTS}"
|
||||
|
||||
- name: Install native bridge binary on target
|
||||
@ -246,7 +321,6 @@ jobs:
|
||||
working-directory: playbooks
|
||||
env:
|
||||
ANSIBLE_CONFIG: ./ansible.cfg
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: |
|
||||
CHECK_MODE_FLAG=""
|
||||
if [[ "${{ steps.deploy_meta.outputs.run_apply }}" != "true" ]]; then
|
||||
@ -304,21 +378,34 @@ jobs:
|
||||
needs:
|
||||
- build
|
||||
- deploy
|
||||
if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
|
||||
if: ${{ github.event_name != 'push' && needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRIDGE_SERVER_URL: https://xworkmate-bridge.svc.plus
|
||||
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN
|
||||
|
||||
- name: Export bridge auth token
|
||||
run: echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate deployed endpoints
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/validate-deploy.sh "$(git rev-parse --short HEAD)" "${BRIDGE_SERVER_URL}"
|
||||
|
||||
- name: Validate public ACP contract
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/verify-public-rpc-contract.sh
|
||||
|
||||
- name: Validate OpenClaw session contract
|
||||
run: bash ./scripts/github-actions/validate-openclaw-session.sh
|
||||
|
||||
109
.github/workflows/runtime-release.yml
vendored
Normal file
109
.github/workflows/runtime-release.yml
vendored
Normal file
@ -0,0 +1,109 @@
|
||||
name: Build XWorkmate Bridge Runtime Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/**]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/runtime-release.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: xworkmate-bridge-runtime-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.os }}-${{ matrix.arch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [linux, darwin]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Build runtime asset
|
||||
env:
|
||||
TARGET_OS: ${{ matrix.os }}
|
||||
TARGET_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="dist/runtime/xworkmate-bridge"
|
||||
mkdir -p "${root}/bin" dist/assets
|
||||
CGO_ENABLED=0 GOOS="${TARGET_OS}" GOARCH="${TARGET_ARCH}" \
|
||||
go build -buildvcs=false -trimpath \
|
||||
-ldflags "-X main.buildCommit=${GITHUB_SHA}" \
|
||||
-o "${root}/bin/xworkmate-go-core" .
|
||||
cat > "${root}/manifest.json" <<JSON
|
||||
{
|
||||
"component": "xworkmate-bridge",
|
||||
"commit": "${GITHUB_SHA}",
|
||||
"os": "${TARGET_OS}",
|
||||
"arch": "${TARGET_ARCH}",
|
||||
"binary": "bin/xworkmate-go-core"
|
||||
}
|
||||
JSON
|
||||
tar -czf "dist/assets/xworkmate-bridge-${TARGET_OS}-${TARGET_ARCH}.tar.gz" \
|
||||
-C dist/runtime xworkmate-bridge
|
||||
(
|
||||
cd dist/assets
|
||||
# Name the per-job checksum file by OS *and* ARCH. Keying on ARCH
|
||||
# alone makes the linux/darwin jobs of the same arch both emit
|
||||
# SHA256SUMS-<arch>, which then clobber each other under the
|
||||
# publish job's merge-multiple download — leaving SHA256SUMS with
|
||||
# only 2 of the 4 platforms and breaking arm64 (and darwin-amd64)
|
||||
# consumers with "missing checksum".
|
||||
sha256sum -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-${TARGET_OS}-${TARGET_ARCH}"
|
||||
)
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xworkmate-bridge-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/assets/*.tar.gz
|
||||
dist/assets/SHA256SUMS-*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: xworkmate-bridge-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="runtime-${GITHUB_SHA::12}"
|
||||
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS
|
||||
rm -f dist/SHA256SUMS-*
|
||||
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
else
|
||||
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--target "${GITHUB_SHA}" \
|
||||
--title "XWorkmate Bridge runtime ${GITHUB_SHA::12}" \
|
||||
--notes "Prebuilt bridge binaries. No target-host Go build is required."
|
||||
fi
|
||||
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Validate Release PR
|
||||
|
||||
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
|
||||
# 详见 iac_modules/docs/tldr-github-branch-model.md
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-release-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.base_ref, 'release/')
|
||||
steps:
|
||||
- name: Check PR source branch
|
||||
run: |
|
||||
SRC="${{ github.head_ref }}"
|
||||
TGT="${{ github.base_ref }}"
|
||||
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
echo "🔍 Validating PR into release branch"
|
||||
echo " source: $SRC"
|
||||
echo " target: $TGT"
|
||||
echo " labels: $LABELS"
|
||||
|
||||
if [[ "$SRC" =~ ^hotfix/ ]]; then
|
||||
echo "✅ Allowed: hotfix/* branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
|
||||
echo "✅ Allowed: cherry-pick/backport labeled PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Rejected."
|
||||
echo "release/* 仅接受:"
|
||||
echo " - 来自 hotfix/* 的 PR"
|
||||
echo " - 带 cherry-pick 或 backport 标签的 PR(已验证 feature 的 backport/cherry-pick)"
|
||||
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
|
||||
exit 1
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
xworkmate-bridge
|
||||
xworkmate-go-core-linux
|
||||
repomix-output.xml
|
||||
|
||||
23
AGENTS.md
23
AGENTS.md
@ -22,3 +22,26 @@ Notes:
|
||||
- `release_id` is the normalized release identifier; use the release tag when available, otherwise use a short git commit id
|
||||
- `hostname` is the target host name
|
||||
- `domain` should be encoded in a filesystem-friendly form; replace `.` with `-`
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Anti-Fallback Rules
|
||||
|
||||
1. **No cascading fallback chains.** Prefer explicit single-path resolution. If a value is missing, fail early with a clear error instead of trying progressively degraded alternatives.
|
||||
|
||||
2. **No silent error swallowing.** Every error must be logged or returned. Do not use `_ = fn()` to suppress errors unless the function contract explicitly documents that the error is benign (e.g., `Close()` on a nil-safe receiver). For all other cases, log or propagate.
|
||||
|
||||
3. **No stale dead code.** Unused functions, types, constants, and entire files must be removed, not commented out or guarded behind unreachable branches.
|
||||
|
||||
4. **No redundant indirection.** One-line wrappers that simply delegate to another function must be removed. Call the canonical function directly.
|
||||
|
||||
5. **No hardcoded model defaults** that bypass configuration. Model selection must route through the resolver/catalog, not be baked into library code.
|
||||
|
||||
6. **No multi-agent orchestration in the bridge.** The bridge handles forwarding only. Any request that includes orchestration parameters (multiAgent, orchestrationMode, mode=multi-agent) must be rejected or treated as unrecognized.
|
||||
|
||||
### Dead Code Elimination
|
||||
|
||||
- Run `go vet` and ensure zero warnings before committing.
|
||||
- Run `go build ./...` and verify compilation succeeds after every refactor.
|
||||
- After removing a source file, verify that no remaining file imports it or references its exported symbols.
|
||||
- Do not modify `*_test.go` files just to hide a production regression. When a requested behavior or protocol contract changes, update the nearest tests first or in the same change, and keep the assertions tied to the new contract.
|
||||
|
||||
13
README.md
13
README.md
@ -62,7 +62,16 @@ The deploy stage checks out:
|
||||
- this service repository into `xworkmate-bridge/`
|
||||
- the `x-evor/playbooks` repository into `playbooks/`
|
||||
|
||||
Then it runs `playbooks/deploy_xworkmate_bridge_vhosts.yml`, which builds the service for `linux/amd64` and deploys it to the target host with Ansible.
|
||||
Then it installs the native `linux/amd64` bridge binary with
|
||||
`scripts/github-actions/deploy-native-binary.sh`. The native bridge runs as the
|
||||
`ubuntu` user's systemd user service:
|
||||
|
||||
- binary: `/home/ubuntu/.local/bin/xworkmate-go-core`
|
||||
- unit: `/home/ubuntu/.config/systemd/user/xworkmate-bridge.service`
|
||||
- restart: `systemctl --user restart xworkmate-bridge.service`
|
||||
|
||||
During migration the script performs a one-time stop/disable of the old system
|
||||
unit, then deploys and restarts through `ubuntu@<target>`.
|
||||
|
||||
### Validate stage
|
||||
|
||||
@ -85,7 +94,7 @@ Optional GitHub secrets:
|
||||
|
||||
Optional workflow input:
|
||||
|
||||
- `internal_service_token`: manual dispatch input that is forwarded to Ansible as `INTERNAL_SERVICE_TOKEN`
|
||||
- `ai_workspace_auth_token`: manual dispatch input that is forwarded as `AI_WORKSPACE_AUTH_TOKEN`
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@ -39,7 +39,8 @@
|
||||
|
||||
环境变量:
|
||||
|
||||
- `BRIDGE_AUTH_TOKEN`
|
||||
- `AI_WORKSPACE_AUTH_TOKEN`:主共享 token,用于 bridge 入站鉴权、上游 provider 转发、OpenClaw Gateway 重签发与任务转发 fallback。
|
||||
- `BRIDGE_AUTH_TOKEN`:旧主 token。没有 `AI_WORKSPACE_AUTH_TOKEN` 时继续生效并参与上游转发,用于存量租户兼容,直到 `AI_WORKSPACE_AUTH_TOKEN` 完成彻底替代后下线。
|
||||
- `BRIDGE_REVIEW_AUTH_TOKEN`(可选):Apple review / beta 工测专用临时 token。清空该环境变量并重启/reload bridge 即可单独关停,不影响主 token。
|
||||
- `ACP_ALLOWED_ORIGINS`
|
||||
|
||||
@ -48,59 +49,107 @@
|
||||
- `/acp` 与 `/acp/rpc` 都做 origin allowlist 校验
|
||||
- 空 `Origin` 默认允许
|
||||
- `/api/ping`、`/acp`、`/acp/rpc` 在任一 bridge token 非空时都要求 bearer header
|
||||
- `BRIDGE_AUTH_TOKEN` 与 `BRIDGE_REVIEW_AUTH_TOKEN` 都为空时默认放行
|
||||
- `AI_WORKSPACE_AUTH_TOKEN`、`BRIDGE_AUTH_TOKEN` 与 `BRIDGE_REVIEW_AUTH_TOKEN` 都为空时默认放行
|
||||
- token 非空时,接受裸 token 或 `Bearer <token>`
|
||||
- 线上 Caddy 入口必须与 bridge origin 保持同一 token set:主 `AI_WORKSPACE_AUTH_TOKEN`、兼容 `BRIDGE_AUTH_TOKEN` 与可选 `BRIDGE_REVIEW_AUTH_TOKEN` 都应放行;无 token 仍返回 `401`
|
||||
- `xworkmate-app` 生产 Origin 固定为 `https://xworkmate.svc.plus`
|
||||
|
||||
## 3.1 Lightweight Distributed Task Forwarding
|
||||
|
||||
bridge 可以把本机收到的 HTTP 任务提交转发到另一个 bridge endpoint,用于轻量分布式部署。当前实现落地的是双节点分布式拓扑:
|
||||
bridge 可以把本机收到的 HTTP 任务提交转发到另一个 bridge endpoint,用于轻量分布式部署。当前实现是一个静态 task router,不做自动发现,不依赖 config center:
|
||||
|
||||
- `cn-xworkmate-bridge.svc.plus` 负责本地入口和鉴权
|
||||
- `xworkmate-bridge.svc.plus` 负责实际 OpenClaw task runtime
|
||||
- cn bridge 配置 `task_forward_peer_id` 后,`POST /acp/rpc` 的 `session.start` / `session.message` 会转发到 peer 的同一路径
|
||||
- 主 bridge 不配置 `task_forward_peer_id`,因此不会把任务反向转发到 CN
|
||||
- `nodes` 是静态 peer catalog,记录 bridge 节点身份、角色、能力和私网 endpoint
|
||||
- `forwarding.rules` 决定哪些 JSON-RPC method 转发到哪个节点或哪类节点
|
||||
- `forwarding.routes` 决定显式 next-hop,用于星状或网状拓扑
|
||||
- `session.start` 选中目标后,`session.message` 通过本机 session route store 粘到同一个目标节点
|
||||
- 公网域名只作为 ingress;bridge 之间的 task forward 只走 WireGuard over VLESS 私网 endpoint
|
||||
|
||||
配置:
|
||||
双节点简写配置:
|
||||
|
||||
```yaml
|
||||
distributed:
|
||||
topology: "dual-node"
|
||||
local_node_id: "cn-xworkmate-bridge"
|
||||
task_forward_peer_id: "xworkmate-bridge"
|
||||
nodes:
|
||||
- id: "xworkmate-bridge"
|
||||
role: "primary"
|
||||
public_base_url: "https://xworkmate-bridge.svc.plus"
|
||||
bridge_endpoint: "http://172.29.10.1:8787"
|
||||
- id: "cn-xworkmate-bridge"
|
||||
role: "edge"
|
||||
public_base_url: "https://cn-xworkmate-bridge.svc.plus"
|
||||
bridge_endpoint: "http://172.29.10.2:8787"
|
||||
task_forward_token: ""
|
||||
```
|
||||
|
||||
单 endpoint 覆盖仍可用于临时验证:
|
||||
`task_forward_peer_id: "xworkmate-bridge"` 等价于把 `session.start` / `session.message` 转发到 peer `xworkmate-bridge`。内置 peer catalog 会把它解析为 `http://172.29.10.1:8787`。主 bridge 不配置 `task_forward_peer_id`,因此不会反向转发。
|
||||
|
||||
```text
|
||||
XWORKMATE_BRIDGE_TASK_FORWARD_ENDPOINT=https://xworkmate-bridge.svc.plus
|
||||
XWORKMATE_BRIDGE_TASK_FORWARD_TOKEN=$PEER_BRIDGE_AUTH_TOKEN
|
||||
通用多节点配置:
|
||||
|
||||
```yaml
|
||||
distributed:
|
||||
local_node_id: "edge-cn"
|
||||
nodes:
|
||||
- id: "edge-cn"
|
||||
role: "edge"
|
||||
zone: "cn"
|
||||
bridge_endpoint: "http://172.29.10.2:8787"
|
||||
capabilities: ["ingress"]
|
||||
- id: "worker-a"
|
||||
role: "executor"
|
||||
zone: "global"
|
||||
bridge_endpoint: "http://172.29.10.11:8787"
|
||||
capabilities: ["openclaw"]
|
||||
- id: "worker-b"
|
||||
role: "executor"
|
||||
zone: "global"
|
||||
bridge_endpoint: "http://172.29.10.12:8787"
|
||||
capabilities: ["openclaw"]
|
||||
forwarding:
|
||||
hop_limit: 3
|
||||
default_action: "execute_local"
|
||||
rules:
|
||||
- methods: ["session.start", "session.message"]
|
||||
target:
|
||||
selector:
|
||||
role: "executor"
|
||||
capability: "openclaw"
|
||||
strategy: "round_robin"
|
||||
```
|
||||
|
||||
星状或显式 next-hop 网状配置:
|
||||
|
||||
```yaml
|
||||
distributed:
|
||||
local_node_id: "edge-cn"
|
||||
nodes:
|
||||
- id: "edge-cn"
|
||||
role: "edge"
|
||||
bridge_endpoint: "http://172.29.10.2:8787"
|
||||
- id: "hub-main"
|
||||
role: "hub"
|
||||
bridge_endpoint: "http://172.29.10.1:8787"
|
||||
- id: "worker-eu"
|
||||
role: "executor"
|
||||
bridge_endpoint: "http://172.29.10.30:8787"
|
||||
forwarding:
|
||||
hop_limit: 3
|
||||
rules:
|
||||
- methods: ["session.start"]
|
||||
target:
|
||||
node_id: "worker-eu"
|
||||
routes:
|
||||
- target_node_id: "worker-eu"
|
||||
next_hop_node_id: "hub-main"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `task_forward_peer_id` 指向的节点 `bridge_endpoint` 是 peer bridge base URL,bridge 会按当前请求路径拼接 `/acp/rpc`
|
||||
- 同步消息不能走公网明文:公网 endpoint 必须使用 `https://`
|
||||
- `http://` 只允许 loopback、private、link-local 这类本机或 VPN 内网地址,用于 WireGuard 等隧道已经提供加密的场景
|
||||
- endpoint 可以是公网 HTTPS,也可以是 VPN 内网 HTTP(S)。bridge 明确支持把 `bridge_endpoint` 配成 WireGuard、WireGuard over VLESS/TCP/TLS、WebSocket/TLS 等隧道后的本机或私网地址
|
||||
- `bridge_endpoint` 是 peer bridge base URL,bridge 会按当前请求路径拼接 `/acp/rpc` 或 `/gateway/openclaw`
|
||||
- 同步消息不能走公网;`bridge_endpoint` 必须是 loopback、private、link-local 这类本机或 VPN 内网地址,用于 WireGuard over VLESS 等隧道已经提供加密的场景
|
||||
- 只要求本机网络能路由到 endpoint;bridge 不依赖 config center 或额外注册中心
|
||||
- `task_forward_token` 为空时复用本机 `BRIDGE_AUTH_TOKEN`
|
||||
- 转发请求会带 `X-XWorkmate-Bridge-Forwarded: 1`,收到该 header 后不会再次转发,避免 bridge 之间循环
|
||||
- `task_forward_token` 为空时复用本机 `AI_WORKSPACE_AUTH_TOKEN`;未配置时兼容复用 `BRIDGE_AUTH_TOKEN`
|
||||
- 转发请求会带 `X-XWorkmate-Bridge-Forwarded: 1`
|
||||
- `X-XWorkmate-Forward-Source` 是源节点,`X-XWorkmate-Forward-Target` 是最终目标节点
|
||||
- `X-XWorkmate-Forward-Hop` 逐跳递增,超过 `forwarding.hop_limit` 时拒绝转发,避免循环
|
||||
- 收到已转发请求时,如果 target 是本机则本机执行;如果 target 不是本机,则按 `forwarding.routes` 查 next-hop
|
||||
|
||||
抗干扰建议:
|
||||
|
||||
- 跨境或 GFW 环境可以直接把 `task_forward_endpoint` 指向 WireGuard over VLESS/TCP/TLS 后的 `http://10.x/172.16-31.x/192.168.x` 私网 bridge endpoint
|
||||
- 运营商 UDP 阻断时,支持把裸 WireGuard UDP 替换为 WireGuard over VLESS/TCP/TLS、WebSocket/TLS 或等价可靠加密通道,bridge 继续使用同一个 peer endpoint 配置
|
||||
- 跨境或 GFW 环境下,`bridge_endpoint` 应指向 WireGuard over VLESS/TCP/TLS 后的 `http://10.x/172.16-31.x/192.168.x` 私网 bridge endpoint
|
||||
- 运营商 UDP 阻断时,支持把裸 WireGuard UDP 替换为 WireGuard over VLESS/TCP/TLS、WebSocket/TLS 或等价可靠加密通道,bridge 继续使用同一个 peer catalog 和 forwarding rules
|
||||
- bridge 层继续使用 bearer token 鉴权;隧道层负责链路加密和抗干扰,应用层负责 peer 身份和任务权限
|
||||
|
||||
推荐 APP 配置:
|
||||
@ -109,7 +158,7 @@ XWORKMATE_BRIDGE_TASK_FORWARD_TOKEN=$PEER_BRIDGE_AUTH_TOKEN
|
||||
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
|
||||
BRIDGE_WS_URL=wss://xworkmate-bridge.svc.plus/acp
|
||||
BRIDGE_HTTP_RPC_URL=https://xworkmate-bridge.svc.plus/acp/rpc
|
||||
Authorization: Bearer $BRIDGE_AUTH_TOKEN
|
||||
Authorization: Bearer $AI_WORKSPACE_AUTH_TOKEN
|
||||
Origin: https://xworkmate.svc.plus
|
||||
```
|
||||
|
||||
|
||||
17
docs/architecture/README.md
Normal file
17
docs/architecture/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# xworkmate-bridge Architecture
|
||||
|
||||
This directory contains architecture documentation for the **xworkmate-bridge** repository -- the Go-based ACP control plane and bridge backend. This repo is the companion to `xworkmate-app` (Flutter frontend) within the Cloud-Neutral Toolkit ecosystem.
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Description |
|
||||
| --- | --- |
|
||||
| [ACP Forwarding Topology](acp-forwarding-topology.md) | Canonical ACP forwarding topology for bridge runtime |
|
||||
| [ADR: Refocus Bridge as Control Plane](adr-refocus-bridge-as-control-plane.md) | Architecture Decision Record for re-focusing the bridge as the ACP control plane |
|
||||
| [ADR: Unified Bridge Entrypoints](adr-unified-bridge-entrypoints.md) | Architecture Decision Record for unifying APP traffic entry points |
|
||||
| [Bridge Runtime Design](bridge-runtime-design.md) | Converged runtime model for xworkmate-bridge |
|
||||
|
||||
## Related Repos
|
||||
|
||||
- [xworkmate-app](https://github.com/cloud-neutral-toolkit/xworkmate-app) -- Flutter AI assistant frontend
|
||||
- [github-org-cloud-neutral-toolkit](https://github.com/cloud-neutral-toolkit/github-org-cloud-neutral-toolkit) -- cross-repo coordination hub
|
||||
@ -1,6 +1,6 @@
|
||||
# ACP Forwarding Topology
|
||||
|
||||
Last Updated: 2026-05-03
|
||||
Last Updated: 2026-06-02
|
||||
|
||||
本文档只描述当前保留的 canonical topology。
|
||||
|
||||
@ -77,6 +77,35 @@ flowchart LR
|
||||
- provider catalog 与 gatewayProviders 由 bridge 独占生成
|
||||
- bridge 只暴露 canonical ACP contract
|
||||
- provider / gateway 实际地址属于 bridge internal truth
|
||||
- bridge-to-bridge task forward 只使用 WireGuard over VLESS 私网 endpoint,公网域名只作为 ingress
|
||||
|
||||
## Distributed Task Router
|
||||
|
||||
分布式转发是 bridge 内部能力,不改变 app-facing canonical surface。每个 bridge 从静态 peer catalog、forwarding rules 和 routes 得出下一跳:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CN["cn-xworkmate-bridge<br/>edge ingress"]
|
||||
MAIN["xworkmate-bridge<br/>primary / hub"]
|
||||
WA["worker-a<br/>executor"]
|
||||
WB["worker-b<br/>executor"]
|
||||
EU["worker-eu<br/>executor"]
|
||||
|
||||
CN -- "task_forward_peer_id or rule<br/>http://172.29.10.1:8787" --> MAIN
|
||||
MAIN -- "selector role=executor<br/>round_robin" --> WA
|
||||
MAIN -- "selector role=executor<br/>round_robin" --> WB
|
||||
CN -- "route target=worker-eu<br/>next_hop=xworkmate-bridge" --> MAIN
|
||||
MAIN -- "private next hop" --> EU
|
||||
```
|
||||
|
||||
Router contract:
|
||||
|
||||
- `nodes` 保存节点身份、角色、能力、zone 和私网 `bridge_endpoint`
|
||||
- `forwarding.rules` 选择最终 target node
|
||||
- `forwarding.routes` 选择 next-hop,用于星状或显式 mesh
|
||||
- `session.start` 选中 target 后,`session.message` 使用本机 session route store 粘到同一个 target
|
||||
- `X-XWorkmate-Forward-Hop` 受 `forwarding.hop_limit` 限制,避免循环
|
||||
- `https://*.svc.plus` 这类公网域名不能作为 bridge-to-bridge endpoint
|
||||
|
||||
## Non-Contract Facts
|
||||
|
||||
|
||||
@ -109,7 +109,7 @@ Gateway access remains bridge-owned via JSON-RPC methods:
|
||||
|
||||
Upstream authentication is unified for both ACP and gateway routes:
|
||||
|
||||
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
|
||||
- `Authorization: Bearer $AI_WORKSPACE_AUTH_TOKEN`
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@ -272,7 +272,7 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提
|
||||
|
||||
## 8. 线上环境事实
|
||||
|
||||
以下为 2026-05-03 通过 `ssh root@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
|
||||
以下为 2026-06-06 通过 `ssh ubuntu@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
|
||||
|
||||
### Caddy
|
||||
|
||||
@ -292,11 +292,23 @@ Caddy 层要求:
|
||||
Authorization: Bearer $BRIDGE_AUTH_TOKEN
|
||||
```
|
||||
|
||||
如果配置了 Apple review / beta token,Caddy 层也必须接受:
|
||||
|
||||
```http
|
||||
Authorization: Bearer $BRIDGE_REVIEW_AUTH_TOKEN
|
||||
```
|
||||
|
||||
验证标准:
|
||||
|
||||
- 无 token:`401`
|
||||
- 主 token:`200`
|
||||
- review token:`200`
|
||||
|
||||
### Systemd / Local Listeners
|
||||
|
||||
| Unit / Runtime | Listener | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin |
|
||||
| `~/.config/systemd/user/xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin, runs as `ubuntu`, binary at `/home/ubuntu/.local/bin/xworkmate-go-core` |
|
||||
| `acp-codex.service` | `127.0.0.1:9001` | Codex ACP backend |
|
||||
| `acp-gemini.service` | `127.0.0.1:8791` | Gemini adapter |
|
||||
| `acp-hermes.service` | `127.0.0.1:3920` | Hermes adapter |
|
||||
@ -305,7 +317,7 @@ Authorization: Bearer $BRIDGE_AUTH_TOKEN
|
||||
|
||||
这些地址只允许 bridge 内部使用。APP 不保存、不展示、不请求这些地址。
|
||||
|
||||
验证时 `xworkmate-bridge`、`acp-codex`、`acp-gemini`、`acp-hermes`、`acp-opencode` 均为 `active`;`openclaw-gateway.service` 返回 `inactive`,但 `ss` 显示 `openclaw` 进程仍监听 `127.0.0.1:18789` 和 `[::1]:18789`。因此 APP contract 只记录 `openclaw` 作为 `gatewayProviders` 能力,不把 systemd unit 状态作为 APP 可见状态。
|
||||
验证时 `systemctl --user status xworkmate-bridge.service` 为 `active`,系统级 `/etc/systemd/system/xworkmate-bridge.service` 为 `disabled/inactive`。`acp-codex`、`acp-gemini`、`acp-hermes`、`acp-opencode` 仍由现有本机 service 提供。APP contract 只记录 provider/gateway 能力,不把 systemd unit 类型作为 APP 可见状态。
|
||||
|
||||
## 9. 线上验证结果
|
||||
|
||||
|
||||
@ -33,6 +33,10 @@
|
||||
- [Remote Agent Local Workspace Test Matrix](./testing/remote-agent-local-workspace-test-matrix.md)
|
||||
- [Gemini ACP Adapter Notes](./gemini-acp-adapter.md)
|
||||
|
||||
### 5. Runbook
|
||||
|
||||
- [WebRTC Remote Desktop White Screen Runbook](./runbooks/webrtc-remote-desktop-white-screen-runbook.md)
|
||||
|
||||
## 文档组织原则
|
||||
|
||||
- `docs/api-reference.md` 是对外运行契约的唯一真相来源。
|
||||
|
||||
632
docs/runbooks/webrtc-remote-desktop-white-screen-runbook.md
Normal file
632
docs/runbooks/webrtc-remote-desktop-white-screen-runbook.md
Normal file
@ -0,0 +1,632 @@
|
||||
# WebRTC Remote Desktop White Screen Runbook
|
||||
|
||||
本文档用于排查与修复 `xworkmate-bridge` 的 WebRTC 远程桌面频发白屏问题,覆盖编码、RTP、WebRTC 协商、远端部署验证和回滚流程。
|
||||
|
||||
适用范围:
|
||||
|
||||
- Bridge 仓库:`/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge`
|
||||
- 远端主机:`ubuntu@xworkmate-bridge.svc.plus`
|
||||
- user service:`xworkmate-bridge.service`
|
||||
- APP 侧入口:`xworkmate-app` 的 Remote Desktop 面板
|
||||
|
||||
## 目标
|
||||
|
||||
- 把“已连接但无画面 / 长时间等待首帧”拆解到编码、RTP、WebRTC、前端显示四层。
|
||||
- 让 Bridge 输出 browser-friendly H.264:
|
||||
- `baseline` / `constrained-baseline`
|
||||
- `yuv420p` / `I420` / 4:2:0
|
||||
- `zerolatency`
|
||||
- `key-int-max=30`
|
||||
- 定期 SPS / PPS
|
||||
- 在白屏现场拿到可判定证据,而不是只看“connected”状态。
|
||||
|
||||
## 症状定义
|
||||
|
||||
- APP 显示 `已连接`,但视频区域白屏。
|
||||
- APP 显示 `WebRTC 已连接,正在等待远程桌面首帧...`,长时间不消失。
|
||||
- 首次连接偶发成功,断开重连后更容易白屏。
|
||||
|
||||
## 前置检查:先排除账号同步 / 鉴权问题
|
||||
|
||||
如果 APP 还没进入 WebRTC 连接状态,不要直接按白屏处理。下面这些信号说明请求尚未进入远程桌面 offer / RTP 链路:
|
||||
|
||||
- APP 远程桌面面板显示 `已断开`,文案为 `未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。`
|
||||
- APP 账号同步显示 `账号同步状态:失败`
|
||||
- APP 同步说明显示 `Bridge token expired or rejected. Please re-sync the account token.`
|
||||
- APP 关于页显示 Bridge runtime `Status: unauthorized`
|
||||
- Bridge 日志里点击 APP 后没有新的 `Starting Remote Desktop session`、`xworkmate.desktop.offer` 或 `WebRTC RTP stats`
|
||||
|
||||
这类现场的首要结论是:App 侧 managed bridge token 已过期、被拒绝或未重新同步。此时 bridge 服务可能已经是最新版本且运行正常,但 APP 没有可用凭据调用受保护接口。
|
||||
|
||||
2026-06-07 现场确认过一个容易漏掉的变体:Go bridge origin 已接受 `BRIDGE_REVIEW_AUTH_TOKEN`,但 Caddy 公网入口只放行主 `BRIDGE_AUTH_TOKEN`,导致 `review@svc.plus` 走公网 `/api/ping` 和 `/acp/rpc` 返回 `401`。这同样表现为 APP token 被拒绝,且不会进入 WebRTC/RTP 层。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 在 APP 设置页执行 `重新同步`。
|
||||
2. 同步成功后刷新版本信息,确认 Bridge runtime 不再是 `unauthorized`。
|
||||
3. 如果账号是 `review@svc.plus` 或使用 review/beta token,确认公网 Caddy 入口也放行 review token。
|
||||
4. 再回到远程桌面面板点击连接,进入 WebRTC / RTP 排查。
|
||||
|
||||
远端校验方式:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" https://xworkmate-bridge.svc.plus/api/ping
|
||||
'
|
||||
```
|
||||
|
||||
无 token 返回 `401` 是预期结果;不要把它误判为部署失败。要确认服务端版本,需要使用 user service 环境里的 token,且不要把 token 打印到日志或文档:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
TOKEN=$(systemctl --user show -p Environment --value xworkmate-bridge.service |
|
||||
tr " " "\n" |
|
||||
sed -n "s/^BRIDGE_AUTH_TOKEN=//p")
|
||||
curl -sS -H "Authorization: Bearer ${TOKEN}" https://xworkmate-bridge.svc.plus/api/ping
|
||||
unset TOKEN
|
||||
'
|
||||
```
|
||||
|
||||
期望看到 `status=ok`,并且 `commit` 等于最新部署 commit。
|
||||
|
||||
如果配置了 `BRIDGE_REVIEW_AUTH_TOKEN`,必须额外验证公网入口也接受 review token:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
TOKEN=$(systemctl --user show -p Environment --value xworkmate-bridge.service |
|
||||
tr " " "\n" |
|
||||
sed -n "s/^BRIDGE_REVIEW_AUTH_TOKEN=//p")
|
||||
if [ -n "${TOKEN}" ]; then
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
https://xworkmate-bridge.svc.plus/api/ping
|
||||
fi
|
||||
unset TOKEN
|
||||
'
|
||||
```
|
||||
|
||||
期望返回 `200`。如果本机 `127.0.0.1:8787` 返回 `200` 但公网 HTTPS 返回 `401`,问题在 Caddy / ingress token allowlist,不在 WebRTC。
|
||||
|
||||
## 根因判断速查
|
||||
|
||||
### 1. ICE connected,但 Bridge RTP 包不增长
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 日志中 `WebRTC RTP stats: packets=0`
|
||||
- 或 `Capture pipeline exited with error`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 Bridge capture / encoder / 本机 RTP 发送侧,不是公网网络抖动。
|
||||
|
||||
### 2. RTP 包增长,但 APP `packetsReceived` 不增长
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 端 `packets`、`bytes` 持续增长
|
||||
- APP 侧 inbound stats 里 `packetsReceived` 不增长
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 ICE candidate、NAT、TURN、链路可达性或浏览器收包侧。
|
||||
|
||||
### 3. `packetsReceived` 增长,但 `framesDecoded` 不增长
|
||||
|
||||
判断:
|
||||
|
||||
- APP 侧 inbound video stats 里 `packetsReceived > 0`
|
||||
- `framesDecoded == 0`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题高度集中在 H.264 profile / pixel format / SPS/PPS / 解码兼容。
|
||||
|
||||
### 4. `framesDecoded` 增长,但仍白屏
|
||||
|
||||
判断:
|
||||
|
||||
- APP 侧 stats 能看到 decoded frames
|
||||
- UI 仍然空白
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 Flutter renderer / track attach / view lifecycle / stale stream。
|
||||
|
||||
### 5. Bridge RTP 增长,但同一 `sessionId` 被反复 stop/start
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 日志里 `WebRTC RTP stats` 持续增长,`writeErrors=0`
|
||||
- 同一时间段频繁出现:
|
||||
|
||||
```text
|
||||
Stopping Remote Desktop session: remote-desktop-session
|
||||
Starting Remote Desktop session: remote-desktop-session
|
||||
Closing WebRTC server...
|
||||
```
|
||||
|
||||
- 本机同时存在多个 `XWorkmate` 进程,或快速断开 / 重连 / 重开窗口
|
||||
- APP 仍停在 `WebRTC 已连接,正在等待远程桌面首帧...`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题不是编码器或公网 RTP 发送层,而是客户端会话抢占 / stale PeerConnection。
|
||||
- APP 旧版本固定使用 `remote-desktop-session`,多个 app 实例或重连会互相关闭同一个远端 desktop session。被抢占的客户端可能还短暂保持 `connected` 状态,但远端 RTP pipeline 已经被新 offer 替换,表现为等待首帧。
|
||||
- 修复方式是 APP 每个 DesktopView / PeerConnection 使用唯一 desktop session id,并且 video-only desktop offer 不再声明无用 audio recvonly transceiver。
|
||||
|
||||
### 6. 画面已稳定,但远程桌面操作不流畅
|
||||
|
||||
判断:
|
||||
|
||||
- 视频区域能显示远程桌面,白屏明显缓解
|
||||
- Bridge 端 `WebRTC RTP stats` 连续增长,`writeErrors=0`
|
||||
- APP 操作表现为鼠标跟手性差、点击延迟、拖动不顺、键盘输入偶发滞后
|
||||
- Bridge 日志中没有 `Capture pipeline exited with error`,也没有明显 RTP write error
|
||||
|
||||
说明:
|
||||
|
||||
- 此时不要继续优先怀疑 H.264 profile 或 RTP 发送层。应转向输入链路排查:
|
||||
- APP `PointerHoverEvent` / `PointerMoveEvent` 是否把每个移动事件都发到 data channel
|
||||
- WebRTC data channel 是否出现 `bufferedAmount` 堆积
|
||||
- Bridge data channel 收到的 input 事件是否能及时写入 persistent `xdotool`
|
||||
- `xdotool` stdin 写失败后的恢复路径是否会卡住 injector
|
||||
- 鼠标移动是可丢弃的高频状态事件,应以“最新位置优先”为原则;点击、键盘、滚轮是关键动作,不能因背压被丢弃。
|
||||
- 修复方式是 APP 对 mouse move 做节流 / 去重 / 背压丢弃,Bridge 修复 xdotool 写失败后的恢复死锁。
|
||||
|
||||
## 本次修复结论
|
||||
|
||||
本次真实根因不是单纯网络延迟,而是两段串联问题:
|
||||
|
||||
1. 旧 GStreamer pipeline 输出 `high-4:4:4` H.264,`profile-level-id=f40020`,对 WebRTC / browser 解码不友好。
|
||||
2. 修成 `I420` 后暴露远端桌面真实尺寸 `1352x847`,高度为奇数,4:2:0 编码链路无法稳定工作,pipeline 直接退出,导致 RTP 始终为 0。
|
||||
|
||||
本次二次排查还发现一个入口层问题:
|
||||
|
||||
3. Caddy 公网入口曾只放行主 `BRIDGE_AUTH_TOKEN`,未放行 user service 中的 `BRIDGE_REVIEW_AUTH_TOKEN`。因此 `review@svc.plus` 会看到 `Bridge token expired or rejected`,并且无法发起 `xworkmate.desktop.offer`。
|
||||
|
||||
2026-06-08 复发排查结论:
|
||||
|
||||
4. Bridge 运行版本 `v1.0-beta2` / commit `0a0d04f` 的 H.264 和 RTP 发送链路是健康的:远端日志确认 `format=(string)I420`、`profile=(string)baseline`、`profile-level-id=(string)42c01f`,并且 `WebRTC RTP stats` 持续增长、`writeErrors=0`。
|
||||
5. 本机同时运行了多个 `XWorkmate` 进程,且 APP 侧仍使用固定 `sessionId='remote-desktop-session'`。远端日志在同一时间段反复出现同一个 session 的 stop/start,说明新连接抢占并关闭旧 PeerConnection / capture pipeline。这个链路会让被抢占的 APP 视图停在“WebRTC 已连接,正在等待远程桌面首帧...”,属于客户端会话生命周期问题。
|
||||
6. 同步修复 APP:为每个 DesktopView 生成唯一 `remote-desktop-*` session id,并将 desktop SDP offer 简化为 video recvonly,避免 video-only Bridge 被无用 audio m-line 干扰。
|
||||
|
||||
2026-06-09 操作不流畅排查结论:
|
||||
|
||||
7. 白屏缓解后,远端日志显示视频发送链路仍然稳定:`WebRTC RTP stats` 每 5 秒约 1100-1200 包,`byteDelta` 约 0.95MB,`writeErrors=0`,没有新的 H.264 profile / I420 / RTP 发送异常。
|
||||
8. 操作不流畅的高概率链路转移到输入通道:APP 侧鼠标 hover / move 事件过密,容易把 data channel 塞成旧轨迹队列;Bridge 侧虽然已有 16ms mouse move worker,但 APP 仍会持续发送大量可丢弃的中间位置。
|
||||
9. 还发现 Bridge `xdotool` stdin 写失败恢复路径存在风险:旧代码持有 injector mutex 时调用 `Start()`,`Start()` 会再次获取同一把锁。一旦 xdotool pipe 异常,输入注入可能死锁,表现为画面仍动但远程操作卡住。
|
||||
10. 同步修复 APP 与 Bridge:APP 对 mouse move 做 16ms 节流、坐标去重、data channel 背压时只丢弃 mouse move;Bridge 修复 xdotool 写失败后的无锁重启路径。
|
||||
|
||||
修复后的稳定策略:
|
||||
|
||||
- 强制把 capture 输出转换到 `I420`
|
||||
- 强制缩放到偶数分辨率
|
||||
- H.264 限制为 `baseline`
|
||||
- `rtph264pay config-interval=1`
|
||||
- `x264enc` 启用 `zerolatency`
|
||||
- Bridge 定期输出 RTP stats
|
||||
- APP 在等待首帧时输出 inbound video stats
|
||||
- APP 每个远程桌面视图使用唯一 desktop session id,避免多实例 / 重连抢占同一个 Bridge session
|
||||
- APP desktop offer 只声明 video recvonly transceiver,避免无用 audio m-line 增加协商不确定性
|
||||
- APP mouse move 事件按约 60fps 节流并去重,点击前强制同步最新坐标
|
||||
- APP data channel 发生背压时只丢弃过期 `mouse_move`,不丢 `mouse_down` / `mouse_up` / `key_*` / `scroll`
|
||||
- Bridge persistent `xdotool` 写失败时必须释放 mutex 后再重启 injector,避免输入链路死锁
|
||||
- Caddy 公网入口同时放行主 token 与 review token,并验证无 token 仍为 `401`
|
||||
- 只保留 user service 作为当前 bridge origin,避免 system service 与 user service 抢占 `127.0.0.1:8787`
|
||||
|
||||
注意:如果 APP 当前显示 `Bridge token expired or rejected` 或 Bridge runtime `unauthorized`,这是鉴权前置问题,不是本次 H.264 / RTP 白屏根因的复发。必须先重新同步账号 token,再验证 WebRTC 链路。
|
||||
|
||||
## 代码落点
|
||||
|
||||
Bridge:
|
||||
|
||||
- [internal/desktop/pipeline.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/pipeline.go)
|
||||
- [internal/desktop/webrtc.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/webrtc.go)
|
||||
- [internal/desktop/pipeline_test.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/pipeline_test.go)
|
||||
|
||||
APP 诊断:
|
||||
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_client.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_input_handler.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_view.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/test/features/desktop/desktop_client_test.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/test/features/desktop/desktop_input_handler_test.dart`
|
||||
|
||||
2026-06-08 复发修复 APP 落点:
|
||||
|
||||
- `desktop_client.dart`:新增唯一 desktop session id helper;desktop offer 只添加 video recvonly transceiver。
|
||||
- `desktop_view.dart`:不再硬编码 `remote-desktop-session`。
|
||||
- `desktop_client_test.dart`:覆盖并行 app 实例生成不同 session id。
|
||||
|
||||
2026-06-09 操作流畅度修复落点:
|
||||
|
||||
- APP `desktop_input_handler.dart`:mouse move / hover 按 16ms 节流,重复坐标去重,点击前强制发送最新坐标。
|
||||
- APP `desktop_client.dart`:data channel `bufferedAmount` 超过阈值时只丢弃过期 `mouse_move`。
|
||||
- APP `desktop_input_handler_test.dart` / `desktop_client_test.dart`:覆盖节流、去重、点击前坐标同步与背压丢弃策略。
|
||||
- Bridge `internal/desktop/input.go`:修复 xdotool stdin 写失败后恢复路径的 mutex 重入死锁。
|
||||
|
||||
## 期望日志
|
||||
|
||||
健康的编码与 RTP 发送链路应该出现下面这类信号:
|
||||
|
||||
```text
|
||||
Starting capture pipeline: gst-launch-1.0 ... videoconvert ! videoscale ! video/x-raw,format=I420,width=1280,height=720,framerate=30/1 ! x264enc ... tune=zerolatency ... key-int-max=30 ! video/x-h264,profile=baseline ! rtph264pay config-interval=1 pt=96
|
||||
... GstVideoScale: caps = video/x-raw, width=(int)1280, height=(int)720, format=(string)I420
|
||||
... GstX264Enc: caps = video/x-h264 ... profile=(string)baseline
|
||||
... GstRtpH264Pay: caps = application/x-rtp ... profile-level-id=(string)42c01f, profile=(string)constrained-baseline
|
||||
WebRTC RTP stats: packets=1471 bytes=1236907 packetDelta=1471 byteDelta=1236907 writeErrors=0
|
||||
```
|
||||
|
||||
不健康的旧信号:
|
||||
|
||||
```text
|
||||
profile=(string)high-4:4:4
|
||||
profile-level-id=(string)f40020
|
||||
Capture pipeline exited with error: exit status 1
|
||||
WebRTC RTP stats: packets=0 bytes=0
|
||||
```
|
||||
|
||||
## 远端部署步骤
|
||||
|
||||
### 1. 本地测试
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
go test ./internal/desktop ./internal/acp
|
||||
```
|
||||
|
||||
如果 APP 侧同步有诊断改动,同时执行:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app
|
||||
flutter test test/features/desktop/desktop_client_test.dart
|
||||
flutter analyze lib/features/desktop/desktop_client.dart lib/features/desktop/desktop_view.dart test/features/desktop/desktop_client_test.dart
|
||||
```
|
||||
|
||||
### 2. 构建 Linux binary
|
||||
|
||||
远端服务运行在 Linux x86_64。不要把 macOS 本机构建物直接部署到远端,否则会出现 `Exec format error`。
|
||||
|
||||
推荐命令:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 OUTPUT_PATH=build/bin/xworkmate-go-core-linux-amd64 make build
|
||||
file build/bin/xworkmate-go-core-linux-amd64
|
||||
```
|
||||
|
||||
### 3. 部署远端
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
scripts/github-actions/deploy-native-binary.sh xworkmate-bridge.svc.plus build/bin/xworkmate-go-core-linux-amd64 <short-commit>
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 恢复 `BRIDGE_AUTH_TOKEN`
|
||||
- 保留/恢复 `BRIDGE_REVIEW_AUTH_TOKEN`
|
||||
- 上传 binary 到远端
|
||||
- 安装到 `/home/ubuntu/.local/bin/xworkmate-go-core`
|
||||
- 重启 `systemctl --user` 的 `xworkmate-bridge.service`
|
||||
- 校验部署后的 commit
|
||||
|
||||
### 4. 确认远端版本
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
systemctl --user --no-pager --full status xworkmate-bridge
|
||||
/home/ubuntu/.local/bin/xworkmate-go-core version
|
||||
'
|
||||
```
|
||||
|
||||
期望看到:
|
||||
|
||||
- `Active: active (running)`
|
||||
- `commit` 等于刚部署的提交
|
||||
|
||||
GitHub Actions 发布成功只能说明 CI/CD 完成。现场仍要确认远端二进制和 HTTPS API:
|
||||
|
||||
```bash
|
||||
gh run view 27095512721 --repo ai-workspace-lab/xworkmate-bridge
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '/home/ubuntu/.local/bin/xworkmate-go-core version'
|
||||
```
|
||||
|
||||
如果 APP 关于页仍显示 `unauthorized`,继续执行“前置检查:先排除账号同步 / 鉴权问题”,不要进入 RTP 判定。
|
||||
|
||||
### 5. 确认 Caddy / service 没有漂移
|
||||
|
||||
当前生产形态以 user service 为准:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
echo "system=$(systemctl is-active xworkmate-bridge.service 2>/dev/null || true)"
|
||||
echo "user=$(systemctl --user is-active xworkmate-bridge.service)"
|
||||
'
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
- `system=inactive`
|
||||
- `user=active`
|
||||
|
||||
如果 system service 处于 `activating` / `failed` / 自动重启,可能与 user service 抢占 `127.0.0.1:8787`,需要先清理 system service 冲突。
|
||||
|
||||
## 白屏排查步骤
|
||||
|
||||
### A. 先看服务与版本
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
systemctl --user --no-pager --full status xworkmate-bridge
|
||||
ps -fp $(systemctl --user show -p MainPID --value xworkmate-bridge.service)
|
||||
'
|
||||
```
|
||||
|
||||
同时确认 APP 侧账号同步已成功。如果 APP 显示 `Bridge token expired or rejected`,先重新同步账号 token。此时日志里通常不会出现新的远程桌面 session,说明请求没有进入 WebRTC 层。
|
||||
|
||||
### B. 跟日志
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
journalctl --user -f -u xworkmate-bridge
|
||||
'
|
||||
```
|
||||
|
||||
重点观察:
|
||||
|
||||
- `Starting Remote Desktop session`
|
||||
- `Starting capture pipeline`
|
||||
- `Capture pipeline exited with error`
|
||||
- `profile=(string)baseline`
|
||||
- `profile-level-id=(string)42c01f`
|
||||
- `WebRTC RTP stats`
|
||||
- `WebRTC RTP final stats`
|
||||
|
||||
### C. 连接 APP,重复执行
|
||||
|
||||
建议至少跑三轮:
|
||||
|
||||
1. 首次连接
|
||||
2. 断开再连接
|
||||
3. 快速重连
|
||||
|
||||
如果问题只在重连出现,要重点看:
|
||||
|
||||
- 旧 session 是否被 `Stopping Remote Desktop session` 正常清理
|
||||
- 旧 pipeline 是否退出
|
||||
- 新 session 是否出现新的 RTP stats 增长
|
||||
|
||||
### D. 判定编码是否兼容
|
||||
|
||||
以下信号说明编码兼容性已进入 WebRTC 友好区间:
|
||||
|
||||
- `format=(string)I420`
|
||||
- `profile=(string)baseline`
|
||||
- `profile-level-id=(string)42c01f`
|
||||
- `packetization-mode=(string)1`
|
||||
- `config-interval=1`
|
||||
|
||||
### E. 判定 RTP 是否真的在发
|
||||
|
||||
看 `WebRTC RTP stats`:
|
||||
|
||||
- `packetDelta > 0`
|
||||
- `byteDelta > 0`
|
||||
- `writeErrors=0`
|
||||
|
||||
如果这些值连续多个周期都不增长:
|
||||
|
||||
- 优先查 GStreamer / FFmpeg capture 是否退出
|
||||
- 再查 display / X11 / encoder 参数
|
||||
|
||||
## APP 侧 stats 判读
|
||||
|
||||
APP 在等待首帧时会定期打印 inbound video stats 摘要。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `packetsReceived`
|
||||
- `bytesReceived`
|
||||
- `framesDecoded`
|
||||
- `framesDropped`
|
||||
- `keyFramesDecoded`
|
||||
- `jitter`
|
||||
- `jitterBufferDelay`
|
||||
|
||||
判读:
|
||||
|
||||
- `packetsReceived == 0`
|
||||
- Bridge 在发,但对端没收到,优先查 ICE / candidate / 网络。
|
||||
- `packetsReceived > 0 && framesDecoded == 0`
|
||||
- 收到 RTP 但解码不了,优先查 H.264 profile / SPS/PPS / browser 兼容。
|
||||
- `framesDecoded > 0`
|
||||
- 编码与网络基本通,继续查 renderer / stale stream / attach。
|
||||
|
||||
## 操作流畅度链路判读
|
||||
|
||||
当画面已经出来,但远程桌面操作不跟手时,按下面顺序排查。
|
||||
|
||||
### 1. 先确认视频链路不是主因
|
||||
|
||||
远端日志应满足:
|
||||
|
||||
```text
|
||||
WebRTC RTP stats: packets=... packetDelta=1100 byteDelta=950000 writeErrors=0
|
||||
WebRTC RTP stats: packets=... packetDelta=1100 byteDelta=950000 writeErrors=0
|
||||
```
|
||||
|
||||
判读:
|
||||
|
||||
- `packetDelta` 和 `byteDelta` 连续增长,且 `writeErrors=0`
|
||||
- 视频 capture / encoder / RTP 写入 WebRTC track 基本健康。
|
||||
- `packetDelta` 大幅抖动、连续归零或 `writeErrors > 0`
|
||||
- 先回到 RTP / encoder / PeerConnection 层排查,不要先改输入。
|
||||
|
||||
### 2. 再看 APP 输入事件是否过密
|
||||
|
||||
高频鼠标移动属于状态同步,不应排队传输所有中间点。APP 侧应满足:
|
||||
|
||||
- `mouse_move` / hover 约 16ms 最多发送一次
|
||||
- 同一归一化坐标重复移动不发送
|
||||
- `mouse_down` 前强制发送最新坐标,保证点击命中
|
||||
- `mouse_move` 走独立 `input-move` data channel,配置为 unordered + 短 packet lifetime
|
||||
- 点击、键盘、滚轮走可靠有序 `input` data channel
|
||||
- `input-move` channel `bufferedAmount` 过高时只丢弃 `mouse_move`
|
||||
- `mouse_down`、`mouse_up`、`key_down`、`key_up`、`scroll` 不因背压丢弃
|
||||
|
||||
判读:
|
||||
|
||||
- 鼠标轨迹延迟但点击最终准确,通常是 `input-move` 背压或 Bridge 侧 mouse move coalescing 积压。
|
||||
- 点击也延迟或丢失,要查 data channel 状态、Bridge input injector、xdotool 写入。
|
||||
|
||||
### 3. 再看 Bridge input injector
|
||||
|
||||
Bridge 侧输入链路是:
|
||||
|
||||
```text
|
||||
APP Pointer/Key event -> WebRTC input/input-move data channel -> Bridge OnDataChannel -> XdotoolInjector.Inject -> persistent xdotool stdin -> X11
|
||||
```
|
||||
|
||||
重点日志:
|
||||
|
||||
```text
|
||||
Data channel 'input'-'...' opened
|
||||
Data channel 'input-move'-'...' opened
|
||||
xdotool write error: ...
|
||||
xdotool mousemove write error: ...
|
||||
```
|
||||
|
||||
判读:
|
||||
|
||||
- 有 `Data channel opened`,但操作没反应:
|
||||
- 优先查 xdotool 是否写失败、DISPLAY 是否解析正确、X11 session 是否仍可注入。
|
||||
- 出现 `xdotool write error` 后操作完全卡住:
|
||||
- 检查 Bridge 是否包含 2026-06-09 的修复:写失败后释放 mutex,再调用 `Start()` 重启 injector。
|
||||
- 没有 xdotool 错误,RTP 也健康,但操作仍延迟:
|
||||
- 优先查 APP data channel 背压与 mouse move 发送频率。
|
||||
|
||||
## 现场验证模板
|
||||
|
||||
### 一次健康验证应满足
|
||||
|
||||
1. Bridge answer / RTP caps 中 H264 协商属于 baseline family:
|
||||
|
||||
```text
|
||||
profile-level-id=42c01f
|
||||
packetization-mode=1
|
||||
```
|
||||
|
||||
说明:`profile-level-id` 以 `42` 开头是 baseline family 的关键特征。现场日志中 `rtph264pay` 常见值为 `42c01f`;不应再出现旧的 `f40020`。
|
||||
|
||||
2. GStreamer caps 中包含:
|
||||
|
||||
```text
|
||||
format=(string)I420
|
||||
profile=(string)baseline
|
||||
width=(int)1280
|
||||
height=(int)720
|
||||
```
|
||||
|
||||
3. RTP 统计连续增长:
|
||||
|
||||
```text
|
||||
WebRTC RTP stats: packets=1471 ...
|
||||
WebRTC RTP stats: packets=2977 ...
|
||||
WebRTC RTP stats: packets=4496 ...
|
||||
```
|
||||
|
||||
4. 结束会话时看到 final stats:
|
||||
|
||||
```text
|
||||
WebRTC RTP final stats: packets=9470 bytes=7962965 writeErrors=0
|
||||
```
|
||||
|
||||
5. 操作流畅度验证:
|
||||
|
||||
- 鼠标快速移动 10 秒,远端指针应跟随最新位置,不应明显执行旧轨迹。
|
||||
- 快速点击按钮 / 菜单,点击前坐标应准确同步,不应出现点击偏移。
|
||||
- 连续输入文本,键盘事件不应因为鼠标 move 背压被丢弃。
|
||||
- Bridge 日志不应出现新的 `xdotool write error` 或 `xdotool mousemove write error`。
|
||||
- 如果远端日志中 RTP 持续健康但操作仍卡,继续抓 APP data channel `bufferedAmount` 与 input event 发送频率。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. `cannot execute binary file: Exec format error`
|
||||
|
||||
原因:
|
||||
|
||||
- 把 macOS binary 部署到了 Linux 远端。
|
||||
|
||||
修复:
|
||||
|
||||
- 用 `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` 重新构建。
|
||||
|
||||
### 2. `Capture pipeline exited with error: exit status 1`
|
||||
|
||||
高概率原因:
|
||||
|
||||
- 4:2:0 输入尺寸为奇数
|
||||
- profile / format 约束与实际 caps 冲突
|
||||
|
||||
修复:
|
||||
|
||||
- 强制 `videoscale`
|
||||
- 输出归一化到偶数 `width/height`
|
||||
|
||||
### 3. 旧 service inactive,但 8787 仍被占用
|
||||
|
||||
说明:
|
||||
|
||||
- 需要区分 system service 和 user service
|
||||
- 以 `systemctl --user status xworkmate-bridge` 为准
|
||||
|
||||
### 4. `packetsReceived` 增长但仍白屏
|
||||
|
||||
优先看:
|
||||
|
||||
- `framesDecoded`
|
||||
- `videoWidth` / `videoHeight`
|
||||
- 前端 renderer 是否拿到首帧
|
||||
|
||||
### 5. APP 显示 `Bridge token expired or rejected`
|
||||
|
||||
说明:
|
||||
|
||||
- 这是账号同步 / token 前置问题,不是 H.264 编码或 RTP 发送问题。
|
||||
- Bridge 可能已经部署到最新 commit,且带 token 的 `/api/ping` 正常。
|
||||
- APP 不会成功发起 `xworkmate.desktop.offer`,所以 Bridge 日志中不会出现新的 desktop session。
|
||||
- 对 `review@svc.plus`,还要确认 Caddy 公网入口接受 `BRIDGE_REVIEW_AUTH_TOKEN`。只测 origin `127.0.0.1:8787` 不够。
|
||||
|
||||
修复:
|
||||
|
||||
- 在 APP 设置页点击 `重新同步`。
|
||||
- 刷新版本信息,确认 Bridge runtime `Status` 不再是 `unauthorized`。
|
||||
- 确认公网 `/api/ping` 对主 token 和 review token 都返回 `200`,无 token 返回 `401`。
|
||||
- 再重新连接远程桌面并观察 RTP / stats。
|
||||
|
||||
## 回滚
|
||||
|
||||
1. 用上一个稳定 commit 重新构建 Linux binary。
|
||||
2. 重新执行部署脚本。
|
||||
3. 重启 user service。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
git checkout <stable-commit>
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 OUTPUT_PATH=build/bin/xworkmate-go-core-linux-amd64 make build
|
||||
scripts/github-actions/deploy-native-binary.sh xworkmate-bridge.svc.plus build/bin/xworkmate-go-core-linux-amd64 <stable-commit>
|
||||
```
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
- 新增 WebRTC 白屏修复时,优先补本 runbook,不要只留在聊天记录里。
|
||||
- 新增关键日志时,必须说明默认是否开启、是否限频、如何关闭。
|
||||
- 如果 H.264 参数再调整,务必同步更新:
|
||||
- 期望 profile
|
||||
- 期望 pixel format
|
||||
- RTP 判读标准
|
||||
- 远端部署命令
|
||||
@ -17,28 +17,43 @@ openclaw_gateway:
|
||||
max_queued: 20
|
||||
queue_timeout: "10m"
|
||||
|
||||
# Lightweight distributed bridge forwarding.
|
||||
# A dual-node topology can keep both bridge nodes explicit while only enabling
|
||||
# task forwarding from the node that sets task_forward_peer_id.
|
||||
# Public plaintext HTTP is rejected; use HTTPS on public paths, or HTTP only on
|
||||
# loopback/private/link-local VPN addresses where the tunnel provides encryption.
|
||||
# bridge_endpoint is the peer bridge base URL; /gateway/openclaw or /acp/rpc is
|
||||
# appended from the inbound request path when forwarding is enabled.
|
||||
# Lightweight distributed bridge task router.
|
||||
# Public domains are ingress only. Bridge-to-bridge forwarding must use HTTP
|
||||
# loopback/private/link-local endpoints protected by WireGuard over VLESS or an
|
||||
# equivalent private transport.
|
||||
distributed:
|
||||
topology: "dual-node"
|
||||
local_node_id: "cn-xworkmate-bridge"
|
||||
# Dual-node shorthand. Equivalent to a forwarding rule that sends
|
||||
# session.start/session.message to xworkmate-bridge.
|
||||
task_forward_peer_id: "xworkmate-bridge"
|
||||
nodes:
|
||||
- id: "xworkmate-bridge"
|
||||
role: "primary"
|
||||
zone: "global"
|
||||
public_base_url: "https://xworkmate-bridge.svc.plus"
|
||||
bridge_endpoint: "http://172.29.10.1:8787"
|
||||
capabilities: ["openclaw", "codex"]
|
||||
- id: "cn-xworkmate-bridge"
|
||||
role: "edge"
|
||||
zone: "cn"
|
||||
public_base_url: "https://cn-xworkmate-bridge.svc.plus"
|
||||
bridge_endpoint: "http://172.29.10.2:8787"
|
||||
# Optional direct override. Leave empty when using the topology above.
|
||||
task_forward_endpoint: ""
|
||||
capabilities: ["ingress"]
|
||||
forwarding:
|
||||
hop_limit: 3
|
||||
default_action: "execute_local"
|
||||
# For multi-node/star/mesh mode, prefer explicit rules over task_forward_peer_id:
|
||||
# rules:
|
||||
# - methods: ["session.start", "session.message"]
|
||||
# target:
|
||||
# selector:
|
||||
# role: "executor"
|
||||
# capability: "openclaw"
|
||||
# strategy: "round_robin"
|
||||
# routes:
|
||||
# - target_node_id: "worker-eu"
|
||||
# next_hop_node_id: "xworkmate-bridge"
|
||||
# Optional. Defaults to BRIDGE_AUTH_TOKEN when omitted.
|
||||
task_forward_token: ""
|
||||
|
||||
@ -55,5 +70,5 @@ notes:
|
||||
- The bridge reads its own auth token from BRIDGE_AUTH_TOKEN.
|
||||
- Upstream URLs can include sub-paths which will be preserved during WebSocket handshake.
|
||||
- Multi-agent and Single-agent modes will use these endpoints to delegate tasks.
|
||||
- Distributed task forwarding rejects public plaintext HTTP; use HTTPS or private VPN HTTP(S) endpoints.
|
||||
- task_forward_endpoint can point at a WireGuard-over-VLESS/TCP/TLS local/private bridge address for GFW or carrier UDP blocking environments.
|
||||
- Distributed task forwarding rejects public endpoints; bridge_endpoint must be private/VPN HTTP.
|
||||
- task_forward_peer_id is the dual-node shorthand; forwarding.rules/routes express multi-node, star, and mesh topologies.
|
||||
|
||||
25
go.mod
25
go.mod
@ -5,3 +5,28 @@ go 1.25.0
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.3 // indirect
|
||||
github.com/pion/ice/v4 v4.2.7 // indirect
|
||||
github.com/pion/interceptor v0.1.45 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.10.2 // indirect
|
||||
github.com/pion/sctp v1.10.0 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.11 // indirect
|
||||
github.com/pion/stun/v3 v3.1.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/turn/v5 v5.0.7 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.14 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
)
|
||||
|
||||
44
go.sum
44
go.sum
@ -1,5 +1,49 @@
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||
github.com/pion/dtls/v3 v3.1.3 h1:OA6J5UCeA8DvRXD8ofaMnlNPXN3ISBLHHJ9P8SWL09E=
|
||||
github.com/pion/dtls/v3 v3.1.3/go.mod h1:GEwid4EzCcakfrNvHXM7bs6ci2mASI5Y5Q4tbtLFuWs=
|
||||
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
|
||||
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
|
||||
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
|
||||
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
|
||||
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
|
||||
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
||||
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
|
||||
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||
github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY=
|
||||
github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.7 h1:cA4zPYZR/tS1qZqOi5myHSQ+cwPENCvY8T/wMloP8Tg=
|
||||
github.com/pion/turn/v5 v5.0.7/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU=
|
||||
github.com/pion/webrtc/v4 v4.2.14 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc=
|
||||
github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
BIN
internal/acp/acp.test
Executable file
BIN
internal/acp/acp.test
Executable file
Binary file not shown.
@ -13,20 +13,13 @@ type CapabilityCatalog struct {
|
||||
ProviderProbeSummary []any `json:"providerProbeSummary"`
|
||||
}
|
||||
|
||||
func (c *CapabilityCatalog) Update(providers []any, targets []any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ProviderCatalog = providers
|
||||
c.AvailableExecutionTargets = targets
|
||||
}
|
||||
|
||||
func (c *CapabilityCatalog) Get() map[string]any {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
result := map[string]any{
|
||||
"singleAgent": true,
|
||||
"multiAgent": true,
|
||||
"multiAgent": false,
|
||||
"providerCatalog": append([]any(nil), c.ProviderCatalog...),
|
||||
"gatewayProviders": append([]any(nil), c.GatewayProviders...),
|
||||
"availableExecutionTargets": append([]any(nil), c.AvailableExecutionTargets...),
|
||||
@ -34,7 +27,7 @@ func (c *CapabilityCatalog) Get() map[string]any {
|
||||
}
|
||||
result["capabilities"] = map[string]any{
|
||||
"single_agent": true,
|
||||
"multi_agent": true,
|
||||
"multi_agent": false,
|
||||
"providerCatalog": append([]any(nil), c.ProviderCatalog...),
|
||||
"gatewayProviders": append([]any(nil), c.GatewayProviders...),
|
||||
"availableExecutionTargets": append([]any(nil), c.AvailableExecutionTargets...),
|
||||
|
||||
@ -29,25 +29,57 @@ type BridgeConfig struct {
|
||||
}
|
||||
|
||||
type DistributedConfig struct {
|
||||
TaskForwardEndpoint string `yaml:"task_forward_endpoint"`
|
||||
TaskForwardToken string `yaml:"task_forward_token"`
|
||||
Topology string `yaml:"topology"`
|
||||
LocalNodeID string `yaml:"local_node_id"`
|
||||
TaskForwardPeerID string `yaml:"task_forward_peer_id"`
|
||||
Nodes []DistributedNodeConfig `yaml:"nodes"`
|
||||
TaskForwardToken string `yaml:"task_forward_token"`
|
||||
Topology string `yaml:"topology"`
|
||||
LocalNodeID string `yaml:"local_node_id"`
|
||||
TaskForwardPeerID string `yaml:"task_forward_peer_id"`
|
||||
Nodes []DistributedNodeConfig `yaml:"nodes"`
|
||||
Forwarding DistributedForwardingConfig `yaml:"forwarding"`
|
||||
}
|
||||
|
||||
type DistributedNodeConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Role string `yaml:"role"`
|
||||
PublicBaseURL string `yaml:"public_base_url"`
|
||||
BridgeEndpoint string `yaml:"bridge_endpoint"`
|
||||
ID string `yaml:"id"`
|
||||
Role string `yaml:"role"`
|
||||
Zone string `yaml:"zone"`
|
||||
PublicBaseURL string `yaml:"public_base_url"`
|
||||
BridgeEndpoint string `yaml:"bridge_endpoint"`
|
||||
Capabilities []string `yaml:"capabilities"`
|
||||
}
|
||||
|
||||
type DistributedForwardingConfig struct {
|
||||
HopLimit int `yaml:"hop_limit"`
|
||||
DefaultAction string `yaml:"default_action"`
|
||||
Rules []DistributedForwardRuleConfig `yaml:"rules"`
|
||||
Routes []DistributedRouteConfig `yaml:"routes"`
|
||||
}
|
||||
|
||||
type DistributedForwardRuleConfig struct {
|
||||
Methods []string `yaml:"methods"`
|
||||
Target DistributedForwardTargetConfig `yaml:"target"`
|
||||
}
|
||||
|
||||
type DistributedForwardTargetConfig struct {
|
||||
NodeID string `yaml:"node_id"`
|
||||
Selector DistributedForwardSelectorConfig `yaml:"selector"`
|
||||
Strategy string `yaml:"strategy"`
|
||||
}
|
||||
|
||||
type DistributedForwardSelectorConfig struct {
|
||||
Role string `yaml:"role"`
|
||||
Zone string `yaml:"zone"`
|
||||
Capability string `yaml:"capability"`
|
||||
}
|
||||
|
||||
type DistributedRouteConfig struct {
|
||||
TargetNodeID string `yaml:"target_node_id"`
|
||||
NextHopNodeID string `yaml:"next_hop_node_id"`
|
||||
}
|
||||
|
||||
type OpenClawGatewayConfig struct {
|
||||
MaxActive *int `yaml:"max_active"`
|
||||
MaxQueued *int `yaml:"max_queued"`
|
||||
QueueTimeout string `yaml:"queue_timeout"`
|
||||
MaxActive *int `yaml:"max_active"`
|
||||
MaxQueued *int `yaml:"max_queued"`
|
||||
QueueTimeout string `yaml:"queue_timeout"`
|
||||
MaxAllowedSilentDuration string `yaml:"max_allowed_silent_duration"`
|
||||
}
|
||||
|
||||
func loadBridgeConfig() *BridgeConfig {
|
||||
@ -77,27 +109,43 @@ func resolveURL(yamlVal string, envKeys ...string) string {
|
||||
}
|
||||
|
||||
func bridgeUpstreamAuthorizationHeader() string {
|
||||
token := bridgeSharedAuthToken()
|
||||
token := bridgePublicAuthToken()
|
||||
if token != "" && !strings.HasPrefix(strings.ToLower(token), "bearer ") {
|
||||
return "Bearer " + token
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func bridgeSharedAuthToken() string {
|
||||
func bridgePublicAuthToken() string {
|
||||
if token := strings.TrimSpace(os.Getenv("AI_WORKSPACE_AUTH_TOKEN")); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
|
||||
}
|
||||
|
||||
func resolveDistributedTaskForwardEndpoint(config *BridgeConfig) string {
|
||||
yamlVal := ""
|
||||
if config != nil {
|
||||
yamlVal = config.Distributed.TaskForwardEndpoint
|
||||
func bridgeSharedAuthToken() string {
|
||||
return bridgePublicAuthToken()
|
||||
}
|
||||
|
||||
func bridgeInboundAuthTokens() []string {
|
||||
var tokens []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, token := range []string{
|
||||
os.Getenv("AI_WORKSPACE_AUTH_TOKEN"),
|
||||
os.Getenv("BRIDGE_AUTH_TOKEN"),
|
||||
os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN"),
|
||||
} {
|
||||
trimmed := strings.TrimSpace(token)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
tokens = append(tokens, trimmed)
|
||||
}
|
||||
return resolveURL(
|
||||
firstNonEmpty(yamlVal, resolveDistributedTopologyTaskForwardEndpoint(config)),
|
||||
"XWORKMATE_BRIDGE_TASK_FORWARD_ENDPOINT",
|
||||
"BRIDGE_TASK_FORWARD_ENDPOINT",
|
||||
)
|
||||
return tokens
|
||||
}
|
||||
|
||||
func resolveDistributedTaskForwardToken(config *BridgeConfig) string {
|
||||
@ -115,38 +163,21 @@ func resolveDistributedTaskForwardToken(config *BridgeConfig) string {
|
||||
return bridgeSharedAuthToken()
|
||||
}
|
||||
|
||||
func resolveDistributedTopologyTaskForwardEndpoint(config *BridgeConfig) string {
|
||||
if config == nil {
|
||||
return ""
|
||||
func defaultDistributedNodes() []DistributedNodeConfig {
|
||||
return []DistributedNodeConfig{
|
||||
{
|
||||
ID: "xworkmate-bridge",
|
||||
Role: "primary",
|
||||
PublicBaseURL: "https://xworkmate-bridge.svc.plus",
|
||||
BridgeEndpoint: "http://172.29.10.1:8787",
|
||||
},
|
||||
{
|
||||
ID: "cn-xworkmate-bridge",
|
||||
Role: "edge",
|
||||
PublicBaseURL: "https://cn-xworkmate-bridge.svc.plus",
|
||||
BridgeEndpoint: "http://172.29.10.2:8787",
|
||||
},
|
||||
}
|
||||
distributed := config.Distributed
|
||||
topology := strings.TrimSpace(distributed.Topology)
|
||||
if topology == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.EqualFold(topology, "dual-node") {
|
||||
return ""
|
||||
}
|
||||
localNodeID := strings.TrimSpace(distributed.LocalNodeID)
|
||||
peerNodeID := strings.TrimSpace(distributed.TaskForwardPeerID)
|
||||
if localNodeID == "" || peerNodeID == "" || localNodeID == peerNodeID {
|
||||
return ""
|
||||
}
|
||||
for _, node := range distributed.Nodes {
|
||||
if strings.TrimSpace(node.ID) == peerNodeID {
|
||||
return strings.TrimSpace(node.BridgeEndpoint)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newProductionProviderCatalog() (*BridgeConfig, map[string]syncedProvider, []string) {
|
||||
|
||||
@ -37,7 +37,7 @@ type RoutingResult struct {
|
||||
Status string `json:"status"` // available, unavailable
|
||||
UnavailableCode string `json:"unavailableCode,omitempty"`
|
||||
UnavailableMsg string `json:"unavailableMessage,omitempty"`
|
||||
|
||||
|
||||
// Enhanced metadata for tests and advanced clients
|
||||
SkillResolutionSource string `json:"skillResolutionSource,omitempty"`
|
||||
NeedsSkillInstall bool `json:"needsSkillInstall,omitempty"`
|
||||
|
||||
@ -1,62 +1,205 @@
|
||||
package acp
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
func TestResolveDistributedTaskForwardEndpointFromDualNodeTopology(t *testing.T) {
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func TestDistributedTaskRouterFromDualNodePeerID(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.Topology = "dual-node"
|
||||
config.Distributed.LocalNodeID = "cn-xworkmate-bridge"
|
||||
config.Distributed.TaskForwardPeerID = "xworkmate-bridge"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{
|
||||
ID: "xworkmate-bridge",
|
||||
BridgeEndpoint: "http://172.29.10.1:8787",
|
||||
},
|
||||
{
|
||||
ID: "cn-xworkmate-bridge",
|
||||
BridgeEndpoint: "http://172.29.10.2:8787",
|
||||
},
|
||||
}
|
||||
|
||||
if got := resolveDistributedTaskForwardEndpoint(config); got != "http://172.29.10.1:8787" {
|
||||
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want %q", got, "http://172.29.10.1:8787")
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
if router == nil {
|
||||
t.Fatal("expected router")
|
||||
}
|
||||
decision, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/gateway/openclaw", nil), sessionStart("s1"))
|
||||
if err != nil {
|
||||
t.Fatalf("forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected forward decision")
|
||||
}
|
||||
if decision.targetNodeID != "xworkmate-bridge" || decision.nextHopID != "xworkmate-bridge" {
|
||||
t.Fatalf("decision = %#v, want target and next hop xworkmate-bridge", decision)
|
||||
}
|
||||
if decision.endpoint != "http://172.29.10.1:8787" {
|
||||
t.Fatalf("endpoint = %q, want private main bridge endpoint", decision.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistributedTaskForwardEndpointDisabledWhenPeerUnset(t *testing.T) {
|
||||
func TestDistributedTaskRouterDisabledWhenPeerUnset(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.Topology = "dual-node"
|
||||
config.Distributed.LocalNodeID = "xworkmate-bridge"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{
|
||||
ID: "xworkmate-bridge",
|
||||
BridgeEndpoint: "http://172.29.10.1:8787",
|
||||
},
|
||||
{
|
||||
ID: "cn-xworkmate-bridge",
|
||||
BridgeEndpoint: "http://172.29.10.2:8787",
|
||||
},
|
||||
}
|
||||
|
||||
if got := resolveDistributedTaskForwardEndpoint(config); got != "" {
|
||||
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want empty endpoint", got)
|
||||
if router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"}); router != nil {
|
||||
t.Fatalf("newDistributedTaskRouter() = %#v, want nil", router)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistributedTaskForwardEndpointKeepsExplicitEndpoint(t *testing.T) {
|
||||
func TestDistributedTaskRouterPrefersConfiguredNodeOverDefault(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.TaskForwardEndpoint = "https://xworkmate-bridge.svc.plus"
|
||||
config.Distributed.Topology = "dual-node"
|
||||
config.Distributed.LocalNodeID = "cn-xworkmate-bridge"
|
||||
config.Distributed.TaskForwardPeerID = "xworkmate-bridge"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{
|
||||
ID: "xworkmate-bridge",
|
||||
BridgeEndpoint: "http://172.29.10.1:8787",
|
||||
BridgeEndpoint: "http://172.29.10.11:8787",
|
||||
},
|
||||
}
|
||||
|
||||
if got := resolveDistributedTaskForwardEndpoint(config); got != "https://xworkmate-bridge.svc.plus" {
|
||||
t.Fatalf("resolveDistributedTaskForwardEndpoint() = %q, want explicit endpoint", got)
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
decision, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/acp/rpc", nil), sessionStart("s1"))
|
||||
if err != nil {
|
||||
t.Fatalf("forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected forward decision")
|
||||
}
|
||||
if decision.endpoint != "http://172.29.10.11:8787" {
|
||||
t.Fatalf("endpoint = %q, want configured endpoint", decision.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributedTaskRouterSelectorRoundRobinKeepsSessionAffinity(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.LocalNodeID = "edge-cn"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{ID: "edge-cn", Role: "edge", Zone: "cn", BridgeEndpoint: "http://172.29.10.2:8787"},
|
||||
{ID: "worker-a", Role: "executor", Zone: "global", BridgeEndpoint: "http://172.29.10.11:8787", Capabilities: []string{"openclaw"}},
|
||||
{ID: "worker-b", Role: "executor", Zone: "global", BridgeEndpoint: "http://172.29.10.12:8787", Capabilities: []string{"openclaw"}},
|
||||
}
|
||||
config.Distributed.Forwarding.Rules = []DistributedForwardRuleConfig{
|
||||
{
|
||||
Methods: []string{"session.start", "session.message"},
|
||||
Target: DistributedForwardTargetConfig{
|
||||
Selector: DistributedForwardSelectorConfig{Role: "executor", Capability: "openclaw"},
|
||||
Strategy: "round_robin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
first, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/acp/rpc", nil), sessionStart("s1"))
|
||||
if err != nil {
|
||||
t.Fatalf("first forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok || first.targetNodeID != "worker-a" {
|
||||
t.Fatalf("first decision = %#v, want worker-a", first)
|
||||
}
|
||||
secondSession, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/acp/rpc", nil), sessionStart("s2"))
|
||||
if err != nil {
|
||||
t.Fatalf("second forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok || secondSession.targetNodeID != "worker-b" {
|
||||
t.Fatalf("second session decision = %#v, want worker-b", secondSession)
|
||||
}
|
||||
followUp, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/acp/rpc", nil), sessionMessage("s1"))
|
||||
if err != nil {
|
||||
t.Fatalf("follow-up forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok || followUp.targetNodeID != "worker-a" {
|
||||
t.Fatalf("follow-up decision = %#v, want sticky worker-a", followUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributedTaskRouterUsesExplicitNextHopRoute(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.LocalNodeID = "edge-cn"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{ID: "edge-cn", Role: "edge", BridgeEndpoint: "http://172.29.10.2:8787"},
|
||||
{ID: "hub-main", Role: "hub", BridgeEndpoint: "http://172.29.10.1:8787"},
|
||||
{ID: "worker-eu", Role: "executor", BridgeEndpoint: "http://172.29.10.30:8787"},
|
||||
}
|
||||
config.Distributed.Forwarding.Rules = []DistributedForwardRuleConfig{
|
||||
{
|
||||
Methods: []string{"session.start"},
|
||||
Target: DistributedForwardTargetConfig{NodeID: "worker-eu"},
|
||||
},
|
||||
}
|
||||
config.Distributed.Forwarding.Routes = []DistributedRouteConfig{
|
||||
{TargetNodeID: "worker-eu", NextHopNodeID: "hub-main"},
|
||||
}
|
||||
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
decision, ok, err := router.forwardDecision(httptest.NewRequest("POST", "/acp/rpc", nil), sessionStart("s1"))
|
||||
if err != nil {
|
||||
t.Fatalf("forwardDecision() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected forward decision")
|
||||
}
|
||||
if decision.targetNodeID != "worker-eu" || decision.nextHopID != "hub-main" || decision.endpoint != "http://172.29.10.1:8787" {
|
||||
t.Fatalf("decision = %#v, want target worker-eu via hub-main", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributedTaskRouterStopsWhenForwardedTargetIsLocal(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.LocalNodeID = "hub-main"
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{ID: "hub-main", Role: "hub", BridgeEndpoint: "http://172.29.10.1:8787"},
|
||||
{ID: "worker-eu", Role: "executor", BridgeEndpoint: "http://172.29.10.30:8787"},
|
||||
}
|
||||
config.Distributed.Forwarding.Rules = []DistributedForwardRuleConfig{
|
||||
{Methods: []string{"session.start"}, Target: DistributedForwardTargetConfig{NodeID: "worker-eu"}},
|
||||
}
|
||||
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
req := httptest.NewRequest("POST", "/acp/rpc", nil)
|
||||
req.Header.Set(distributedForwardedHeader, "1")
|
||||
req.Header.Set(distributedTargetHeader, "hub-main")
|
||||
if _, ok, err := router.forwardDecision(req, sessionStart("s1")); err != nil || ok {
|
||||
t.Fatalf("forwardDecision() ok=%t err=%v, want local execution", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributedTaskRouterRejectsHopLimitExceeded(t *testing.T) {
|
||||
config := &BridgeConfig{}
|
||||
config.Distributed.LocalNodeID = "hub-main"
|
||||
config.Distributed.Forwarding.HopLimit = 1
|
||||
config.Distributed.Nodes = []DistributedNodeConfig{
|
||||
{ID: "hub-main", Role: "hub", BridgeEndpoint: "http://172.29.10.1:8787"},
|
||||
{ID: "worker-eu", Role: "executor", BridgeEndpoint: "http://172.29.10.30:8787"},
|
||||
}
|
||||
config.Distributed.Forwarding.Rules = []DistributedForwardRuleConfig{
|
||||
{Methods: []string{"session.start"}, Target: DistributedForwardTargetConfig{NodeID: "worker-eu"}},
|
||||
}
|
||||
|
||||
router := newDistributedTaskRouter(distributedTaskRouterConfig{Config: config, Token: "token"})
|
||||
req := httptest.NewRequest("POST", "/acp/rpc", nil)
|
||||
req.Header.Set(distributedForwardedHeader, "1")
|
||||
req.Header.Set(distributedTargetHeader, "worker-eu")
|
||||
req.Header.Set(distributedHopHeader, "1")
|
||||
if _, ok, err := router.forwardDecision(req, sessionStart("s1")); err == nil || ok {
|
||||
t.Fatalf("forwardDecision() ok=%t err=%v, want hop limit error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributedForwardURLRejectsPublicEndpoint(t *testing.T) {
|
||||
if _, err := distributedForwardURL("https://xworkmate-bridge.svc.plus", "/acp/rpc"); err == nil {
|
||||
t.Fatal("expected public endpoint rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func sessionStart(sessionID string) shared.RPCRequest {
|
||||
return shared.RPCRequest{
|
||||
ID: sessionID,
|
||||
Method: "session.start",
|
||||
Params: map[string]any{"sessionId": sessionID, "openclawSessionKey": "thread-" + sessionID, "threadId": "thread-" + sessionID},
|
||||
}
|
||||
}
|
||||
|
||||
func sessionMessage(sessionID string) shared.RPCRequest {
|
||||
return shared.RPCRequest{
|
||||
ID: sessionID + "-message",
|
||||
Method: "session.message",
|
||||
Params: map[string]any{"sessionId": sessionID, "openclawSessionKey": "thread-" + sessionID, "threadId": "thread-" + sessionID},
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,51 +9,148 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
const distributedForwardedHeader = "X-XWorkmate-Bridge-Forwarded"
|
||||
const (
|
||||
distributedForwardedHeader = "X-XWorkmate-Bridge-Forwarded"
|
||||
distributedSourceHeader = "X-XWorkmate-Forward-Source"
|
||||
distributedTargetHeader = "X-XWorkmate-Forward-Target"
|
||||
distributedTraceHeader = "X-XWorkmate-Forward-Trace"
|
||||
distributedHopHeader = "X-XWorkmate-Forward-Hop"
|
||||
|
||||
type distributedTaskForwarderConfig struct {
|
||||
Endpoint string
|
||||
Token string
|
||||
defaultDistributedHopLimit = 3
|
||||
defaultSessionRouteTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type distributedTaskRouterConfig struct {
|
||||
Config *BridgeConfig
|
||||
Token string
|
||||
}
|
||||
|
||||
type distributedTaskForwarder struct {
|
||||
endpoint string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
type distributedTaskRouter struct {
|
||||
localNodeID string
|
||||
token string
|
||||
hopLimit int
|
||||
nodes map[string]DistributedNodeConfig
|
||||
rules []DistributedForwardRuleConfig
|
||||
routes map[string]string
|
||||
routeStore *distributedSessionRouteStore
|
||||
roundRobin map[string]int
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newDistributedTaskForwarder(config distributedTaskForwarderConfig) *distributedTaskForwarder {
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(config.Endpoint), "/")
|
||||
if endpoint == "" {
|
||||
type distributedForwardDecision struct {
|
||||
targetNodeID string
|
||||
nextHopID string
|
||||
endpoint string
|
||||
}
|
||||
|
||||
type distributedSessionRoute struct {
|
||||
TargetNodeID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type distributedSessionRouteStore struct {
|
||||
mu sync.Mutex
|
||||
routes map[string]distributedSessionRoute
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newDistributedTaskRouter(config distributedTaskRouterConfig) *distributedTaskRouter {
|
||||
if config.Config == nil {
|
||||
return nil
|
||||
}
|
||||
return &distributedTaskForwarder{
|
||||
endpoint: endpoint,
|
||||
token: strings.TrimSpace(config.Token),
|
||||
httpClient: &http.Client{
|
||||
Timeout: openClawAgentWaitMaxTimeout + openClawAgentWaitHTTPMargin,
|
||||
},
|
||||
distributed := config.Config.Distributed
|
||||
localNodeID := strings.TrimSpace(distributed.LocalNodeID)
|
||||
if localNodeID == "" {
|
||||
return nil
|
||||
}
|
||||
nodes := distributedNodeCatalog(distributed.Nodes)
|
||||
if _, ok := nodes[localNodeID]; !ok {
|
||||
return nil
|
||||
}
|
||||
rules := distributedForwardRules(distributed)
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
hopLimit := distributed.Forwarding.HopLimit
|
||||
if hopLimit <= 0 {
|
||||
hopLimit = defaultDistributedHopLimit
|
||||
}
|
||||
return &distributedTaskRouter{
|
||||
localNodeID: localNodeID,
|
||||
token: strings.TrimSpace(config.Token),
|
||||
hopLimit: hopLimit,
|
||||
nodes: nodes,
|
||||
rules: rules,
|
||||
routes: distributedRouteMap(distributed.Forwarding.Routes),
|
||||
routeStore: newDistributedSessionRouteStore(defaultSessionRouteTTL),
|
||||
roundRobin: make(map[string]int),
|
||||
httpClient: shared.NewHTTPClient(openClawAgentWaitMaxTimeout + openClawAgentWaitHTTPMargin),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *distributedTaskForwarder) shouldForward(r *http.Request, request shared.RPCRequest) bool {
|
||||
if f == nil || strings.TrimSpace(f.endpoint) == "" || r == nil {
|
||||
return false
|
||||
func distributedNodeCatalog(configured []DistributedNodeConfig) map[string]DistributedNodeConfig {
|
||||
nodes := make(map[string]DistributedNodeConfig)
|
||||
for _, node := range defaultDistributedNodes() {
|
||||
if id := strings.TrimSpace(node.ID); id != "" {
|
||||
node.ID = id
|
||||
nodes[id] = node
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(r.Header.Get(distributedForwardedHeader)) != "" {
|
||||
return false
|
||||
for _, node := range configured {
|
||||
if id := strings.TrimSpace(node.ID); id != "" {
|
||||
node.ID = id
|
||||
nodes[id] = node
|
||||
}
|
||||
}
|
||||
method := strings.TrimSpace(request.Method)
|
||||
return method == "session.start" || method == "session.message"
|
||||
return nodes
|
||||
}
|
||||
|
||||
func (f *distributedTaskForwarder) forward(ctx context.Context, w http.ResponseWriter, r *http.Request, request shared.RPCRequest) bool {
|
||||
if !f.shouldForward(r, request) {
|
||||
func distributedForwardRules(distributed DistributedConfig) []DistributedForwardRuleConfig {
|
||||
if len(distributed.Forwarding.Rules) > 0 {
|
||||
return distributed.Forwarding.Rules
|
||||
}
|
||||
if peerID := strings.TrimSpace(distributed.TaskForwardPeerID); peerID != "" {
|
||||
return []DistributedForwardRuleConfig{
|
||||
{
|
||||
Methods: []string{"session.start", "session.message"},
|
||||
Target: DistributedForwardTargetConfig{
|
||||
NodeID: peerID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func distributedRouteMap(routes []DistributedRouteConfig) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, route := range routes {
|
||||
target := strings.TrimSpace(route.TargetNodeID)
|
||||
nextHop := strings.TrimSpace(route.NextHopNodeID)
|
||||
if target != "" && nextHop != "" && target != nextHop {
|
||||
result[target] = nextHop
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) forward(ctx context.Context, w http.ResponseWriter, req *http.Request, request shared.RPCRequest) bool {
|
||||
decision, ok, err := r.forwardDecision(req, request)
|
||||
if err != nil {
|
||||
shared.WriteJSONError(w, request.ID, http.StatusBadGateway, -32060, err.Error())
|
||||
return true
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
payload, err := json.Marshal(request)
|
||||
@ -61,7 +158,7 @@ func (f *distributedTaskForwarder) forward(ctx context.Context, w http.ResponseW
|
||||
shared.WriteJSONError(w, request.ID, http.StatusInternalServerError, -32603, "TASK_FORWARD_ENCODE_FAILED")
|
||||
return true
|
||||
}
|
||||
forwardURL, err := f.forwardURL(r.URL.Path)
|
||||
forwardURL, err := distributedForwardURL(decision.endpoint, req.URL.Path)
|
||||
if err != nil {
|
||||
shared.WriteJSONError(w, request.ID, http.StatusBadGateway, -32060, err.Error())
|
||||
return true
|
||||
@ -71,15 +168,9 @@ func (f *distributedTaskForwarder) forward(ctx context.Context, w http.ResponseW
|
||||
shared.WriteJSONError(w, request.ID, http.StatusBadGateway, -32060, "TASK_FORWARD_REQUEST_BUILD_FAILED: "+err.Error())
|
||||
return true
|
||||
}
|
||||
outbound.Header.Set("Content-Type", "application/json")
|
||||
outbound.Header.Set(distributedForwardedHeader, "1")
|
||||
copyForwardHeader(outbound.Header, r.Header, "Accept")
|
||||
copyForwardHeader(outbound.Header, r.Header, "Origin")
|
||||
if f.token != "" {
|
||||
outbound.Header.Set("Authorization", distributedForwardBearerHeader(f.token))
|
||||
}
|
||||
r.copyForwardRequestHeaders(outbound.Header, req.Header, request, decision)
|
||||
|
||||
response, err := f.httpClient.Do(outbound)
|
||||
response, err := r.httpClient.Do(outbound)
|
||||
if err != nil {
|
||||
shared.WriteJSONError(w, request.ID, http.StatusBadGateway, -32060, "TASK_FORWARD_FAILED: "+err.Error())
|
||||
return true
|
||||
@ -91,26 +182,243 @@ func (f *distributedTaskForwarder) forward(ctx context.Context, w http.ResponseW
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *distributedTaskForwarder) forwardURL(path string) (string, error) {
|
||||
base, err := url.Parse(f.endpoint)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return "", fmt.Errorf("TASK_FORWARD_ENDPOINT_INVALID: %s", f.endpoint)
|
||||
func (r *distributedTaskRouter) forwardDecision(req *http.Request, request shared.RPCRequest) (distributedForwardDecision, bool, error) {
|
||||
if r == nil || req == nil {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
if !distributedForwardEndpointEncryptedOrPrivate(base) {
|
||||
return "", fmt.Errorf("TASK_FORWARD_ENDPOINT_INSECURE: use https or a private VPN endpoint")
|
||||
forwardedTarget := strings.TrimSpace(req.Header.Get(distributedTargetHeader))
|
||||
if strings.TrimSpace(req.Header.Get(distributedForwardedHeader)) != "" && forwardedTarget == "" {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
if forwardedTarget != "" {
|
||||
if strings.EqualFold(forwardedTarget, r.localNodeID) {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
return r.decisionForTarget(forwardedTarget, req)
|
||||
}
|
||||
|
||||
method := strings.TrimSpace(request.Method)
|
||||
if !distributedForwardableMethod(method) {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
sessionKey := distributedSessionRouteKey(request)
|
||||
if sessionKey != "" {
|
||||
if routed, ok := r.routeStore.get(sessionKey); ok {
|
||||
return r.decisionForTarget(routed, req)
|
||||
}
|
||||
}
|
||||
targetNodeID, ok := r.targetForRequest(method)
|
||||
if !ok || targetNodeID == "" || strings.EqualFold(targetNodeID, r.localNodeID) {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
if sessionKey != "" {
|
||||
r.routeStore.set(sessionKey, targetNodeID)
|
||||
}
|
||||
return r.decisionForTarget(targetNodeID, req)
|
||||
}
|
||||
|
||||
func distributedForwardableMethod(method string) bool {
|
||||
return method == "session.start" || method == "session.message"
|
||||
}
|
||||
|
||||
func distributedSessionRouteKey(request shared.RPCRequest) string {
|
||||
params := shared.AsMap(request.Params)
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
if sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", "")); sessionID != "" {
|
||||
return "session:" + sessionID
|
||||
}
|
||||
if threadID := strings.TrimSpace(shared.StringArg(params, "threadId", "")); threadID != "" {
|
||||
return "thread:" + threadID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) targetForRequest(method string) (string, bool) {
|
||||
for _, rule := range r.rules {
|
||||
if !distributedRuleMatchesMethod(rule, method) {
|
||||
continue
|
||||
}
|
||||
if target := strings.TrimSpace(rule.Target.NodeID); target != "" {
|
||||
return target, true
|
||||
}
|
||||
target, ok := r.selectNode(rule.Target)
|
||||
return target, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func distributedRuleMatchesMethod(rule DistributedForwardRuleConfig, method string) bool {
|
||||
for _, candidate := range rule.Methods {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "*" || candidate == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) selectNode(target DistributedForwardTargetConfig) (string, bool) {
|
||||
candidates := make([]string, 0, len(r.nodes))
|
||||
for id, node := range r.nodes {
|
||||
if id == r.localNodeID {
|
||||
continue
|
||||
}
|
||||
if !distributedNodeMatchesSelector(node, target.Selector) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
sort.Strings(candidates)
|
||||
strategy := strings.TrimSpace(target.Strategy)
|
||||
if strategy == "" || strings.EqualFold(strategy, "first") {
|
||||
return candidates[0], true
|
||||
}
|
||||
if strings.EqualFold(strategy, "round_robin") {
|
||||
key := distributedSelectorKey(target.Selector)
|
||||
r.mu.Lock()
|
||||
index := r.roundRobin[key] % len(candidates)
|
||||
r.roundRobin[key]++
|
||||
r.mu.Unlock()
|
||||
return candidates[index], true
|
||||
}
|
||||
return candidates[0], true
|
||||
}
|
||||
|
||||
func distributedNodeMatchesSelector(node DistributedNodeConfig, selector DistributedForwardSelectorConfig) bool {
|
||||
if role := strings.TrimSpace(selector.Role); role != "" && !strings.EqualFold(strings.TrimSpace(node.Role), role) {
|
||||
return false
|
||||
}
|
||||
if zone := strings.TrimSpace(selector.Zone); zone != "" && !strings.EqualFold(strings.TrimSpace(node.Zone), zone) {
|
||||
return false
|
||||
}
|
||||
if capability := strings.TrimSpace(selector.Capability); capability != "" && !distributedNodeHasCapability(node, capability) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func distributedNodeHasCapability(node DistributedNodeConfig, capability string) bool {
|
||||
for _, candidate := range node.Capabilities {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), capability) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func distributedSelectorKey(selector DistributedForwardSelectorConfig) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(selector.Role),
|
||||
strings.TrimSpace(selector.Zone),
|
||||
strings.TrimSpace(selector.Capability),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) decisionForTarget(targetNodeID string, req *http.Request) (distributedForwardDecision, bool, error) {
|
||||
targetNodeID = strings.TrimSpace(targetNodeID)
|
||||
nextHopID := r.nextHopForTarget(targetNodeID)
|
||||
if nextHopID == "" || strings.EqualFold(nextHopID, r.localNodeID) {
|
||||
return distributedForwardDecision{}, false, nil
|
||||
}
|
||||
node, ok := r.nodes[nextHopID]
|
||||
if !ok {
|
||||
return distributedForwardDecision{}, false, fmt.Errorf("TASK_FORWARD_PEER_UNKNOWN: %s", nextHopID)
|
||||
}
|
||||
if err := r.validateHopLimit(req); err != nil {
|
||||
return distributedForwardDecision{}, false, err
|
||||
}
|
||||
endpoint := strings.TrimSpace(node.BridgeEndpoint)
|
||||
if endpoint == "" {
|
||||
return distributedForwardDecision{}, false, fmt.Errorf("TASK_FORWARD_ENDPOINT_MISSING: %s", nextHopID)
|
||||
}
|
||||
return distributedForwardDecision{
|
||||
targetNodeID: targetNodeID,
|
||||
nextHopID: nextHopID,
|
||||
endpoint: endpoint,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) nextHopForTarget(targetNodeID string) string {
|
||||
if nextHopID := strings.TrimSpace(r.routes[targetNodeID]); nextHopID != "" {
|
||||
return nextHopID
|
||||
}
|
||||
return targetNodeID
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) validateHopLimit(req *http.Request) error {
|
||||
nextHop := distributedForwardHop(req) + 1
|
||||
if nextHop > r.hopLimit {
|
||||
return fmt.Errorf("TASK_FORWARD_HOP_LIMIT_EXCEEDED: %d", r.hopLimit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *distributedTaskRouter) copyForwardRequestHeaders(dst http.Header, src http.Header, request shared.RPCRequest, decision distributedForwardDecision) {
|
||||
dst.Set("Content-Type", "application/json")
|
||||
dst.Set(distributedForwardedHeader, "1")
|
||||
dst.Set(distributedSourceHeader, firstForwardHeader(src, distributedSourceHeader, r.localNodeID))
|
||||
dst.Set(distributedTargetHeader, decision.targetNodeID)
|
||||
dst.Set(distributedTraceHeader, firstForwardHeader(src, distributedTraceHeader, distributedTraceID(request)))
|
||||
dst.Set(distributedHopHeader, strconv.Itoa(distributedForwardHopFromHeader(src.Get(distributedHopHeader))+1))
|
||||
copyForwardHeader(dst, src, "Accept")
|
||||
copyForwardHeader(dst, src, "Origin")
|
||||
if r.token != "" {
|
||||
dst.Set("Authorization", distributedForwardBearerHeader(r.token))
|
||||
}
|
||||
}
|
||||
|
||||
func distributedTraceID(request shared.RPCRequest) string {
|
||||
if request.ID != nil {
|
||||
return fmt.Sprint(request.ID)
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func firstForwardHeader(src http.Header, key string, fallback string) string {
|
||||
if value := strings.TrimSpace(src.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func distributedForwardHop(req *http.Request) int {
|
||||
if req == nil {
|
||||
return 0
|
||||
}
|
||||
return distributedForwardHopFromHeader(req.Header.Get(distributedHopHeader))
|
||||
}
|
||||
|
||||
func distributedForwardHopFromHeader(value string) int {
|
||||
hop, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || hop < 0 {
|
||||
return 0
|
||||
}
|
||||
return hop
|
||||
}
|
||||
|
||||
func distributedForwardURL(endpoint string, path string) (string, error) {
|
||||
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||
base, err := url.Parse(endpoint)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return "", fmt.Errorf("TASK_FORWARD_ENDPOINT_INVALID: %s", endpoint)
|
||||
}
|
||||
if !distributedForwardEndpointPrivate(base) {
|
||||
return "", fmt.Errorf("TASK_FORWARD_ENDPOINT_INSECURE: use a private VPN endpoint")
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(path, "/")
|
||||
base.RawQuery = ""
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func distributedForwardEndpointEncryptedOrPrivate(endpoint *url.URL) bool {
|
||||
func distributedForwardEndpointPrivate(endpoint *url.URL) bool {
|
||||
if endpoint == nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(endpoint.Scheme, "https") {
|
||||
return true
|
||||
}
|
||||
if !strings.EqualFold(endpoint.Scheme, "http") {
|
||||
return false
|
||||
}
|
||||
@ -146,3 +454,40 @@ func copyForwardResponseHeaders(dst http.Header, src http.Header) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDistributedSessionRouteStore(ttl time.Duration) *distributedSessionRouteStore {
|
||||
return &distributedSessionRouteStore{
|
||||
routes: make(map[string]distributedSessionRoute),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *distributedSessionRouteStore) get(key string) (string, bool) {
|
||||
if s == nil || strings.TrimSpace(key) == "" {
|
||||
return "", false
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
route, ok := s.routes[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !route.ExpiresAt.IsZero() && now.After(route.ExpiresAt) {
|
||||
delete(s.routes, key)
|
||||
return "", false
|
||||
}
|
||||
return route.TargetNodeID, true
|
||||
}
|
||||
|
||||
func (s *distributedSessionRouteStore) set(key string, targetNodeID string) {
|
||||
if s == nil || strings.TrimSpace(key) == "" || strings.TrimSpace(targetNodeID) == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.routes[key] = distributedSessionRoute{
|
||||
TargetNodeID: strings.TrimSpace(targetNodeID),
|
||||
ExpiresAt: time.Now().Add(s.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ func handleGatewayConnect(
|
||||
server.gateway = gatewayruntime.NewManager()
|
||||
}
|
||||
|
||||
result := server.gateway.Connect(request, notify)
|
||||
result := connectOpenClawGateway(server.gateway, request, notify, usesBridgeIdentity)
|
||||
if result.OK && usesBridgeIdentity {
|
||||
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
|
||||
}
|
||||
@ -115,12 +115,12 @@ func applyProductionGatewayRouting(
|
||||
|
||||
gatewayURL := resolveURL(server.config.Upstream.GatewayURL, "GATEWAY_RPC_URL")
|
||||
if gatewayURL == "" {
|
||||
return request
|
||||
return withDefaultOpenClawGatewayEndpoint(request)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(gatewayURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return request
|
||||
return withDefaultOpenClawGatewayEndpoint(request)
|
||||
}
|
||||
|
||||
tls := strings.ToLower(parsed.Scheme) == "https" || strings.ToLower(parsed.Scheme) == "wss"
|
||||
@ -145,6 +145,45 @@ func applyProductionGatewayRouting(
|
||||
request.ConnectAuthFields = []string{"token"}
|
||||
request.ConnectAuthSources = []string{"bridge"}
|
||||
request.HasSharedAuth = request.Auth.Token != ""
|
||||
return withDefaultOpenClawGatewayEndpoint(request)
|
||||
}
|
||||
|
||||
func gatewayStatusForManager(manager *gatewayruntime.Manager) string {
|
||||
if manager != nil && manager.HasConnectedSession() {
|
||||
return "connected"
|
||||
}
|
||||
return "disconnected"
|
||||
}
|
||||
|
||||
func (s *Server) gatewayStatusForSystemLogs() string {
|
||||
if gatewayStatusForManager(s.gateway) == "connected" {
|
||||
return "connected"
|
||||
}
|
||||
if rpcErr := ensureProductionGatewayConnected(s, "openclaw", nil); rpcErr != nil {
|
||||
return "disconnected"
|
||||
}
|
||||
return gatewayStatusForManager(s.gateway)
|
||||
}
|
||||
|
||||
func withDefaultOpenClawGatewayEndpoint(
|
||||
request gatewayruntime.ConnectRequest,
|
||||
) gatewayruntime.ConnectRequest {
|
||||
if !isOpenClawMode(request.Mode) {
|
||||
return request
|
||||
}
|
||||
if strings.TrimSpace(request.Endpoint.Host) == "" {
|
||||
request.Endpoint.Host = "127.0.0.1"
|
||||
}
|
||||
if request.Endpoint.Port <= 0 {
|
||||
if strings.TrimSpace(request.Endpoint.Host) == "127.0.0.1" ||
|
||||
strings.TrimSpace(request.Endpoint.Host) == "localhost" {
|
||||
request.Endpoint.Port = 18789
|
||||
} else if request.Endpoint.TLS {
|
||||
request.Endpoint.Port = 443
|
||||
} else {
|
||||
request.Endpoint.Port = 80
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
@ -153,13 +192,38 @@ func handleGatewayRequest(
|
||||
params map[string]any,
|
||||
notify func(map[string]any),
|
||||
) map[string]any {
|
||||
method := strings.TrimSpace(shared.StringArg(params, "method", ""))
|
||||
if server.gateway == nil {
|
||||
return map[string]any{"ok": false, "error": map[string]any{"message": "gateway not initialized"}}
|
||||
server.gateway = gatewayruntime.NewManager()
|
||||
}
|
||||
if method == "skills.status" {
|
||||
if rpcErr := ensureProductionGatewayConnected(server, "openclaw", notify); rpcErr != nil {
|
||||
return map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]any{
|
||||
"message": rpcErr.Message,
|
||||
"code": "OFFLINE",
|
||||
},
|
||||
}
|
||||
}
|
||||
timeout := time.Duration(parsePositiveInt(params["timeoutMs"])) * time.Millisecond
|
||||
result := server.gateway.RequestByMode(
|
||||
"openclaw",
|
||||
method,
|
||||
shared.AsMap(params["params"]),
|
||||
timeout,
|
||||
notify,
|
||||
)
|
||||
return map[string]any{
|
||||
"ok": result.OK,
|
||||
"payload": result.Payload,
|
||||
"error": result.Error,
|
||||
}
|
||||
}
|
||||
timeout := time.Duration(parsePositiveInt(params["timeoutMs"])) * time.Millisecond
|
||||
result := server.gateway.Request(
|
||||
strings.TrimSpace(shared.StringArg(params, "runtimeId", "")),
|
||||
strings.TrimSpace(shared.StringArg(params, "method", "")),
|
||||
method,
|
||||
shared.AsMap(params["params"]),
|
||||
timeout,
|
||||
notify,
|
||||
@ -220,7 +284,7 @@ func ensureProductionGatewayConnected(
|
||||
request.Auth.DeviceToken = deviceToken
|
||||
request.HasDeviceToken = deviceToken != ""
|
||||
request.ReportedRemoteAddress = resolveGatewayReportedRemoteAddress(server, request)
|
||||
result := server.gateway.Connect(request, notify)
|
||||
result := connectOpenClawGateway(server.gateway, request, notify, true)
|
||||
if result.OK {
|
||||
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
|
||||
return nil
|
||||
@ -233,6 +297,43 @@ func ensureProductionGatewayConnected(
|
||||
return &shared.RPCError{Code: -32002, Message: "GATEWAY_CONNECT_FAILED: " + message}
|
||||
}
|
||||
|
||||
func connectOpenClawGateway(
|
||||
manager *gatewayruntime.Manager,
|
||||
request gatewayruntime.ConnectRequest,
|
||||
notify func(map[string]any),
|
||||
usesBridgeIdentity bool,
|
||||
) gatewayruntime.ConnectResult {
|
||||
result := manager.Connect(request, notify)
|
||||
if !usesBridgeIdentity || !shouldRetryOpenClawGatewayWithSharedToken(result) {
|
||||
return result
|
||||
}
|
||||
|
||||
clearBridgeGatewayDeviceToken()
|
||||
request.Auth.DeviceToken = ""
|
||||
request.HasDeviceToken = false
|
||||
request.Auth.Token = bridgeSharedAuthToken()
|
||||
request.HasSharedAuth = true
|
||||
request.ConnectAuthMode = "shared-token"
|
||||
request.ConnectAuthFields = []string{"token"}
|
||||
request.ConnectAuthSources = []string{"bridge:device-token-reissue"}
|
||||
return manager.Connect(request, notify)
|
||||
}
|
||||
|
||||
func shouldRetryOpenClawGatewayWithSharedToken(result gatewayruntime.ConnectResult) bool {
|
||||
if result.OK || strings.TrimSpace(bridgeSharedAuthToken()) == "" {
|
||||
return false
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(shared.StringArg(result.Error, "code", "")))
|
||||
message := strings.ToLower(strings.TrimSpace(shared.StringArg(result.Error, "message", "")))
|
||||
details := shared.AsMap(result.Error["details"])
|
||||
detailCode := strings.ToUpper(strings.TrimSpace(shared.StringArg(details, "code", "")))
|
||||
return detailCode == "AUTH_DEVICE_TOKEN_MISMATCH" ||
|
||||
detailCode == "PAIRING_REQUIRED" ||
|
||||
code == "NOT_PAIRED" ||
|
||||
strings.Contains(message, "device token mismatch") ||
|
||||
strings.Contains(message, "rotate/reissue device token")
|
||||
}
|
||||
|
||||
func configureProductionOpenClawGatewayRuntime(manager *gatewayruntime.Manager) {
|
||||
if manager == nil {
|
||||
return
|
||||
|
||||
@ -138,6 +138,20 @@ func saveBridgeGatewayDeviceToken(deviceToken string) {
|
||||
)
|
||||
}
|
||||
|
||||
func clearBridgeGatewayDeviceToken() {
|
||||
bridgeGatewayIdentity.Lock()
|
||||
defer bridgeGatewayIdentity.Unlock()
|
||||
if strings.TrimSpace(bridgeGatewayIdentity.value.DeviceID) == "" {
|
||||
return
|
||||
}
|
||||
bridgeGatewayIdentity.deviceToken = ""
|
||||
_ = persistBridgeGatewayIdentity(
|
||||
bridgeGatewayIdentityPath(),
|
||||
bridgeGatewayIdentity.value,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func persistBridgeGatewayIdentity(
|
||||
path string,
|
||||
identity gatewayruntime.DeviceIdentity,
|
||||
|
||||
@ -144,6 +144,26 @@ func TestBridgeGatewayIdentityPersistsReturnedDeviceToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeGatewayIdentityClearsStoredDeviceToken(t *testing.T) {
|
||||
identityPath := filepath.Join(t.TempDir(), "openclaw-device.json")
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", identityPath)
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
identity := newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("device-token-2")
|
||||
clearBridgeGatewayDeviceToken()
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
|
||||
reloaded, token := bridgeGatewayOpenClawCredentials()
|
||||
if reloaded.DeviceID != identity.DeviceID {
|
||||
t.Fatalf("reloaded identity = %q, want %q", reloaded.DeviceID, identity.DeviceID)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("device token should be cleared, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func resetBridgeGatewayIdentityForTest() {
|
||||
bridgeGatewayIdentity.Lock()
|
||||
defer bridgeGatewayIdentity.Unlock()
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"xworkmate-bridge/internal/gatewayruntime"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func TestApplyProductionGatewayRoutingPreservesGatewayURLPath(t *testing.T) {
|
||||
@ -35,3 +37,188 @@ func TestApplyProductionGatewayRoutingPreservesGatewayURLPath(t *testing.T) {
|
||||
t.Fatalf("expected gateway URL path to be preserved, got %#v", request.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProductionGatewayRoutingDefaultsOpenClawEndpoint(t *testing.T) {
|
||||
t.Setenv("GATEWAY_RPC_URL", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
server := NewServer()
|
||||
|
||||
request := applyProductionGatewayRouting(
|
||||
server,
|
||||
gatewayruntime.ConnectRequest{Mode: "openclaw"},
|
||||
)
|
||||
|
||||
if request.Endpoint.Host != "127.0.0.1" {
|
||||
t.Fatalf("expected built-in gateway host, got %#v", request.Endpoint)
|
||||
}
|
||||
if request.Endpoint.Port != 18789 {
|
||||
t.Fatalf("expected built-in gateway port, got %#v", request.Endpoint)
|
||||
}
|
||||
if request.Endpoint.TLS {
|
||||
t.Fatalf("expected built-in gateway endpoint to use local plaintext, got %#v", request.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemLogsConnectsProductionGatewayForStatus(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected gatewayStatus connected, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 1 {
|
||||
t.Fatalf("expected one gateway connect attempt, got %d", got)
|
||||
}
|
||||
|
||||
result, rpcErr = server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status-again",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("second system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected second gatewayStatus connected, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 1 {
|
||||
t.Fatalf("expected connected status to reuse gateway session, got %d connect attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayReconnectsWithSharedTokenAfterDeviceTokenMismatch(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
identityPath := filepath.Join(t.TempDir(), "openclaw-device.json")
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", identityPath)
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected gatewayStatus connected after repair, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 2 {
|
||||
t.Fatalf("expected stale device token retry with shared token, got %d connects", got)
|
||||
}
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
_, token := bridgeGatewayOpenClawCredentials()
|
||||
if token != "device-token-1" {
|
||||
t.Fatalf("expected repaired device token to be persisted, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayReconnectPrefersAIWorkspaceToken(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected gatewayStatus connected after AI workspace token repair, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 2 {
|
||||
t.Fatalf("expected stale device token retry with AI workspace token, got %d connects", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayDoesNotUseInternalServiceTokenFallback(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "disconnected" {
|
||||
t.Fatalf("expected gatewayStatus disconnected without AI workspace token, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 1 {
|
||||
t.Fatalf("expected no retry with internal token, got %d connects", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -584,17 +584,6 @@ func cloneMap(source map[string]any) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMapSlice(source []map[string]any) []map[string]any {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]map[string]any, 0, len(source))
|
||||
for _, item := range source {
|
||||
result = append(result, cloneMap(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseSkillsCandidates(raw []any) []skills.Candidate {
|
||||
result := make([]skills.Candidate, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
|
||||
@ -37,6 +37,7 @@ func (s *Server) Handler() http.Handler {
|
||||
"commit": info.Commit,
|
||||
"version": info.Version,
|
||||
"buildDate": info.BuildDate,
|
||||
"metrics": bridgeStabilityMetricsSnapshot(), // T12
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -46,6 +47,8 @@ func (s *Server) Handler() http.Handler {
|
||||
s.HandleRPC(w, r)
|
||||
case "/acp":
|
||||
s.HandleWebSocket(w, r)
|
||||
case "/gateway/openclaw":
|
||||
s.HandleOpenClawGatewayRPC(w, r)
|
||||
case openClawArtifactDownloadPath:
|
||||
s.HandleOpenClawArtifactDownload(w, r)
|
||||
default:
|
||||
@ -112,6 +115,10 @@ func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPCWithTransform(w, r, nil)
|
||||
}
|
||||
|
||||
func (s *Server) HandleOpenClawGatewayRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPCWithTransform(w, r, forceOpenClawGatewayRequest)
|
||||
}
|
||||
|
||||
func (s *Server) HandleDisabledProviderDirectPath(w http.ResponseWriter, r *http.Request) {
|
||||
shared.ApplyCORS(w, r, s.allowedOrigins)
|
||||
if r.Method == http.MethodOptions {
|
||||
@ -180,7 +187,7 @@ func (s *Server) handleRPCWithTransform(
|
||||
}
|
||||
request = transformed
|
||||
}
|
||||
if s.taskForwarder.forward(r.Context(), w, r, request) {
|
||||
if s.taskRouter.forward(r.Context(), w, r, request) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -207,20 +214,22 @@ func (s *Server) handleRPCWithTransform(
|
||||
if !stream {
|
||||
return
|
||||
}
|
||||
if openClawGatewayTask {
|
||||
if reason := openClawGatewayNotificationDropReason(message); reason != "" {
|
||||
log.Printf(
|
||||
"level=warn component=acp_sse event=notification_dropped path=%q rpcMethod=%q requestId=%q sessionId=%q threadId=%q reason=%q notificationMethod=%q",
|
||||
r.URL.Path,
|
||||
request.Method,
|
||||
fmt.Sprint(request.ID),
|
||||
shared.StringArg(request.Params, "sessionId", ""),
|
||||
shared.StringArg(request.Params, "threadId", ""),
|
||||
reason,
|
||||
shared.StringArg(message, "method", ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
if reason := gatewaySSEBridgeNotificationDropReason(
|
||||
request.Method,
|
||||
openClawGatewayTask,
|
||||
message,
|
||||
); reason != "" {
|
||||
log.Printf(
|
||||
"level=warn component=acp_sse event=notification_dropped path=%q rpcMethod=%q requestId=%q sessionId=%q threadId=%q reason=%q notificationMethod=%q",
|
||||
r.URL.Path,
|
||||
request.Method,
|
||||
fmt.Sprint(request.ID),
|
||||
shared.StringArg(request.Params, "sessionId", ""),
|
||||
shared.StringArg(request.Params, "threadId", ""),
|
||||
reason,
|
||||
shared.StringArg(message, "method", ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
streamWriter.write(message)
|
||||
}
|
||||
@ -275,9 +284,14 @@ func (s *Server) handleRPCWithTransform(
|
||||
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(request.ID, response))
|
||||
}
|
||||
|
||||
func openClawGatewayNotificationDropReason(message map[string]any) string {
|
||||
func gatewaySSEBridgeNotificationDropReason(
|
||||
rpcMethod string,
|
||||
openClawGatewayTask bool,
|
||||
message map[string]any,
|
||||
) string {
|
||||
method := strings.TrimSpace(shared.StringArg(message, "method", ""))
|
||||
if strings.HasPrefix(method, "xworkmate.gateway.") {
|
||||
if strings.HasPrefix(method, "xworkmate.gateway.") &&
|
||||
(openClawGatewayTask || strings.TrimSpace(rpcMethod) == "xworkmate.gateway.request") {
|
||||
return "raw_gateway_event"
|
||||
}
|
||||
if !openClawGatewayNotificationWithinLimit(message) {
|
||||
@ -440,6 +454,56 @@ func sseEventType(payload map[string]any) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func forceOpenClawGatewayRequest(request shared.RPCRequest) (shared.RPCRequest, *shared.RPCError) {
|
||||
method := strings.TrimSpace(request.Method)
|
||||
switch method {
|
||||
case "session.start", "session.message":
|
||||
default:
|
||||
return request, &shared.RPCError{Code: -32601, Message: "OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED: " + method}
|
||||
}
|
||||
params := shared.AsMap(request.Params)
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if provider := strings.TrimSpace(shared.StringArg(params, "provider", "")); provider != "" {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: provider must not be set on /gateway/openclaw"}
|
||||
}
|
||||
for _, key := range []string{"executionTarget", "requestedExecutionTarget"} {
|
||||
if target := strings.TrimSpace(shared.StringArg(params, key, "")); target != "" && !strings.EqualFold(target, "gateway") {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: " + key + " must be gateway"}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} {
|
||||
if provider := strings.TrimSpace(shared.StringArg(params, key, "")); provider != "" && !strings.EqualFold(provider, "openclaw") {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: gateway provider must be openclaw"}
|
||||
}
|
||||
}
|
||||
routing := shared.AsMap(params["routing"])
|
||||
if routing == nil {
|
||||
routing = map[string]any{}
|
||||
}
|
||||
if provider := strings.TrimSpace(shared.StringArg(routing, "explicitProviderId", "")); provider != "" {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: explicitProviderId must not be set on /gateway/openclaw"}
|
||||
}
|
||||
if target := strings.TrimSpace(shared.StringArg(routing, "explicitExecutionTarget", "")); target != "" && !strings.EqualFold(target, "gateway") {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: explicitExecutionTarget must be gateway"}
|
||||
}
|
||||
for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} {
|
||||
if provider := strings.TrimSpace(shared.StringArg(routing, key, "")); provider != "" && !strings.EqualFold(provider, "openclaw") {
|
||||
return request, &shared.RPCError{Code: -32602, Message: "OPENCLAW_GATEWAY_CONFLICT: gateway provider must be openclaw"}
|
||||
}
|
||||
}
|
||||
routing["routingMode"] = "explicit"
|
||||
routing["explicitExecutionTarget"] = "gateway"
|
||||
routing["preferredGatewayProviderId"] = "openclaw"
|
||||
delete(routing, "explicitProviderId")
|
||||
params["routing"] = routing
|
||||
params["requestedExecutionTarget"] = "gateway"
|
||||
params["executionTarget"] = "gateway"
|
||||
request.Params = params
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func requestUsesOpenClawGatewaySubmit(params map[string]any) bool {
|
||||
if len(params) == 0 {
|
||||
return false
|
||||
|
||||
@ -116,7 +116,7 @@ func (m *jobManager) run(parent context.Context, job *bridgeJob, notify func(map
|
||||
params["threadId"] = job.ThreadID
|
||||
params["taskPrompt"] = job.Prompt
|
||||
params["workingDirectory"] = job.WorkingDir
|
||||
if job.ProviderID != "" && !isMultiAgentSessionRequest(params) {
|
||||
if job.ProviderID != "" {
|
||||
params["routing"] = map[string]any{
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "singleAgent",
|
||||
|
||||
29
internal/acp/metrics.go
Normal file
29
internal/acp/metrics.go
Normal file
@ -0,0 +1,29 @@
|
||||
package acp
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// 关键稳定性指标(T12,docs/cases/06 §5)。
|
||||
//
|
||||
// 进程内累计计数,经 /api/ping 暴露,用于把「网关抖动 / run 超时」从靠用户截图
|
||||
// 变为可监控。三个计数对应三类已知的不稳定来源:
|
||||
// - gatewaySocketClosed : gatewayRPCError 命中 OPENCLAW_GATEWAY_SOCKET_CLOSED(连接断)
|
||||
// - taskGetUnconfirmedFallback: tasks.get 走持久 run 仓兜底(gateway 无法确认 run,T7)
|
||||
// - runDeadlineInterrupt : run 超过 DeadlineAt 且 gateway 无法确认,回 interrupted(T9)
|
||||
var bridgeStabilityMetrics struct {
|
||||
gatewaySocketClosed atomic.Int64
|
||||
taskGetUnconfirmedFallback atomic.Int64
|
||||
runDeadlineInterrupt atomic.Int64
|
||||
}
|
||||
|
||||
func metricGatewaySocketClosedInc() { bridgeStabilityMetrics.gatewaySocketClosed.Add(1) }
|
||||
func metricTaskGetUnconfirmedFallbackInc() { bridgeStabilityMetrics.taskGetUnconfirmedFallback.Add(1) }
|
||||
func metricRunDeadlineInterruptInc() { bridgeStabilityMetrics.runDeadlineInterrupt.Add(1) }
|
||||
|
||||
// bridgeStabilityMetricsSnapshot 返回当前计数快照,供 /api/ping 输出。
|
||||
func bridgeStabilityMetricsSnapshot() map[string]any {
|
||||
return map[string]any{
|
||||
"gatewaySocketClosed": bridgeStabilityMetrics.gatewaySocketClosed.Load(),
|
||||
"taskGetUnconfirmedFallback": bridgeStabilityMetrics.taskGetUnconfirmedFallback.Load(),
|
||||
"runDeadlineInterrupt": bridgeStabilityMetrics.runDeadlineInterrupt.Load(),
|
||||
}
|
||||
}
|
||||
@ -1,609 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
multiAgentTargetID = "multi-agent"
|
||||
defaultMultiAgentTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type multiAgentPlan struct {
|
||||
Mode string
|
||||
Steps []multiAgentStep
|
||||
MaxTurns int
|
||||
StopConditions []string
|
||||
SharedWorkspace string
|
||||
}
|
||||
|
||||
type multiAgentStep struct {
|
||||
Index int
|
||||
ProviderID string
|
||||
Prompt string
|
||||
OutputAs string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type multiAgentStepResult struct {
|
||||
Index int
|
||||
ProviderID string
|
||||
Status string
|
||||
Output string
|
||||
Error string
|
||||
DurationMs int64
|
||||
Result map[string]any
|
||||
}
|
||||
|
||||
func isMultiAgentSessionRequest(params map[string]any) bool {
|
||||
if parseBool(params["multiAgent"]) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(shared.StringArg(params, "mode", "")), multiAgentTargetID) {
|
||||
return true
|
||||
}
|
||||
routing := shared.AsMap(params["routing"])
|
||||
return strings.TrimSpace(shared.StringArg(routing, "orchestrationMode", "")) != ""
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) ProcessMultiAgent(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||||
if method != "session.start" && method != "session.message" {
|
||||
return nil, &shared.RPCError{Code: -32601, Message: "MULTI_AGENT_METHOD_NOT_ALLOWED: " + method}
|
||||
}
|
||||
plan, rpcErr := o.parseMultiAgentPlan(params)
|
||||
if rpcErr != nil {
|
||||
return nil, rpcErr
|
||||
}
|
||||
|
||||
sessionID := shared.StringArg(params, "sessionId", "")
|
||||
threadID := shared.StringArg(params, "threadId", sessionID)
|
||||
turnID := fmt.Sprintf("turn-%d", time.Now().UnixNano())
|
||||
sess := o.server.getOrCreateSession(sessionID, threadID)
|
||||
sess.mu.Lock()
|
||||
sess.target = multiAgentTargetID
|
||||
sess.provider = ""
|
||||
sess.mode = multiAgentTargetID
|
||||
sess.control.ControlPlaneSessionID = sessionID
|
||||
sess.control.ThreadID = threadID
|
||||
sess.control.RequestedWorkingDir = plan.SharedWorkspace
|
||||
sess.control.RemoteWorkingDirHint = strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectoryHint", ""))
|
||||
sess.control.UpdatedAt = time.Now()
|
||||
sess.task = QueuedTask{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
Target: multiAgentTargetID,
|
||||
State: TaskStateRunning,
|
||||
Kind: TaskKindMultiAgent,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", "")); prompt != "" {
|
||||
sess.history = append(sess.history, "USER: "+prompt)
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||||
"type": "status",
|
||||
"event": "multi_agent_started",
|
||||
"message": "multi-agent orchestration started",
|
||||
"mode": plan.Mode,
|
||||
"stepCount": len(plan.Steps),
|
||||
"pending": true,
|
||||
"error": false,
|
||||
})
|
||||
|
||||
result := o.runMultiAgentPlan(ctx, method, params, plan, sessionID, threadID, turnID, notify)
|
||||
routing := RoutingResult{TargetID: multiAgentTargetID}
|
||||
normalized := o.normalizeResult(sess, result, routing, turnID, withMultiAgentWorkingDirectory(params, plan.SharedWorkspace))
|
||||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||||
"type": "status",
|
||||
"event": "multi_agent_completed",
|
||||
"message": strings.TrimSpace(shared.StringArg(normalized, "summary", "multi-agent orchestration completed")),
|
||||
"pending": false,
|
||||
"error": !parseBool(normalized["success"]),
|
||||
"result": normalized,
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) parseMultiAgentPlan(params map[string]any) (multiAgentPlan, *shared.RPCError) {
|
||||
routing := shared.AsMap(params["routing"])
|
||||
mode := strings.ToLower(strings.TrimSpace(shared.StringArg(routing, "orchestrationMode", "")))
|
||||
if mode == "" {
|
||||
mode = strings.ToLower(strings.TrimSpace(shared.StringArg(params, "orchestrationMode", "")))
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "sequence"
|
||||
}
|
||||
switch mode {
|
||||
case "sequence", "parallel", "race", "conversation":
|
||||
default:
|
||||
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_INVALID_MODE: " + mode}
|
||||
}
|
||||
|
||||
sharedWorkspace := strings.TrimSpace(shared.StringArg(routing, "sharedWorkspace", ""))
|
||||
if sharedWorkspace == "" {
|
||||
sharedWorkspace = strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||||
}
|
||||
if sharedWorkspace == "" {
|
||||
sharedWorkspace = filepath.Join(os.TempDir(), "xworkmate-bridge", "multi-agent", safeMultiAgentPathSegment(shared.StringArg(params, "sessionId", "session")))
|
||||
}
|
||||
if err := os.MkdirAll(sharedWorkspace, 0o755); err != nil {
|
||||
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_WORKSPACE_UNAVAILABLE: " + err.Error()}
|
||||
}
|
||||
|
||||
steps := parseMultiAgentSteps(shared.ListArg(routing, "steps"), params, defaultMultiAgentTimeout)
|
||||
if len(steps) == 0 {
|
||||
steps = parseMultiAgentSteps(shared.ListArg(params, "steps"), params, defaultMultiAgentTimeout)
|
||||
}
|
||||
if mode == "conversation" && len(steps) == 0 {
|
||||
steps = parseConversationParticipants(shared.ListArg(routing, "participants"), params, defaultMultiAgentTimeout)
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_STEPS_REQUIRED"}
|
||||
}
|
||||
for index := range steps {
|
||||
steps[index].Index = index
|
||||
if steps[index].ProviderID == "" {
|
||||
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_PROVIDER_REQUIRED"}
|
||||
}
|
||||
if steps[index].Prompt == "" {
|
||||
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_PROMPT_REQUIRED"}
|
||||
}
|
||||
}
|
||||
|
||||
maxTurns := parsePositiveInt(routing["maxTurns"])
|
||||
if maxTurns == 0 {
|
||||
maxTurns = parsePositiveInt(params["maxTurns"])
|
||||
}
|
||||
if maxTurns == 0 {
|
||||
maxTurns = len(steps)
|
||||
}
|
||||
if mode == "conversation" && maxTurns < len(steps) {
|
||||
maxTurns = len(steps)
|
||||
}
|
||||
|
||||
stopConditions := parseMultiAgentStringList(routing["stopConditions"])
|
||||
if len(stopConditions) == 0 {
|
||||
stopConditions = []string{"STATUS: DONE", "STATUS: CONSENSUS"}
|
||||
}
|
||||
|
||||
return multiAgentPlan{
|
||||
Mode: mode,
|
||||
Steps: steps,
|
||||
MaxTurns: maxTurns,
|
||||
StopConditions: stopConditions,
|
||||
SharedWorkspace: sharedWorkspace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseMultiAgentSteps(raw []any, params map[string]any, fallbackTimeout time.Duration) []multiAgentStep {
|
||||
steps := make([]multiAgentStep, 0, len(raw))
|
||||
defaultPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||
for _, item := range raw {
|
||||
stepParams := shared.AsMap(item)
|
||||
if len(stepParams) == 0 {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(parsePositiveInt(stepParams["timeoutMs"])) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(parsePositiveInt(stepParams["timeoutSeconds"])) * time.Second
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = fallbackTimeout
|
||||
}
|
||||
prompt := strings.TrimSpace(firstNonEmptyString(stepParams, "prompt", "taskPrompt"))
|
||||
if prompt == "" {
|
||||
prompt = defaultPrompt
|
||||
}
|
||||
steps = append(steps, multiAgentStep{
|
||||
ProviderID: strings.TrimSpace(firstNonEmptyString(stepParams, "providerId", "provider", "agent")),
|
||||
Prompt: prompt,
|
||||
OutputAs: strings.TrimSpace(shared.StringArg(stepParams, "outputAs", "")),
|
||||
Timeout: timeout,
|
||||
})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func parseConversationParticipants(raw []any, params map[string]any, fallbackTimeout time.Duration) []multiAgentStep {
|
||||
steps := make([]multiAgentStep, 0, len(raw))
|
||||
prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||
for _, item := range raw {
|
||||
providerID := strings.TrimSpace(fmt.Sprint(item))
|
||||
if mapped := shared.AsMap(item); len(mapped) > 0 {
|
||||
providerID = strings.TrimSpace(firstNonEmptyString(mapped, "providerId", "provider", "agent"))
|
||||
}
|
||||
if providerID == "" || providerID == "<nil>" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, multiAgentStep{
|
||||
ProviderID: providerID,
|
||||
Prompt: prompt,
|
||||
Timeout: fallbackTimeout,
|
||||
})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentPlan(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
|
||||
switch plan.Mode {
|
||||
case "parallel":
|
||||
return o.runMultiAgentParallel(ctx, method, params, plan, sessionID, threadID, turnID, notify)
|
||||
case "race":
|
||||
return o.runMultiAgentRace(ctx, method, params, plan, sessionID, threadID, turnID, notify)
|
||||
case "conversation":
|
||||
return o.runMultiAgentConversation(ctx, method, params, plan, sessionID, threadID, turnID, notify)
|
||||
default:
|
||||
return o.runMultiAgentSequence(ctx, method, params, plan, sessionID, threadID, turnID, notify)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentSequence(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
|
||||
values := map[string]string{"input": strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))}
|
||||
results := make([]map[string]any, 0, len(plan.Steps))
|
||||
var lastOutput string
|
||||
for _, step := range plan.Steps {
|
||||
step.Prompt = renderMultiAgentPrompt(step.Prompt, values)
|
||||
stepResult := o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
|
||||
results = append(results, stepResult.Map())
|
||||
if stepResult.Status != "completed" {
|
||||
return multiAgentResult(plan, results, "", stepResult.Error, false)
|
||||
}
|
||||
lastOutput = stepResult.Output
|
||||
values["previousOutput"] = stepResult.Output
|
||||
if step.OutputAs != "" {
|
||||
values[step.OutputAs] = stepResult.Output
|
||||
}
|
||||
}
|
||||
return multiAgentResult(plan, results, lastOutput, "", true)
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentParallel(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
|
||||
results := make([]multiAgentStepResult, len(plan.Steps))
|
||||
var wg sync.WaitGroup
|
||||
for _, step := range plan.Steps {
|
||||
wg.Add(1)
|
||||
go func(step multiAgentStep) {
|
||||
defer wg.Done()
|
||||
results[step.Index] = o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
|
||||
}(step)
|
||||
}
|
||||
wg.Wait()
|
||||
return multiAgentAggregateResult(plan, results)
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentRace(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
resultsCh := make(chan multiAgentStepResult, len(plan.Steps))
|
||||
for _, step := range plan.Steps {
|
||||
go func(step multiAgentStep) {
|
||||
resultsCh <- o.runMultiAgentProviderStep(raceCtx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
|
||||
}(step)
|
||||
}
|
||||
results := make([]multiAgentStepResult, 0, len(plan.Steps))
|
||||
for range plan.Steps {
|
||||
stepResult := <-resultsCh
|
||||
results = append(results, stepResult)
|
||||
if stepResult.Status == "completed" {
|
||||
cancel()
|
||||
return multiAgentResult(plan, multiAgentStepResultMaps(results), stepResult.Output, "", true)
|
||||
}
|
||||
}
|
||||
return multiAgentResult(plan, multiAgentStepResultMaps(results), "", "all agents failed", false)
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentConversation(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
|
||||
results := make([]map[string]any, 0, plan.MaxTurns)
|
||||
started := make(map[string]bool)
|
||||
lastAgent := ""
|
||||
lastOutput := ""
|
||||
topic := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||
for turn := 0; turn < plan.MaxTurns; turn++ {
|
||||
step := plan.Steps[turn%len(plan.Steps)]
|
||||
if lastOutput == "" {
|
||||
step.Prompt = fmt.Sprintf("Topic:\n%s\n\nShared workspace: %s\n\nRespond as %s.", topic, plan.SharedWorkspace, step.ProviderID)
|
||||
} else {
|
||||
step.Prompt = fmt.Sprintf("[%s]: %s\n\nContinue the discussion as %s. Shared workspace: %s", lastAgent, lastOutput, step.ProviderID, plan.SharedWorkspace)
|
||||
}
|
||||
useSend := started[step.ProviderID]
|
||||
stepResult := o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, useSend, notify)
|
||||
started[step.ProviderID] = true
|
||||
entry := stepResult.Map()
|
||||
entry["conversationTurn"] = turn + 1
|
||||
results = append(results, entry)
|
||||
if stepResult.Status != "completed" {
|
||||
return multiAgentResult(plan, results, "", stepResult.Error, false)
|
||||
}
|
||||
lastAgent = step.ProviderID
|
||||
lastOutput = stepResult.Output
|
||||
if multiAgentConversationShouldStop(lastOutput, plan.StopConditions) {
|
||||
result := multiAgentResult(plan, results, lastOutput, "", true)
|
||||
result["stopReason"] = "stop_condition"
|
||||
return result
|
||||
}
|
||||
}
|
||||
result := multiAgentResult(plan, results, lastOutput, "", true)
|
||||
result["stopReason"] = "max_turns"
|
||||
return result
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) runMultiAgentProviderStep(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, step multiAgentStep, sessionID string, threadID string, turnID string, useSend bool, notify func(map[string]any)) multiAgentStepResult {
|
||||
startedAt := time.Now()
|
||||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||||
"type": "status",
|
||||
"event": "multi_agent_step_started",
|
||||
"providerId": step.ProviderID,
|
||||
"stepIndex": step.Index,
|
||||
"pending": true,
|
||||
"error": false,
|
||||
})
|
||||
compat, ok := o.server.providers[step.ProviderID]
|
||||
if !ok {
|
||||
return failedMultiAgentStep(step, startedAt, "provider unavailable")
|
||||
}
|
||||
|
||||
stepCtx := ctx
|
||||
cancel := func() {}
|
||||
if step.Timeout > 0 {
|
||||
stepCtx, cancel = context.WithTimeout(ctx, step.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stepSessionID := fmt.Sprintf("%s/%s/%d/%s", sessionID, turnID, step.Index, step.ProviderID)
|
||||
stepParams := multiAgentStepParams(params, step, plan.SharedWorkspace)
|
||||
sink := func(update map[string]any) {
|
||||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||||
"type": "delta",
|
||||
"event": "multi_agent_step_update",
|
||||
"providerId": step.ProviderID,
|
||||
"stepIndex": step.Index,
|
||||
"providerUpdate": update,
|
||||
"pending": true,
|
||||
"error": false,
|
||||
})
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
var err error
|
||||
if useSend {
|
||||
result, err = compat.SendMessage(stepCtx, stepSessionID, threadID, stepParams, sink)
|
||||
if _, ok := asSessionContinuationUnavailableError(err); ok {
|
||||
result, err = compat.StartSession(stepCtx, stepSessionID, threadID, stepParams, sink)
|
||||
}
|
||||
} else {
|
||||
result, err = compat.StartSession(stepCtx, stepSessionID, threadID, stepParams, sink)
|
||||
}
|
||||
stepResult := multiAgentStepResult{
|
||||
Index: step.Index,
|
||||
ProviderID: step.ProviderID,
|
||||
Status: "completed",
|
||||
Output: strings.TrimSpace(multiAgentOutputFromResult(result)),
|
||||
DurationMs: time.Since(startedAt).Milliseconds(),
|
||||
Result: result,
|
||||
}
|
||||
if err != nil {
|
||||
stepResult.Status = "failed"
|
||||
stepResult.Error = err.Error()
|
||||
} else if result != nil {
|
||||
if value, ok := result["success"]; ok && !parseBool(value) {
|
||||
stepResult.Status = "failed"
|
||||
stepResult.Error = firstNonEmptyString(result, "error", "message", "unavailableMessage")
|
||||
}
|
||||
}
|
||||
if stepResult.Status == "completed" && stepResult.Output == "" {
|
||||
stepResult.Status = "failed"
|
||||
stepResult.Error = "provider returned no displayable output"
|
||||
}
|
||||
if stepResult.Error == "" && stepResult.Status == "failed" {
|
||||
stepResult.Error = "provider execution failed"
|
||||
}
|
||||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||||
"type": "status",
|
||||
"event": "multi_agent_step_completed",
|
||||
"providerId": step.ProviderID,
|
||||
"stepIndex": step.Index,
|
||||
"status": stepResult.Status,
|
||||
"message": stepResult.Output,
|
||||
"pending": false,
|
||||
"error": stepResult.Status != "completed",
|
||||
})
|
||||
return stepResult
|
||||
}
|
||||
|
||||
func multiAgentStepParams(params map[string]any, step multiAgentStep, sharedWorkspace string) map[string]any {
|
||||
next := make(map[string]any, len(params)+2)
|
||||
for key, value := range params {
|
||||
next[key] = value
|
||||
}
|
||||
delete(next, "multiAgent")
|
||||
delete(next, "mode")
|
||||
next["taskPrompt"] = step.Prompt
|
||||
next["workingDirectory"] = sharedWorkspace
|
||||
next["routing"] = map[string]any{
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "singleAgent",
|
||||
"explicitProviderId": step.ProviderID,
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func multiAgentOutputFromResult(result map[string]any) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmptyString(result, "output", "summary", "message", "text")
|
||||
}
|
||||
|
||||
func failedMultiAgentStep(step multiAgentStep, startedAt time.Time, message string) multiAgentStepResult {
|
||||
return multiAgentStepResult{
|
||||
Index: step.Index,
|
||||
ProviderID: step.ProviderID,
|
||||
Status: "failed",
|
||||
Error: message,
|
||||
DurationMs: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r multiAgentStepResult) Map() map[string]any {
|
||||
result := map[string]any{
|
||||
"index": r.Index,
|
||||
"providerId": r.ProviderID,
|
||||
"status": r.Status,
|
||||
"durationMs": r.DurationMs,
|
||||
}
|
||||
if r.Output != "" {
|
||||
result["output"] = r.Output
|
||||
}
|
||||
if r.Error != "" {
|
||||
result["error"] = r.Error
|
||||
}
|
||||
if r.Result != nil {
|
||||
result["result"] = r.Result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func multiAgentAggregateResult(plan multiAgentPlan, results []multiAgentStepResult) map[string]any {
|
||||
mapped := multiAgentStepResultMaps(results)
|
||||
outputs := make([]string, 0, len(results))
|
||||
var errorText string
|
||||
success := true
|
||||
for _, result := range results {
|
||||
if result.Status != "completed" {
|
||||
success = false
|
||||
if errorText == "" {
|
||||
errorText = result.ProviderID + ": " + result.Error
|
||||
}
|
||||
continue
|
||||
}
|
||||
if result.Output != "" {
|
||||
outputs = append(outputs, result.ProviderID+": "+result.Output)
|
||||
}
|
||||
}
|
||||
return multiAgentResult(plan, mapped, strings.Join(outputs, "\n"), errorText, success)
|
||||
}
|
||||
|
||||
func multiAgentStepResultMaps(results []multiAgentStepResult) []map[string]any {
|
||||
mapped := make([]map[string]any, 0, len(results))
|
||||
for _, result := range results {
|
||||
mapped = append(mapped, result.Map())
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func multiAgentResult(plan multiAgentPlan, steps []map[string]any, output string, errorText string, success bool) map[string]any {
|
||||
status := "completed"
|
||||
if !success {
|
||||
status = "failed"
|
||||
}
|
||||
if output == "" && errorText != "" {
|
||||
output = errorText
|
||||
}
|
||||
result := map[string]any{
|
||||
"success": success,
|
||||
"status": status,
|
||||
"mode": multiAgentTargetID,
|
||||
"orchestrationMode": plan.Mode,
|
||||
"resolvedExecutionTarget": multiAgentTargetID,
|
||||
"resolvedProviderId": "",
|
||||
"resolvedGatewayProviderId": "",
|
||||
"steps": steps,
|
||||
"sharedWorkspace": plan.SharedWorkspace,
|
||||
}
|
||||
if output != "" {
|
||||
result["output"] = output
|
||||
result["summary"] = output
|
||||
result["message"] = output
|
||||
}
|
||||
if errorText != "" {
|
||||
result["error"] = errorText
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func renderMultiAgentPrompt(prompt string, values map[string]string) string {
|
||||
rendered := prompt
|
||||
for key, value := range values {
|
||||
rendered = strings.ReplaceAll(rendered, "{{"+key+"}}", value)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func multiAgentConversationShouldStop(output string, stopConditions []string) bool {
|
||||
normalizedOutput := strings.ToUpper(output)
|
||||
for _, condition := range stopConditions {
|
||||
condition = strings.TrimSpace(condition)
|
||||
if condition == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedOutput, strings.ToUpper(condition)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseMultiAgentStringList(raw any) []string {
|
||||
switch values := raw.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), values...)
|
||||
case []any:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text != "" && text != "<nil>" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withMultiAgentWorkingDirectory(params map[string]any, workingDirectory string) map[string]any {
|
||||
next := make(map[string]any, len(params)+1)
|
||||
for key, value := range params {
|
||||
next[key] = value
|
||||
}
|
||||
if workingDirectory != "" {
|
||||
next["workingDirectory"] = workingDirectory
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func safeMultiAgentPathSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "default"
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
builder.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-':
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteByte('-')
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return "default"
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@ -46,7 +46,10 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
sessionKey := strings.TrimSpace(query.Get("sessionKey"))
|
||||
sessionKey := strings.TrimSpace(query.Get("openclawSessionKey"))
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(query.Get("sessionKey"))
|
||||
}
|
||||
runID := strings.TrimSpace(query.Get("runId"))
|
||||
rawArtifactScope := strings.TrimSpace(query.Get("artifactScope"))
|
||||
artifactScope := safeOpenClawArtifactDownloadArtifactScope(rawArtifactScope)
|
||||
@ -80,10 +83,11 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
readParams := map[string]any{
|
||||
"sessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"relativePath": relativePath,
|
||||
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
|
||||
"openclawSessionKey": sessionKey,
|
||||
"sessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"relativePath": relativePath,
|
||||
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
|
||||
}
|
||||
if artifactScope != "" {
|
||||
readParams["artifactScope"] = artifactScope
|
||||
@ -323,6 +327,7 @@ func (s *Server) openClawArtifactDownloadURL(
|
||||
parsed.RawQuery = ""
|
||||
expires := strconv.FormatInt(now.Add(openClawArtifactDownloadTTL).Unix(), 10)
|
||||
query := parsed.Query()
|
||||
query.Set("openclawSessionKey", sessionKey)
|
||||
query.Set("sessionKey", sessionKey)
|
||||
query.Set("runId", runID)
|
||||
query.Set("artifactScope", artifactScope)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -10,38 +9,30 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
openClawTaskProbeTimeout = 2 * time.Second
|
||||
openClawTaskProbeTimeoutMs = 1000
|
||||
openClawTaskMonitorInterval = time.Second
|
||||
openClawShortTaskMinutes = 10
|
||||
openClawLongTaskMinutes = 30
|
||||
openClawComplexTaskMinutes = 60
|
||||
openClawShortTaskMinutes = 10
|
||||
openClawLongTaskMinutes = 30
|
||||
openClawComplexTaskMinutes = 60
|
||||
)
|
||||
|
||||
type OpenClawTaskRecord struct {
|
||||
SessionID string
|
||||
ThreadID string
|
||||
TurnID string
|
||||
RunID string
|
||||
SessionKey string
|
||||
GatewayProviderID string
|
||||
TaskLoadClass string
|
||||
ArtifactSinceUnixMs int64
|
||||
RuntimeBudgetMinutes int
|
||||
StartedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
LastProbeAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
ProgressTerminal bool
|
||||
ChatParams map[string]any
|
||||
PreparedArtifact *openClawPreparedArtifactScope
|
||||
ArtifactContract openClawArtifactContract
|
||||
ResolvedModel string
|
||||
ResolvedSkills []string
|
||||
MonitorStarted bool
|
||||
ProbeInFlight bool
|
||||
AdmissionRelease func()
|
||||
SessionID string
|
||||
ThreadID string
|
||||
TurnID string
|
||||
RunID string
|
||||
SessionKey string
|
||||
GatewayProviderID string
|
||||
TaskLoadClass string
|
||||
RuntimeBudgetMinutes int
|
||||
StartedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
PreparedArtifact *openClawPreparedArtifactScope
|
||||
RequiresArtifactExport bool
|
||||
ExpectedArtifactDirs []string
|
||||
RequiredArtifactExts []string
|
||||
ResolvedModel string
|
||||
ResolvedSkills []string
|
||||
}
|
||||
|
||||
func openClawTaskRuntimePolicy(params map[string]any, chatParams map[string]any, contract openClawArtifactContract) (string, int) {
|
||||
@ -50,13 +41,28 @@ func openClawTaskRuntimePolicy(params map[string]any, chatParams map[string]any,
|
||||
message = openClawCurrentTurnMessage(params)
|
||||
}
|
||||
lower := strings.ToLower(message)
|
||||
taskLoadClass := strings.TrimSpace(contract.TaskLoadClass)
|
||||
if taskLoadClass == "" {
|
||||
taskLoadClass = strings.TrimSpace(shared.StringArg(params, "taskLoadClass", ""))
|
||||
}
|
||||
metadataClass := strings.TrimSpace(shared.StringArg(shared.AsMap(params["metadata"]), "taskLoadClass", ""))
|
||||
if taskLoadClass == "" {
|
||||
taskLoadClass = metadataClass
|
||||
}
|
||||
switch taskLoadClass {
|
||||
case "short_task":
|
||||
return "short_task", openClawShortTaskMinutes
|
||||
case "long_task":
|
||||
return "long_task", openClawLongTaskMinutes
|
||||
case "complex_chain_task", "complex_long_chain_task":
|
||||
return "complex_chain_task", openClawComplexTaskMinutes
|
||||
}
|
||||
if metadataClass == "complex_long_chain_task" || contract.ComplexLongChain || openClawMessageContainsAny(lower, []string{
|
||||
"复杂链路", "多章节", "每章", "拆章节", "汇总排版", "gpt images", "images2", "image generation", "视频", "渲染", "hyperframes", "remotion", "ffmpeg",
|
||||
}) {
|
||||
return "complex_chain_task", openClawComplexTaskMinutes
|
||||
}
|
||||
if len(contract.RequiredFinalExtensions) > 0 || openClawMessageContainsAny(lower, []string{
|
||||
if openClawMessageContainsAny(lower, []string{
|
||||
"生成文件", "同步生成文件", "产物", "附件", "pdf", "docx", "ppt", "pptx", "markdown", ".md", "png", "jpg", "jpeg", "mp4",
|
||||
}) || len(shared.ListArg(params, "attachments"))+len(shared.ListArg(params, "inlineAttachments")) >= 2 {
|
||||
return "long_task", openClawLongTaskMinutes
|
||||
@ -75,11 +81,13 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
|
||||
"runId": record.RunID,
|
||||
"sessionId": record.SessionID,
|
||||
"threadId": record.ThreadID,
|
||||
"sessionKey": record.SessionKey,
|
||||
"appThreadKey": record.ThreadID,
|
||||
"openclawSessionKey": record.SessionKey,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedGatewayProviderId": record.GatewayProviderID,
|
||||
"taskLoadClass": record.TaskLoadClass,
|
||||
"runtimeBudgetMinutes": record.RuntimeBudgetMinutes,
|
||||
"requiresArtifactExport": record.RequiresArtifactExport,
|
||||
"startedAt": record.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||
"deadlineAt": record.DeadlineAt.UTC().Format(time.RFC3339Nano),
|
||||
"progress": openClawTaskProgress(record),
|
||||
@ -87,11 +95,11 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
|
||||
if record.PreparedArtifact != nil {
|
||||
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
|
||||
}
|
||||
if len(record.ArtifactContract.RequiredFinalExtensions) > 0 {
|
||||
result["requiredArtifactExtensions"] = append([]string(nil), record.ArtifactContract.RequiredFinalExtensions...)
|
||||
if len(record.ExpectedArtifactDirs) > 0 {
|
||||
result["expectedArtifactDirs"] = append([]string(nil), record.ExpectedArtifactDirs...)
|
||||
}
|
||||
if len(record.ArtifactContract.ExpectedArtifactExtensions) > 0 {
|
||||
result["expectedArtifactExtensions"] = append([]string(nil), record.ArtifactContract.ExpectedArtifactExtensions...)
|
||||
if len(record.RequiredArtifactExts) > 0 {
|
||||
result["requiredArtifactExtensions"] = append([]string(nil), record.RequiredArtifactExts...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -107,12 +115,11 @@ func openClawTaskProgress(record *OpenClawTaskRecord) map[string]any {
|
||||
message = "OpenClaw task is running"
|
||||
}
|
||||
return map[string]any{
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"elapsedMs": maxInt64(0, now.Sub(record.StartedAt).Milliseconds()),
|
||||
"budgetMs": (time.Duration(record.RuntimeBudgetMinutes) * time.Minute).Milliseconds(),
|
||||
"lastProbeAtMs": record.LastProbeAt.UnixMilli(),
|
||||
"terminal": record.ProgressTerminal,
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"elapsedMs": maxInt64(0, now.Sub(record.StartedAt).Milliseconds()),
|
||||
"budgetMs": (time.Duration(record.RuntimeBudgetMinutes) * time.Minute).Milliseconds(),
|
||||
"terminal": false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,397 +129,3 @@ func maxInt64(a int64, b int64) int64 {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) startOpenClawTaskMonitor(sess *session) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil || record.MonitorStarted || isTerminalTaskState(sess.task.State) {
|
||||
sess.mu.Unlock()
|
||||
return
|
||||
}
|
||||
record.MonitorStarted = true
|
||||
sess.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer o.releaseOpenClawAdmission(sess)
|
||||
time.Sleep(openClawTaskMonitorInterval)
|
||||
for {
|
||||
sess.mu.Lock()
|
||||
state := sess.task.State
|
||||
deadline := sess.task.DeadlineAt
|
||||
sess.mu.Unlock()
|
||||
if isTerminalTaskState(state) {
|
||||
return
|
||||
}
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
o.failOpenClawTask(sess, "TASK_SLA_EXPIRED", "OpenClaw task exceeded its runtime SLA")
|
||||
return
|
||||
}
|
||||
o.probeOpenClawTask(context.Background(), sess, nil)
|
||||
sess.mu.Lock()
|
||||
state = sess.task.State
|
||||
sess.mu.Unlock()
|
||||
if isTerminalTaskState(state) {
|
||||
return
|
||||
}
|
||||
time.Sleep(openClawTaskMonitorInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isTerminalTaskState(state TaskState) bool {
|
||||
return state == TaskStateCompleted || state == TaskStateFailed || state == TaskStateCancelled
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) releaseOpenClawAdmission(sess *session) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
var release func()
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
release = sess.openClaw.AdmissionRelease
|
||||
sess.openClaw.AdmissionRelease = nil
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) failOpenClawTask(sess *session, code string, message string) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"success": false, "status": string(TaskStateFailed), "code": code, "message": message}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = code
|
||||
}
|
||||
sess.mu.Lock()
|
||||
turnID := sess.task.TurnID
|
||||
runID := sess.task.RunID
|
||||
gatewayProviderID := sess.task.GatewayProviderID
|
||||
sess.task.State = TaskStateFailed
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "failed"
|
||||
sess.task.ProgressMessage = message
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "failed"
|
||||
sess.openClaw.ProgressMessage = message
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
result := map[string]any{
|
||||
"success": false,
|
||||
"status": string(TaskStateFailed),
|
||||
"code": code,
|
||||
"error": message,
|
||||
"message": message,
|
||||
"summary": message,
|
||||
"output": message,
|
||||
"turnId": turnID,
|
||||
"runId": runID,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedGatewayProviderId": gatewayProviderID,
|
||||
}
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
o.releaseOpenClawAdmission(sess)
|
||||
return result
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) probeOpenClawTask(ctx context.Context, sess *session, notify func(map[string]any)) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"status": "not_found"}
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if isTerminalTaskState(sess.task.State) {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if !record.DeadlineAt.IsZero() && time.Now().After(record.DeadlineAt) {
|
||||
sess.mu.Unlock()
|
||||
return o.failOpenClawTask(sess, "TASK_SLA_EXPIRED", "OpenClaw task exceeded its runtime SLA")
|
||||
}
|
||||
if record.ProbeInFlight {
|
||||
result := openClawRunningTaskResult(record)
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
record.ProbeInFlight = true
|
||||
record.LastProbeAt = time.Now()
|
||||
record.ProgressStage = "probing"
|
||||
record.ProgressMessage = "Checking OpenClaw task status"
|
||||
sess.task.LastProbeAt = record.LastProbeAt
|
||||
sess.task.ProgressStage = record.ProgressStage
|
||||
sess.task.ProgressMessage = record.ProgressMessage
|
||||
gatewayProvider := record.GatewayProviderID
|
||||
runID := record.RunID
|
||||
sessionID := record.SessionID
|
||||
threadID := record.ThreadID
|
||||
turnID := record.TurnID
|
||||
sess.mu.Unlock()
|
||||
|
||||
collector := newOpenClawChatCollector()
|
||||
notifyWithCollection := func(message map[string]any) {
|
||||
collector.observe(message)
|
||||
if notify == nil {
|
||||
return
|
||||
}
|
||||
if update := openClawGatewaySessionUpdate(message, sessionID, threadID, turnID); update != nil {
|
||||
notify(update)
|
||||
}
|
||||
}
|
||||
waitStarted := time.Now()
|
||||
waitResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
"agent.wait",
|
||||
map[string]any{
|
||||
"runId": runID,
|
||||
"timeoutMs": openClawTaskProbeTimeoutMs,
|
||||
},
|
||||
openClawTaskProbeTimeout,
|
||||
notifyWithCollection,
|
||||
)
|
||||
logOpenClawGatewayTiming(
|
||||
gatewayProvider,
|
||||
"agent.wait.probe",
|
||||
sessionID,
|
||||
runID,
|
||||
time.Since(waitStarted),
|
||||
waitResult.OK,
|
||||
)
|
||||
if !waitResult.OK {
|
||||
if openClawProbeStillRunning(waitResult.Error) {
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "running"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
sess.task.ProgressStage = "running"
|
||||
sess.task.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
result := openClawRunningTaskResult(sess.openClaw)
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
code := strings.TrimSpace(shared.StringArg(waitResult.Error, "code", "OPENCLAW_WAIT_FAILED"))
|
||||
message := strings.TrimSpace(shared.StringArg(waitResult.Error, "message", "openclaw wait failed"))
|
||||
return o.failOpenClawTask(sess, code, message)
|
||||
}
|
||||
return o.completeOpenClawTask(sess, shared.AsMap(waitResult.Payload), collector, notify)
|
||||
}
|
||||
|
||||
func openClawProbeStillRunning(errorPayload map[string]any) bool {
|
||||
code := strings.TrimSpace(strings.ToUpper(shared.StringArg(errorPayload, "code", "")))
|
||||
if code == "TIMEOUT" || code == "RPC_TIMEOUT" || code == "REQUEST_TIMEOUT" ||
|
||||
code == "OFFLINE" || code == "SOCKET_FAILURE" || code == "SOCKET_CLOSED" {
|
||||
return true
|
||||
}
|
||||
message := strings.TrimSpace(strings.ToLower(shared.StringArg(errorPayload, "message", "")))
|
||||
return strings.Contains(message, "timeout") || strings.Contains(message, "timed out")
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) completeOpenClawTask(
|
||||
sess *session,
|
||||
waitPayload map[string]any,
|
||||
collector *openClawChatCollector,
|
||||
notify func(map[string]any),
|
||||
) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"status": "not_found"}
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if isTerminalTaskState(sess.task.State) {
|
||||
record.ProbeInFlight = false
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
output := ""
|
||||
if collector != nil {
|
||||
output = collector.output()
|
||||
}
|
||||
if output == "" {
|
||||
output = firstNonEmptyString(waitPayload, "output", "message", "summary", "assistantText", "text")
|
||||
}
|
||||
noDisplayableOutput := strings.TrimSpace(output) == ""
|
||||
if output == "" {
|
||||
output = openClawNoDisplayableText
|
||||
}
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"output": output,
|
||||
"message": output,
|
||||
"summary": output,
|
||||
"turnId": record.TurnID,
|
||||
"runId": record.RunID,
|
||||
"sessionId": record.SessionID,
|
||||
"threadId": record.ThreadID,
|
||||
"sessionKey": record.SessionKey,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedExecutionTarget": router.ExecutionTargetGatewayChat,
|
||||
"resolvedProviderId": record.GatewayProviderID,
|
||||
"resolvedGatewayProviderId": record.GatewayProviderID,
|
||||
"resolvedModel": record.ResolvedModel,
|
||||
"resolvedSkills": append([]string(nil), record.ResolvedSkills...),
|
||||
"taskLoadClass": record.TaskLoadClass,
|
||||
"runtimeBudgetMinutes": record.RuntimeBudgetMinutes,
|
||||
}
|
||||
mergeOpenClawArtifactPayload(result, waitPayload)
|
||||
if collector != nil {
|
||||
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
|
||||
}
|
||||
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
|
||||
artifactPayload := o.openClawArtifactExport(
|
||||
record.GatewayProviderID,
|
||||
record.ChatParams,
|
||||
record.RunID,
|
||||
record.ArtifactSinceUnixMs,
|
||||
record.PreparedArtifact,
|
||||
notify,
|
||||
)
|
||||
mergeOpenClawArtifactPayload(result, artifactPayload)
|
||||
result[openClawArtifactExportAttemptedField] = true
|
||||
exportedCount := openClawArtifactPayloadCount(result)
|
||||
logOpenClawArtifactSync(record.GatewayProviderID, record.SessionKey, record.RunID, "export", record.PreparedArtifact != nil, exportedCount > 0, exportedCount == 0)
|
||||
o.server.decorateOpenClawArtifactDownloadURLs(result, record.SessionKey, record.RunID)
|
||||
stripOpenClawArtifactInlineContent(result)
|
||||
applyOpenClawArtifactContractResult(result, record.ArtifactContract)
|
||||
guardOpenClawAgentFailedBeforeReplyResult(result)
|
||||
guardOpenClawNoDisplayableResult(result, noDisplayableOutput)
|
||||
delete(result, openClawArtifactExportAttemptedField)
|
||||
|
||||
success := parseBool(result["success"])
|
||||
state := TaskStateCompleted
|
||||
stage := "completed"
|
||||
if !success {
|
||||
state = TaskStateFailed
|
||||
stage = "failed"
|
||||
}
|
||||
artifactRecord := buildArtifactRecord(sess, result, output)
|
||||
if len(artifactRecord.Artifacts) > 0 {
|
||||
result["artifacts"] = artifactRecord.Artifacts
|
||||
}
|
||||
if artifactRecord.RemoteWorkingDirectory != "" {
|
||||
result["remoteWorkingDirectory"] = artifactRecord.RemoteWorkingDirectory
|
||||
}
|
||||
if artifactRecord.RemoteWorkspaceRefKind != "" {
|
||||
result["remoteWorkspaceRefKind"] = artifactRecord.RemoteWorkspaceRefKind
|
||||
}
|
||||
if artifactRecord.ResultSummary != "" && strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" {
|
||||
result["resultSummary"] = artifactRecord.ResultSummary
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
sess.task.State = state
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = stage
|
||||
sess.task.ProgressMessage = output
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = stage
|
||||
sess.openClaw.ProgressMessage = output
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
if output != "" {
|
||||
sess.history = append(sess.history, "ASSISTANT: "+output)
|
||||
}
|
||||
sess.artifacts = artifactRecord
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
o.releaseOpenClawAdmission(sess)
|
||||
if notify != nil {
|
||||
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(record.SessionID, record.ThreadID, record.TurnID, result)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawSessionSnapshotLocked(sess *session) map[string]any {
|
||||
payload := map[string]any{
|
||||
"status": string(sess.task.State),
|
||||
"sessionId": sess.sessionID,
|
||||
"threadId": sess.threadID,
|
||||
"task": openClawTaskMapLocked(sess),
|
||||
}
|
||||
if len(sess.lastResult) > 0 {
|
||||
payload["result"] = cloneMap(sess.lastResult)
|
||||
}
|
||||
if len(sess.artifacts.Artifacts) > 0 ||
|
||||
sess.artifacts.RemoteWorkingDirectory != "" ||
|
||||
sess.artifacts.RemoteWorkspaceRefKind != "" ||
|
||||
sess.artifacts.ResultSummary != "" {
|
||||
payload["artifacts"] = map[string]any{
|
||||
"items": cloneMapSlice(sess.artifacts.Artifacts),
|
||||
"remoteWorkingDirectory": sess.artifacts.RemoteWorkingDirectory,
|
||||
"remoteWorkspaceRefKind": sess.artifacts.RemoteWorkspaceRefKind,
|
||||
"resultSummary": sess.artifacts.ResultSummary,
|
||||
"updatedAt": sess.artifacts.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
if sess.openClaw != nil {
|
||||
payload["progress"] = openClawTaskProgress(sess.openClaw)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawTaskMapLocked(sess *session) map[string]any {
|
||||
task := sess.task
|
||||
payload := map[string]any{
|
||||
"sessionId": task.SessionID,
|
||||
"threadId": task.ThreadID,
|
||||
"turnId": task.TurnID,
|
||||
"runId": task.RunID,
|
||||
"sessionKey": task.SessionKey,
|
||||
"provider": task.Provider,
|
||||
"target": task.Target,
|
||||
"gatewayProviderId": task.GatewayProviderID,
|
||||
"state": string(task.State),
|
||||
"kind": string(task.Kind),
|
||||
"taskLoadClass": task.TaskLoadClass,
|
||||
"artifactScope": task.ArtifactScope,
|
||||
"artifactDirectory": task.ArtifactDirectory,
|
||||
"runtimeBudgetMinutes": task.RuntimeBudgetMinutes,
|
||||
"updatedAt": task.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if !task.StartedAt.IsZero() {
|
||||
payload["startedAt"] = task.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !task.DeadlineAt.IsZero() {
|
||||
payload["deadlineAt"] = task.DeadlineAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !task.LastProbeAt.IsZero() {
|
||||
payload["lastProbeAt"] = task.LastProbeAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if task.ProgressStage != "" || task.ProgressMessage != "" {
|
||||
payload["progress"] = map[string]any{
|
||||
"stage": task.ProgressStage,
|
||||
"message": task.ProgressMessage,
|
||||
"terminal": task.ProgressTerminal,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
197
internal/acp/openclaw_run_registry.go
Normal file
197
internal/acp/openclaw_run_registry.go
Normal file
@ -0,0 +1,197 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
// 持久 run 仓 / run 关联与 WS 解耦(T7/T8/T9)。
|
||||
//
|
||||
// 背景:OpenClaw gateway turn 采用异步模型——chat.send 快速返回 runId,bridge 把
|
||||
// run 记录(sess.openClaw)、预算(sess.task.DeadlineAt)、运行句柄(sess.lastResult)
|
||||
// 落在「按 sessionID 维度」的 per-session store 里(s.sessions),其生命周期独立于
|
||||
// bridge↔gateway 的 WebSocket 连接。客户端随后轮询 tasks.get。
|
||||
//
|
||||
// 此前 tasks.get 每次都强依赖 gateway 应答:一旦 WS 抖动 / 重连后 run 内存态丢失,
|
||||
// tasks.get 回 not_found 或 socket_closed,已完成的结果就此丢失,客户端要么硬失败、
|
||||
// 要么(修复前)无限轮询。下列辅助把 tasks.get 改造为「优先用持久 run 仓兜底」:
|
||||
//
|
||||
// T8 已观察到的终态结果缓存进 sess.lastResult,gateway 之后查不到也不丢;
|
||||
// T7 gateway 暂时无法确认(unavailable / socket closed / not_found)但 run 仍在预算内时,
|
||||
// 合成一个 running 句柄让客户端继续轮询,跨越瞬时抖动(与 WS 生命周期解耦);
|
||||
// T9 run 超过 DeadlineAt 且 gateway 仍无法确认时,回确定性的 interrupted 终态。
|
||||
|
||||
// openClawTaskGetResultIsTerminal 判断一个 tasks.get 结果是否表示 run 已结束。
|
||||
// 注意:仍在 artifact 同步中的结果会被 normalizeOpenClawTaskGetResult 重写为 status=running,
|
||||
// 因此这里只认显式终态,不会把「同步中」误判为终态。
|
||||
func openClawTaskGetResultIsTerminal(payload map[string]any) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) {
|
||||
case string(TaskStateCompleted), string(TaskStateFailed), string(TaskStateCancelled),
|
||||
"interrupted", "partially_delivered":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheOpenClawTaskGetResultIfTerminal 把一次 gateway 确认的终态结果落进 per-session 持久 run 仓(T8)。
|
||||
func (s *Server) cacheOpenClawTaskGetResultIfTerminal(params map[string]any, payload map[string]any) {
|
||||
if len(payload) == 0 || !openClawTaskGetResultIsTerminal(payload) {
|
||||
return
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) {
|
||||
case string(TaskStateFailed):
|
||||
sess.task.State = TaskStateFailed
|
||||
case string(TaskStateCancelled):
|
||||
sess.task.State = TaskStateCancelled
|
||||
default:
|
||||
sess.task.State = TaskStateCompleted
|
||||
}
|
||||
sess.task.ProgressTerminal = true
|
||||
sess.task.ProgressStage = strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.lastResult = cloneMap(payload)
|
||||
}
|
||||
|
||||
// cachedTerminalOpenClawResult 返回某 run 此前已观察到的终态结果(若有)(T7/T8)。
|
||||
func (s *Server) cachedTerminalOpenClawResult(params map[string]any) (map[string]any, bool) {
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return nil, false
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
return cachedTerminalForRunLocked(sess, params)
|
||||
}
|
||||
|
||||
// cachedTerminalForRunLocked 仅当缓存终态确实属于「本次请求的 runId」时才命中,
|
||||
// 防止同一 session 复用后把旧 run 的终态错配给新 run。调用方须持有 sess.mu。
|
||||
func cachedTerminalForRunLocked(sess *session, params map[string]any) (map[string]any, bool) {
|
||||
if !sess.task.ProgressTerminal || len(sess.lastResult) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if !openClawTaskGetResultIsTerminal(sess.lastResult) {
|
||||
return nil, false
|
||||
}
|
||||
requestedRun := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
if requestedRun == "" {
|
||||
requestedRun = strings.TrimSpace(shared.StringArg(params, "taskId", ""))
|
||||
}
|
||||
if requestedRun != "" {
|
||||
cachedRun := firstNonEmptyString(sess.lastResult, "runId", "taskId")
|
||||
if cachedRun != "" && !strings.EqualFold(cachedRun, requestedRun) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cloneMap(sess.lastResult), true
|
||||
}
|
||||
|
||||
// openClawTaskGetGatewayUnconfirmedFallback 在 gateway 无法确认 run 时,用持久 run 仓兜底(T7/T9):
|
||||
// - 已有缓存终态 -> 直接返回;
|
||||
// - run 仍在预算内 -> 合成 running 句柄,客户端继续轮询,跨越瞬时抖动;
|
||||
// - run 超过 deadline -> 回确定性 interrupted 终态。
|
||||
//
|
||||
// 没有任何 per-session 记录时退回旧行为(not_found),不改变无状态查询的语义。
|
||||
func (s *Server) openClawTaskGetGatewayUnconfirmedFallback(params map[string]any, code string, message string) map[string]any {
|
||||
notFound := func() map[string]any {
|
||||
return map[string]any{
|
||||
"ok": false,
|
||||
"status": "not_found",
|
||||
"code": fallbackString(code, "TASK_LOOKUP_FAILED"),
|
||||
"message": fallbackString(message, "openclaw native task lookup failed"),
|
||||
}
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return notFound()
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if cached, ok := cachedTerminalForRunLocked(sess, params); ok {
|
||||
return cached
|
||||
}
|
||||
if sess.openClaw == nil {
|
||||
return notFound()
|
||||
}
|
||||
now := time.Now()
|
||||
if !sess.task.DeadlineAt.IsZero() && now.After(sess.task.DeadlineAt) {
|
||||
return s.markOpenClawRunDeadlineInterruptedLocked(sess, code, message)
|
||||
}
|
||||
// 仍在预算内:合成 running 句柄让客户端继续轮询,不因一次瞬时抖动硬失败。
|
||||
metricTaskGetUnconfirmedFallbackInc() // T12
|
||||
running := openClawRunningTaskResult(sess.openClaw)
|
||||
running["transportDegraded"] = true
|
||||
if strings.TrimSpace(code) != "" {
|
||||
running["transportDegradedCode"] = strings.TrimSpace(code)
|
||||
}
|
||||
// T11:带 runId 的日志,便于与 App / 插件 / 网关四层按 runId 串联。
|
||||
log.Printf("level=warn component=openclaw_run_registry event=tasks_get_unconfirmed_fallback runId=%q openclawSessionKey=%q code=%q",
|
||||
sess.openClaw.RunID, sess.openClaw.SessionKey, strings.TrimSpace(code))
|
||||
sess.lastResult = cloneMap(running)
|
||||
return running
|
||||
}
|
||||
|
||||
// markOpenClawRunDeadlineInterruptedLocked 为「超过预算且 gateway 无法确认」的 run 生成确定性
|
||||
// interrupted 终态(T9)。调用方须持有 sess.mu。
|
||||
func (s *Server) markOpenClawRunDeadlineInterruptedLocked(sess *session, code string, message string) map[string]any {
|
||||
now := time.Now()
|
||||
sess.task.State = TaskStateFailed
|
||||
sess.task.ProgressTerminal = true
|
||||
sess.task.ProgressStage = "interrupted"
|
||||
sess.task.ProgressMessage = "OpenClaw run exceeded its budget and could not be confirmed"
|
||||
sess.task.UpdatedAt = now
|
||||
metricRunDeadlineInterruptInc() // T12
|
||||
// T11:带 runId 的终态日志。
|
||||
if sess.openClaw != nil {
|
||||
log.Printf("level=warn component=openclaw_run_registry event=run_deadline_interrupt runId=%q openclawSessionKey=%q deadlineAt=%q code=%q",
|
||||
sess.openClaw.RunID, sess.openClaw.SessionKey,
|
||||
sess.openClaw.DeadlineAt.UTC().Format(time.RFC3339Nano), strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"status": "interrupted",
|
||||
"event": "interrupted",
|
||||
"pending": false,
|
||||
"code": "OPENCLAW_RUN_DEADLINE_EXCEEDED",
|
||||
"artifactSyncStatus": "interrupted",
|
||||
"message": "OpenClaw 任务超过预算上限且网关无法确认结果,已结束本轮等待。任务可能已在后台完成,请重新发送请求以拿回结果。",
|
||||
"artifacts": []any{},
|
||||
}
|
||||
if strings.TrimSpace(code) != "" {
|
||||
result["gatewayUnconfirmedCode"] = strings.TrimSpace(code)
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
result["gatewayUnconfirmedMessage"] = strings.TrimSpace(message)
|
||||
}
|
||||
if record := sess.openClaw; record != nil {
|
||||
result["runId"] = record.RunID
|
||||
result["taskId"] = record.RunID
|
||||
result["turnId"] = record.TurnID
|
||||
result["sessionId"] = record.SessionID
|
||||
result["threadId"] = record.ThreadID
|
||||
result["appThreadKey"] = record.ThreadID
|
||||
result["openclawSessionKey"] = record.SessionKey
|
||||
result["resolvedGatewayProviderId"] = record.GatewayProviderID
|
||||
result["startedAt"] = record.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
result["deadlineAt"] = record.DeadlineAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
sess.lastResult = cloneMap(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackString(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
154
internal/acp/openclaw_run_registry_test.go
Normal file
154
internal/acp/openclaw_run_registry_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func newRunRegistryTestServer(deadline time.Time) (*Server, map[string]any) {
|
||||
sess := &session{sessionID: "s1", threadID: "t1"}
|
||||
sess.task.RunID = "run-1"
|
||||
sess.task.SessionKey = "sk"
|
||||
sess.task.GatewayProviderID = "openclaw"
|
||||
sess.task.DeadlineAt = deadline
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: "s1",
|
||||
ThreadID: "t1",
|
||||
TurnID: "turn-1",
|
||||
RunID: "run-1",
|
||||
SessionKey: "sk",
|
||||
GatewayProviderID: "openclaw",
|
||||
StartedAt: time.Now().Add(-time.Minute),
|
||||
DeadlineAt: deadline,
|
||||
}
|
||||
srv := &Server{sessions: map[string]*session{"s1": sess}}
|
||||
params := map[string]any{"sessionId": "s1", "runId": "run-1"}
|
||||
return srv, params
|
||||
}
|
||||
|
||||
func TestOpenClawTaskGetResultIsTerminal(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"completed", true},
|
||||
{"failed", true},
|
||||
{"cancelled", true},
|
||||
{"interrupted", true},
|
||||
{"partially_delivered", true},
|
||||
{"running", false},
|
||||
{"syncing-artifacts", false},
|
||||
{"queued", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := openClawTaskGetResultIsTerminal(map[string]any{"status": tc.status}); got != tc.want {
|
||||
t.Errorf("status=%q: got %v, want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T7: gateway 无法确认但 run 仍在预算内 -> 合成 running 句柄续轮询。
|
||||
func TestGatewayUnconfirmedFallbackWithinBudgetKeepsPolling(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("status = %q, want running", status)
|
||||
}
|
||||
if !parseBool(got["transportDegraded"]) {
|
||||
t.Fatalf("transportDegraded not set: %v", got)
|
||||
}
|
||||
if shared.StringArg(got, "runId", "") != "run-1" {
|
||||
t.Fatalf("runId mismatch: %v", got["runId"])
|
||||
}
|
||||
}
|
||||
|
||||
// T9: run 超过 deadline 且 gateway 无法确认 -> 确定性 interrupted 终态。
|
||||
func TestGatewayUnconfirmedFallbackPastDeadlineInterrupts(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(-time.Minute))
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if status := shared.StringArg(got, "status", ""); status != "interrupted" {
|
||||
t.Fatalf("status = %q, want interrupted", status)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != "OPENCLAW_RUN_DEADLINE_EXCEEDED" {
|
||||
t.Fatalf("code = %q, want OPENCLAW_RUN_DEADLINE_EXCEEDED", code)
|
||||
}
|
||||
if parseBool(got["success"]) {
|
||||
t.Fatalf("interrupted result must not be success")
|
||||
}
|
||||
sess := srv.findTaskSession(params)
|
||||
if sess == nil || !sess.task.ProgressTerminal || sess.task.State != TaskStateFailed {
|
||||
t.Fatalf("session terminal state not recorded: %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
// T8: 已观察到的终态被缓存,且即使之后 gateway 不可达也优先返回缓存终态。
|
||||
func TestTerminalResultCachedAndServedAfterGatewayLoss(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
terminal := map[string]any{
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"runId": "run-1",
|
||||
"message": "done",
|
||||
}
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, terminal)
|
||||
|
||||
cached, ok := srv.cachedTerminalOpenClawResult(params)
|
||||
if !ok {
|
||||
t.Fatalf("expected cached terminal result")
|
||||
}
|
||||
if shared.StringArg(cached, "status", "") != "completed" {
|
||||
t.Fatalf("cached status = %q, want completed", cached["status"])
|
||||
}
|
||||
|
||||
// 即使 run 已过 deadline + gateway 丢失,也应优先返回缓存终态而非 interrupted。
|
||||
sess := srv.findTaskSession(params)
|
||||
sess.mu.Lock()
|
||||
sess.task.DeadlineAt = time.Now().Add(-time.Hour)
|
||||
sess.mu.Unlock()
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if shared.StringArg(got, "status", "") != "completed" {
|
||||
t.Fatalf("expected cached completed to win over deadline interrupt, got %v", got["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// 同一 session 复用后,旧 run 的终态不得错配给新 runId 的查询。
|
||||
func TestCachedTerminalNotServedForDifferentRunId(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, map[string]any{
|
||||
"status": "completed", "success": true, "runId": "run-1",
|
||||
})
|
||||
// 新一轮查询带不同 runId -> 不应命中旧缓存。
|
||||
newParams := map[string]any{"sessionId": "s1", "runId": "run-2"}
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(newParams); ok {
|
||||
t.Fatalf("stale terminal for run-1 must not be served for run-2")
|
||||
}
|
||||
// 原 runId 仍应命中。
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(params); !ok {
|
||||
t.Fatalf("terminal for run-1 should still be served for run-1")
|
||||
}
|
||||
}
|
||||
|
||||
// running 结果不应被当作终态缓存。
|
||||
func TestRunningResultNotCachedAsTerminal(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, map[string]any{"status": "running", "runId": "run-1"})
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(params); ok {
|
||||
t.Fatalf("running result must not be cached as terminal")
|
||||
}
|
||||
}
|
||||
|
||||
// 无 per-session 记录时退回旧的 not_found 行为。
|
||||
func TestGatewayUnconfirmedFallbackWithoutSessionReturnsNotFound(t *testing.T) {
|
||||
srv := &Server{sessions: map[string]*session{}}
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(map[string]any{"sessionId": "missing"}, "X", "y")
|
||||
if parseBool(got["ok"]) {
|
||||
t.Fatalf("expected ok=false not_found, got %v", got)
|
||||
}
|
||||
if shared.StringArg(got, "status", "") != "not_found" {
|
||||
t.Fatalf("status = %q, want not_found", got["status"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
374
internal/acp/orchestrator_normalize_result_test.go
Normal file
374
internal/acp/orchestrator_normalize_result_test.go
Normal file
@ -0,0 +1,374 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func TestNormalizeResultGatewaySuccessEvidenceAdjudication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
routingTarget string
|
||||
result map[string]any
|
||||
wantSuccess bool
|
||||
wantStatus string
|
||||
wantCode string
|
||||
wantSuccessSource string
|
||||
}{
|
||||
{
|
||||
name: "gateway absent success without output or artifacts fails",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{},
|
||||
wantSuccess: false,
|
||||
wantStatus: string(TaskStateFailed),
|
||||
wantCode: "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE",
|
||||
},
|
||||
{
|
||||
name: "gateway absent success with output is inferred",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"output": "done"},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
wantSuccessSource: "inferred",
|
||||
},
|
||||
{
|
||||
name: "gateway absent success with artifacts is inferred",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{
|
||||
"artifacts": []any{map[string]any{"relativePath": "reports/final.md"}},
|
||||
},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
wantSuccessSource: "inferred",
|
||||
},
|
||||
{
|
||||
name: "gateway explicit false remains failed",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"success": false, "output": "failed"},
|
||||
wantSuccess: false,
|
||||
wantStatus: string(TaskStateFailed),
|
||||
},
|
||||
{
|
||||
name: "gateway explicit true remains completed",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"success": true},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
},
|
||||
{
|
||||
name: "non gateway absent success keeps legacy inference",
|
||||
routingTarget: "single-agent",
|
||||
result: map[string]any{"output": "done"},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewServer()
|
||||
orchestrator := NewSessionOrchestrator(server)
|
||||
sess := server.getOrCreateSession("session-"+strings.ReplaceAll(tc.name, " ", "-"), "thread")
|
||||
|
||||
got := orchestrator.normalizeResult(
|
||||
sess,
|
||||
tc.result,
|
||||
RoutingResult{TargetID: tc.routingTarget, ProviderID: "provider", GatewayProviderID: "openclaw"},
|
||||
"turn-1",
|
||||
map[string]any{},
|
||||
)
|
||||
|
||||
if parseBool(got["success"]) != tc.wantSuccess {
|
||||
t.Fatalf("success = %#v, want %v in %#v", got["success"], tc.wantSuccess, got)
|
||||
}
|
||||
if status := shared.StringArg(got, "status", ""); status != tc.wantStatus {
|
||||
t.Fatalf("status = %q, want %q in %#v", status, tc.wantStatus, got)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != tc.wantCode {
|
||||
t.Fatalf("code = %q, want %q in %#v", code, tc.wantCode, got)
|
||||
}
|
||||
if source := shared.StringArg(got, "successSource", ""); source != tc.wantSuccessSource {
|
||||
t.Fatalf("successSource = %q, want %q in %#v", source, tc.wantSuccessSource, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawTaskGetUnknownArtifactEvidenceKeepsActiveRecordRunning(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"status": "unknown",
|
||||
"taskStatus": "unknown",
|
||||
"evidence": "artifacts_present",
|
||||
"artifactCount": 1,
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"artifacts": []any{map[string]any{"relativePath": "series.config.json"}},
|
||||
}
|
||||
record := &OpenClawTaskRecord{
|
||||
RunID: "run",
|
||||
SessionKey: "session",
|
||||
GatewayProviderID: "openclaw",
|
||||
RequiresArtifactExport: true,
|
||||
DeadlineAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
|
||||
got := normalizeOpenClawTaskGetResult(
|
||||
map[string]any{"requiredArtifactExtensions": []any{"pdf"}},
|
||||
payload,
|
||||
"openclaw",
|
||||
record,
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected active unknown artifact evidence to remain running, got %#v", got)
|
||||
}
|
||||
if evidence := shared.StringArg(got, "artifactEvidence", ""); evidence != "artifacts_present" {
|
||||
t.Fatalf("expected artifact evidence audit field, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedArtifactDirectoriesDoNotBlockTerminalTaskState(t *testing.T) {
|
||||
params := map[string]any{"expectedArtifactDirs": []any{"reports/", "artifacts/"}}
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"expectedArtifactDirs": []any{
|
||||
"reports/",
|
||||
"artifacts/",
|
||||
},
|
||||
}
|
||||
|
||||
if openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
t.Fatal("expectedArtifactDirs must remain non-blocking scan hints")
|
||||
}
|
||||
got := normalizeOpenClawTaskGetResult(params, payload, "openclaw", nil)
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateCompleted) {
|
||||
t.Fatalf("expected terminal status to remain completed, got %#v", got)
|
||||
}
|
||||
if parseBool(got["pending"]) {
|
||||
t.Fatalf("expected terminal payload not to become pending, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredArtifactExtensionsStillBlockUntilVerified(t *testing.T) {
|
||||
params := map[string]any{"requiredArtifactExtensions": []any{"md"}}
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
}
|
||||
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
t.Fatal("requiredArtifactExtensions must remain a blocking delivery contract")
|
||||
}
|
||||
got := normalizeOpenClawTaskGetResult(params, payload, "openclaw", nil)
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected missing required artifact to remain syncing, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawTaskGetUnknownArtifactEvidenceFailsAfterDeadlineWithoutRequiredArtifacts(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"status": "unknown",
|
||||
"taskStatus": "unknown",
|
||||
"evidence": "artifacts_present",
|
||||
"artifactCount": 1,
|
||||
"runId": "run",
|
||||
"openclawSessionKey": "session",
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"artifacts": []any{map[string]any{"relativePath": "series.config.json"}},
|
||||
}
|
||||
record := &OpenClawTaskRecord{DeadlineAt: time.Now().Add(-time.Minute)}
|
||||
|
||||
got := normalizeOpenClawTaskGetResult(
|
||||
map[string]any{"requiredArtifactExtensions": []any{"pdf"}},
|
||||
payload,
|
||||
"openclaw",
|
||||
record,
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateFailed) {
|
||||
t.Fatalf("expected expired unknown artifact evidence to fail, got %#v", got)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE" {
|
||||
t.Fatalf("expected evidence failure code, got %#v", got)
|
||||
}
|
||||
if missing := shared.ListArg(got, "missingRequiredExtensions"); !slices.ContainsFunc(missing, func(value any) bool {
|
||||
return strings.TrimSpace(shared.StringArg(map[string]any{"value": value}, "value", "")) == "pdf"
|
||||
}) {
|
||||
t.Fatalf("expected missing pdf extension, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactConstraintFieldsArePropagatedAndMarkPartialDelivery(t *testing.T) {
|
||||
result := map[string]any{}
|
||||
mergeOpenClawArtifactPayload(result, map[string]any{
|
||||
"constraintSatisfied": false,
|
||||
"missingRequiredExtensions": []any{"pdf"},
|
||||
})
|
||||
if got := result["constraintSatisfied"]; got != false {
|
||||
t.Fatalf("expected constraintSatisfied=false to propagate, got %#v", result)
|
||||
}
|
||||
if missing := shared.ListArg(result, "missingRequiredExtensions"); len(missing) != 1 || missing[0] != "pdf" {
|
||||
t.Fatalf("expected missingRequiredExtensions to propagate, got %#v", result)
|
||||
}
|
||||
|
||||
server := NewServer()
|
||||
orchestrator := NewSessionOrchestrator(server)
|
||||
sess := server.getOrCreateSession("session-partial-delivery", "thread-partial-delivery")
|
||||
got := orchestrator.normalizeResult(
|
||||
sess,
|
||||
map[string]any{
|
||||
"success": true,
|
||||
"output": "created some files",
|
||||
"constraintSatisfied": false,
|
||||
"missingRequiredExtensions": []any{"pdf"},
|
||||
},
|
||||
RoutingResult{TargetID: "gateway", ProviderID: "gateway", GatewayProviderID: "openclaw"},
|
||||
"turn-partial-delivery",
|
||||
map[string]any{},
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != "partially_delivered" {
|
||||
t.Fatalf("expected partially_delivered status, got %#v", got)
|
||||
}
|
||||
if !parseBool(got["success"]) {
|
||||
t.Fatalf("partial delivery should preserve success=true, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactsSatisfyEveryRequiredExtension(t *testing.T) {
|
||||
artifacts := []map[string]any{
|
||||
{"relativePath": "exports/final.pdf"},
|
||||
}
|
||||
if openClawArtifactsSatisfyRequiredExtensions(artifacts, []string{"pdf", "mp4"}) {
|
||||
t.Fatalf("expected only pdf artifact to miss mp4 requirement")
|
||||
}
|
||||
if !openClawArtifactsSatisfyRequiredExtensions(
|
||||
append(artifacts, map[string]any{"relativePath": "exports/final.MP4"}),
|
||||
[]string{"pdf", "mp4"},
|
||||
) {
|
||||
t.Fatalf("expected pdf and mp4 artifacts to satisfy both requirements")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskGetArtifactExportReceivesRequiredArtifactExtensions(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
|
||||
server := NewServer()
|
||||
start, rpcErr := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "session-export-required-exts",
|
||||
"threadId": "thread-export-required-exts",
|
||||
"taskPrompt": "say pong",
|
||||
"workingDirectory": t.TempDir(),
|
||||
"metadata": map[string]any{
|
||||
"xworkmateTaskArtifactContract": map[string]any{
|
||||
"requiresExportBeforeFinalResponse": true,
|
||||
},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "gateway",
|
||||
"preferredGatewayProviderId": "openclaw",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected running task handle, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
response, rpcErr := server.handleRequest(shared.RPCRequest{
|
||||
Method: "xworkmate.tasks.get",
|
||||
Params: map[string]any{
|
||||
"sessionId": shared.StringArg(start, "sessionId", ""),
|
||||
"threadId": shared.StringArg(start, "threadId", ""),
|
||||
"turnId": shared.StringArg(start, "turnId", ""),
|
||||
"runId": shared.StringArg(start, "runId", ""),
|
||||
"appThreadKey": shared.StringArg(start, "appThreadKey", ""),
|
||||
"openclawSessionKey": shared.StringArg(start, "openclawSessionKey", ""),
|
||||
"artifactScope": shared.StringArg(start, "artifactScope", ""),
|
||||
"artifactDirectory": shared.StringArg(start, "artifactDirectory", ""),
|
||||
"gatewayProviderId": shared.StringArg(start, "resolvedGatewayProviderId", ""),
|
||||
"requiresArtifactExport": true,
|
||||
"requiredArtifactExtensions": []any{"pdf"},
|
||||
"expectedFileCountByExtension": map[string]any{
|
||||
"pdf": 1,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected task lookup response, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
if status := shared.StringArg(response, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected missing required artifact to keep syncing, got %#v", response)
|
||||
}
|
||||
exportParams := gateway.LastArtifactExportParams()
|
||||
if got := shared.ListArg(exportParams, "requiredArtifactExtensions"); len(got) != 1 || got[0] != "pdf" {
|
||||
t.Fatalf("expected requiredArtifactExtensions to reach export, got %#v", exportParams)
|
||||
}
|
||||
if got := shared.AsMap(exportParams["expectedFileCountByExtension"]); openClawPositiveInt(got["pdf"]) != 1 {
|
||||
t.Fatalf("expected expectedFileCountByExtension to reach export, got %#v", exportParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenClawUnknownMethodErrorAcceptsNumericGatewayCodes(t *testing.T) {
|
||||
const method = "xworkmate.session.prepare"
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "string invalid_request code",
|
||||
payload: map[string]any{"code": "INVALID_REQUEST", "message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric -32002 (real gateway shape that previously hard-failed)",
|
||||
payload: map[string]any{"code": float64(-32002), "message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric -32601 method not found",
|
||||
payload: map[string]any{"code": float64(-32601), "message": "Unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty code",
|
||||
payload: map[string]any{"message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated error must not be swallowed",
|
||||
payload: map[string]any{"code": float64(-32002), "message": "gateway socket closed"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown method for a different method name",
|
||||
payload: map[string]any{"code": float64(-32601), "message": "unknown method: chat.send"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOpenClawUnknownMethodError(tc.payload, method); got != tc.want {
|
||||
t.Fatalf("isOpenClawUnknownMethodError(%v) = %v, want %v", tc.payload, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -78,9 +78,7 @@ func newProviderCompat(provider syncedProvider) ProviderCompat {
|
||||
endpoint: resolveSingleAgentForwardEndpoint(provider),
|
||||
authHeader: provider.AuthorizationHeader,
|
||||
category: providerCategory(provider.ProviderID),
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
},
|
||||
client: shared.NewHTTPClient(5 * time.Minute),
|
||||
}
|
||||
switch provider.ProviderID {
|
||||
case "gemini":
|
||||
|
||||
@ -37,12 +37,12 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
capabilities := response
|
||||
if capabilities["multiAgent"] != true {
|
||||
t.Fatalf("expected multiAgent capability to be enabled, got %#v", capabilities["multiAgent"])
|
||||
if capabilities["multiAgent"] != false {
|
||||
t.Fatalf("expected multiAgent capability to be disabled, got %#v", capabilities["multiAgent"])
|
||||
}
|
||||
nestedCapabilities := shared.AsMap(capabilities["capabilities"])
|
||||
if nestedCapabilities["multi_agent"] != true {
|
||||
t.Fatalf("expected nested multi_agent capability to be enabled, got %#v", nestedCapabilities["multi_agent"])
|
||||
if nestedCapabilities["multi_agent"] != false {
|
||||
t.Fatalf("expected nested multi_agent capability to be disabled, got %#v", nestedCapabilities["multi_agent"])
|
||||
}
|
||||
targets, ok := capabilities["availableExecutionTargets"].([]any)
|
||||
if !ok {
|
||||
@ -95,8 +95,8 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogFallsBackToBridgeAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
@ -109,9 +109,24 @@ func TestProductionProviderCatalogFallsBackToBridgeAuthToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogPrefersAIWorkspaceAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
if !ok {
|
||||
t.Fatal("missing codex")
|
||||
}
|
||||
|
||||
if got := p.AuthorizationHeader; got != "Bearer ai-workspace-token" {
|
||||
t.Fatalf("expected AI workspace bearer header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogPrefersDedicatedBridgeAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "dedicated-token")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "legacy-token")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
@ -125,6 +140,7 @@ func TestProductionProviderCatalogPrefersDedicatedBridgeAuthToken(t *testing.T)
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogIgnoresInternalServiceToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "legacy-token")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,10 +2,14 @@ package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"xworkmate-bridge/internal/desktop"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||||
@ -16,6 +20,13 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
|
||||
case "health":
|
||||
return map[string]any{"status": "ok", "version": "0.7.0", "role": "acp-control-plane"}, nil
|
||||
|
||||
case "system.logs":
|
||||
return map[string]any{
|
||||
"bridgeStatus": "ok",
|
||||
"gatewayStatus": s.gatewayStatusForSystemLogs(),
|
||||
"bridgeLogs": shared.GlobalLogBuffer.GetLines(),
|
||||
}, nil
|
||||
|
||||
case "acp.capabilities":
|
||||
return s.catalog.Get(), nil
|
||||
|
||||
@ -56,12 +67,18 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
|
||||
// Gateway 语义由专门的 Gateway 组件通过 Adapter 处理
|
||||
return s.handleGatewayMethod(ctx, method, request.Params, notify)
|
||||
|
||||
case "xworkmate.desktop.offer", "xworkmate.desktop.ice", "xworkmate.desktop.close":
|
||||
return s.handleDesktopMethod(ctx, method, request.Params, notify)
|
||||
|
||||
case "xworkmate.jobs.submit", "xworkmate.jobs.get", "xworkmate.jobs.list", "xworkmate.jobs.stats":
|
||||
return s.handleJobMethod(ctx, method, request.Params, notify)
|
||||
|
||||
case "xworkmate.tasks.get":
|
||||
return s.handleTaskGet(ctx, request.Params, notify), nil
|
||||
|
||||
case "xworkmate.session.prepare":
|
||||
return s.handleSessionPrepare(ctx, request.Params, notify)
|
||||
|
||||
case "xworkmate.tasks.cancel":
|
||||
return s.handleTaskCancel(ctx, request.Params, notify), nil
|
||||
|
||||
@ -76,42 +93,533 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionPrepare(ctx context.Context, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
}
|
||||
if rpcErr := ensureProductionGatewayConnected(s, gatewayProvider, notify); rpcErr != nil {
|
||||
return openClawFallbackSessionPreparePayload(params), nil
|
||||
}
|
||||
result := s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"xworkmate.session.prepare",
|
||||
params,
|
||||
30*time.Second,
|
||||
notify,
|
||||
)
|
||||
if result.OK {
|
||||
payload := shared.AsMap(result.Payload)
|
||||
if openClawPreparedArtifactScopeFromPayload(payload) != nil {
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
if !result.OK && !isOpenClawUnknownMethodError(result.Error, "xworkmate.session.prepare") {
|
||||
return nil, gatewayRPCError(result.Error, "openclaw artifact prepare failed")
|
||||
}
|
||||
return openClawFallbackSessionPreparePayload(params), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskGet(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
|
||||
params = s.taskGetParamsWithSessionScope(params)
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
}
|
||||
// T7/T8: 一旦观察到终态就从持久 run 仓返回,避免之后 gateway 查不到导致结果丢失。
|
||||
if cached, ok := s.cachedTerminalOpenClawResult(params); ok {
|
||||
return cached
|
||||
}
|
||||
if rpcErr := ensureProductionGatewayConnected(s, gatewayProvider, notify); rpcErr != nil {
|
||||
// T7/T9: gateway 不可达时按持久 run 仓兜底(续轮询 / deadline 终态),而非裸 not_found。
|
||||
return s.openClawTaskGetGatewayUnconfirmedFallback(params, "GATEWAY_UNAVAILABLE", rpcErr.Message)
|
||||
}
|
||||
result := s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"xworkmate.tasks.get",
|
||||
openClawTaskLookupParams(params),
|
||||
30*time.Second,
|
||||
notify,
|
||||
)
|
||||
if result.OK {
|
||||
payload := shared.AsMap(result.Payload)
|
||||
activeOpenClawTask := s.activeOpenClawTaskRecord(params)
|
||||
s.mergeOpenClawTaskGetArtifactExport(payload, params, gatewayProvider, notify)
|
||||
payload = normalizeOpenClawTaskGetResult(params, payload, gatewayProvider, activeOpenClawTask)
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
s.decorateOpenClawArtifactDownloadURLs(payload, sessionKey, runID)
|
||||
stripOpenClawArtifactInlineContent(payload)
|
||||
// T8: 缓存「最终客户端可见形态」(已 decorate 下载 URL + strip 内联内容),
|
||||
// 这样从缓存回放时与正常路径完全一致。
|
||||
s.cacheOpenClawTaskGetResultIfTerminal(params, payload)
|
||||
return payload
|
||||
}
|
||||
// T7/T9: gateway 返回错误(socket closed / not_found / lookup failed)时同样走持久 run 仓兜底。
|
||||
message := strings.TrimSpace(shared.StringArg(result.Error, "message", "openclaw native task lookup failed"))
|
||||
code := strings.TrimSpace(shared.StringArg(result.Error, "code", "TASK_LOOKUP_FAILED"))
|
||||
return s.openClawTaskGetGatewayUnconfirmedFallback(params, code, message)
|
||||
}
|
||||
|
||||
func (s *Server) taskGetParamsWithSessionScope(params map[string]any) map[string]any {
|
||||
next := make(map[string]any, len(params)+8)
|
||||
for key, value := range params {
|
||||
next[key] = value
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
sess = s.reassociateOpenClawTask(params)
|
||||
return next
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if strings.TrimSpace(shared.StringArg(next, "runId", "")) == "" {
|
||||
next["runId"] = sess.task.RunID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "taskId", "")) == "" {
|
||||
next["taskId"] = sess.task.RunID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "gatewayProviderId", "")) == "" {
|
||||
next["gatewayProviderId"] = sess.task.GatewayProviderID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "artifactScope", "")) == "" {
|
||||
next["artifactScope"] = sess.task.ArtifactScope
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "artifactDirectory", "")) == "" {
|
||||
next["artifactDirectory"] = sess.task.ArtifactDirectory
|
||||
}
|
||||
if _, ok := next["requiresArtifactExport"]; !ok && sess.openClaw != nil && sess.openClaw.RequiresArtifactExport {
|
||||
next["requiresArtifactExport"] = true
|
||||
}
|
||||
if _, ok := next["expectedArtifactDirs"]; !ok && sess.openClaw != nil && len(sess.openClaw.ExpectedArtifactDirs) > 0 {
|
||||
next["expectedArtifactDirs"] = append([]string(nil), sess.openClaw.ExpectedArtifactDirs...)
|
||||
}
|
||||
if _, ok := next["requiredArtifactExtensions"]; !ok && sess.openClaw != nil && len(sess.openClaw.RequiredArtifactExts) > 0 {
|
||||
next["requiredArtifactExtensions"] = append([]string(nil), sess.openClaw.RequiredArtifactExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "openclawSessionKey", "")) == "" {
|
||||
next["openclawSessionKey"] = sess.task.SessionKey
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "appThreadKey", "")) == "" {
|
||||
next["appThreadKey"] = sess.threadID
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) mergeOpenClawTaskGetArtifactExport(payload map[string]any, params map[string]any, gatewayProvider string, notify func(map[string]any)) {
|
||||
if len(payload) == 0 || s == nil || s.orchestrator == nil {
|
||||
return
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
if status == string(TaskStateRunning) || status == string(TaskStateFailed) || status == string(TaskStateCancelled) {
|
||||
return
|
||||
}
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
return
|
||||
}
|
||||
success := true
|
||||
if value, ok := payload["success"]; ok {
|
||||
success = parseBool(value)
|
||||
}
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
artifactScope := firstNonEmptyString(payload, "artifactScope")
|
||||
if artifactScope == "" {
|
||||
artifactScope = strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
}
|
||||
artifactDirectory := firstNonEmptyString(payload, "artifactDirectory")
|
||||
if artifactDirectory == "" {
|
||||
artifactDirectory = strings.TrimSpace(shared.StringArg(params, "artifactDirectory", ""))
|
||||
}
|
||||
if sessionKey == "" || runID == "" || artifactScope == "" || artifactDirectory == "" {
|
||||
return
|
||||
}
|
||||
prepared := &openClawPreparedArtifactScope{
|
||||
RemoteWorkingDirectory: remoteWorkingDirectory,
|
||||
RemoteWorkspaceRefKind: strings.TrimSpace(shared.StringArg(payload, "remoteWorkspaceRefKind", "")),
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: artifactDirectory,
|
||||
ScopeKind: "task",
|
||||
}
|
||||
exportParams := map[string]any{
|
||||
"openclawSessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"artifactScope": artifactScope,
|
||||
"sinceUnixMs": 0,
|
||||
"maxFiles": 64,
|
||||
"maxInlineBytes": 0,
|
||||
"includeContent": false,
|
||||
}
|
||||
if expectedDirs := openClawTaskGetExpectedArtifactDirs(params, payload); len(expectedDirs) > 0 {
|
||||
exportParams["expectedArtifactDirs"] = expectedDirs
|
||||
}
|
||||
if requiredExts := openClawTaskGetRequiredArtifactExtensions(params, payload); len(requiredExts) > 0 {
|
||||
exportParams["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if expectedCounts := openClawTaskGetExpectedFileCounts(params, payload); len(expectedCounts) > 0 {
|
||||
exportParams["expectedFileCountByExtension"] = expectedCounts
|
||||
}
|
||||
exportPayload := s.orchestrator.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
|
||||
if openClawArtifactExportPayloadAuthoritative(exportPayload) {
|
||||
replaceOpenClawArtifactPayload(payload, exportPayload)
|
||||
} else {
|
||||
mergeOpenClawArtifactPayload(payload, exportPayload)
|
||||
}
|
||||
applyOpenClawPreparedArtifactToResult(payload, prepared)
|
||||
s.decorateOpenClawArtifactDownloadURLs(payload, sessionKey, runID)
|
||||
stripOpenClawArtifactInlineContent(payload)
|
||||
}
|
||||
|
||||
func (s *Server) activeOpenClawTaskRecord(params map[string]any) *OpenClawTaskRecord {
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return map[string]any{"status": "not_found"}
|
||||
return nil
|
||||
}
|
||||
return s.orchestrator.probeOpenClawTask(ctx, sess, notify)
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if sess.task.State != TaskStateRunning {
|
||||
return nil
|
||||
}
|
||||
if sess.openClaw == nil {
|
||||
return nil
|
||||
}
|
||||
record := *sess.openClaw
|
||||
return &record
|
||||
}
|
||||
|
||||
func normalizeOpenClawTaskGetResult(params map[string]any, payload map[string]any, gatewayProvider string, activeRecord *OpenClawTaskRecord) map[string]any {
|
||||
if len(payload) == 0 {
|
||||
return payload
|
||||
}
|
||||
artifactScope := firstNonEmptyString(payload, "artifactScope")
|
||||
if artifactScope == "" {
|
||||
artifactScope = strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
}
|
||||
artifactDirectory := firstNonEmptyString(payload, "artifactDirectory")
|
||||
if artifactDirectory == "" {
|
||||
artifactDirectory = strings.TrimSpace(shared.StringArg(params, "artifactDirectory", ""))
|
||||
}
|
||||
if artifactScope == "" && artifactDirectory == "" {
|
||||
return payload
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
|
||||
artifacts := extractArtifactPayloads(payload, remoteWorkingDirectory)
|
||||
requiredExts := openClawTaskGetRequiredArtifactExtensions(params, payload)
|
||||
if openClawUnknownArtifactEvidence(payload, artifacts) {
|
||||
return adjudicateOpenClawUnknownArtifactEvidence(params, payload, gatewayProvider, activeRecord, artifacts, requiredExts, artifactScope, artifactDirectory)
|
||||
}
|
||||
applyOpenClawConstraintDeliveryStatus(payload)
|
||||
if strings.TrimSpace(shared.StringArg(payload, "status", "")) == "partially_delivered" {
|
||||
return payload
|
||||
}
|
||||
if len(artifacts) > 0 && openClawArtifactsSatisfyRequiredExtensions(artifacts, requiredExts) {
|
||||
return payload
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
success := true
|
||||
if value, ok := payload["success"]; ok {
|
||||
success = parseBool(value)
|
||||
}
|
||||
if !success || status == string(TaskStateRunning) || status == string(TaskStateFailed) || status == string(TaskStateCancelled) {
|
||||
return payload
|
||||
}
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
return payload
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
payload["success"] = true
|
||||
payload["status"] = string(TaskStateRunning)
|
||||
payload["event"] = string(TaskStateRunning)
|
||||
payload["pending"] = true
|
||||
payload["artifactSyncStatus"] = "syncing"
|
||||
payload["message"] = "OpenClaw task completed; waiting for artifact export."
|
||||
payload["runId"] = runID
|
||||
payload["taskId"] = runID
|
||||
payload["openclawSessionKey"] = sessionKey
|
||||
if strings.TrimSpace(shared.StringArg(payload, "appThreadKey", "")) == "" {
|
||||
payload["appThreadKey"] = strings.TrimSpace(shared.StringArg(params, "appThreadKey", ""))
|
||||
}
|
||||
payload["artifactScope"] = artifactScope
|
||||
payload["artifactDirectory"] = artifactDirectory
|
||||
if len(requiredExts) > 0 {
|
||||
payload["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "resolvedGatewayProviderId", "")) == "" {
|
||||
payload["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
payload["progress"] = map[string]any{
|
||||
"stage": "syncing-artifacts",
|
||||
"message": "Waiting for OpenClaw artifact export.",
|
||||
"terminal": false,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawUnknownArtifactEvidence(payload map[string]any, artifacts []map[string]any) bool {
|
||||
if strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) != "unknown" {
|
||||
return false
|
||||
}
|
||||
evidence := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "evidence", "")))
|
||||
if evidence == "artifacts_present" {
|
||||
return true
|
||||
}
|
||||
return parseBool(payload["artifactsPresent"]) ||
|
||||
shared.IntArg(shared.StringArg(payload, "artifactCount", ""), 0) > 0 ||
|
||||
len(artifacts) > 0
|
||||
}
|
||||
|
||||
func adjudicateOpenClawUnknownArtifactEvidence(
|
||||
params map[string]any,
|
||||
payload map[string]any,
|
||||
gatewayProvider string,
|
||||
activeRecord *OpenClawTaskRecord,
|
||||
artifacts []map[string]any,
|
||||
requiredExts []string,
|
||||
artifactScope string,
|
||||
artifactDirectory string,
|
||||
) map[string]any {
|
||||
if openClawTaskRecordStillActive(activeRecord) {
|
||||
running := openClawRunningTaskResult(activeRecord)
|
||||
running["artifactEvidence"] = "artifacts_present"
|
||||
if strings.TrimSpace(shared.StringArg(running, "resolvedGatewayProviderId", "")) == "" {
|
||||
running["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
return running
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
if len(requiredExts) > 0 && openClawArtifactsSatisfyRequiredExtensions(artifacts, requiredExts) {
|
||||
payload["success"] = true
|
||||
payload["successSource"] = "inferred"
|
||||
payload["status"] = string(TaskStateCompleted)
|
||||
payload["event"] = string(TaskStateCompleted)
|
||||
payload["pending"] = false
|
||||
} else {
|
||||
payload["success"] = false
|
||||
payload["status"] = string(TaskStateFailed)
|
||||
payload["event"] = string(TaskStateFailed)
|
||||
payload["pending"] = false
|
||||
payload["code"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
payload["error"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
payload["message"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
if len(requiredExts) > 0 {
|
||||
payload["missingRequiredExtensions"] = openClawMissingRequiredExtensions(artifacts, requiredExts)
|
||||
}
|
||||
}
|
||||
payload["runId"] = runID
|
||||
payload["taskId"] = runID
|
||||
payload["openclawSessionKey"] = sessionKey
|
||||
if strings.TrimSpace(shared.StringArg(payload, "appThreadKey", "")) == "" {
|
||||
payload["appThreadKey"] = strings.TrimSpace(shared.StringArg(params, "appThreadKey", ""))
|
||||
}
|
||||
payload["artifactScope"] = artifactScope
|
||||
payload["artifactDirectory"] = artifactDirectory
|
||||
if len(requiredExts) > 0 {
|
||||
payload["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "resolvedGatewayProviderId", "")) == "" {
|
||||
payload["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawTaskRecordStillActive(record *OpenClawTaskRecord) bool {
|
||||
if record == nil {
|
||||
return false
|
||||
}
|
||||
return record.DeadlineAt.IsZero() || time.Now().Before(record.DeadlineAt)
|
||||
}
|
||||
|
||||
func openClawTaskGetRequiresArtifactExport(params map[string]any, payload map[string]any) bool {
|
||||
if parseBool(params["requiresArtifactExport"]) || parseBool(payload["requiresArtifactExport"]) {
|
||||
return true
|
||||
}
|
||||
if parseBool(params["requiresExportBeforeFinalResponse"]) || parseBool(payload["requiresExportBeforeFinalResponse"]) {
|
||||
return true
|
||||
}
|
||||
// expectedArtifactDirs are discovery hints for the plugin's workspace-root
|
||||
// scan. They do not prove that the caller requires a file before the run can
|
||||
// reach a terminal state. Treating them as a blocking contract turns a
|
||||
// failed/no-output agent run into an endless "syncing-artifacts" loop.
|
||||
return len(shared.ListArg(params, "requiredArtifactExtensions")) > 0 ||
|
||||
len(shared.ListArg(payload, "requiredArtifactExtensions")) > 0
|
||||
}
|
||||
|
||||
func openClawTaskGetExpectedArtifactDirs(params map[string]any, payload map[string]any) []any {
|
||||
seen := map[string]bool{}
|
||||
result := []any{}
|
||||
for _, values := range [][]any{
|
||||
shared.ListArg(params, "expectedArtifactDirs"),
|
||||
shared.ListArg(payload, "expectedArtifactDirs"),
|
||||
} {
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(fmt.Sprint(value))
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawArtifactExportPayloadAuthoritative(payload map[string]any) bool {
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")) != "" {
|
||||
return true
|
||||
}
|
||||
_, hasArtifacts := payload["artifacts"]
|
||||
_, hasFiles := payload["files"]
|
||||
_, hasAttachments := payload["attachments"]
|
||||
return hasArtifacts || hasFiles || hasAttachments
|
||||
}
|
||||
|
||||
func replaceOpenClawArtifactPayload(result map[string]any, source map[string]any) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"artifacts", "files", "attachments"} {
|
||||
delete(result, key)
|
||||
}
|
||||
mergeOpenClawArtifactPayload(result, source)
|
||||
}
|
||||
|
||||
func openClawTaskGetRequiredArtifactExtensions(params map[string]any, payload map[string]any) []string {
|
||||
return normalizeOpenClawArtifactExtList(openClawTaskGetMergedList(params, payload, "requiredArtifactExtensions"))
|
||||
}
|
||||
|
||||
func openClawTaskGetExpectedFileCounts(params map[string]any, payload map[string]any) map[string]int {
|
||||
result := normalizeOpenClawArtifactExtCountMap(shared.AsMap(payload["expectedFileCountByExtension"]))
|
||||
for ext, count := range normalizeOpenClawArtifactExtCountMap(shared.AsMap(params["expectedFileCountByExtension"])) {
|
||||
if result == nil {
|
||||
result = map[string]int{}
|
||||
}
|
||||
result[ext] = count
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawTaskGetMergedList(params map[string]any, payload map[string]any, key string) []any {
|
||||
seen := map[string]bool{}
|
||||
result := []any{}
|
||||
for _, values := range [][]any{
|
||||
shared.ListArg(params, key),
|
||||
shared.ListArg(payload, key),
|
||||
} {
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(fmt.Sprint(value))
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawArtifactsSatisfyRequiredExtensions(artifacts []map[string]any, requiredExts []string) bool {
|
||||
if len(requiredExts) == 0 {
|
||||
return true
|
||||
}
|
||||
return len(openClawMissingRequiredExtensions(artifacts, requiredExts)) == 0
|
||||
}
|
||||
|
||||
func openClawMissingRequiredExtensions(artifacts []map[string]any, requiredExts []string) []any {
|
||||
missing := make([]any, 0)
|
||||
for _, ext := range requiredExts {
|
||||
normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, artifact := range artifacts {
|
||||
relativePath := strings.ToLower(strings.TrimSpace(shared.StringArg(artifact, "relativePath", "")))
|
||||
if strings.HasSuffix(relativePath, "."+normalized) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
missing = append(missing, normalized)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
sess = s.reassociateOpenClawTask(params)
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
}
|
||||
if sess == nil {
|
||||
return map[string]any{"accepted": false, "status": "not_found"}
|
||||
if sess != nil {
|
||||
sess.mu.Lock()
|
||||
if runID == "" {
|
||||
runID = sess.task.RunID
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = sess.task.GatewayProviderID
|
||||
}
|
||||
sess.task.State = TaskStateCancelled
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "cancelled"
|
||||
sess.task.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "cancelled"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task cancelled"
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
sess.mu.Lock()
|
||||
gatewayProvider := sess.task.GatewayProviderID
|
||||
runID := sess.task.RunID
|
||||
sess.task.State = TaskStateCancelled
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "cancelled"
|
||||
sess.task.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "cancelled"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
}
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
s.orchestrator.releaseOpenClawAdmission(sess)
|
||||
if strings.TrimSpace(gatewayProvider) != "" && strings.TrimSpace(runID) != "" && s.gateway != nil {
|
||||
if strings.TrimSpace(runID) != "" && s.gateway != nil {
|
||||
_ = s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"agent.cancel",
|
||||
@ -120,8 +628,7 @@ func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, no
|
||||
notify,
|
||||
)
|
||||
}
|
||||
snapshot["accepted"] = true
|
||||
return snapshot
|
||||
return map[string]any{"accepted": strings.TrimSpace(runID) != "", "runId": runID}
|
||||
}
|
||||
|
||||
func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
@ -129,7 +636,6 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", ""))
|
||||
turnID := strings.TrimSpace(shared.StringArg(params, "turnId", ""))
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if sessionID != "" && s.sessions[sessionID] != nil {
|
||||
@ -142,8 +648,7 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
candidate.mu.Lock()
|
||||
matches := (threadID != "" && candidate.threadID == threadID) ||
|
||||
(turnID != "" && candidate.task.TurnID == turnID) ||
|
||||
(runID != "" && candidate.task.RunID == runID) ||
|
||||
(artifactScope != "" && candidate.task.ArtifactScope == artifactScope)
|
||||
(runID != "" && candidate.task.RunID == runID)
|
||||
candidate.mu.Unlock()
|
||||
if matches {
|
||||
return candidate
|
||||
@ -152,91 +657,31 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) reassociateOpenClawTask(params map[string]any) *session {
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
if runID == "" || artifactScope == "" {
|
||||
return nil
|
||||
func openClawTaskLookupParams(params map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
for _, key := range []string{
|
||||
"appThreadKey",
|
||||
"openclawSessionKey",
|
||||
"runId",
|
||||
"taskId",
|
||||
"includeArtifacts",
|
||||
"includeContent",
|
||||
"expectedArtifactDirs",
|
||||
"workspaceDir",
|
||||
"artifactScope",
|
||||
"artifactDirectory",
|
||||
"requiresArtifactExport",
|
||||
} {
|
||||
if value, ok := params[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
|
||||
if sessionID == "" {
|
||||
sessionID = threadID
|
||||
if strings.TrimSpace(shared.StringArg(result, "workspaceDir", "")) == "" {
|
||||
if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" {
|
||||
result["workspaceDir"] = workspaceDir
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = "openclaw:" + runID
|
||||
}
|
||||
if threadID == "" {
|
||||
threadID = sessionID
|
||||
}
|
||||
turnID := strings.TrimSpace(shared.StringArg(params, "turnId", runID))
|
||||
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", threadID))
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", "openclaw"))
|
||||
budget := shared.IntArg(shared.StringArg(params, "runtimeBudgetMinutes", ""), openClawComplexTaskMinutes)
|
||||
now := time.Now()
|
||||
prepared := &openClawPreparedArtifactScope{
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: strings.TrimSpace(shared.StringArg(params, "artifactDirectory", "")),
|
||||
RelativeArtifactDirectory: artifactScope,
|
||||
ScopeKind: "task",
|
||||
RemoteWorkingDirectory: strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectory", "")),
|
||||
RemoteWorkspaceRefKind: strings.TrimSpace(shared.StringArg(params, "remoteWorkspaceRefKind", "")),
|
||||
}
|
||||
contract := openClawArtifactContract{
|
||||
TaskLoadClass: strings.TrimSpace(shared.StringArg(params, "taskLoadClass", "")),
|
||||
ExpectedArtifactExtensions: normalizeOpenClawExtensionList(shared.ListArg(params, "expectedArtifactExtensions")),
|
||||
RequiredFinalExtensions: normalizeOpenClawExtensionList(shared.ListArg(params, "requiredArtifactExtensions")),
|
||||
}
|
||||
sess := s.getOrCreateSession(sessionID, threadID)
|
||||
sess.mu.Lock()
|
||||
sess.provider = gatewayProvider
|
||||
sess.target = "gateway"
|
||||
sess.mode = "gateway"
|
||||
sess.task = QueuedTask{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
Provider: gatewayProvider,
|
||||
Target: "gateway",
|
||||
GatewayProviderID: gatewayProvider,
|
||||
State: TaskStateRunning,
|
||||
Kind: TaskKindGateway,
|
||||
TaskLoadClass: contract.TaskLoadClass,
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: prepared.ArtifactDirectory,
|
||||
RuntimeBudgetMinutes: budget,
|
||||
StartedAt: now,
|
||||
DeadlineAt: now.Add(time.Duration(budget) * time.Minute),
|
||||
UpdatedAt: now,
|
||||
ProgressStage: "reassociated",
|
||||
ProgressMessage: "OpenClaw task reassociated from task handle",
|
||||
}
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
GatewayProviderID: gatewayProvider,
|
||||
TaskLoadClass: contract.TaskLoadClass,
|
||||
ArtifactSinceUnixMs: 0,
|
||||
RuntimeBudgetMinutes: budget,
|
||||
StartedAt: now,
|
||||
DeadlineAt: now.Add(time.Duration(budget) * time.Minute),
|
||||
ProgressStage: "reassociated",
|
||||
ProgressMessage: "OpenClaw task reassociated from task handle",
|
||||
ChatParams: map[string]any{"sessionKey": sessionKey},
|
||||
PreparedArtifact: prepared,
|
||||
ArtifactContract: contract,
|
||||
}
|
||||
sess.lastResult = openClawRunningTaskResult(sess.openClaw)
|
||||
sess.mu.Unlock()
|
||||
return sess
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) cancelSession(ctx context.Context, sessionID string) {
|
||||
@ -254,15 +699,106 @@ func (s *Server) cancelSession(ctx context.Context, sessionID string) {
|
||||
|
||||
func (s *Server) closeSession(ctx context.Context, sessionID string) bool {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[sessionID]
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
if ok && sess != nil && sess.compat != nil {
|
||||
sess.mu.Lock()
|
||||
sess.task.State = TaskStateCancelled
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.mu.Unlock()
|
||||
_ = sess.compat.CloseSession(ctx, sessionID)
|
||||
_, existed := s.sessions[sessionID]
|
||||
if existed {
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return existed
|
||||
}
|
||||
|
||||
func (s *Server) handleDesktopMethod(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
if sessionID == "" {
|
||||
sessionID = "default"
|
||||
}
|
||||
|
||||
srv := desktop.GetService()
|
||||
|
||||
switch method {
|
||||
case "xworkmate.desktop.offer":
|
||||
sdpOffer := strings.TrimSpace(shared.StringArg(params, "sdpOffer", ""))
|
||||
if sdpOffer == "" {
|
||||
return nil, &shared.RPCError{Code: -32602, Message: "sdpOffer is required"}
|
||||
}
|
||||
// Pion WebRTC strict parser requires SDP strings to end with \r\n
|
||||
if !strings.HasSuffix(sdpOffer, "\r\n") {
|
||||
sdpOffer += "\r\n"
|
||||
}
|
||||
|
||||
display := strings.TrimSpace(shared.StringArg(params, "display", ""))
|
||||
width := shared.IntArg(shared.StringArg(params, "width", ""), 1280)
|
||||
height := shared.IntArg(shared.StringArg(params, "height", ""), 720)
|
||||
fps := shared.IntArg(shared.StringArg(params, "fps", ""), 30)
|
||||
bitrate := shared.IntArg(shared.StringArg(params, "bitrate", ""), 2000)
|
||||
useGPU := shared.BoolArg(shared.StringArg(params, "useGpu", ""), false)
|
||||
|
||||
var iceServers []string
|
||||
if rawIce, ok := params["iceServers"].([]any); ok {
|
||||
for _, ice := range rawIce {
|
||||
if s, ok := ice.(string); ok {
|
||||
iceServers = append(iceServers, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg := desktop.PipelineConfig{
|
||||
Display: display,
|
||||
Port: desktop.DefaultRTPPort,
|
||||
Width: width,
|
||||
Height: height,
|
||||
FPS: fps,
|
||||
Bitrate: bitrate,
|
||||
UseGPU: useGPU,
|
||||
ToolType: "auto",
|
||||
}
|
||||
|
||||
sess, err := srv.StartSession(sessionID, cfg, iceServers)
|
||||
if err != nil {
|
||||
return nil, &shared.RPCError{Code: -32001, Message: fmt.Sprintf("failed to start desktop session: %v", err)}
|
||||
}
|
||||
|
||||
sdpAnswer, err := sess.WebRTC.ProcessOffer(sdpOffer)
|
||||
if err != nil {
|
||||
srv.StopSession(sessionID)
|
||||
return nil, &shared.RPCError{Code: -32002, Message: fmt.Sprintf("failed to process SDP offer: %v", err)}
|
||||
}
|
||||
if err := srv.StartCapture(sessionID); err != nil {
|
||||
srv.StopSession(sessionID)
|
||||
return nil, &shared.RPCError{Code: -32004, Message: fmt.Sprintf("failed to start desktop capture: %v", err)}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"sdpAnswer": sdpAnswer,
|
||||
}, nil
|
||||
|
||||
case "xworkmate.desktop.ice":
|
||||
candidateData, ok := params["candidate"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, &shared.RPCError{Code: -32602, Message: "candidate object is required"}
|
||||
}
|
||||
|
||||
var candidate webrtc.ICECandidateInit
|
||||
bytes, err := json.Marshal(candidateData)
|
||||
if err != nil {
|
||||
return nil, &shared.RPCError{Code: -32602, Message: fmt.Sprintf("failed to marshal candidate: %v", err)}
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &candidate); err != nil {
|
||||
return nil, &shared.RPCError{Code: -32602, Message: fmt.Sprintf("failed to unmarshal candidate: %v", err)}
|
||||
}
|
||||
|
||||
if err := srv.AddICECandidate(sessionID, candidate); err != nil {
|
||||
return nil, &shared.RPCError{Code: -32003, Message: fmt.Sprintf("failed to add ICE candidate: %v", err)}
|
||||
}
|
||||
|
||||
return map[string]any{"status": "ok"}, nil
|
||||
|
||||
case "xworkmate.desktop.close":
|
||||
srv.StopSession(sessionID)
|
||||
return map[string]any{"status": "closed"}, nil
|
||||
|
||||
default:
|
||||
return nil, &shared.RPCError{Code: -32601, Message: fmt.Sprintf("unknown desktop method: %s", method)}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
@ -43,18 +43,25 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server {
|
||||
|
||||
func NewServer() *Server {
|
||||
config := loadBridgeConfig()
|
||||
authTokens := bridgeInboundAuthTokens()
|
||||
authToken := ""
|
||||
authExtraTokens := []string(nil)
|
||||
if len(authTokens) > 0 {
|
||||
authToken = authTokens[0]
|
||||
authExtraTokens = authTokens[1:]
|
||||
}
|
||||
s := &Server{
|
||||
sessions: make(map[string]*session),
|
||||
config: config,
|
||||
allowedOrigins: shared.ParseAllowedOrigins(shared.EnvOrDefault("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*")),
|
||||
authService: service.NewStaticTokenAuthService(
|
||||
shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""),
|
||||
shared.EnvOrDefault("BRIDGE_REVIEW_AUTH_TOKEN", ""),
|
||||
authToken,
|
||||
authExtraTokens...,
|
||||
),
|
||||
openClawGate: newOpenClawGatewayAdmissionGate(config),
|
||||
taskForwarder: newDistributedTaskForwarder(distributedTaskForwarderConfig{
|
||||
Endpoint: resolveDistributedTaskForwardEndpoint(config),
|
||||
Token: resolveDistributedTaskForwardToken(config),
|
||||
taskRouter: newDistributedTaskRouter(distributedTaskRouterConfig{
|
||||
Config: config,
|
||||
Token: resolveDistributedTaskForwardToken(config),
|
||||
}),
|
||||
}
|
||||
s.Bootstrap()
|
||||
|
||||
158
internal/acp/test.log
Normal file
158
internal/acp/test.log
Normal file
@ -0,0 +1,158 @@
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="silent-session" runId="silent-run" durationMs=0 ok=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412623849000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412623849000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw" runId="turn-1780701412623849000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412623849000" stage="export" prepared=true exported=false empty=true
|
||||
--- FAIL: TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw (0.00s)
|
||||
routing_test.go:532: expected readable OpenClaw session key, got map[string]interface {}{"appThreadKey":"thread-openclaw", "expectedArtifactDirs":[]interface {}{"assets/images/", "reports/"}, "externalTaskId":"turn-1780701412623849000", "openclawSessionKey":"test-session", "requestId":"turn-1780701412623849000", "runId":"turn-1780701412623849000", "schemaVersion":1, "sessionId":"session-openclaw", "threadId":"thread-openclaw"}
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412628012000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412628012000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-no-output" runId="turn-1780701412628012000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412628012000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412629576000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412629576000" durationMs=0 ok=true
|
||||
--- FAIL: TestExecuteSessionTaskGatewayFailsClosedWhenOpenClawAcceptsDifferentSession (0.00s)
|
||||
routing_test.go:851: expected Bridge to request the app-mapped OpenClaw session, got map[string]interface {}{"idempotencyKey":"turn-1780701412629576000", "message":"say pong", "sessionKey":"test-session", "systemProvenanceReceipt":"XWorkmate task artifact context:\n- Treat artifactDirectory as the working directory for all files generated in this turn.\n- Write final artifacts directly under artifactDirectory using relative paths such as assets/images/... or prompts/....\n- Do not create a nested task_artifacts/<session> directory inside artifactDirectory.\n- Environment contract for shell commands:\n export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_ARTIFACT_DIRECTORY='/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_ARTIFACT_SCOPE='tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_SESSION_KEY='test-session'\n export XWORKMATE_RUN_ID='turn-1780701412629576000'\n cd '/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\nartifactDirectory: /remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000\nartifactScope: tasks/test-session/turn-1780701412629576000\nrelativeArtifactDirectory: tasks/test-session/turn-1780701412629576000\nremoteWorkingDirectory: /remote/openclaw/workspace"}
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412630899000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412630899000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412632427000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412633591000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412633591000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-agent-failed" runId="turn-1780701412633591000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412633591000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412634940000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412634940000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw" runId="turn-1780701412634940000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412634940000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:53 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:53 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414658278000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414658278000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414659932000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414659932000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-retry" runId="turn-1780701414659932000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414659932000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414661850000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414661850000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414663673000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414663673000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-fail" runId="turn-1780701414663673000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414664955000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414664955000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-artifact" runId="turn-1780701414664955000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414664955000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414666478000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414666478000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-latest-artifact" runId="turn-1780701414666478000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414666478000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414668574000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414668574000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="openclaw-run-actual" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-actual-run" runId="openclaw-run-actual" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="openclaw-run-actual" stage="export" prepared=true exported=true empty=false
|
||||
--- FAIL: TestExecuteSessionTaskGatewayDoesNotExportArtifactScopeDeclaredInOutput (0.00s)
|
||||
routing_test.go:1725: expected gateway response, got rpc error: &shared.RPCError{Code:-32602, Message:"openclaw artifact prepare requires openclawSessionKey and runId", Data:interface {}(nil)}
|
||||
--- FAIL: TestExecuteSessionTaskGatewayDoesNotExportDraftScopeVariant (0.00s)
|
||||
routing_test.go:1780: expected gateway response, got rpc error: &shared.RPCError{Code:-32602, Message:"openclaw artifact prepare requires openclawSessionKey and runId", Data:interface {}(nil)}
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414673111000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414673111000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-claimed-artifact" runId="turn-1780701414673111000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414673111000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414674771000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414674771000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-message-artifact" runId="turn-1780701414674771000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414674771000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414956527000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414956527000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-event-artifact" runId="turn-1780701414956527000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414956527000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414958364000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414958364000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-artifact-missing" runId="turn-1780701414958364000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414958364000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415258804000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415258804000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260880000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415260880000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701415258804000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415258804000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701415260880000" durationMs=1500 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260880000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260905000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415260905000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766587000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766525000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766826000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766815000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766587000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766525000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766815000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766451000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766826000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766451000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s4" runId="turn-1780701417766587000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766587000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s3" runId="turn-1780701417766525000" durationMs=200 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766525000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s1" runId="turn-1780701417766815000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766815000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s0" runId="turn-1780701417766451000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766451000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s2" runId="turn-1780701417766826000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766826000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418788112000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418788112000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418800358000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418800358000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-filter" runId="turn-1780701418800358000" durationMs=1 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418800358000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418803902000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418803902000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701418803902000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418803902000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418806273000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418806273000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701418806273000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418806273000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=stream_write path="" rpcMethod="" requestId="" sessionId="" threadId="" sseEvent="xworkmate.gateway.push" reason="panic" error="closed response writer"
|
||||
FAIL
|
||||
FAIL xworkmate-bridge/internal/acp 6.479s
|
||||
FAIL
|
||||
@ -23,7 +23,6 @@ type TaskKind string
|
||||
const (
|
||||
TaskKindSingleAgent TaskKind = "single-agent"
|
||||
TaskKindGateway TaskKind = "gateway"
|
||||
TaskKindMultiAgent TaskKind = "multi-agent"
|
||||
)
|
||||
|
||||
type ControlPlaneSession struct {
|
||||
@ -53,7 +52,6 @@ type QueuedTask struct {
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
LastProbeAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
ProgressTerminal bool
|
||||
@ -101,7 +99,7 @@ type Server struct {
|
||||
gateway *gatewayruntime.Manager
|
||||
openClawGate *openClawGatewayAdmissionGate
|
||||
jobs *jobManager
|
||||
taskForwarder *distributedTaskForwarder
|
||||
taskRouter *distributedTaskRouter
|
||||
|
||||
// Legacy / Common
|
||||
authService interface{} // Minimal auth dependency
|
||||
|
||||
@ -18,25 +18,6 @@ import (
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func sseResultEnvelope(t *testing.T, body string, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, rawLine := range strings.Split(body, "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &envelope); err != nil {
|
||||
t.Fatalf("decode SSE envelope %q: %v", line, err)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(envelope, "id", "")) == id {
|
||||
return envelope
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing SSE result envelope %q in body: %s", id, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sseFirstResultEnvelope(t *testing.T, body string) map[string]any {
|
||||
t.Helper()
|
||||
for _, rawLine := range strings.Split(body, "\n") {
|
||||
@ -59,19 +40,11 @@ func sseFirstResultEnvelope(t *testing.T, body string) map[string]any {
|
||||
func taskGetHTTPResult(t *testing.T, handler http.Handler, handle map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(
|
||||
`{"jsonrpc":"2.0","id":"task-get","method":"xworkmate.tasks.get","params":{"sessionId":%q,"threadId":%q,"turnId":%q,"runId":%q,"sessionKey":%q,"artifactScope":%q,"artifactDirectory":%q,"gatewayProviderId":%q,"runtimeBudgetMinutes":%q,"taskLoadClass":%q,"expectedArtifactExtensions":%s,"requiredArtifactExtensions":%s}}`,
|
||||
shared.StringArg(handle, "sessionId", ""),
|
||||
shared.StringArg(handle, "threadId", ""),
|
||||
shared.StringArg(handle, "turnId", ""),
|
||||
`{"jsonrpc":"2.0","id":"task-get","method":"xworkmate.tasks.get","params":{"runId":%q,"appThreadKey":%q,"openclawSessionKey":%q,"gatewayProviderId":%q,"includeArtifacts":true}}`,
|
||||
shared.StringArg(handle, "runId", ""),
|
||||
shared.StringArg(handle, "sessionKey", ""),
|
||||
shared.StringArg(handle, "artifactScope", ""),
|
||||
shared.StringArg(handle, "artifactDirectory", ""),
|
||||
shared.StringArg(handle, "appThreadKey", ""),
|
||||
shared.StringArg(handle, "openclawSessionKey", ""),
|
||||
shared.StringArg(handle, "resolvedGatewayProviderId", "openclaw"),
|
||||
shared.StringArg(handle, "runtimeBudgetMinutes", ""),
|
||||
shared.StringArg(handle, "taskLoadClass", ""),
|
||||
jsonArrayString(t, shared.ListArg(handle, "expectedArtifactExtensions")),
|
||||
jsonArrayString(t, shared.ListArg(handle, "requiredArtifactExtensions")),
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/acp/rpc", strings.NewReader(body))
|
||||
@ -104,20 +77,9 @@ func taskGetHTTPTerminalResult(t *testing.T, handler http.Handler, handle map[st
|
||||
}
|
||||
}
|
||||
|
||||
func jsonArrayString(t *testing.T, values []any) string {
|
||||
t.Helper()
|
||||
if values == nil {
|
||||
return "[]"
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
t.Fatalf("encode array: %v", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
SetRuntimeVersionInfo(RuntimeVersionInfo{
|
||||
Commit: "0123456",
|
||||
Version: "v1.1.4-protocol4",
|
||||
@ -269,7 +231,7 @@ func TestHTTPHandlerGatewayOpenClawReturnsRunningEnvelopeAndDone(t *testing.T) {
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
@ -334,7 +296,7 @@ func TestHTTPHandlerGatewayOpenClawReturnsRunningEnvelopeAndDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing.T) {
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.agentWaitDelayMs.Store(1500)
|
||||
@ -364,7 +326,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","openclawSessionKey":"t`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
results <- result{err: err}
|
||||
@ -393,36 +355,25 @@ func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing
|
||||
}
|
||||
close(start)
|
||||
waitForOpenClawGatewayCount(t, func() int { return gateway.ChatSendCount() }, 1)
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("expected admission gate to hold queued chat.send while one is active, got %d", got)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var sawQueued bool
|
||||
var runningHandleCount int
|
||||
for item := range results {
|
||||
if item.err != nil {
|
||||
t.Fatalf("concurrent request failed: %v", item.err)
|
||||
}
|
||||
if strings.Contains(item.body, `"event":"queued"`) {
|
||||
sawQueued = true
|
||||
}
|
||||
envelope := sseFirstResultEnvelope(t, item.body)
|
||||
result := shared.AsMap(envelope["result"])
|
||||
if result["status"] == "running" && strings.TrimSpace(shared.StringArg(result, "runId", "")) != "" {
|
||||
runningHandleCount += 1
|
||||
}
|
||||
}
|
||||
if !sawQueued {
|
||||
t.Fatalf("expected one queued session.update event")
|
||||
}
|
||||
if runningHandleCount != 2 {
|
||||
t.Fatalf("expected both requests to return running handles, got %d", runningHandleCount)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 2 {
|
||||
t.Fatalf("expected queued request to run after a slot releases, got %d chat.send calls", got)
|
||||
t.Fatalf("expected admission to release after accepted native chat.send, got %d chat.send calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -443,11 +394,11 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
defer httpServer.Close()
|
||||
|
||||
prompts := []string{
|
||||
"采集最新AI资讯,保存在md文件",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频,附件带有图片",
|
||||
"从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片",
|
||||
"参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片",
|
||||
"拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件 make artifact",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频",
|
||||
"围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进\n输出Markdown格式文件, 微信公众号短图文 400-600字 插入关键词的软文\n输出Markdown格式文件, 小红书风格 600-800字 插入钩子话题的软文\n输出Markdown格式文件, X文案串 小于144字的英语 鲜明的观点\n输出Markdown格式文件, 微信公众号文章 800-1200字左右\n输出Markdown格式文件, 头条号长文 800-1200字左右",
|
||||
"围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF",
|
||||
}
|
||||
type result struct {
|
||||
body string
|
||||
@ -462,7 +413,8 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
body := fmt.Sprintf(
|
||||
`{"jsonrpc":"2.0","id":"e2e-%d","method":"session.start","params":{"sessionId":"e2e-s%d","threadId":"e2e-t%d","taskPrompt":%q,"workingDirectory":%q,"routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`,
|
||||
`{"jsonrpc":"2.0","id":"e2e-%d","method":"session.start","params":{"sessionId":"e2e-s%d","openclawSessionKey":"e2e-t%d","threadId":"e2e-t%d","taskPrompt":%q,"workingDirectory":%q,"routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
@ -530,7 +482,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
}
|
||||
runningHandleCount += 1
|
||||
result := taskGetHTTPTerminalResult(t, httpServer.Config.Handler, handle)
|
||||
if result["code"] == "OPENCLAW_REQUIRED_ARTIFACT_MISSING" {
|
||||
if result["status"] == "completed" {
|
||||
missingFinalArtifactCount += 1
|
||||
}
|
||||
}
|
||||
@ -538,7 +490,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
t.Fatalf("expected all five e2e requests to return running handles, got %d", runningHandleCount)
|
||||
}
|
||||
if missingFinalArtifactCount != len(prompts) {
|
||||
t.Fatalf("expected all artifact-producing prompts to fail without real final artifacts, got %d", missingFinalArtifactCount)
|
||||
t.Fatalf("expected all artifact-producing prompts to complete successfully, got %d", missingFinalArtifactCount)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 1 {
|
||||
t.Fatalf("expected bridge to reuse one established OpenClaw connection, got %d connects", got)
|
||||
@ -547,12 +499,12 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
if got := gateway.ChatSendCount(); got != expectedGatewayTurns {
|
||||
t.Fatalf("expected five primary chat.send calls without model repair turns, got %d", got)
|
||||
}
|
||||
if got := gateway.AgentWaitCount(); got != expectedGatewayTurns {
|
||||
t.Fatalf("expected five primary agent.wait calls without model repair turns, got %d", got)
|
||||
if got := gateway.AgentWaitCount(); got != 0 {
|
||||
t.Fatalf("expected task polling to use native task-registry without Bridge-owned agent.wait, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.agentWaitDelayMs.Store(300)
|
||||
@ -570,7 +522,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
|
||||
firstRequest, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","openclawSessionKey":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build first request: %v", err)
|
||||
@ -594,7 +546,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
|
||||
secondRequest, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","openclawSessionKey":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build second request: %v", err)
|
||||
@ -612,14 +564,13 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
|
||||
t.Fatalf("read second response: %v", err)
|
||||
}
|
||||
bodyText := string(body)
|
||||
if !strings.Contains(bodyText, openClawGatewayBusyErrorCode) {
|
||||
t.Fatalf("expected busy error, got %s", bodyText)
|
||||
envelope := sseFirstResultEnvelope(t, bodyText)
|
||||
result := shared.AsMap(envelope["result"])
|
||||
if result["status"] != "running" {
|
||||
t.Fatalf("expected second request to receive running handle after first native chat was accepted, got %s", bodyText)
|
||||
}
|
||||
if strings.Contains(bodyText, `"result"`) {
|
||||
t.Fatalf("busy response must not return a result envelope: %s", bodyText)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("rejected request must not reach chat.send, got %d", got)
|
||||
if got := gateway.ChatSendCount(); got != 2 {
|
||||
t.Fatalf("accepted native task must not hold admission slot, got %d chat.send calls", got)
|
||||
}
|
||||
if err := <-firstDone; err != nil {
|
||||
t.Fatalf("first request failed: %v", err)
|
||||
@ -642,7 +593,7 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","openclawSessionKey":"thread-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
@ -737,8 +688,8 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
|
||||
if !strings.Contains(fmt.Sprint(result), openClawArtifactDownloadPath) {
|
||||
t.Fatalf("expected normalized artifact download URL in task result, got %#v", result)
|
||||
}
|
||||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
|
||||
t.Fatalf("expected artifact workflow methods to prepare before chat.send, got %#v", got)
|
||||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) {
|
||||
t.Fatalf("expected prepare, chat.send, then native task lookup, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -756,7 +707,7 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
@ -783,8 +734,8 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
|
||||
if got := result["status"]; got != "completed" {
|
||||
t.Fatalf("expected completed task result, got %#v", result)
|
||||
}
|
||||
if gateway.AgentWaitCount() != 1 {
|
||||
t.Fatalf("expected one OpenClaw agent.wait, got %d", gateway.AgentWaitCount())
|
||||
if gateway.AgentWaitCount() != 0 {
|
||||
t.Fatalf("expected native task-registry lookup without Bridge-owned agent.wait, got %d", gateway.AgentWaitCount())
|
||||
}
|
||||
}
|
||||
|
||||
@ -802,7 +753,7 @@ func TestHTTPHandlerTasksGetReturnsCompletedOpenClawResult(t *testing.T) {
|
||||
startRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
startRequest.Header.Set("Content-Type", "application/json")
|
||||
startRequest.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
@ -833,6 +784,54 @@ func TestHTTPHandlerTasksGetReturnsCompletedOpenClawResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRequestSSEFiltersRawGatewayEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rpcMethod string
|
||||
openClawGatewayTask bool
|
||||
notificationMethod string
|
||||
wantDropReason string
|
||||
}{
|
||||
{
|
||||
name: "direct gateway request raw push",
|
||||
rpcMethod: "xworkmate.gateway.request",
|
||||
notificationMethod: "xworkmate.gateway.push",
|
||||
wantDropReason: "raw_gateway_event",
|
||||
},
|
||||
{
|
||||
name: "openclaw task raw push",
|
||||
rpcMethod: "session.message",
|
||||
openClawGatewayTask: true,
|
||||
notificationMethod: "xworkmate.gateway.snapshot",
|
||||
wantDropReason: "raw_gateway_event",
|
||||
},
|
||||
{
|
||||
name: "ordinary session notification",
|
||||
rpcMethod: "session.message",
|
||||
notificationMethod: "session.update",
|
||||
wantDropReason: "",
|
||||
},
|
||||
{
|
||||
name: "non openclaw gateway raw push remains scoped out",
|
||||
rpcMethod: "session.message",
|
||||
notificationMethod: "xworkmate.gateway.push",
|
||||
wantDropReason: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := gatewaySSEBridgeNotificationDropReason(
|
||||
tt.rpcMethod,
|
||||
tt.openClawGatewayTask,
|
||||
map[string]any{"method": tt.notificationMethod},
|
||||
)
|
||||
if got != tt.wantDropReason {
|
||||
t.Fatalf("expected drop reason %q, got %q", tt.wantDropReason, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSSEStreamDropsLateNotificationsAfterClose(t *testing.T) {
|
||||
writer := &panicSSEWriter{header: http.Header{}}
|
||||
stream := newSafeSSEStream(context.Background(), writer, safeSSEStreamMeta{})
|
||||
@ -876,6 +875,7 @@ func (w *panicSSEWriter) Write(payload []byte) (int, error) {
|
||||
func (w *panicSSEWriter) WriteHeader(int) {}
|
||||
|
||||
func TestHTTPHandlerPingRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -890,7 +890,27 @@ func TestHTTPHandlerPingRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerPingAcceptsAIWorkspaceBearerAuthorization(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
handler := server.Handler()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil)
|
||||
request.Header.Set("Authorization", "Bearer ai-workspace-test-token")
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for AI workspace token, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerPingAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
@ -910,6 +930,7 @@ func TestHTTPHandlerPingAllowsReviewBearerAuthorizationWhenConfigured(t *testing
|
||||
func TestHandleWebSocketRejectsUnknownOrigin(t *testing.T) {
|
||||
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -930,6 +951,7 @@ func TestHandleWebSocketRejectsUnknownOrigin(t *testing.T) {
|
||||
func TestHandleRPCAllowsPreflightForConfiguredOrigin(t *testing.T) {
|
||||
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -949,7 +971,10 @@ func TestHandleRPCAllowsPreflightForConfiguredOrigin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleRPCAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -968,6 +993,7 @@ func TestHandleRPCAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testi
|
||||
}
|
||||
|
||||
func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -976,7 +1002,7 @@ func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *te
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test","openclawSessionKey":"test","threadId":"test"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@ -988,6 +1014,7 @@ func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *te
|
||||
}
|
||||
|
||||
func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -1007,6 +1034,7 @@ func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConf
|
||||
}
|
||||
|
||||
func TestHandleRPCAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
@ -1030,6 +1058,7 @@ func TestHandleRPCAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
|
||||
func TestHandleRPCRejectsUnknownOrigin(t *testing.T) {
|
||||
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -1059,6 +1088,7 @@ func TestHandleRPCRejectsUnknownOrigin(t *testing.T) {
|
||||
|
||||
func TestHandleRPCMethodErrorUsesJSONEnvelope(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -1077,6 +1107,7 @@ func TestHandleRPCMethodErrorUsesJSONEnvelope(t *testing.T) {
|
||||
|
||||
func TestHandleRPCCapabilitiesStillReturnsJSONResult(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -1103,6 +1134,7 @@ func TestHandleRPCCapabilitiesStillReturnsJSONResult(t *testing.T) {
|
||||
|
||||
func TestAuthorizedAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp", nil)
|
||||
@ -1113,6 +1145,7 @@ func TestAuthorizedAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *test
|
||||
|
||||
func TestHandleRPCCapabilitiesReturnsCanonicalProviderContract(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -1210,7 +1243,7 @@ func TestHandleRPCSessionStartSucceedsWithExplicitProvider(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply with exactly pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply with exactly pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
|
||||
419
internal/desktop/input.go
Normal file
419
internal/desktop/input.go
Normal file
@ -0,0 +1,419 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InputEvent represents a client mouse or keyboard action
|
||||
type InputEvent struct {
|
||||
Type string `json:"type"` // "mouse_move", "mouse_down", "mouse_up", "key_down", "key_up", "scroll"
|
||||
X float64 `json:"x,omitempty"` // normalized x coordinate (0.0 to 1.0)
|
||||
Y float64 `json:"y,omitempty"` // normalized y coordinate (0.0 to 1.0)
|
||||
Button int `json:"button,omitempty"` // mouse button: 1=left, 2=middle, 3=right, 4=scroll_up, 5=scroll_down
|
||||
Key string `json:"key,omitempty"` // key symbol or keycode
|
||||
}
|
||||
|
||||
// XdotoolInjector injects inputs by writing to a persistent xdotool process stdin
|
||||
type XdotoolInjector struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
mu sync.Mutex
|
||||
display string
|
||||
width int
|
||||
height int
|
||||
isStarted bool
|
||||
|
||||
moveMu sync.Mutex
|
||||
pendingMove *InputEvent
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewXdotoolInjector(display string) *XdotoolInjector {
|
||||
if display == "" {
|
||||
display = ":0.0"
|
||||
}
|
||||
return &XdotoolInjector{
|
||||
display: display,
|
||||
width: 1280, // Default fallbacks
|
||||
height: 720,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches xdotool and queries screen resolution
|
||||
func (xi *XdotoolInjector) Start() error {
|
||||
xi.mu.Lock()
|
||||
defer xi.mu.Unlock()
|
||||
|
||||
if xi.isStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Resolve screen resolution
|
||||
w, h, err := xi.queryDisplayGeometry()
|
||||
if err == nil {
|
||||
xi.width = w
|
||||
xi.height = h
|
||||
log.Printf("Detected remote display geometry: %dx%d on %s", w, h, xi.display)
|
||||
} else {
|
||||
log.Printf("Warning: Failed to query display geometry: %v. Using default: %dx%d", err, xi.width, xi.height)
|
||||
}
|
||||
|
||||
// 2. Launch persistent xdotool process
|
||||
cmd := exec.Command("xdotool", "-")
|
||||
cmd.Env = desktopCommandEnv(xi.display)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open xdotool stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = stdin.Close()
|
||||
return fmt.Errorf("failed to start xdotool process: %w", err)
|
||||
}
|
||||
|
||||
xi.cmd = cmd
|
||||
xi.stdin = stdin
|
||||
xi.isStarted = true
|
||||
|
||||
if xi.stopChan == nil {
|
||||
xi.stopChan = make(chan struct{})
|
||||
go xi.mouseMoveWorker()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inject sends a command to the persistent xdotool process
|
||||
func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
||||
xi.mu.Lock()
|
||||
if !xi.isStarted || xi.stdin == nil {
|
||||
xi.mu.Unlock()
|
||||
return fmt.Errorf("injector is not running")
|
||||
}
|
||||
|
||||
var cmdStr string
|
||||
|
||||
switch event.Type {
|
||||
case "mouse_move":
|
||||
xi.moveMu.Lock()
|
||||
xi.pendingMove = &event
|
||||
xi.moveMu.Unlock()
|
||||
xi.mu.Unlock()
|
||||
return nil
|
||||
|
||||
case "mouse_down":
|
||||
btn := xi.mapButton(event.Button)
|
||||
cmdStr = fmt.Sprintf("mousedown %d\n", btn)
|
||||
|
||||
case "mouse_up":
|
||||
btn := xi.mapButton(event.Button)
|
||||
cmdStr = fmt.Sprintf("mouseup %d\n", btn)
|
||||
|
||||
case "key_down":
|
||||
key := xi.sanitizeKey(event.Key)
|
||||
cmdStr = fmt.Sprintf("keydown %s\n", key)
|
||||
|
||||
case "key_up":
|
||||
key := xi.sanitizeKey(event.Key)
|
||||
cmdStr = fmt.Sprintf("keyup %s\n", key)
|
||||
|
||||
case "scroll":
|
||||
// xdotool maps scroll up to button 4 and scroll down to button 5
|
||||
if event.Button == 4 || event.Button == 5 {
|
||||
cmdStr = fmt.Sprintf("click %d\n", event.Button)
|
||||
}
|
||||
|
||||
default:
|
||||
xi.mu.Unlock()
|
||||
return fmt.Errorf("unsupported input type: %s", event.Type)
|
||||
}
|
||||
|
||||
if cmdStr != "" {
|
||||
_, err := xi.stdin.Write([]byte(cmdStr))
|
||||
if err != nil {
|
||||
// Try to restart if pipe is broken
|
||||
log.Printf("xdotool write error: %v. Attempting to restart injector.", err)
|
||||
xi.isStarted = false
|
||||
if xi.stdin != nil {
|
||||
_ = xi.stdin.Close()
|
||||
}
|
||||
xi.stdin = nil
|
||||
xi.cmd = nil
|
||||
xi.mu.Unlock()
|
||||
if restartErr := xi.Start(); restartErr == nil {
|
||||
xi.mu.Lock()
|
||||
_, _ = xi.stdin.Write([]byte(cmdStr))
|
||||
xi.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
xi.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close terminates the xdotool process
|
||||
func (xi *XdotoolInjector) Close() error {
|
||||
xi.mu.Lock()
|
||||
defer xi.mu.Unlock()
|
||||
|
||||
if !xi.isStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
xi.isStarted = false
|
||||
if xi.stdin != nil {
|
||||
_ = xi.stdin.Close()
|
||||
}
|
||||
if xi.cmd != nil {
|
||||
_ = xi.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
xi.stdin = nil
|
||||
xi.cmd = nil
|
||||
|
||||
if xi.stopChan != nil {
|
||||
close(xi.stopChan)
|
||||
xi.stopChan = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (xi *XdotoolInjector) mouseMoveWorker() {
|
||||
ticker := time.NewTicker(16 * time.Millisecond) // ~60fps
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-xi.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
xi.moveMu.Lock()
|
||||
event := xi.pendingMove
|
||||
xi.pendingMove = nil
|
||||
xi.moveMu.Unlock()
|
||||
|
||||
if event != nil {
|
||||
xi.mu.Lock()
|
||||
if xi.isStarted && xi.stdin != nil {
|
||||
absX := int(event.X * float64(xi.width))
|
||||
absY := int(event.Y * float64(xi.height))
|
||||
cmdStr := fmt.Sprintf("mousemove %d %d\n", absX, absY)
|
||||
if _, err := xi.stdin.Write([]byte(cmdStr)); err != nil {
|
||||
log.Printf("xdotool mousemove write error: %v", err)
|
||||
}
|
||||
}
|
||||
xi.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (xi *XdotoolInjector) queryDisplayGeometry() (int, int, error) {
|
||||
return queryDisplayGeometry(xi.display)
|
||||
}
|
||||
|
||||
func queryDisplayGeometry(display string) (int, int, error) {
|
||||
cmd := exec.Command("xdotool", "getdisplaygeometry")
|
||||
cmd.Env = desktopCommandEnv(display)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, fmt.Errorf("invalid geometry output: %s", string(out))
|
||||
}
|
||||
|
||||
w, err1 := strconv.Atoi(parts[0])
|
||||
h, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse geometry: %w, %w", err1, err2)
|
||||
}
|
||||
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func ResolveDesktopDisplay(requested string) string {
|
||||
resolved, ok := resolveDesktopDisplayWithProber(
|
||||
requested,
|
||||
os.Getenv("DISPLAY"),
|
||||
x11SocketDisplays("/tmp/.X11-unix"),
|
||||
func(display string) bool {
|
||||
_, _, err := queryDisplayGeometry(display)
|
||||
return err == nil
|
||||
},
|
||||
)
|
||||
if ok {
|
||||
if strings.TrimSpace(requested) != "" && strings.TrimSpace(requested) != resolved {
|
||||
log.Printf("Resolved remote desktop display %q to active X11 display %s", requested, resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
requested = strings.TrimSpace(requested)
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if envDisplay := strings.TrimSpace(os.Getenv("DISPLAY")); envDisplay != "" {
|
||||
return envDisplay
|
||||
}
|
||||
return ":0.0"
|
||||
}
|
||||
|
||||
func resolveDesktopDisplayWithProber(
|
||||
requested string,
|
||||
envDisplay string,
|
||||
socketDisplays []string,
|
||||
probe func(string) bool,
|
||||
) (string, bool) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
envDisplay = strings.TrimSpace(envDisplay)
|
||||
|
||||
if requested != "" && !isAutoDesktopDisplay(requested) {
|
||||
return requested, true
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, len(socketDisplays)+3)
|
||||
if envDisplay != "" {
|
||||
candidates = append(candidates, envDisplay)
|
||||
}
|
||||
candidates = append(candidates, socketDisplays...)
|
||||
if requested != "" {
|
||||
candidates = append(candidates, requested)
|
||||
}
|
||||
candidates = append(candidates, ":0.0", ":0")
|
||||
|
||||
for _, candidate := range uniqueDisplayCandidates(candidates) {
|
||||
if probe(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isAutoDesktopDisplay(display string) bool {
|
||||
switch strings.TrimSpace(display) {
|
||||
case "", ":0", ":0.0":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func x11SocketDisplays(dir string) []string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
displays := make([]int, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasPrefix(name, "X") {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimPrefix(name, "X"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
displays = append(displays, value)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(displays)))
|
||||
|
||||
result := make([]string, 0, len(displays))
|
||||
for _, display := range displays {
|
||||
result = append(result, fmt.Sprintf(":%d", display))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uniqueDisplayCandidates(candidates []string) []string {
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
result := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[candidate]; exists {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func desktopCommandEnv(display string) []string {
|
||||
env := os.Environ()
|
||||
if strings.TrimSpace(display) == "" {
|
||||
return env
|
||||
}
|
||||
filtered := make([]string, 0, len(env)+1)
|
||||
hasXauthority := false
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, "DISPLAY=") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(item, "XAUTHORITY=") {
|
||||
hasXauthority = true
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
filtered = append(filtered, "DISPLAY="+display)
|
||||
if !hasXauthority {
|
||||
if home := strings.TrimSpace(os.Getenv("HOME")); home != "" {
|
||||
xauthority := filepath.Join(home, ".Xauthority")
|
||||
if _, err := os.Stat(xauthority); err == nil {
|
||||
filtered = append(filtered, "XAUTHORITY="+xauthority)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (xi *XdotoolInjector) mapButton(btn int) int {
|
||||
// Standard mapping: 1=left, 2=middle, 3=right
|
||||
if btn <= 0 || btn > 3 {
|
||||
return 1
|
||||
}
|
||||
return btn
|
||||
}
|
||||
|
||||
func (xi *XdotoolInjector) sanitizeKey(key string) string {
|
||||
// Clean or map keys if they need special handling in xdotool
|
||||
// For example, Flutter "Backspace" needs to be "BackSpace" or "Return" to "Return".
|
||||
key = strings.TrimSpace(key)
|
||||
switch strings.ToLower(key) {
|
||||
case "enter":
|
||||
return "Return"
|
||||
case "backspace":
|
||||
return "BackSpace"
|
||||
case "tab":
|
||||
return "Tab"
|
||||
case "escape":
|
||||
return "Escape"
|
||||
case "control":
|
||||
return "ctrl"
|
||||
case "shift":
|
||||
return "shift"
|
||||
case "alt":
|
||||
return "alt"
|
||||
case "meta":
|
||||
return "super"
|
||||
}
|
||||
return key
|
||||
}
|
||||
98
internal/desktop/input_test.go
Normal file
98
internal/desktop/input_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesktopCommandEnvPreservesProcessEnvironmentAndOverridesDisplay(t *testing.T) {
|
||||
t.Setenv("PATH", "/usr/local/bin:/usr/bin")
|
||||
t.Setenv("HOME", "/home/ubuntu")
|
||||
t.Setenv("DISPLAY", ":old")
|
||||
|
||||
env := desktopCommandEnv(":0.0")
|
||||
|
||||
if !envContains(env, "PATH=/usr/local/bin:/usr/bin") {
|
||||
t.Fatalf("expected PATH to be preserved, got %#v", env)
|
||||
}
|
||||
if !envContains(env, "HOME=/home/ubuntu") {
|
||||
t.Fatalf("expected HOME to be preserved, got %#v", env)
|
||||
}
|
||||
if !envContains(env, "DISPLAY=:0.0") {
|
||||
t.Fatalf("expected DISPLAY override, got %#v", env)
|
||||
}
|
||||
if countEnvPrefix(env, "DISPLAY=") != 1 {
|
||||
t.Fatalf("expected exactly one DISPLAY entry, got %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopCommandEnvAddsHomeXauthorityWhenAvailable(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("PATH", "/usr/local/bin:/usr/bin")
|
||||
t.Setenv("DISPLAY", ":old")
|
||||
if err := os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("cookie"), 0o600); err != nil {
|
||||
t.Fatalf("failed to create Xauthority fixture: %v", err)
|
||||
}
|
||||
|
||||
env := desktopCommandEnv(":12")
|
||||
|
||||
if !envContains(env, "DISPLAY=:12") {
|
||||
t.Fatalf("expected DISPLAY override, got %#v", env)
|
||||
}
|
||||
if !envContains(env, "XAUTHORITY="+filepath.Join(home, ".Xauthority")) {
|
||||
t.Fatalf("expected XAUTHORITY from HOME, got %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDesktopDisplayWithProberUsesRequestedExplicitDisplay(t *testing.T) {
|
||||
got, ok := resolveDesktopDisplayWithProber(
|
||||
":12",
|
||||
"",
|
||||
[]string{":11"},
|
||||
func(display string) bool {
|
||||
t.Fatalf("explicit display should not be probed, got %s", display)
|
||||
return false
|
||||
},
|
||||
)
|
||||
|
||||
if !ok || got != ":12" {
|
||||
t.Fatalf("expected explicit display :12, got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDesktopDisplayWithProberSelectsActiveSocketDisplay(t *testing.T) {
|
||||
got, ok := resolveDesktopDisplayWithProber(
|
||||
":0.0",
|
||||
"",
|
||||
[]string{":12", ":11", ":10"},
|
||||
func(display string) bool {
|
||||
return display == ":11"
|
||||
},
|
||||
)
|
||||
|
||||
if !ok || got != ":11" {
|
||||
t.Fatalf("expected first probed active display :11, got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func envContains(env []string, expected string) bool {
|
||||
for _, item := range env {
|
||||
if item == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countEnvPrefix(env []string, prefix string) int {
|
||||
count := 0
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
277
internal/desktop/pipeline.go
Normal file
277
internal/desktop/pipeline.go
Normal file
@ -0,0 +1,277 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultRTPPort = 5004
|
||||
|
||||
func normalizeRTPPort(port int) int {
|
||||
if port <= 0 {
|
||||
return DefaultRTPPort
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func normalizeVideoDimension(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
value = fallback
|
||||
}
|
||||
if value%2 != 0 {
|
||||
value--
|
||||
}
|
||||
if value < 2 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizePipelineConfig(cfg PipelineConfig) PipelineConfig {
|
||||
cfg.Port = normalizeRTPPort(cfg.Port)
|
||||
cfg.Width = normalizeVideoDimension(cfg.Width, 1280)
|
||||
cfg.Height = normalizeVideoDimension(cfg.Height, 720)
|
||||
if cfg.FPS <= 0 {
|
||||
cfg.FPS = 30
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 2000
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// PipelineManager manages the screen capture process lifecycle
|
||||
type PipelineManager struct {
|
||||
cmd *exec.Cmd
|
||||
mu sync.Mutex
|
||||
isRunning bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type PipelineConfig struct {
|
||||
Display string // e.g. ":0"
|
||||
Port int // e.g. 5004
|
||||
Width int // e.g. 1920
|
||||
Height int // e.g. 1080
|
||||
FPS int // e.g. 30
|
||||
Bitrate int // in kbps, e.g. 2000
|
||||
UseGPU bool // try using hardware accelerated encoding (nvh264enc / h264_nvenc)
|
||||
ToolType string // "gstreamer" or "ffmpeg" or "auto"
|
||||
}
|
||||
|
||||
func NewPipelineManager() *PipelineManager {
|
||||
return &PipelineManager{}
|
||||
}
|
||||
|
||||
// Start spawns the GStreamer or FFmpeg capture process in the background
|
||||
func (pm *PipelineManager) Start(cfg PipelineConfig) error {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
if pm.isRunning {
|
||||
return fmt.Errorf("pipeline is already running")
|
||||
}
|
||||
|
||||
if cfg.Display == "" {
|
||||
cfg.Display = os.Getenv("DISPLAY")
|
||||
if cfg.Display == "" {
|
||||
cfg.Display = ":0.0"
|
||||
}
|
||||
}
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
|
||||
tool, args, err := pm.resolvePipeline(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve pipeline command: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
pm.cancel = cancel
|
||||
|
||||
cmd := exec.CommandContext(ctx, tool, args...)
|
||||
cmd.Env = desktopCommandEnv(cfg.Display)
|
||||
|
||||
// Capture stdout/stderr for logging
|
||||
cmd.Stdout = log.Writer()
|
||||
cmd.Stderr = log.Writer()
|
||||
|
||||
log.Printf("Starting capture pipeline: %s %s", tool, strings.Join(args, " "))
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("failed to start pipeline process: %w", err)
|
||||
}
|
||||
|
||||
pm.cmd = cmd
|
||||
pm.isRunning = true
|
||||
|
||||
// Monitor process termination asynchronously
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
pm.mu.Lock()
|
||||
pm.isRunning = false
|
||||
pm.cmd = nil
|
||||
pm.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("Capture pipeline exited with error: %v", err)
|
||||
} else {
|
||||
log.Printf("Capture pipeline stopped cleanly")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop terminates the capture process
|
||||
func (pm *PipelineManager) Stop() {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
if !pm.isRunning || pm.cancel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Stopping capture pipeline...")
|
||||
pm.cancel() // cancels the context, sending SIGKILL or SIGTERM
|
||||
|
||||
// Wait up to 2 seconds for clean exit
|
||||
for i := 0; i < 20; i++ {
|
||||
if !pm.isRunning {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
pm.isRunning = false
|
||||
pm.cmd = nil
|
||||
pm.cancel = nil
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) IsRunning() bool {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
return pm.isRunning
|
||||
}
|
||||
|
||||
// resolvePipeline checks for available software and builds command arguments
|
||||
func (pm *PipelineManager) resolvePipeline(cfg PipelineConfig) (string, []string, error) {
|
||||
tool := cfg.ToolType
|
||||
if tool == "auto" || tool == "" {
|
||||
if pm.hasExecutable("gst-launch-1.0") {
|
||||
tool = "gstreamer"
|
||||
} else if pm.hasExecutable("ffmpeg") {
|
||||
tool = "ffmpeg"
|
||||
} else {
|
||||
return "", nil, fmt.Errorf("neither GStreamer (gst-launch-1.0) nor FFmpeg was found in PATH")
|
||||
}
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case "gstreamer":
|
||||
return pm.buildGStreamer(cfg)
|
||||
case "ffmpeg":
|
||||
return pm.buildFFmpeg(cfg)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported capture tool type: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) hasExecutable(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string, error) {
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
var pipelineParts []string
|
||||
|
||||
// 1. Capture Source (X11)
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("ximagesrc display-name=%s", cfg.Display))
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("video/x-raw,framerate=%d/1", cfg.FPS))
|
||||
pipelineParts = append(pipelineParts, "videoconvert")
|
||||
pipelineParts = append(pipelineParts, "videoscale")
|
||||
pipelineParts = append(
|
||||
pipelineParts,
|
||||
fmt.Sprintf("video/x-raw,format=I420,width=%d,height=%d,framerate=%d/1", cfg.Width, cfg.Height, cfg.FPS),
|
||||
)
|
||||
|
||||
// 2. Encoder
|
||||
encoderStr := "x264enc speed-preset=ultrafast tune=zerolatency bitrate=" + fmt.Sprintf("%d", cfg.Bitrate) + " byte-stream=true key-int-max=30"
|
||||
if cfg.UseGPU {
|
||||
// Detect if nvcodec is present by calling gst-inspect-1.0 or simply attempting it.
|
||||
// We'll default to nvh264enc.
|
||||
encoderStr = "nvh264enc bitrate=" + fmt.Sprintf("%d", cfg.Bitrate) + " preset=low-latency gop-size=30"
|
||||
}
|
||||
pipelineParts = append(pipelineParts, encoderStr)
|
||||
|
||||
// 3. Payload and Sink
|
||||
pipelineParts = append(pipelineParts, "video/x-h264,profile=baseline")
|
||||
pipelineParts = append(pipelineParts, "rtph264pay config-interval=1 pt=96")
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("udpsink host=127.0.0.1 port=%d sync=false async=false", cfg.Port))
|
||||
|
||||
// Join GStreamer pipeline with '!'
|
||||
pipelineStr := strings.Join(pipelineParts, " ! ")
|
||||
args := []string{"-v"}
|
||||
args = append(args, strings.Split(pipelineStr, " ")...)
|
||||
|
||||
var cleanArgs []string
|
||||
for _, arg := range args {
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
if trimmed != "" {
|
||||
cleanArgs = append(cleanArgs, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return "gst-launch-1.0", cleanArgs, nil
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, error) {
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
args := []string{
|
||||
"-f", "x11grab",
|
||||
"-draw_mouse", "1",
|
||||
"-framerate", fmt.Sprintf("%d", cfg.FPS),
|
||||
"-video_size", fmt.Sprintf("%dx%d", cfg.Width, cfg.Height),
|
||||
"-i", cfg.Display,
|
||||
}
|
||||
|
||||
// Encoder config
|
||||
if cfg.UseGPU {
|
||||
args = append(args,
|
||||
"-c:v", "h264_nvenc",
|
||||
"-preset", "llhp", // low latency high quality
|
||||
"-tune", "zerolatency",
|
||||
"-g", "30",
|
||||
"-pix_fmt", "yuv420p",
|
||||
)
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-x264-params", "repeat-headers=1",
|
||||
"-g", "30",
|
||||
)
|
||||
}
|
||||
|
||||
// Constant Bitrate
|
||||
args = append(args,
|
||||
"-b:v", fmt.Sprintf("%dk", cfg.Bitrate),
|
||||
"-maxrate", fmt.Sprintf("%dk", cfg.Bitrate),
|
||||
"-bufsize", fmt.Sprintf("%dk", cfg.Bitrate*2),
|
||||
)
|
||||
|
||||
// RTP Stream over UDP
|
||||
args = append(args,
|
||||
"-f", "rtp",
|
||||
fmt.Sprintf("rtp://127.0.0.1:%d", cfg.Port),
|
||||
)
|
||||
|
||||
return "ffmpeg", args, nil
|
||||
}
|
||||
85
internal/desktop/pipeline_test.go
Normal file
85
internal/desktop/pipeline_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildGStreamerUsesBrowserFriendlyH264(t *testing.T) {
|
||||
pm := NewPipelineManager()
|
||||
|
||||
tool, args, err := pm.buildGStreamer(PipelineConfig{
|
||||
Display: ":11.0",
|
||||
Port: 5004,
|
||||
Bitrate: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildGStreamer returned error: %v", err)
|
||||
}
|
||||
|
||||
if tool != "gst-launch-1.0" {
|
||||
t.Fatalf("expected gst-launch-1.0, got %q", tool)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, expected := range []string{
|
||||
"video/x-raw,format=I420",
|
||||
"width=1280",
|
||||
"height=720",
|
||||
"x264enc",
|
||||
"tune=zerolatency",
|
||||
"key-int-max=30",
|
||||
"byte-stream=true",
|
||||
"video/x-h264,profile=baseline",
|
||||
"rtph264pay",
|
||||
"config-interval=1",
|
||||
} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("expected GStreamer args to contain %q, got %s", expected, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoDimensionUsesEvenValues(t *testing.T) {
|
||||
if got := normalizeVideoDimension(1353, 1280); got != 1352 {
|
||||
t.Fatalf("expected odd dimension to be rounded down to 1352, got %d", got)
|
||||
}
|
||||
if got := normalizeVideoDimension(0, 720); got != 720 {
|
||||
t.Fatalf("expected fallback for zero dimension, got %d", got)
|
||||
}
|
||||
if got := normalizeVideoDimension(1, 720); got != 720 {
|
||||
t.Fatalf("expected fallback for too-small dimension, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegUsesBrowserFriendlyH264(t *testing.T) {
|
||||
pm := NewPipelineManager()
|
||||
|
||||
tool, args, err := pm.buildFFmpeg(PipelineConfig{
|
||||
Display: ":11.0",
|
||||
Port: 5004,
|
||||
Bitrate: 2000,
|
||||
FPS: 30,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildFFmpeg returned error: %v", err)
|
||||
}
|
||||
|
||||
if tool != "ffmpeg" {
|
||||
t.Fatalf("expected ffmpeg, got %q", tool)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, expected := range []string{
|
||||
"-c:v libx264",
|
||||
"-tune zerolatency",
|
||||
"-profile:v baseline",
|
||||
"-pix_fmt yuv420p",
|
||||
"-x264-params repeat-headers=1",
|
||||
"-g 30",
|
||||
} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("expected FFmpeg args to contain %q, got %s", expected, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
164
internal/desktop/service.go
Normal file
164
internal/desktop/service.go
Normal file
@ -0,0 +1,164 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type DesktopSession struct {
|
||||
SessionID string
|
||||
Port int
|
||||
Config PipelineConfig
|
||||
Pipeline *PipelineManager
|
||||
Injector *XdotoolInjector
|
||||
WebRTC *WebRTCServer
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
sessions map[string]*DesktopSession
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Service
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func GetService() *Service {
|
||||
once.Do(func() {
|
||||
instance = &Service{
|
||||
sessions: make(map[string]*DesktopSession),
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
func (s *Service) StartSession(sessionID string, cfg PipelineConfig, iceServers []string) (*DesktopSession, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cfg.Port = normalizeRTPPort(cfg.Port)
|
||||
cfg.Display = ResolveDesktopDisplay(cfg.Display)
|
||||
|
||||
s.stopSessionByIDLocked(sessionID)
|
||||
s.stopSessionsOnPortLocked(cfg.Port)
|
||||
|
||||
log.Printf("Starting Remote Desktop session: %s", sessionID)
|
||||
|
||||
// 1. Initialize input injector
|
||||
injector := NewXdotoolInjector(cfg.Display)
|
||||
if err := injector.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start input injector: %w", err)
|
||||
}
|
||||
|
||||
// 2. Initialize WebRTC server
|
||||
webrtcSrv, err := NewWebRTCServer(injector)
|
||||
if err != nil {
|
||||
_ = injector.Close()
|
||||
return nil, fmt.Errorf("failed to create WebRTC server: %w", err)
|
||||
}
|
||||
|
||||
if err := webrtcSrv.InitPeerConnection(iceServers); err != nil {
|
||||
_ = injector.Close()
|
||||
return nil, fmt.Errorf("failed to init peer connection: %w", err)
|
||||
}
|
||||
|
||||
// Start local UDP listener for GStreamer RTP packets
|
||||
if err := webrtcSrv.StartRTPReceiver(cfg.Port); err != nil {
|
||||
webrtcSrv.Close()
|
||||
_ = injector.Close()
|
||||
return nil, fmt.Errorf("failed to start RTP receiver: %w", err)
|
||||
}
|
||||
|
||||
sess := &DesktopSession{
|
||||
SessionID: sessionID,
|
||||
Port: cfg.Port,
|
||||
Config: cfg,
|
||||
Pipeline: NewPipelineManager(),
|
||||
Injector: injector,
|
||||
WebRTC: webrtcSrv,
|
||||
}
|
||||
|
||||
s.sessions[sessionID] = sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Service) StartCapture(sessionID string) error {
|
||||
sess, err := s.GetSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.Pipeline == nil {
|
||||
sess.Pipeline = NewPipelineManager()
|
||||
}
|
||||
if sess.Pipeline.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
if err := sess.Pipeline.Start(sess.Config); err != nil {
|
||||
return fmt.Errorf("failed to start capture pipeline: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetSession(sessionID string) (*DesktopSession, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
sess, exists := s.sessions[sessionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Service) StopSession(sessionID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.stopSessionByIDLocked(sessionID)
|
||||
}
|
||||
|
||||
func (s *Service) stopSessionByIDLocked(sessionID string) {
|
||||
if sess, exists := s.sessions[sessionID]; exists {
|
||||
s.stopSessionLocked(sess)
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stopSessionsOnPortLocked(port int) {
|
||||
if port <= 0 {
|
||||
return
|
||||
}
|
||||
for sessionID, sess := range s.sessions {
|
||||
if sess.Port != port {
|
||||
continue
|
||||
}
|
||||
log.Printf("Stopping Remote Desktop session on RTP port %d: %s", port, sessionID)
|
||||
s.stopSessionLocked(sess)
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stopSessionLocked(sess *DesktopSession) {
|
||||
log.Printf("Stopping Remote Desktop session: %s", sess.SessionID)
|
||||
if sess.Pipeline != nil {
|
||||
sess.Pipeline.Stop()
|
||||
}
|
||||
if sess.WebRTC != nil {
|
||||
sess.WebRTC.Close()
|
||||
}
|
||||
if sess.Injector != nil {
|
||||
_ = sess.Injector.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) AddICECandidate(sessionID string, candidate webrtc.ICECandidateInit) error {
|
||||
sess, err := s.GetSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sess.WebRTC.AddICECandidate(candidate)
|
||||
}
|
||||
39
internal/desktop/service_test.go
Normal file
39
internal/desktop/service_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package desktop
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeRTPPortUsesDesktopDefault(t *testing.T) {
|
||||
if got := normalizeRTPPort(0); got != DefaultRTPPort {
|
||||
t.Fatalf("expected default RTP port %d, got %d", DefaultRTPPort, got)
|
||||
}
|
||||
if got := normalizeRTPPort(-1); got != DefaultRTPPort {
|
||||
t.Fatalf("expected default RTP port %d for negative port, got %d", DefaultRTPPort, got)
|
||||
}
|
||||
if got := normalizeRTPPort(6004); got != 6004 {
|
||||
t.Fatalf("expected explicit RTP port to be preserved, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopSessionsOnPortReleasesOnlyMatchingRTPPort(t *testing.T) {
|
||||
svc := &Service{
|
||||
sessions: map[string]*DesktopSession{
|
||||
"old-5004": {
|
||||
SessionID: "old-5004",
|
||||
Port: DefaultRTPPort,
|
||||
},
|
||||
"other-6004": {
|
||||
SessionID: "other-6004",
|
||||
Port: 6004,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc.stopSessionsOnPortLocked(DefaultRTPPort)
|
||||
|
||||
if _, ok := svc.sessions["old-5004"]; ok {
|
||||
t.Fatalf("expected old session on RTP port %d to be removed", DefaultRTPPort)
|
||||
}
|
||||
if _, ok := svc.sessions["other-6004"]; !ok {
|
||||
t.Fatalf("expected session on a different RTP port to remain")
|
||||
}
|
||||
}
|
||||
299
internal/desktop/webrtc.go
Normal file
299
internal/desktop/webrtc.go
Normal file
@ -0,0 +1,299 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
desktopReliableInputChannelLabel = "input"
|
||||
desktopMoveInputChannelLabel = "input-move"
|
||||
desktopICEGatheringTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type WebRTCServer struct {
|
||||
peerConnection *webrtc.PeerConnection
|
||||
videoTrack *webrtc.TrackLocalStaticRTP
|
||||
udpListener net.PacketConn
|
||||
inputInjector *XdotoolInjector
|
||||
mu sync.Mutex
|
||||
isClosed bool
|
||||
rtpPackets uint64
|
||||
rtpBytes uint64
|
||||
rtpWriteErrors uint64
|
||||
}
|
||||
|
||||
func desktopWebRTCDiagnosticsEnabled() bool {
|
||||
value := os.Getenv("XWORKMATE_DESKTOP_WEBRTC_DEBUG")
|
||||
switch value {
|
||||
case "0", "false", "FALSE", "off", "OFF", "no", "NO":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebRTCServer(injector *XdotoolInjector) (*WebRTCServer, error) {
|
||||
return &WebRTCServer{
|
||||
inputInjector: injector,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitPeerConnection sets up the Pion PeerConnection and local H.264 video track
|
||||
func (w *WebRTCServer) InitPeerConnection(iceServers []string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
var webrtcIceServers []webrtc.ICEServer
|
||||
for _, url := range iceServers {
|
||||
webrtcIceServers = append(webrtcIceServers, webrtc.ICEServer{
|
||||
URLs: []string{url},
|
||||
})
|
||||
}
|
||||
if len(webrtcIceServers) == 0 {
|
||||
// Default STUN server
|
||||
webrtcIceServers = append(webrtcIceServers, webrtc.ICEServer{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
})
|
||||
}
|
||||
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: webrtcIceServers,
|
||||
}
|
||||
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WebRTC PeerConnection: %w", err)
|
||||
}
|
||||
|
||||
// Create H.264 video track
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||
"video",
|
||||
"xworkmate-desktop",
|
||||
)
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return fmt.Errorf("failed to create video track: %w", err)
|
||||
}
|
||||
|
||||
_, err = pc.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return fmt.Errorf("failed to add video track: %w", err)
|
||||
}
|
||||
|
||||
// Handle Data Channel for inputs
|
||||
pc.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
log.Printf("Data channel '%s'-'%d' opened", d.Label(), d.ID())
|
||||
label := d.Label()
|
||||
if !isDesktopInputDataChannelLabel(label) {
|
||||
return
|
||||
}
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
var event InputEvent
|
||||
if err := json.Unmarshal(msg.Data, &event); err != nil {
|
||||
log.Printf("Failed to unmarshal input event: %v", err)
|
||||
return
|
||||
}
|
||||
if label == desktopMoveInputChannelLabel && event.Type != "mouse_move" {
|
||||
log.Printf("Ignoring non-mouse_move input event on %s channel: %s", label, event.Type)
|
||||
return
|
||||
}
|
||||
if err := w.inputInjector.Inject(event); err != nil {
|
||||
log.Printf("Failed to inject input event: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
w.peerConnection = pc
|
||||
w.videoTrack = videoTrack
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDesktopInputDataChannelLabel(label string) bool {
|
||||
return label == desktopReliableInputChannelLabel || label == desktopMoveInputChannelLabel
|
||||
}
|
||||
|
||||
// StartRTPReceiver listens on local UDP port for GStreamer RTP stream and forwards to WebRTC video track
|
||||
func (w *WebRTCServer) StartRTPReceiver(port int) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
conn, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind UDP port %s: %w", addr, err)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.udpListener = conn
|
||||
w.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
log.Printf("WebRTC RTP receiver listening on UDP %s", addr)
|
||||
statsDone := make(chan struct{})
|
||||
if desktopWebRTCDiagnosticsEnabled() {
|
||||
statsTicker := time.NewTicker(5 * time.Second)
|
||||
defer statsTicker.Stop()
|
||||
defer close(statsDone)
|
||||
go func() {
|
||||
var lastPackets uint64
|
||||
var lastBytes uint64
|
||||
for {
|
||||
select {
|
||||
case <-statsTicker.C:
|
||||
packets := atomic.LoadUint64(&w.rtpPackets)
|
||||
bytes := atomic.LoadUint64(&w.rtpBytes)
|
||||
errors := atomic.LoadUint64(&w.rtpWriteErrors)
|
||||
log.Printf(
|
||||
"WebRTC RTP stats: packets=%d bytes=%d packetDelta=%d byteDelta=%d writeErrors=%d",
|
||||
packets,
|
||||
bytes,
|
||||
packets-lastPackets,
|
||||
bytes-lastBytes,
|
||||
errors,
|
||||
)
|
||||
lastPackets = packets
|
||||
lastBytes = bytes
|
||||
case <-statsDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
w.mu.Lock()
|
||||
closed := w.isClosed
|
||||
w.mu.Unlock()
|
||||
if !closed {
|
||||
log.Printf("UDP RTP read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
atomic.AddUint64(&w.rtpPackets, 1)
|
||||
atomic.AddUint64(&w.rtpBytes, uint64(n))
|
||||
|
||||
// Forward packet directly to WebRTC track (zero-copy)
|
||||
w.mu.Lock()
|
||||
track := w.videoTrack
|
||||
w.mu.Unlock()
|
||||
|
||||
if track != nil {
|
||||
if _, err := track.Write(buf[:n]); err != nil {
|
||||
atomic.AddUint64(&w.rtpWriteErrors, 1)
|
||||
log.Printf("Failed to write RTP packet to track: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessOffer handles SDP offer, sets remote description, generates and returns SDP answer
|
||||
func (w *WebRTCServer) ProcessOffer(sdpOffer string) (string, error) {
|
||||
w.mu.Lock()
|
||||
pc := w.peerConnection
|
||||
w.mu.Unlock()
|
||||
|
||||
if pc == nil {
|
||||
return "", fmt.Errorf("peer connection not initialized")
|
||||
}
|
||||
|
||||
offer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: sdpOffer,
|
||||
}
|
||||
|
||||
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||
return "", fmt.Errorf("failed to set remote description: %w", err)
|
||||
}
|
||||
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create SDP answer: %w", err)
|
||||
}
|
||||
|
||||
// Gather ICE candidates
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
return "", fmt.Errorf("failed to set local description: %w", err)
|
||||
}
|
||||
|
||||
if err := waitForICEGatheringComplete(gatherComplete, desktopICEGatheringTimeout); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
localDesc := pc.LocalDescription()
|
||||
if localDesc == nil {
|
||||
return "", fmt.Errorf("local description is nil after gathering")
|
||||
}
|
||||
|
||||
return localDesc.SDP, nil
|
||||
}
|
||||
|
||||
func waitForICEGatheringComplete(done <-chan struct{}, timeout time.Duration) error {
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timed out waiting for ICE gathering after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// AddICECandidate adds a remote ICE candidate
|
||||
func (w *WebRTCServer) AddICECandidate(candidate webrtc.ICECandidateInit) error {
|
||||
w.mu.Lock()
|
||||
pc := w.peerConnection
|
||||
w.mu.Unlock()
|
||||
|
||||
if pc == nil {
|
||||
return fmt.Errorf("peer connection not initialized")
|
||||
}
|
||||
|
||||
if err := pc.AddICECandidate(candidate); err != nil {
|
||||
return fmt.Errorf("failed to add remote ICE candidate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close terminates the WebRTC server
|
||||
func (w *WebRTCServer) Close() {
|
||||
w.mu.Lock()
|
||||
if w.isClosed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.isClosed = true
|
||||
pc := w.peerConnection
|
||||
conn := w.udpListener
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Println("Closing WebRTC server...")
|
||||
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
if pc != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
log.Printf(
|
||||
"WebRTC RTP final stats: packets=%d bytes=%d writeErrors=%d",
|
||||
atomic.LoadUint64(&w.rtpPackets),
|
||||
atomic.LoadUint64(&w.rtpBytes),
|
||||
atomic.LoadUint64(&w.rtpWriteErrors),
|
||||
)
|
||||
}
|
||||
40
internal/desktop/webrtc_test.go
Normal file
40
internal/desktop/webrtc_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsDesktopInputDataChannelLabelAllowsReliableAndMoveChannels(t *testing.T) {
|
||||
if !isDesktopInputDataChannelLabel(desktopReliableInputChannelLabel) {
|
||||
t.Fatalf("expected reliable input channel label to be accepted")
|
||||
}
|
||||
if !isDesktopInputDataChannelLabel(desktopMoveInputChannelLabel) {
|
||||
t.Fatalf("expected mouse move input channel label to be accepted")
|
||||
}
|
||||
if isDesktopInputDataChannelLabel("debug") {
|
||||
t.Fatalf("expected unrelated data channel label to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForICEGatheringCompleteReturnsWhenDoneCloses(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
|
||||
if err := waitForICEGatheringComplete(done, time.Second); err != nil {
|
||||
t.Fatalf("expected closed gathering channel to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForICEGatheringCompleteTimesOut(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
|
||||
start := time.Now()
|
||||
err := waitForICEGatheringComplete(done, 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("timeout helper waited too long: %s", elapsed)
|
||||
}
|
||||
}
|
||||
@ -219,6 +219,23 @@ func (m *Manager) lookupConnectedByMode(mode string) *session {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) HasConnectedSession() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, current := range m.sessions {
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
current.mu.Lock()
|
||||
connected := current.snapshot.Status == "connected"
|
||||
current.mu.Unlock()
|
||||
if connected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type session struct {
|
||||
manager *Manager
|
||||
runtimeID string
|
||||
@ -343,7 +360,11 @@ func sameConnectTarget(current ConnectRequest, next ConnectRequest) bool {
|
||||
strings.TrimSpace(current.Endpoint.Host) == strings.TrimSpace(next.Endpoint.Host) &&
|
||||
current.Endpoint.Port == next.Endpoint.Port &&
|
||||
current.Endpoint.TLS == next.Endpoint.TLS &&
|
||||
normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path)
|
||||
normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path) &&
|
||||
strings.TrimSpace(current.Identity.DeviceID) == strings.TrimSpace(next.Identity.DeviceID) &&
|
||||
strings.TrimSpace(current.Auth.Token) == strings.TrimSpace(next.Auth.Token) &&
|
||||
strings.TrimSpace(current.Auth.DeviceToken) == strings.TrimSpace(next.Auth.DeviceToken) &&
|
||||
strings.TrimSpace(current.Auth.Password) == strings.TrimSpace(next.Auth.Password)
|
||||
}
|
||||
|
||||
func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
|
||||
@ -395,11 +416,6 @@ func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
|
||||
snapshotPayload := asMap(payload["snapshot"])
|
||||
sessionDefaults := asMap(snapshotPayload["sessionDefaults"])
|
||||
returnedDeviceToken := strings.TrimSpace(stringValue(auth["deviceToken"]))
|
||||
if returnedDeviceToken != "" {
|
||||
s.mu.Lock()
|
||||
s.config.Auth.DeviceToken = returnedDeviceToken
|
||||
s.mu.Unlock()
|
||||
}
|
||||
negotiatedScopes := stringSlice(auth["scopes"])
|
||||
negotiatedRole := strings.TrimSpace(stringValue(auth["role"]))
|
||||
if negotiatedRole == "" {
|
||||
|
||||
@ -310,7 +310,7 @@ func TestSessionEmitsNormalizedChatRunPushEvents(t *testing.T) {
|
||||
map[string]any{"seq": 7},
|
||||
map[string]any{
|
||||
"runId": "run-1",
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionKey": "main",
|
||||
"state": "final",
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
|
||||
@ -19,9 +19,9 @@ type rpcClient interface {
|
||||
}
|
||||
|
||||
type initializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AuthMethods []map[string]any `json:"authMethods"`
|
||||
AgentCapabilities map[string]any `json:"agentCapabilities"`
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AuthMethods []map[string]any `json:"authMethods"`
|
||||
AgentCapabilities map[string]any `json:"agentCapabilities"`
|
||||
}
|
||||
|
||||
type stdioRPCClient struct {
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
)
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(username, password string) error
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
service Authenticator
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc Authenticator) *AuthHandler {
|
||||
return &AuthHandler{service: svc}
|
||||
}
|
||||
|
||||
func NewServiceAdapter(svc *service.AuthService) Authenticator {
|
||||
return authServiceAdapter{service: svc}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.service.Authenticate(payload.Username, payload.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type authServiceAdapter struct {
|
||||
service *service.AuthService
|
||||
}
|
||||
|
||||
func (a authServiceAdapter) Authenticate(username, password string) error {
|
||||
return a.service.Authenticate(context.TODO(), username, password)
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAuthenticator struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeAuthenticator) Authenticate(username, password string) error {
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestAuthHandlerRejectsInvalidJSON(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString("{"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlerReturnsUnauthorizedOnServiceFailure(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{err: errors.New("invalid credentials")})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlerReturnsOKOnSuccess(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
"xworkmate-bridge/internal/service"
|
||||
)
|
||||
|
||||
type TokenAuthHandler struct {
|
||||
service *service.StaticTokenAuthService
|
||||
}
|
||||
|
||||
func NewTokenAuthHandler(service *service.StaticTokenAuthService) *TokenAuthHandler {
|
||||
return &TokenAuthHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *TokenAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.service == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(nil, -32000, "auth service unavailable"))
|
||||
return
|
||||
}
|
||||
token := r.Header.Get("Authorization")
|
||||
if h.service.ValidateAuthorizationHeader(token) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"ok": true,
|
||||
"type": "res",
|
||||
"payload": map[string]any{"authenticated": true},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(nil, -32001, "unauthorized"))
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
)
|
||||
|
||||
func TestTokenAuthHandlerServeHTTP(t *testing.T) {
|
||||
h := NewTokenAuthHandler(service.NewStaticTokenAuthService("secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAuthHandlerRejectsUnauthorized(t *testing.T) {
|
||||
h := NewTokenAuthHandler(service.NewStaticTokenAuthService("secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
codexManagedMCPBlockStart = "# BEGIN XWORKMATE MANAGED MCP BLOCK"
|
||||
codexManagedMCPBlockEnd = "# END XWORKMATE MANAGED MCP BLOCK"
|
||||
opencodeManagedMCPBlockStart = "# BEGIN XWORKMATE MANAGED MCP BLOCK"
|
||||
opencodeManagedMCPBlockEnd = "# END XWORKMATE MANAGED MCP BLOCK"
|
||||
)
|
||||
|
||||
var mcpServerSectionPattern = regexp.MustCompile(
|
||||
`(?m)^\[mcp_servers\.[^\]]+\]`,
|
||||
)
|
||||
|
||||
func countMCPSections(content string) int {
|
||||
return len(mcpServerSectionPattern.FindAllStringIndex(content, -1))
|
||||
}
|
||||
|
||||
func defaultCodexHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".codex")
|
||||
}
|
||||
|
||||
func defaultOpencodeHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".opencode")
|
||||
}
|
||||
|
||||
func defaultOpenClawHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".openclaw")
|
||||
}
|
||||
|
||||
func stripManagedBlock(content, startMarker, endMarker string) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
remaining := content
|
||||
for {
|
||||
start := strings.Index(remaining, startMarker)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(remaining[start:], endMarker)
|
||||
if end < 0 {
|
||||
remaining = remaining[:start]
|
||||
break
|
||||
}
|
||||
end += start
|
||||
remaining = remaining[:start] + remaining[end+len(endMarker):]
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func mergeManagedBlock(content, block, startMarker, endMarker string) string {
|
||||
preserved := strings.TrimRight(
|
||||
stripManagedBlock(content, startMarker, endMarker),
|
||||
"\n",
|
||||
)
|
||||
if preserved == "" {
|
||||
return block + "\n"
|
||||
}
|
||||
return preserved + "\n\n" + block + "\n"
|
||||
}
|
||||
|
||||
func buildCodexManagedMCPBlock(servers []ManagedMCPServer) string {
|
||||
var buffer strings.Builder
|
||||
buffer.WriteString(codexManagedMCPBlockStart)
|
||||
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
|
||||
for _, server := range servers {
|
||||
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
|
||||
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
|
||||
if len(server.Args) > 0 {
|
||||
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
|
||||
}
|
||||
buffer.WriteString("\n")
|
||||
}
|
||||
buffer.WriteString(codexManagedMCPBlockEnd)
|
||||
return strings.TrimRight(buffer.String(), "\n")
|
||||
}
|
||||
|
||||
func buildOpencodeManagedMCPBlock(servers []ManagedMCPServer) string {
|
||||
var buffer strings.Builder
|
||||
buffer.WriteString(opencodeManagedMCPBlockStart)
|
||||
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
|
||||
for _, server := range servers {
|
||||
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
|
||||
if strings.TrimSpace(server.URL) != "" {
|
||||
_, _ = fmt.Fprintf(&buffer, "url = %q\n", strings.TrimSpace(server.URL))
|
||||
} else {
|
||||
buffer.WriteString("type = \"stdio\"\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
|
||||
if len(server.Args) > 0 {
|
||||
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
|
||||
}
|
||||
}
|
||||
buffer.WriteString("\n")
|
||||
}
|
||||
buffer.WriteString(opencodeManagedMCPBlockEnd)
|
||||
return strings.TrimRight(buffer.String(), "\n")
|
||||
}
|
||||
|
||||
func formatTOMLArray(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
var quoted []string
|
||||
for _, item := range items {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
func applyManagedBlock(configPath, block, startMarker, endMarker string) error {
|
||||
configDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
merged := mergeManagedBlock(string(content), block, startMarker, endMarker)
|
||||
return os.WriteFile(configPath, []byte(merged), 0o644)
|
||||
}
|
||||
@ -1,444 +0,0 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagedMCPServer struct {
|
||||
ID string
|
||||
Name string
|
||||
Transport string
|
||||
Command string
|
||||
URL string
|
||||
Args []string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AutoSync bool
|
||||
UsesAris bool
|
||||
ManagedMCPServers []ManagedMCPServer
|
||||
}
|
||||
|
||||
type ArisInput struct {
|
||||
Available bool
|
||||
BundleVersion string
|
||||
LLMChatServerPath string
|
||||
SkillCount int
|
||||
BridgeAvailable bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Config Config
|
||||
AIGatewayURL string
|
||||
ConfiguredCodexCLIPath string
|
||||
CodexHome string
|
||||
OpencodeHome string
|
||||
OpenClawHome string
|
||||
Aris ArisInput
|
||||
}
|
||||
|
||||
type MountTargetState struct {
|
||||
TargetID string
|
||||
Label string
|
||||
Available bool
|
||||
SupportsSkills bool
|
||||
SupportsMCP bool
|
||||
SupportsAIGatewayInjection bool
|
||||
DiscoveryState string
|
||||
SyncState string
|
||||
DiscoveredSkillCount int
|
||||
DiscoveredMCPCount int
|
||||
ManagedMCPCount int
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
MountTargets []MountTargetState
|
||||
ArisBundleVersion string
|
||||
ArisCompatStatus string
|
||||
}
|
||||
|
||||
func Reconcile(request Request) Result {
|
||||
states := []MountTargetState{
|
||||
reconcileAris(request.Config, request.Aris),
|
||||
reconcileCodex(
|
||||
request.Config,
|
||||
request.AIGatewayURL,
|
||||
request.ConfiguredCodexCLIPath,
|
||||
request.CodexHome,
|
||||
),
|
||||
reconcileCLIListTarget(
|
||||
request.Config,
|
||||
"claude",
|
||||
"Claude",
|
||||
[]string{"claude", "mcp", "list"},
|
||||
),
|
||||
reconcileCLIListTarget(
|
||||
request.Config,
|
||||
"gemini",
|
||||
"Gemini",
|
||||
[]string{"gemini", "mcp", "list"},
|
||||
),
|
||||
reconcileOpencode(request.Config, request.OpencodeHome),
|
||||
reconcileOpenClaw(request.Config, request.OpenClawHome),
|
||||
}
|
||||
|
||||
result := Result{
|
||||
MountTargets: states,
|
||||
ArisBundleVersion: strings.TrimSpace(request.Aris.BundleVersion),
|
||||
ArisCompatStatus: "idle",
|
||||
}
|
||||
for _, state := range states {
|
||||
if state.TargetID == "aris" {
|
||||
result.ArisCompatStatus = state.SyncState
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ResultMap(result Result) map[string]any {
|
||||
rawTargets := make([]map[string]any, 0, len(result.MountTargets))
|
||||
for _, target := range result.MountTargets {
|
||||
rawTargets = append(rawTargets, map[string]any{
|
||||
"targetId": target.TargetID,
|
||||
"label": target.Label,
|
||||
"available": target.Available,
|
||||
"supportsSkills": target.SupportsSkills,
|
||||
"supportsMcp": target.SupportsMCP,
|
||||
"supportsAiGatewayInjection": target.SupportsAIGatewayInjection,
|
||||
"discoveryState": target.DiscoveryState,
|
||||
"syncState": target.SyncState,
|
||||
"discoveredSkillCount": target.DiscoveredSkillCount,
|
||||
"discoveredMcpCount": target.DiscoveredMCPCount,
|
||||
"managedMcpCount": target.ManagedMCPCount,
|
||||
"detail": target.Detail,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"mountTargets": rawTargets,
|
||||
"arisBundleVersion": result.ArisBundleVersion,
|
||||
"arisCompatStatus": result.ArisCompatStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileAris(config Config, input ArisInput) MountTargetState {
|
||||
state := placeholderState("aris", "ARIS", true, true, false)
|
||||
if strings.TrimSpace(input.Error) != "" {
|
||||
state.Available = false
|
||||
state.DiscoveryState = "error"
|
||||
state.SyncState = "error"
|
||||
state.Detail = strings.TrimSpace(input.Error)
|
||||
return state
|
||||
}
|
||||
if !input.Available {
|
||||
state.DiscoveryState = "missing"
|
||||
state.SyncState = "missing"
|
||||
state.Detail = "Embedded ARIS bundle is unavailable."
|
||||
return state
|
||||
}
|
||||
|
||||
state.Available = true
|
||||
state.DiscoveryState = "ready"
|
||||
state.DiscoveredSkillCount = input.SkillCount
|
||||
llmChatReady := strings.TrimSpace(input.LLMChatServerPath) != ""
|
||||
if config.UsesAris && llmChatReady && input.BridgeAvailable {
|
||||
state.SyncState = "ready"
|
||||
state.DiscoveredMCPCount = 1
|
||||
state.ManagedMCPCount = 1
|
||||
state.Detail = "Embedded bundle " +
|
||||
strings.TrimSpace(input.BundleVersion) +
|
||||
" ready; XWorkmate Go core manages llm-chat and claude-review."
|
||||
return state
|
||||
}
|
||||
state.SyncState = "embedded"
|
||||
if llmChatReady {
|
||||
state.DiscoveredMCPCount = 1
|
||||
}
|
||||
if llmChatReady {
|
||||
state.Detail = "Embedded bundle extracted, but the XWorkmate Go core is not available yet."
|
||||
} else {
|
||||
state.Detail = "Embedded bundle extracted, but llm-chat metadata is missing."
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileCodex(
|
||||
config Config,
|
||||
aiGatewayURL string,
|
||||
configuredCodexCLIPath string,
|
||||
codexHome string,
|
||||
) MountTargetState {
|
||||
state := placeholderState("codex", "Codex", true, true, true)
|
||||
available := codexAvailable(configuredCodexCLIPath)
|
||||
configHome := strings.TrimSpace(codexHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultCodexHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "config.toml")
|
||||
content, _ := os.ReadFile(configPath)
|
||||
discovered := countMCPSections(string(content))
|
||||
managedServers := enabledCodexServers(config.ManagedMCPServers)
|
||||
if available && config.AutoSync && len(managedServers) > 0 {
|
||||
_ = applyManagedBlock(
|
||||
configPath,
|
||||
buildCodexManagedMCPBlock(managedServers),
|
||||
codexManagedMCPBlockStart,
|
||||
codexManagedMCPBlockEnd,
|
||||
)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
switch {
|
||||
case !available:
|
||||
state.SyncState = "missing"
|
||||
case config.AutoSync:
|
||||
state.SyncState = "ready"
|
||||
default:
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(managedServers)
|
||||
state.Detail = "Codex is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileCLIListTarget(
|
||||
config Config,
|
||||
targetID string,
|
||||
label string,
|
||||
command []string,
|
||||
) MountTargetState {
|
||||
state := placeholderState(targetID, label, true, true, true)
|
||||
available := binaryExists(command[0])
|
||||
discovered := 0
|
||||
if available {
|
||||
discovered = countListedEntries(command)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
if available && config.AutoSync {
|
||||
state.SyncState = "launch-only"
|
||||
} else {
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(enabledServers(config.ManagedMCPServers))
|
||||
|
||||
if targetID == "gemini" {
|
||||
state.Detail = "Gemini is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
} else {
|
||||
state.Detail = "MCP discovery uses `" + strings.Join(command, " ") +
|
||||
"`; LLM API stays launch-scoped."
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileOpencode(config Config, opencodeHome string) MountTargetState {
|
||||
state := placeholderState("opencode", "OpenCode", true, true, true)
|
||||
available := binaryExists("opencode")
|
||||
configHome := strings.TrimSpace(opencodeHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultOpencodeHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "config.toml")
|
||||
content, _ := os.ReadFile(configPath)
|
||||
discovered := countMCPSections(string(content))
|
||||
managedServers := enabledServers(config.ManagedMCPServers)
|
||||
if available && config.AutoSync && len(managedServers) > 0 {
|
||||
_ = applyManagedBlock(
|
||||
configPath,
|
||||
buildOpencodeManagedMCPBlock(managedServers),
|
||||
opencodeManagedMCPBlockStart,
|
||||
opencodeManagedMCPBlockEnd,
|
||||
)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
switch {
|
||||
case !available:
|
||||
state.SyncState = "missing"
|
||||
case config.AutoSync:
|
||||
state.SyncState = "ready"
|
||||
default:
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(managedServers)
|
||||
state.Detail = "OpenCode is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileOpenClaw(config Config, openClawHome string) MountTargetState {
|
||||
state := placeholderState("openclaw", "OpenClaw", true, false, true)
|
||||
available := binaryExists("openclaw")
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
if available && config.AutoSync {
|
||||
state.SyncState = "launch-only"
|
||||
} else {
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.Detail = "OpenClaw acts as the host/control plane mount."
|
||||
|
||||
configHome := strings.TrimSpace(openClawHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultOpenClawHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "openclaw.json")
|
||||
if content, err := os.ReadFile(configPath); err == nil {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(content, &decoded); err == nil {
|
||||
agents := 0
|
||||
if rawAgents, ok := decoded["agents"].(map[string]any); ok {
|
||||
if rawList, ok := rawAgents["list"].([]any); ok {
|
||||
agents = len(rawList)
|
||||
}
|
||||
}
|
||||
skillsDir := filepath.Join(configHome, "skills")
|
||||
if entries, err := os.ReadDir(skillsDir); err == nil {
|
||||
state.DiscoveredSkillCount = len(entries)
|
||||
}
|
||||
state.Detail = "agents: " + itoa(agents) + " · skills: " +
|
||||
itoa(state.DiscoveredSkillCount)
|
||||
} else {
|
||||
state.Detail = "OpenClaw config detected but could not be fully parsed."
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func placeholderState(
|
||||
targetID string,
|
||||
label string,
|
||||
supportsSkills bool,
|
||||
supportsMCP bool,
|
||||
supportsAIGatewayInjection bool,
|
||||
) MountTargetState {
|
||||
return MountTargetState{
|
||||
TargetID: targetID,
|
||||
Label: label,
|
||||
SupportsSkills: supportsSkills,
|
||||
SupportsMCP: supportsMCP,
|
||||
SupportsAIGatewayInjection: supportsAIGatewayInjection,
|
||||
DiscoveryState: "idle",
|
||||
SyncState: "idle",
|
||||
}
|
||||
}
|
||||
|
||||
func codexAvailable(configuredPath string) bool {
|
||||
if strings.TrimSpace(configuredPath) != "" {
|
||||
if _, err := os.Stat(strings.TrimSpace(configuredPath)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return binaryExists("codex")
|
||||
}
|
||||
|
||||
func binaryExists(command string) bool {
|
||||
_, err := exec.LookPath(command)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func countListedEntries(command []string) int {
|
||||
output := strings.TrimSpace(runCommand(command))
|
||||
if output == "" ||
|
||||
strings.Contains(output, "No MCP servers configured") ||
|
||||
strings.Contains(output, "No MCP servers configured yet") ||
|
||||
strings.Contains(output, "No MCP servers configured.") {
|
||||
return 0
|
||||
}
|
||||
lines := strings.Split(output, "\n")
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case trimmed == "":
|
||||
case strings.HasPrefix(trimmed, "Usage:"):
|
||||
case strings.HasPrefix(trimmed, "┌"):
|
||||
case strings.HasPrefix(trimmed, "│"):
|
||||
case strings.HasPrefix(trimmed, "└"):
|
||||
default:
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func runCommand(command []string) string {
|
||||
if len(command) == 0 {
|
||||
return ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func enabledServers(servers []ManagedMCPServer) []ManagedMCPServer {
|
||||
filtered := make([]ManagedMCPServer, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if !server.Enabled {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
func enabledCodexServers(servers []ManagedMCPServer) []ManagedMCPServer {
|
||||
filtered := make([]ManagedMCPServer, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if !server.Enabled || strings.TrimSpace(server.Command) == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReconcileCodexAppliesManagedBlockAndPreservesUserEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configuredBinary := filepath.Join(tempDir, "custom-codex")
|
||||
if err := os.WriteFile(configuredBinary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write configured binary: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(tempDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
[mcp_servers.user_server]
|
||||
command = "user-mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
result := Reconcile(Request{
|
||||
Config: Config{
|
||||
AutoSync: true,
|
||||
ManagedMCPServers: []ManagedMCPServer{
|
||||
{ID: "xworkmate_server", Command: "xworkmate-mcp", Args: []string{"--port", "7777"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
ConfiguredCodexCLIPath: configuredBinary,
|
||||
CodexHome: tempDir,
|
||||
})
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.user_server]`) {
|
||||
t.Fatalf("expected user entry preserved: %s", string(content))
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.xworkmate_server]`) {
|
||||
t.Fatalf("expected managed entry written: %s", string(content))
|
||||
}
|
||||
if strings.Count(string(content), codexManagedMCPBlockStart) != 1 {
|
||||
t.Fatalf("expected single managed block: %s", string(content))
|
||||
}
|
||||
if result.MountTargets[1].ManagedMCPCount != 1 {
|
||||
t.Fatalf("expected codex managed count 1, got %d", result.MountTargets[1].ManagedMCPCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOpencodeAppliesManagedBlockAndPreservesUserEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
binDir := t.TempDir()
|
||||
originalPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath)
|
||||
if err := os.WriteFile(filepath.Join(binDir, "opencode"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write opencode binary: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(tempDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
[model]
|
||||
name = "user-default"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
result := Reconcile(Request{
|
||||
Config: Config{
|
||||
AutoSync: true,
|
||||
ManagedMCPServers: []ManagedMCPServer{
|
||||
{ID: "xworkmate_server", Command: "xworkmate-mcp", Args: []string{"--port", "3001"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
OpencodeHome: tempDir,
|
||||
})
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), `[model]`) {
|
||||
t.Fatalf("expected user config preserved: %s", string(content))
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.xworkmate_server]`) {
|
||||
t.Fatalf("expected managed opencode entry written: %s", string(content))
|
||||
}
|
||||
if strings.Count(string(content), opencodeManagedMCPBlockStart) != 1 {
|
||||
t.Fatalf("expected single opencode managed block: %s", string(content))
|
||||
}
|
||||
if result.MountTargets[4].ManagedMCPCount != 1 {
|
||||
t.Fatalf("expected opencode managed count 1, got %d", result.MountTargets[4].ManagedMCPCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileArisReportsReadyWhenBundleAndBridgeAreAvailable(t *testing.T) {
|
||||
result := Reconcile(Request{
|
||||
Config: Config{UsesAris: true},
|
||||
Aris: ArisInput{
|
||||
Available: true,
|
||||
BundleVersion: "test",
|
||||
LLMChatServerPath: "mcp-server.py",
|
||||
SkillCount: 2,
|
||||
BridgeAvailable: true,
|
||||
},
|
||||
})
|
||||
|
||||
if got := result.MountTargets[0].SyncState; got != "ready" {
|
||||
t.Fatalf("expected ready aris state, got %q", got)
|
||||
}
|
||||
if got := result.ArisBundleVersion; got != "test" {
|
||||
t.Fatalf("expected bundle version test, got %q", got)
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ func newOpenCodeHTTPClient(command string, args []string) *opencodeHTTPClient {
|
||||
command: strings.TrimSpace(command),
|
||||
args: append([]string(nil), args...),
|
||||
baseURL: "http://127.0.0.1:38993",
|
||||
client: &http.Client{Timeout: 5 * time.Minute},
|
||||
client: shared.NewHTTPClient(5 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -132,8 +132,8 @@ func TestResolveComplexTaskNoLongerPromotesToMultiAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
result := resolver.Resolve(Request{
|
||||
Prompt: "analyze these files, review the output, and summarize multiple deliverables",
|
||||
AvailableProviders: []string{"codex"},
|
||||
Prompt: "analyze these files, review the output, and summarize multiple deliverables",
|
||||
AvailableProviders: []string{"codex"},
|
||||
})
|
||||
|
||||
if result.ResolvedExecutionTarget != ExecutionTargetSingleAgent {
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@ -74,3 +75,18 @@ func ParseAllowedOrigins(raw string) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an http.Client with a customized high-performance transport.
|
||||
// It uses a significantly larger connection pool to prevent socket exhaustion and
|
||||
// performance degradation when hitting the same backend hosts heavily.
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.MaxIdleConns = 1000
|
||||
t.MaxIdleConnsPerHost = 100
|
||||
t.IdleConnTimeout = 90 * time.Second
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: t,
|
||||
}
|
||||
}
|
||||
|
||||
47
internal/shared/logger.go
Normal file
47
internal/shared/logger.go
Normal file
@ -0,0 +1,47 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogRingBuffer stores the last N log lines in memory.
|
||||
type LogRingBuffer struct {
|
||||
lines []string
|
||||
size int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Global log buffer for the bridge
|
||||
var GlobalLogBuffer = NewLogRingBuffer(200)
|
||||
|
||||
// NewLogRingBuffer creates a new ring buffer with the given size.
|
||||
func NewLogRingBuffer(size int) *LogRingBuffer {
|
||||
return &LogRingBuffer{
|
||||
lines: make([]string, 0, size),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer to intercept logs.
|
||||
func (r *LogRingBuffer) Write(p []byte) (n int, err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
line := string(p)
|
||||
if len(r.lines) >= r.size {
|
||||
// pop front
|
||||
r.lines = r.lines[1:]
|
||||
}
|
||||
r.lines = append(r.lines, line)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// GetLines returns a copy of the current log lines.
|
||||
func (r *LogRingBuffer) GetLines() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
res := make([]string, len(r.lines))
|
||||
copy(res, r.lines)
|
||||
return res
|
||||
}
|
||||
@ -92,26 +92,3 @@ func NotificationEnvelope(method string, params map[string]any) map[string]any {
|
||||
"seq": 0,
|
||||
}
|
||||
}
|
||||
|
||||
func ErrorResponse(id any, code int, message string) map[string]any {
|
||||
return ErrorEnvelope(id, code, message)
|
||||
}
|
||||
|
||||
func ToolTextResult(id any, content string) map[string]any {
|
||||
result := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": content},
|
||||
},
|
||||
}
|
||||
return ResultEnvelope(id, result)
|
||||
}
|
||||
|
||||
func ToolErrorResult(id any, err error) map[string]any {
|
||||
result := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": fmt.Sprintf("Error: %v", err)},
|
||||
},
|
||||
"isError": true,
|
||||
}
|
||||
return ResultEnvelope(id, result)
|
||||
}
|
||||
|
||||
@ -276,7 +276,7 @@ func CallOpenAICompatibleCtx(
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
client := NewHTTPClient(120 * time.Second)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -342,30 +342,6 @@ func HandleChatTool(arguments map[string]any) (string, error) {
|
||||
return CallOpenAICompatible(baseURL, apiKey, model, messages)
|
||||
}
|
||||
|
||||
func HandleClaudeReviewTool(arguments map[string]any) (string, error) {
|
||||
prompt := strings.TrimSpace(StringArg(arguments, "prompt", ""))
|
||||
if prompt == "" {
|
||||
return "", errors.New("prompt is required")
|
||||
}
|
||||
model := strings.TrimSpace(
|
||||
StringArg(arguments, "model", EnvOrDefault("CLAUDE_REVIEW_MODEL", "")),
|
||||
)
|
||||
system := strings.TrimSpace(
|
||||
StringArg(arguments, "system", EnvOrDefault("CLAUDE_REVIEW_SYSTEM", "")),
|
||||
)
|
||||
tools := strings.TrimSpace(
|
||||
StringArg(arguments, "tools", EnvOrDefault("CLAUDE_REVIEW_TOOLS", "")),
|
||||
)
|
||||
timeout := IntArg(EnvOrDefault("CLAUDE_REVIEW_TIMEOUT_SEC", "600"), 600)
|
||||
return RunClaudeReview(
|
||||
prompt,
|
||||
model,
|
||||
system,
|
||||
tools,
|
||||
time.Duration(timeout)*time.Second,
|
||||
)
|
||||
}
|
||||
|
||||
func CallOpenAICompatible(
|
||||
baseURL,
|
||||
apiKey,
|
||||
|
||||
@ -203,7 +203,7 @@ func doVaultRequest(
|
||||
if payload != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := NewHTTPClient(30 * time.Second)
|
||||
response, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -35,10 +35,10 @@ type InstallApproval struct {
|
||||
}
|
||||
|
||||
type ResolveResult struct {
|
||||
ResolvedSkills []string
|
||||
Candidates []Candidate
|
||||
Source string
|
||||
NeedsInstall bool
|
||||
ResolvedSkills []string
|
||||
Candidates []Candidate
|
||||
Source string
|
||||
NeedsInstall bool
|
||||
InstallRequestID string
|
||||
}
|
||||
|
||||
@ -139,10 +139,10 @@ func Resolve(req ResolveRequest, finder Finder, installer Installer) ResolveResu
|
||||
}
|
||||
|
||||
return ResolveResult{
|
||||
Candidates: fallback,
|
||||
Source: "find_skills",
|
||||
NeedsInstall: len(uninstalled) > 0,
|
||||
InstallRequestID: installRequestID,
|
||||
Candidates: fallback,
|
||||
Source: "find_skills",
|
||||
NeedsInstall: len(uninstalled) > 0,
|
||||
InstallRequestID: installRequestID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
main.go
6
main.go
@ -3,12 +3,15 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"xworkmate-bridge/internal/acp"
|
||||
"xworkmate-bridge/internal/geminiadapter"
|
||||
"xworkmate-bridge/internal/hermesadapter"
|
||||
"xworkmate-bridge/internal/opencodeadapter"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -18,6 +21,9 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Intercept standard logs
|
||||
log.SetOutput(io.MultiWriter(os.Stdout, shared.GlobalLogBuffer))
|
||||
|
||||
acp.SetRuntimeVersionInfo(acp.RuntimeVersionInfo{
|
||||
Commit: buildCommit,
|
||||
Version: buildVersion,
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
HERMES_RPC_URL="${HERMES_RPC_URL:-${BRIDGE_SERVER_URL%/}/acp/rpc}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -4,16 +4,33 @@ set -euo pipefail
|
||||
TARGET_HOST="${1:?target host is required}"
|
||||
BINARY_PATH="${2:?binary path is required}"
|
||||
EXPECTED_COMMIT="${3:?expected short commit is required}"
|
||||
|
||||
DEPLOY_USER="${DEPLOY_USER:-ubuntu}"
|
||||
REMOTE_TMP="/tmp/xworkmate-bridge-${EXPECTED_COMMIT}"
|
||||
REMOTE_BINARY="${REMOTE_BINARY:-/usr/local/bin/xworkmate-go-core}"
|
||||
STALE_DROPIN="/etc/systemd/system/xworkmate-bridge.service.d/10-hotfix-openclaw-artifacts.conf"
|
||||
SERVICE_NAME="xworkmate-bridge.service"
|
||||
REMOTE_BINARY="${REMOTE_BINARY:-/home/${DEPLOY_USER}/.local/bin/xworkmate-go-core}"
|
||||
REMOTE_WORKING_DIR="${REMOTE_WORKING_DIR:-/opt/cloud-neutral/xworkmate-bridge}"
|
||||
BRIDGE_CONFIG_PATH="${BRIDGE_CONFIG_PATH:-/opt/cloud-neutral/xworkmate-bridge/config.yaml}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-xworkmate-bridge.service}"
|
||||
SERVICE_LISTEN_ADDR="${SERVICE_LISTEN_ADDR:-127.0.0.1:8787}"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-/home/${DEPLOY_USER}/.config/systemd/user}"
|
||||
SYSTEM_SERVICE_NAME="${SYSTEM_SERVICE_NAME:-xworkmate-bridge.service}"
|
||||
MIGRATE_SYSTEM_SERVICE="${MIGRATE_SYSTEM_SERVICE:-true}"
|
||||
SYSTEM_MIGRATION_USER="${SYSTEM_MIGRATION_USER:-root}"
|
||||
STALE_DROPIN="${STALE_DROPIN:-/etc/systemd/system/xworkmate-bridge.service.d/10-hotfix-openclaw-artifacts.conf}"
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK="${DEPLOY_NATIVE_SKIP_PROC_CHECK:-false}"
|
||||
SYSTEMD_SYSTEM_DIR="${SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}"
|
||||
SYSTEM_SERVICE_UNIT_PATH="${SYSTEM_SERVICE_UNIT_PATH:-${SYSTEMD_SYSTEM_DIR}/${SYSTEM_SERVICE_NAME}}"
|
||||
|
||||
if [[ ! "${TARGET_HOST}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "invalid target host: ${TARGET_HOST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${DEPLOY_USER}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "invalid deploy user: ${DEPLOY_USER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${EXPECTED_COMMIT}" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
echo "invalid expected commit: ${EXPECTED_COMMIT}" >&2
|
||||
exit 1
|
||||
@ -24,34 +41,84 @@ if [[ ! -f "${BINARY_PATH}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "${BINARY_PATH}"
|
||||
scp -q "${BINARY_PATH}" "root@${TARGET_HOST}:${REMOTE_TMP}"
|
||||
|
||||
ssh "root@${TARGET_HOST}" "EXPECTED_COMMIT='${EXPECTED_COMMIT}' REMOTE_TMP='${REMOTE_TMP}' REMOTE_BINARY='${REMOTE_BINARY}' STALE_DROPIN='${STALE_DROPIN}' SERVICE_NAME='${SERVICE_NAME}' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
had_immutable=0
|
||||
restore_immutable() {
|
||||
if [[ "${had_immutable}" == "1" ]] && command -v chattr >/dev/null 2>&1 && [[ -e "${REMOTE_BINARY}" ]]; then
|
||||
chattr +i "${REMOTE_BINARY}" 2>/dev/null || true
|
||||
fi
|
||||
escape_systemd_env() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
value = sys.argv[1]
|
||||
print(value.replace("\\", "\\\\").replace('"', '\\"'))
|
||||
PY
|
||||
}
|
||||
trap restore_immutable EXIT
|
||||
|
||||
if command -v lsattr >/dev/null 2>&1 && [[ -e "${REMOTE_BINARY}" ]]; then
|
||||
attrs="$(lsattr "${REMOTE_BINARY}" 2>/dev/null || true)"
|
||||
if [[ "${attrs}" == *i* ]]; then
|
||||
had_immutable=1
|
||||
chattr -i "${REMOTE_BINARY}"
|
||||
resolve_token_from_unit() {
|
||||
local unit_path="$1"
|
||||
local key="$2"
|
||||
sed -n "s/^Environment=\"${key}=\\(.*\\)\"$/\\1/p" "${unit_path}" 2>/dev/null | head -n 1
|
||||
}
|
||||
|
||||
REMOTE_SYSTEM_SERVICE_UNIT_CONTENT="$(ssh -o BatchMode=yes "${SYSTEM_MIGRATION_USER}@${TARGET_HOST}" "cat '${SYSTEM_SERVICE_UNIT_PATH}' 2>/dev/null || true" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
AI_WORKSPACE_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "AI_WORKSPACE_AUTH_TOKEN")"
|
||||
if [[ -n "${AI_WORKSPACE_AUTH_TOKEN}" ]]; then
|
||||
echo "recovered AI_WORKSPACE_AUTH_TOKEN from ${SYSTEM_SERVICE_UNIT_PATH} on ${TARGET_HOST}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
install -o root -g root -m 0755 "${REMOTE_TMP}" "${REMOTE_BINARY}"
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -z "${BRIDGE_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
BRIDGE_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "BRIDGE_AUTH_TOKEN")"
|
||||
if [[ -n "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "recovered BRIDGE_AUTH_TOKEN from ${SYSTEM_SERVICE_UNIT_PATH} on ${TARGET_HOST}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
restore_immutable
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -z "${BRIDGE_AUTH_TOKEN:-}" ]]; then
|
||||
echo "::error::AI_WORKSPACE_AUTH_TOKEN is required: pass it via env, -e ai_workspace_auth_token=, or keep AI_WORKSPACE_AUTH_TOKEN/BRIDGE_AUTH_TOKEN in the existing system service unit at ${SYSTEM_SERVICE_UNIT_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${BRIDGE_REVIEW_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
BRIDGE_REVIEW_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "BRIDGE_REVIEW_AUTH_TOKEN")"
|
||||
fi
|
||||
|
||||
AUTH_TOKEN_LINE=""
|
||||
if [[ -n "${AI_WORKSPACE_AUTH_TOKEN:-}" ]]; then
|
||||
AUTH_TOKEN_LINE="Environment=\"AI_WORKSPACE_AUTH_TOKEN=$(escape_systemd_env "${AI_WORKSPACE_AUTH_TOKEN}")\""
|
||||
else
|
||||
AUTH_TOKEN_LINE="Environment=\"BRIDGE_AUTH_TOKEN=$(escape_systemd_env "${BRIDGE_AUTH_TOKEN}")\""
|
||||
fi
|
||||
|
||||
REVIEW_TOKEN_LINE=""
|
||||
if [[ -n "${BRIDGE_REVIEW_AUTH_TOKEN:-}" ]]; then
|
||||
REVIEW_TOKEN_LINE="Environment=\"BRIDGE_REVIEW_AUTH_TOKEN=$(escape_systemd_env "${BRIDGE_REVIEW_AUTH_TOKEN}")\""
|
||||
fi
|
||||
UNIT_ENV_LINES_B64="$(printf '%s\n%s\n' "${AUTH_TOKEN_LINE}" "${REVIEW_TOKEN_LINE}" | base64 | tr -d '\n')"
|
||||
|
||||
chmod +x "${BINARY_PATH}"
|
||||
|
||||
if [[ "${MIGRATE_SYSTEM_SERVICE}" == "true" ]]; then
|
||||
ssh "${SYSTEM_MIGRATION_USER}@${TARGET_HOST}" \
|
||||
"SYSTEM_SERVICE_NAME='${SYSTEM_SERVICE_NAME}' STALE_DROPIN='${STALE_DROPIN}' bash -s" <<'REMOTE_ROOT'
|
||||
set -euo pipefail
|
||||
|
||||
if systemctl list-unit-files "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1; then
|
||||
systemctl disable --now "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1 || systemctl stop "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
rm -f "${STALE_DROPIN}"
|
||||
rmdir --ignore-fail-on-non-empty "$(dirname "${STALE_DROPIN}")" 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
REMOTE_ROOT
|
||||
fi
|
||||
|
||||
scp -q "${BINARY_PATH}" "${DEPLOY_USER}@${TARGET_HOST}:${REMOTE_TMP}"
|
||||
|
||||
ssh "${DEPLOY_USER}@${TARGET_HOST}" \
|
||||
"EXPECTED_COMMIT='${EXPECTED_COMMIT}' REMOTE_TMP='${REMOTE_TMP}' REMOTE_BINARY='${REMOTE_BINARY}' REMOTE_WORKING_DIR='${REMOTE_WORKING_DIR}' BRIDGE_CONFIG_PATH='${BRIDGE_CONFIG_PATH}' SERVICE_NAME='${SERVICE_NAME}' SERVICE_LISTEN_ADDR='${SERVICE_LISTEN_ADDR}' USER_SYSTEMD_DIR='${USER_SYSTEMD_DIR}' SYSTEM_SERVICE_NAME='${SYSTEM_SERVICE_NAME}' SYSTEM_SERVICE_UNIT_PATH='${SYSTEM_SERVICE_UNIT_PATH}' UNIT_ENV_LINES_B64='${UNIT_ENV_LINES_B64}' DEPLOY_NATIVE_SKIP_PROC_CHECK='${DEPLOY_NATIVE_SKIP_PROC_CHECK}' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$(dirname "${REMOTE_BINARY}")" "${USER_SYSTEMD_DIR}"
|
||||
install -m 0755 "${REMOTE_TMP}" "${REMOTE_BINARY}"
|
||||
rm -f "${REMOTE_TMP}"
|
||||
|
||||
version_json="$("${REMOTE_BINARY}" version)"
|
||||
actual_commit="$(VERSION_JSON="${version_json}" python3 - <<'PY'
|
||||
@ -66,14 +133,76 @@ if [[ "${actual_commit}" != "${EXPECTED_COMMIT}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
unit_env_lines="$(printf '%s' "${UNIT_ENV_LINES_B64}" | base64 -d | sed '/^$/d')"
|
||||
existing_env="$(
|
||||
{
|
||||
systemctl --user show -p Environment --value "${SERVICE_NAME}" 2>/dev/null || true
|
||||
systemctl show -p Environment --value "${SYSTEM_SERVICE_NAME}" 2>/dev/null || true
|
||||
if [[ -f "${SYSTEM_SERVICE_UNIT_PATH}" ]]; then
|
||||
sed -n 's/^Environment="\(AI_WORKSPACE_AUTH_TOKEN=[^"]*\|BRIDGE_AUTH_TOKEN=[^"]*\|BRIDGE_REVIEW_AUTH_TOKEN=[^"]*\)"$/\1/p' "${SYSTEM_SERVICE_UNIT_PATH}"
|
||||
fi
|
||||
} | sed '/^$/d' | head -n 1
|
||||
)"
|
||||
unit_env_lines="$(
|
||||
UNIT_ENV_LINES="${unit_env_lines}" EXISTING_ENV="${existing_env}" python3 - <<'PY'
|
||||
import os
|
||||
import shlex
|
||||
|
||||
lines = [line for line in os.environ.get("UNIT_ENV_LINES", "").splitlines() if line.strip()]
|
||||
present = set()
|
||||
for line in lines:
|
||||
prefix = 'Environment="'
|
||||
if line.startswith(prefix):
|
||||
key = line[len(prefix):].split("=", 1)[0]
|
||||
present.add(key)
|
||||
|
||||
for item in shlex.split(os.environ.get("EXISTING_ENV", "")):
|
||||
key, sep, value = item.partition("=")
|
||||
if sep and key in {"AI_WORKSPACE_AUTH_TOKEN", "BRIDGE_AUTH_TOKEN", "BRIDGE_REVIEW_AUTH_TOKEN"} and key not in present:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
lines.append(f'Environment="{key}={escaped}"')
|
||||
present.add(key)
|
||||
|
||||
print("\n".join(lines))
|
||||
PY
|
||||
)"
|
||||
|
||||
cat >"${USER_SYSTEMD_DIR}/${SERVICE_NAME}" <<UNIT
|
||||
[Unit]
|
||||
Description=XWorkmate bridge control plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${REMOTE_WORKING_DIR}
|
||||
Environment="HOME=/home/${USER}"
|
||||
Environment="TERM=xterm-256color"
|
||||
Environment="BRIDGE_CONFIG_PATH=${BRIDGE_CONFIG_PATH}"
|
||||
${unit_env_lines}
|
||||
ExecStart=${REMOTE_BINARY} serve --listen ${SERVICE_LISTEN_ADDR}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "${SERVICE_NAME}" >/dev/null
|
||||
|
||||
if systemctl is-active --quiet "${SYSTEM_SERVICE_NAME}" 2>/dev/null; then
|
||||
echo "${SYSTEM_SERVICE_NAME} is still active as a system service; disable it before starting the user service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl --user restart "${SERVICE_NAME}"
|
||||
deadline=$((SECONDS + 20))
|
||||
actual_exe=""
|
||||
pid=""
|
||||
while (( SECONDS < deadline )); do
|
||||
pid="$(systemctl show -p MainPID --value "${SERVICE_NAME}")"
|
||||
if [[ -n "${pid}" && "${pid}" != "0" && -e "/proc/${pid}/exe" ]]; then
|
||||
pid="$(systemctl --user show -p MainPID --value "${SERVICE_NAME}")"
|
||||
if [[ -n "${pid}" && "${pid}" != "0" ]] && [[ "${DEPLOY_NATIVE_SKIP_PROC_CHECK}" == "true" || -e "/proc/${pid}/exe" ]]; then
|
||||
actual_exe="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)"
|
||||
if [[ "${actual_exe}" == "${REMOTE_BINARY}" ]]; then
|
||||
exit 0
|
||||
@ -82,7 +211,7 @@ while (( SECONDS < deadline )); do
|
||||
sleep 1
|
||||
done
|
||||
if [[ -z "${pid}" || "${pid}" == "0" ]]; then
|
||||
echo "${SERVICE_NAME} did not start" >&2
|
||||
echo "${SERVICE_NAME} did not start as a user service" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${SERVICE_NAME} is not running ${REMOTE_BINARY}; pid=${pid}; actual=${actual_exe:-unknown}" >&2
|
||||
|
||||
@ -20,6 +20,6 @@ if [[ "${RUN_APPLY}" != "true" ]]; then
|
||||
fi
|
||||
|
||||
ANSIBLE_CONFIG="${PWD}/ansible.cfg" \
|
||||
BRIDGE_AUTH_TOKEN="${INTERNAL_SERVICE_TOKEN:-}" \
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}" \
|
||||
BRIDGE_REVIEW_AUTH_TOKEN="${BRIDGE_REVIEW_AUTH_TOKEN:-}" \
|
||||
"${args[@]}"
|
||||
|
||||
@ -5,3 +5,4 @@ go mod download
|
||||
go mod verify
|
||||
golangci-lint run ./...
|
||||
go test ./...
|
||||
bash scripts/github-actions/test-deploy-native-binary.sh
|
||||
|
||||
@ -4,12 +4,19 @@ set -euo pipefail
|
||||
TARGET_HOST="${1:?target host is required}"
|
||||
SSH_KNOWN_HOSTS_PAYLOAD="${2:-}"
|
||||
|
||||
test -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}"
|
||||
if [[ -z "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}" && -z "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]]; then
|
||||
echo "::error::SINGLE_NODE_VPS_SSH_PRIVATE_KEY or SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${HOME}/.ssh"
|
||||
chmod 700 "${HOME}/.ssh"
|
||||
|
||||
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
|
||||
if [[ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]]; then
|
||||
printf '%s' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64}" | base64 -d > "${HOME}/.ssh/id_rsa"
|
||||
else
|
||||
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
|
||||
fi
|
||||
chmod 600 "${HOME}/.ssh/id_rsa"
|
||||
ssh-keygen -y -f "${HOME}/.ssh/id_rsa" >/dev/null
|
||||
|
||||
|
||||
@ -31,8 +31,9 @@ curl_args=(
|
||||
--max-time 20
|
||||
)
|
||||
|
||||
if [[ -n "${BRIDGE_AUTH_TOKEN:-}" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer ${BRIDGE_AUTH_TOKEN}")
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
if [[ -n "${AUTH_TOKEN}" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
fi
|
||||
|
||||
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
|
||||
@ -83,4 +84,3 @@ print(f"production_tag={deployed_tag}")
|
||||
print(f"production_commit={deployed_commit}")
|
||||
print(f"production_version={deployed_version}")
|
||||
PY
|
||||
|
||||
|
||||
243
scripts/github-actions/test-deploy-native-binary.sh
Executable file
243
scripts/github-actions/test-deploy-native-binary.sh
Executable file
@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT_PATH="${ROOT_DIR}/scripts/github-actions/deploy-native-binary.sh"
|
||||
EXPECTED_COMMIT="425a38f"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
if [[ "${haystack}" != *"${needle}"* ]]; then
|
||||
fail "expected output to contain: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
fail "expected file to exist: ${file}"
|
||||
fi
|
||||
if ! grep -qF -- "${needle}" "${file}"; then
|
||||
fail "expected ${file} to contain: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
build_fake_bin_dir() {
|
||||
local bin_dir="$1"
|
||||
|
||||
cat >"${bin_dir}/scp" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
src="$2"
|
||||
dest="$3"
|
||||
printf 'scp %s\n' "${dest}" >>"${FAKE_DEPLOY_LOG}"
|
||||
dest_path="${dest#*:}"
|
||||
cp "${src}" "${dest_path}"
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/ssh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
while [[ "${1:-}" == -* ]]; do
|
||||
case "${1}" in
|
||||
-o|-i|-p|-l)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
target="$1"
|
||||
shift
|
||||
printf 'ssh %s\n' "${target}" >>"${FAKE_DEPLOY_LOG}"
|
||||
cmd="$*"
|
||||
case "${cmd}" in
|
||||
cat\ *)
|
||||
path="${cmd#cat }"
|
||||
path="${path%% 2>/dev/null*}"
|
||||
path="${path%% || true*}"
|
||||
path="${path%\'}"
|
||||
path="${path#\'}"
|
||||
cat "${path}" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
bash -c "${cmd}"
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/systemctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'systemctl %s\n' "$*" >>"${FAKE_DEPLOY_LOG}"
|
||||
if [[ "${1:-}" == "is-active" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${1:-}" == "--user" && "${2:-}" == "show" ]]; then
|
||||
printf '1\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/readlink" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "-f" && "${2:-}" == /proc/*/exe ]]; then
|
||||
printf '%s\n' "${REMOTE_BINARY}"
|
||||
exit 0
|
||||
fi
|
||||
/usr/bin/readlink "$@"
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/sleep" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x "${bin_dir}/"*
|
||||
}
|
||||
|
||||
create_fake_binary() {
|
||||
local path="$1"
|
||||
cat >"${path}" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "version" ]]; then
|
||||
printf '{"commit":"425a38f","version":"test"}\n'
|
||||
exit 0
|
||||
fi
|
||||
sleep 3600
|
||||
EOF
|
||||
chmod +x "${path}"
|
||||
}
|
||||
|
||||
setup_test_env() {
|
||||
local label="$1"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
local bin_dir="${tmp_dir}/bin"
|
||||
local remote_dir="${tmp_dir}/remote"
|
||||
mkdir -p "${bin_dir}" "${remote_dir}/tmp" "${remote_dir}/opt/cloud-neutral/xworkmate-bridge"
|
||||
|
||||
create_fake_binary "${tmp_dir}/xworkmate-bridge"
|
||||
build_fake_bin_dir "${bin_dir}"
|
||||
|
||||
printf '%s\n' "${label}" >&2
|
||||
echo "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_deploy() {
|
||||
local bin_dir="$1"
|
||||
local log_file="$2"
|
||||
shift 2
|
||||
env \
|
||||
PATH="${bin_dir}:${PATH}" \
|
||||
FAKE_DEPLOY_LOG="${log_file}" \
|
||||
"$@" \
|
||||
bash "${SCRIPT_PATH}" "example.test" "${bin_dir}/../xworkmate-bridge" "${EXPECTED_COMMIT}"
|
||||
}
|
||||
|
||||
run_env_token_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: AI_WORKSPACE_AUTH_TOKEN env var drives the unit file")"
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local unit_file="${tmp_dir}/remote/home/ubuntu/.config/systemd/user/xworkmate-bridge.service"
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true \
|
||||
AI_WORKSPACE_AUTH_TOKEN="test-token"
|
||||
|
||||
local log_output
|
||||
log_output="$(cat "${log_file}")"
|
||||
assert_contains "${log_output}" "ssh root@example.test"
|
||||
assert_contains "${log_output}" "scp ubuntu@example.test:"
|
||||
assert_contains "${log_output}" "ssh ubuntu@example.test"
|
||||
assert_contains "${log_output}" "systemctl --user restart xworkmate-bridge.service"
|
||||
assert_file_contains "${unit_file}" 'Environment="AI_WORKSPACE_AUTH_TOKEN=test-token"'
|
||||
assert_file_contains "${unit_file}" "WantedBy=default.target"
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_unit_fallback_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: AI_WORKSPACE_AUTH_TOKEN recovered from system service unit file")"
|
||||
|
||||
local system_unit_dir="${tmp_dir}/remote/etc/systemd/system"
|
||||
mkdir -p "${system_unit_dir}"
|
||||
local system_unit_file="${system_unit_dir}/xworkmate-bridge.service"
|
||||
cat >"${system_unit_file}" <<'EOF'
|
||||
[Unit]
|
||||
Description=Stale system service
|
||||
[Service]
|
||||
Environment="AI_WORKSPACE_AUTH_TOKEN=recovered-from-systemd"
|
||||
Environment="BRIDGE_REVIEW_AUTH_TOKEN=recovered-review-token"
|
||||
ExecStart=/bin/true
|
||||
EOF
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local unit_file="${tmp_dir}/remote/home/ubuntu/.config/systemd/user/xworkmate-bridge.service"
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
SYSTEM_SERVICE_UNIT_PATH="${system_unit_file}" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true
|
||||
|
||||
assert_file_contains "${unit_file}" 'Environment="AI_WORKSPACE_AUTH_TOKEN=recovered-from-systemd"'
|
||||
assert_file_contains "${unit_file}" 'Environment="BRIDGE_REVIEW_AUTH_TOKEN=recovered-review-token"'
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_fail_fast_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: missing AI_WORKSPACE_AUTH_TOKEN fails fast with clear error")"
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local stderr_file="${tmp_dir}/deploy.stderr"
|
||||
set +e
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true \
|
||||
2>"${stderr_file}"
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
if [[ "${exit_code}" == "0" ]]; then
|
||||
fail "expected deploy to fail when AI_WORKSPACE_AUTH_TOKEN is empty and no system service unit exists"
|
||||
fi
|
||||
assert_contains "$(cat "${stderr_file}")" "AI_WORKSPACE_AUTH_TOKEN is required"
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
main() {
|
||||
run_env_token_case
|
||||
run_unit_fallback_case
|
||||
run_fail_fast_case
|
||||
printf 'deploy-native-binary regression tests passed\n'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -28,7 +28,11 @@ fi
|
||||
|
||||
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")"
|
||||
RPC_URL="${BASE_URL%/}/acp/rpc"
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:?BRIDGE_AUTH_TOKEN is required}"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fast_http_curl_common=(
|
||||
--silent
|
||||
|
||||
327
scripts/github-actions/validate-openclaw-session.sh
Executable file
327
scripts/github-actions/validate-openclaw-session.sh
Executable file
@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
REQUEST_ORIGIN="${OPENCLAW_SMOKE_ORIGIN:-https://xworkmate.svc.plus}"
|
||||
RPC_TIMEOUT_SECONDS="${OPENCLAW_SMOKE_RPC_TIMEOUT_SECONDS:-180}"
|
||||
POLL_TIMEOUT_SECONDS="${OPENCLAW_SMOKE_POLL_TIMEOUT_SECONDS:-120}"
|
||||
POLL_INTERVAL_SECONDS="${OPENCLAW_SMOKE_POLL_INTERVAL_SECONDS:-2}"
|
||||
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_url() {
|
||||
local raw="$1"
|
||||
printf '%s\n' "${raw%/}"
|
||||
}
|
||||
|
||||
resolved_base_url="$(normalize_url "${BASE_URL}")"
|
||||
rpc_url="${resolved_base_url}/acp/rpc"
|
||||
|
||||
stream_file="$(mktemp)"
|
||||
trap 'rm -f "$stream_file"' EXIT
|
||||
|
||||
session_id="validate-openclaw-$(date +%s)"
|
||||
|
||||
request_body="$(cat <<JSON
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "validate-openclaw",
|
||||
"method": "session.start",
|
||||
"params": {
|
||||
"sessionId": "${session_id}",
|
||||
"threadId": "${session_id}",
|
||||
"appThreadKey": "${session_id}",
|
||||
"taskPrompt": "Reply exactly pong.",
|
||||
"workingDirectory": "/tmp",
|
||||
"routing": {
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "gateway",
|
||||
"preferredGatewayProviderId": "openclaw"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)"
|
||||
|
||||
echo "OpenClaw smoke -> POST ${rpc_url} (session=${session_id})"
|
||||
|
||||
curl --http1.1 --fail --silent --show-error --no-buffer --max-time "${RPC_TIMEOUT_SECONDS}" \
|
||||
-H "Authorization: Bearer ${AUTH_TOKEN}" \
|
||||
-H "Origin: ${REQUEST_ORIGIN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: text/event-stream" \
|
||||
--data "${request_body}" \
|
||||
"${rpc_url}" > "${stream_file}"
|
||||
|
||||
OPENCLAW_AUTH_TOKEN="${AUTH_TOKEN}" \
|
||||
OPENCLAW_STREAM_FILE="${stream_file}" \
|
||||
OPENCLAW_POLL_URL="${rpc_url}" \
|
||||
OPENCLAW_POLL_TIMEOUT_SECONDS="${POLL_TIMEOUT_SECONDS}" \
|
||||
OPENCLAW_POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS}" \
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
stream_path = os.environ["OPENCLAW_STREAM_FILE"]
|
||||
poll_url = os.environ["OPENCLAW_POLL_URL"]
|
||||
auth_token = os.environ["OPENCLAW_AUTH_TOKEN"]
|
||||
poll_timeout = int(os.environ["OPENCLAW_POLL_TIMEOUT_SECONDS"])
|
||||
poll_interval = float(os.environ["OPENCLAW_POLL_INTERVAL_SECONDS"])
|
||||
|
||||
payloads = []
|
||||
for block in open(stream_path, encoding="utf-8").read().split("\n\n"):
|
||||
data_lines = [
|
||||
line[len("data: "):]
|
||||
for line in block.splitlines()
|
||||
if line.startswith("data: ")
|
||||
]
|
||||
if not data_lines:
|
||||
continue
|
||||
payload = "\n".join(data_lines).strip()
|
||||
if payload == "[DONE]":
|
||||
payloads.append({"done": True})
|
||||
continue
|
||||
payloads.append(json.loads(payload))
|
||||
|
||||
def terminal_result(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
nested = payload.get("result")
|
||||
status = str(payload.get("status", "")).lower()
|
||||
if isinstance(nested, dict) and nested and status in {
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"canceled",
|
||||
}:
|
||||
return nested
|
||||
return payload
|
||||
|
||||
|
||||
def output_text_from(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
candidates = []
|
||||
stack = [payload]
|
||||
seen = set()
|
||||
while stack:
|
||||
item = stack.pop(0)
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identity = id(item)
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
for key in ("output", "message", "summary", "resultSummary", "assistantText", "text"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str):
|
||||
candidates.append(value)
|
||||
for key in ("result", "payload", "data", "task", "progress", "artifacts"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, dict):
|
||||
stack.append(value)
|
||||
return " ".join(part for part in candidates if part)
|
||||
|
||||
|
||||
def require_nonempty(payload, key):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return
|
||||
raise SystemExit(f"OpenClaw smoke result missing {key}: {json.dumps(payload, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
|
||||
|
||||
def first_nonempty(payload, *keys):
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def openclaw_session_key_for_app_thread(app_thread_key):
|
||||
app_thread_key = str(app_thread_key or "").strip()
|
||||
if not app_thread_key:
|
||||
app_thread_key = "main"
|
||||
return "agent:main:" + app_thread_key
|
||||
|
||||
|
||||
def task_handle_from_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
candidates = []
|
||||
for key in ("result", "payload", "params"):
|
||||
if isinstance(payload.get(key), dict):
|
||||
candidates.append(payload[key])
|
||||
candidates.append(payload)
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
session_id = first_nonempty(candidate, "sessionId")
|
||||
thread_id = first_nonempty(candidate, "threadId")
|
||||
turn_id = first_nonempty(candidate, "turnId")
|
||||
run_id = first_nonempty(candidate, "runId")
|
||||
app_thread_key = first_nonempty(candidate, "appThreadKey")
|
||||
openclaw_session_key = first_nonempty(candidate, "openclawSessionKey")
|
||||
if (app_thread_key and openclaw_session_key and run_id) or (session_id and thread_id and (turn_id or run_id)):
|
||||
return {
|
||||
"sessionId": session_id,
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
"runId": run_id or turn_id,
|
||||
"appThreadKey": app_thread_key or session_id or thread_id,
|
||||
"openclawSessionKey": openclaw_session_key or openclaw_session_key_for_app_thread(app_thread_key or session_id or thread_id),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def find_task_handle(payloads, final):
|
||||
for payload in reversed(payloads):
|
||||
handle = task_handle_from_payload(payload)
|
||||
if handle:
|
||||
return handle
|
||||
return task_handle_from_payload(final)
|
||||
|
||||
|
||||
def is_valid_no_displayable_contract(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if payload.get("code") != "OPENCLAW_NO_DISPLAYABLE_OUTPUT":
|
||||
return False
|
||||
if payload.get("resolvedGatewayProviderId") != "openclaw":
|
||||
return False
|
||||
for key in ("sessionId", "threadId", "runId", "artifactScope"):
|
||||
require_nonempty(payload, key)
|
||||
if not first_nonempty(payload, "openclawSessionKey", "sessionKey"):
|
||||
artifact_scope = first_nonempty(payload, "artifactScope")
|
||||
if not artifact_scope.startswith("tasks/"):
|
||||
raise SystemExit(f"OpenClaw smoke result missing session scope: {json.dumps(payload, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_no_native_task_record_contract(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if payload.get("code") != "no_native_task_record":
|
||||
return False
|
||||
if payload.get("ok") is not False:
|
||||
return False
|
||||
message = str(payload.get("message") or "")
|
||||
if "No native OpenClaw task record found" not in message:
|
||||
raise SystemExit(f"OpenClaw smoke native task lookup returned unexpected message: {json.dumps(payload, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
return True
|
||||
|
||||
|
||||
final = next(
|
||||
(item for item in payloads if isinstance(item, dict) and item.get("id") == "validate-openclaw"),
|
||||
None,
|
||||
)
|
||||
if final is None:
|
||||
raise SystemExit("missing final OpenClaw result envelope")
|
||||
if not payloads or payloads[-1].get("done") is not True:
|
||||
raise SystemExit("missing SSE done marker")
|
||||
if isinstance(final.get("error"), dict) and final["error"]:
|
||||
error = final["error"]
|
||||
message = error.get("message") or json.dumps(error, ensure_ascii=False, sort_keys=True)
|
||||
preview = json.dumps(final, ensure_ascii=False, sort_keys=True)[:1500]
|
||||
raise SystemExit(f"OpenClaw smoke RPC error: {message}\nenvelope preview: {preview}")
|
||||
|
||||
result = terminal_result(final.get("result") or final.get("payload") or {})
|
||||
handle = result
|
||||
if result.get("status") != "running":
|
||||
handle = find_task_handle(payloads, final)
|
||||
if handle.get("status") == "running" or (not result and handle):
|
||||
run_id = first_nonempty(handle, "runId", "turnId")
|
||||
app_thread_key = first_nonempty(handle, "appThreadKey", "sessionId", "threadId")
|
||||
openclaw_session_key = first_nonempty(handle, "openclawSessionKey") or openclaw_session_key_for_app_thread(app_thread_key)
|
||||
|
||||
if not app_thread_key:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing appThreadKey: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
if not openclaw_session_key:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing openclawSessionKey: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
if not run_id:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing runId: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
|
||||
deadline = time.time() + poll_timeout
|
||||
while time.time() < deadline:
|
||||
req_body = json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "poll-task",
|
||||
"method": "xworkmate.tasks.get",
|
||||
"params": {
|
||||
"appThreadKey": app_thread_key,
|
||||
"openclawSessionKey": openclaw_session_key,
|
||||
"runId": run_id,
|
||||
},
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
poll_url,
|
||||
data=req_body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||||
poll_result = resp_data.get("result") or {}
|
||||
status = poll_result.get("status")
|
||||
if is_valid_no_native_task_record_contract(poll_result):
|
||||
result = poll_result
|
||||
final["result"] = poll_result
|
||||
break
|
||||
if status in ("completed", "failed", "cancelled"):
|
||||
terminal = terminal_result(poll_result)
|
||||
result = terminal if terminal else poll_result
|
||||
final["result"] = poll_result
|
||||
break
|
||||
except Exception as exc:
|
||||
print(f"poll error: {exc}", file=sys.stderr)
|
||||
time.sleep(poll_interval)
|
||||
else:
|
||||
raise SystemExit("timeout waiting for OpenClaw smoke task to complete")
|
||||
|
||||
error_text = json.dumps(final.get("error", {}), ensure_ascii=False)
|
||||
for code in (
|
||||
"GATEWAY_PROVIDER_REQUIRED",
|
||||
"OPENCLAW_GATEWAY_METHOD_NOT_ALLOWED",
|
||||
"OPENCLAW_GATEWAY_CONFLICT",
|
||||
"OPENCLAW_TASK_ENDPOINT_REQUIRED",
|
||||
):
|
||||
if code in error_text:
|
||||
raise SystemExit(f"legacy OpenClaw routing error remained: {code}")
|
||||
|
||||
final_text = json.dumps(final, ensure_ascii=False)
|
||||
for marker in (
|
||||
"Requested agent harness",
|
||||
"provider is not one of",
|
||||
"Agent failed before reply",
|
||||
"ACP_HTTP_",
|
||||
):
|
||||
if marker in final_text:
|
||||
raise SystemExit(f"OpenClaw smoke returned runtime error text: {marker}")
|
||||
|
||||
output_text = output_text_from(result)
|
||||
if "pong" not in output_text.lower():
|
||||
if is_valid_no_displayable_contract(result):
|
||||
print("OpenClaw smoke OK: session contract completed without displayable output")
|
||||
sys.exit(0)
|
||||
if is_valid_no_native_task_record_contract(result):
|
||||
print("OpenClaw smoke OK: session started; no native task record available for polling")
|
||||
sys.exit(0)
|
||||
result_preview = json.dumps(result, ensure_ascii=False, sort_keys=True)[:1000]
|
||||
payload_preview = json.dumps(payloads[:6], ensure_ascii=False, sort_keys=True)[:1500]
|
||||
raise SystemExit(f"OpenClaw smoke did not return pong: {output_text[:500]}\nresult preview: {result_preview}\nSSE preview: {payload_preview}")
|
||||
|
||||
print("OpenClaw smoke OK: pong received from session contract")
|
||||
PY
|
||||
@ -2,12 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
|
||||
RPC_TIMEOUT_SECONDS="${RPC_TIMEOUT_SECONDS:-90}"
|
||||
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user