Compare commits

..

2 Commits

Author SHA1 Message Date
Haitao Pan
6acdb01eb4 Handle legacy OpenClaw prepare gateways 2026-06-06 06:52:11 +08:00
Haitao Pan
1f617e9c63 Recover OpenClaw smoke handle from SSE 2026-06-06 06:39:21 +08:00
64 changed files with 2474 additions and 4747 deletions

View File

@ -1,9 +1,5 @@
name: Pipeline
env:
VAULT_ADDR: https://vault.svc.plus
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
on:
pull_request:
branches: [main]
@ -21,7 +17,7 @@ on:
required: true
default: true
type: boolean
ai_workspace_auth_token:
internal_service_token:
description: "Optional ACP auth token for deploy"
required: false
default: ""
@ -30,7 +26,6 @@ on:
permissions:
contents: read
packages: write
id-token: write
concurrency:
group: pipeline-${{ github.ref }}
@ -40,6 +35,9 @@ defaults:
run:
shell: bash
env:
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
jobs:
production_state:
name: Production State
@ -53,40 +51,12 @@ 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}")
@ -136,19 +106,6 @@ 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
@ -161,7 +118,7 @@ jobs:
with:
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME || github.repository_owner }}
password: ${{ steps.vault.outputs.GHCR_TOKEN || github.token }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
- name: Resolve service image ref
id: service_ref
@ -212,55 +169,25 @@ jobs:
deploy:
name: Deploy
needs: build
if: ${{ github.event_name != 'pull_request' && github.event_name != 'push' }}
if: ${{ github.event_name != 'pull_request' }}
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: ${{ steps.vault.outputs.WORKSPACE_REPO_TOKEN || github.token }}
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
path: playbooks
- name: Download build image ref artifact
@ -284,7 +211,6 @@ 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
@ -307,9 +233,8 @@ jobs:
- name: Prepare runner SSH access
working-directory: xworkmate-bridge
env:
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 }}
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.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
@ -321,6 +246,7 @@ 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
@ -378,34 +304,26 @@ jobs:
needs:
- build
- deploy
if: ${{ github.event_name != 'push' && needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
if: ${{ 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
env:
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
run: bash ./scripts/github-actions/validate-openclaw-session.sh

View File

@ -1,109 +0,0 @@
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

View File

@ -1,44 +0,0 @@
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

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
build/
dist/
.env
xworkmate-bridge
xworkmate-go-core-linux

View File

@ -62,16 +62,7 @@ The deploy stage checks out:
- this service repository into `xworkmate-bridge/`
- the `x-evor/playbooks` repository into `playbooks/`
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>`.
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.
### Validate stage
@ -94,7 +85,7 @@ Optional GitHub secrets:
Optional workflow input:
- `ai_workspace_auth_token`: manual dispatch input that is forwarded as `AI_WORKSPACE_AUTH_TOKEN`
- `internal_service_token`: manual dispatch input that is forwarded to Ansible as `INTERNAL_SERVICE_TOKEN`
## Environment

View File

@ -39,8 +39,7 @@
环境变量:
- `AI_WORKSPACE_AUTH_TOKEN`:主共享 token用于 bridge 入站鉴权、上游 provider 转发、OpenClaw Gateway 重签发与任务转发 fallback。
- `BRIDGE_AUTH_TOKEN`:旧主 token。没有 `AI_WORKSPACE_AUTH_TOKEN` 时继续生效并参与上游转发,用于存量租户兼容,直到 `AI_WORKSPACE_AUTH_TOKEN` 完成彻底替代后下线。
- `BRIDGE_AUTH_TOKEN`
- `BRIDGE_REVIEW_AUTH_TOKEN`可选Apple review / beta 工测专用临时 token。清空该环境变量并重启/reload bridge 即可单独关停,不影响主 token。
- `ACP_ALLOWED_ORIGINS`
@ -49,9 +48,8 @@
- `/acp``/acp/rpc` 都做 origin allowlist 校验
- 空 `Origin` 默认允许
- `/api/ping`、`/acp`、`/acp/rpc` 在任一 bridge token 非空时都要求 bearer header
- `AI_WORKSPACE_AUTH_TOKEN`、`BRIDGE_AUTH_TOKEN` 与 `BRIDGE_REVIEW_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
@ -140,7 +138,7 @@ distributed:
- `bridge_endpoint` 是 peer bridge base URLbridge 会按当前请求路径拼接 `/acp/rpc``/gateway/openclaw`
- 同步消息不能走公网;`bridge_endpoint` 必须是 loopback、private、link-local 这类本机或 VPN 内网地址,用于 WireGuard over VLESS 等隧道已经提供加密的场景
- 只要求本机网络能路由到 endpointbridge 不依赖 config center 或额外注册中心
- `task_forward_token` 为空时复用本机 `AI_WORKSPACE_AUTH_TOKEN`;未配置时兼容复用 `BRIDGE_AUTH_TOKEN`
- `task_forward_token` 为空时复用本机 `BRIDGE_AUTH_TOKEN`
- 转发请求会带 `X-XWorkmate-Bridge-Forwarded: 1`
- `X-XWorkmate-Forward-Source` 是源节点,`X-XWorkmate-Forward-Target` 是最终目标节点
- `X-XWorkmate-Forward-Hop` 逐跳递增,超过 `forwarding.hop_limit` 时拒绝转发,避免循环
@ -158,7 +156,7 @@ distributed:
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 $AI_WORKSPACE_AUTH_TOKEN
Authorization: Bearer $BRIDGE_AUTH_TOKEN
Origin: https://xworkmate.svc.plus
```

View File

@ -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 $AI_WORKSPACE_AUTH_TOKEN`
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
## Consequences

View File

@ -272,7 +272,7 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提
## 8. 线上环境事实
以下为 2026-06-06 通过 `ssh ubuntu@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
以下为 2026-05-03 通过 `ssh root@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
### Caddy
@ -292,23 +292,11 @@ Caddy 层要求:
Authorization: Bearer $BRIDGE_AUTH_TOKEN
```
如果配置了 Apple review / beta tokenCaddy 层也必须接受:
```http
Authorization: Bearer $BRIDGE_REVIEW_AUTH_TOKEN
```
验证标准:
- 无 token`401`
- 主 token`200`
- review token`200`
### Systemd / Local Listeners
| Unit / Runtime | Listener | 说明 |
| --- | --- | --- |
| `~/.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` |
| `xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin |
| `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 |
@ -317,7 +305,7 @@ Authorization: Bearer $BRIDGE_REVIEW_AUTH_TOKEN
这些地址只允许 bridge 内部使用。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 可见状态。
验证时 `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 可见状态。
## 9. 线上验证结果

View File

@ -33,10 +33,6 @@
- [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` 是对外运行契约的唯一真相来源。

View File

@ -1,632 +0,0 @@
# 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 与 BridgeAPP 对 mouse move 做 16ms 节流、坐标去重、data channel 背压时只丢弃 mouse moveBridge 修复 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 helperdesktop 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 判读标准
- 远端部署命令

Binary file not shown.

View File

@ -13,6 +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()

View File

@ -109,43 +109,15 @@ func resolveURL(yamlVal string, envKeys ...string) string {
}
func bridgeUpstreamAuthorizationHeader() string {
token := bridgePublicAuthToken()
token := bridgeSharedAuthToken()
if token != "" && !strings.HasPrefix(strings.ToLower(token), "bearer ") {
return "Bearer " + token
}
return token
}
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 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 tokens
return strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
}
func resolveDistributedTaskForwardToken(config *BridgeConfig) string {

View File

@ -192,7 +192,7 @@ 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},
Params: map[string]any{"sessionId": sessionID, "threadId": "thread-" + sessionID},
}
}
@ -200,6 +200,6 @@ 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},
Params: map[string]any{"sessionId": sessionID, "threadId": "thread-" + sessionID},
}
}

View File

@ -5,10 +5,14 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"xworkmate-bridge/internal/shared"
)
func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) {
@ -557,3 +561,116 @@ func TestExternalACPNotificationCollectorIgnoresCodexCommentaryMessages(t *testi
t.Fatalf("expected commentary to be hidden and duplicate final line collapsed, got %#v", result)
}
}
func TestProbeOpenClawTaskFailsAfterMaxAllowedSilentDuration(t *testing.T) {
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_SILENT_DURATION", "2s")
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
server := NewServer()
orchestrator := server.orchestrator
sess := server.getOrCreateSession("silent-session", "silent-thread")
startedAt := time.Now().Add(-time.Minute)
sess.mu.Lock()
sess.task = QueuedTask{
SessionID: "silent-session",
ThreadID: "silent-thread",
TurnID: "silent-turn",
RunID: "silent-run",
SessionKey: "silent-session",
GatewayProviderID: "openclaw",
State: TaskStateRunning,
Kind: TaskKindGateway,
RuntimeBudgetMinutes: openClawLongTaskMinutes,
StartedAt: startedAt,
DeadlineAt: time.Now().Add(time.Minute),
}
sess.openClaw = &OpenClawTaskRecord{
SessionID: "silent-session",
ThreadID: "silent-thread",
TurnID: "silent-turn",
RunID: "silent-run",
SessionKey: "silent-session",
GatewayProviderID: "openclaw",
TaskLoadClass: "long_task",
RuntimeBudgetMinutes: openClawLongTaskMinutes,
StartedAt: startedAt,
DeadlineAt: time.Now().Add(time.Minute),
FirstSilentFailureAt: time.Now().Add(-3 * time.Second),
}
sess.mu.Unlock()
result := orchestrator.probeOpenClawTask(context.Background(), sess, nil, false)
if got := result["status"]; got != string(TaskStateFailed) {
t.Fatalf("expected failed status after silent duration, got %#v", result)
}
if got := result["code"]; got != "OPENCLAW_GATEWAY_LOST" {
t.Fatalf("expected OPENCLAW_GATEWAY_LOST, got %#v", result)
}
sess.mu.Lock()
state := sess.task.State
sess.mu.Unlock()
if state != TaskStateFailed {
t.Fatalf("task state = %s, want %s", state, TaskStateFailed)
}
}
func TestTerminalOpenClawTaskRemovesInlineAttachmentDirectory(t *testing.T) {
workspace := t.TempDir()
turnID := "turn-inline-gc"
chatParams, rpcErr := openClawChatSendParams(map[string]any{
"threadId": "thread-inline-gc",
"taskPrompt": "inspect uploaded file",
"workingDirectory": workspace,
"inlineAttachments": []any{
map[string]any{
"name": "note.txt",
"mimeType": "text/plain",
"content": "bm90ZQ==",
},
},
}, turnID)
if rpcErr != nil {
t.Fatalf("expected chat params, got rpc error: %#v", rpcErr)
}
attachments := shared.ListArg(chatParams, "attachments")
if len(attachments) != 1 {
t.Fatalf("expected materialized attachment, got %#v", attachments)
}
attachmentPath := shared.StringArg(shared.AsMap(attachments[0]), "path", "")
attachmentDirectory := filepath.Dir(attachmentPath)
if _, err := os.Stat(attachmentDirectory); err != nil {
t.Fatalf("expected attachment directory before terminal task state: %v", err)
}
server := NewServer()
sess := server.getOrCreateSession("gc-session", "gc-thread")
now := time.Now()
sess.mu.Lock()
sess.task = QueuedTask{
SessionID: "gc-session",
ThreadID: "gc-thread",
TurnID: turnID,
RunID: "gc-run",
State: TaskStateRunning,
Kind: TaskKindGateway,
StartedAt: now,
}
sess.openClaw = &OpenClawTaskRecord{
SessionID: "gc-session",
ThreadID: "gc-thread",
TurnID: turnID,
RunID: "gc-run",
StartedAt: now,
ChatParams: map[string]any{
"workingDirectory": workspace,
},
}
sess.mu.Unlock()
server.orchestrator.failOpenClawTask(sess, "TEST_FAILED", "terminal")
if _, err := os.Stat(attachmentDirectory); !os.IsNotExist(err) {
t.Fatalf("expected terminal task to remove attachment directory, stat err=%v", err)
}
}

View File

@ -92,7 +92,7 @@ func handleGatewayConnect(
server.gateway = gatewayruntime.NewManager()
}
result := connectOpenClawGateway(server.gateway, request, notify, usesBridgeIdentity)
result := server.gateway.Connect(request, notify)
if result.OK && usesBridgeIdentity {
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
}
@ -284,7 +284,7 @@ func ensureProductionGatewayConnected(
request.Auth.DeviceToken = deviceToken
request.HasDeviceToken = deviceToken != ""
request.ReportedRemoteAddress = resolveGatewayReportedRemoteAddress(server, request)
result := connectOpenClawGateway(server.gateway, request, notify, true)
result := server.gateway.Connect(request, notify)
if result.OK {
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
return nil
@ -297,43 +297,6 @@ 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

View File

@ -138,20 +138,6 @@ 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,

View File

@ -144,26 +144,6 @@ 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()

View File

@ -64,3 +64,48 @@ func TestResolveGatewayReportedRemoteAddressNormalizesExplicitPublicRemoteHost(
t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want)
}
}
func TestReassociateOpenClawTaskDerivesRuntimeBudgetWithoutExplicitBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
params map[string]any
want int
}{
{
name: "short task load class",
params: map[string]any{
"runId": "run-short",
"artifactScope": "tasks/main/run-short",
"taskLoadClass": "short_task",
},
want: openClawShortTaskMinutes,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
server := NewServer()
sess := server.reassociateOpenClawTask(tc.params)
if sess == nil {
t.Fatal("expected reassociated session")
} else {
sess.mu.Lock()
gotTaskBudget := sess.task.RuntimeBudgetMinutes
gotRecordBudget := sess.openClaw.RuntimeBudgetMinutes
sess.mu.Unlock()
if gotTaskBudget != tc.want {
t.Fatalf("task RuntimeBudgetMinutes = %d, want %d", gotTaskBudget, tc.want)
}
if gotRecordBudget != tc.want {
t.Fatalf("record RuntimeBudgetMinutes = %d, want %d", gotRecordBudget, tc.want)
}
}
})
}
}

View File

@ -108,117 +108,3 @@ func TestSystemLogsConnectsProductionGatewayForStatus(t *testing.T) {
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)
}
}

View File

@ -584,6 +584,17 @@ 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 {

View File

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

View File

@ -1,29 +0,0 @@
package acp
import "sync/atomic"
// 关键稳定性指标T12docs/cases/06 §5
//
// 进程内累计计数,经 /api/ping 暴露,用于把「网关抖动 / run 超时」从靠用户截图
// 变为可监控。三个计数对应三类已知的不稳定来源:
// - gatewaySocketClosed : gatewayRPCError 命中 OPENCLAW_GATEWAY_SOCKET_CLOSED连接断
// - taskGetUnconfirmedFallback: tasks.get 走持久 run 仓兜底gateway 无法确认 runT7
// - runDeadlineInterrupt : run 超过 DeadlineAt 且 gateway 无法确认,回 interruptedT9
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(),
}
}

View File

@ -46,10 +46,7 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
}
query := r.URL.Query()
sessionKey := strings.TrimSpace(query.Get("openclawSessionKey"))
if sessionKey == "" {
sessionKey = strings.TrimSpace(query.Get("sessionKey"))
}
sessionKey := strings.TrimSpace(query.Get("sessionKey"))
runID := strings.TrimSpace(query.Get("runId"))
rawArtifactScope := strings.TrimSpace(query.Get("artifactScope"))
artifactScope := safeOpenClawArtifactDownloadArtifactScope(rawArtifactScope)
@ -83,11 +80,10 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
return
}
readParams := map[string]any{
"openclawSessionKey": sessionKey,
"sessionKey": sessionKey,
"runId": runID,
"relativePath": relativePath,
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
"sessionKey": sessionKey,
"runId": runID,
"relativePath": relativePath,
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
}
if artifactScope != "" {
readParams["artifactScope"] = artifactScope
@ -327,7 +323,6 @@ 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)

View File

@ -1,6 +1,9 @@
package acp
import (
"context"
"os"
"path/filepath"
"strings"
"time"
@ -9,30 +12,40 @@ import (
)
const (
openClawShortTaskMinutes = 10
openClawLongTaskMinutes = 30
openClawComplexTaskMinutes = 60
openClawTaskProbeTimeout = 2 * time.Second
openClawTaskProbeTimeoutMs = 1000
openClawTaskMonitorInterval = time.Second
openClawShortTaskMinutes = 10
openClawLongTaskMinutes = 30
openClawComplexTaskMinutes = 60
openClawDefaultMaxAllowedSilentDuration = 10 * time.Minute
)
type OpenClawTaskRecord struct {
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
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
FirstSilentFailureAt time.Time
ChatParams map[string]any
PreparedArtifact *openClawPreparedArtifactScope
ArtifactContract openClawArtifactContract
ResolvedModel string
ResolvedSkills []string
MonitorStarted bool
ProbeInFlight bool
AdmissionRelease func()
}
func openClawTaskRuntimePolicy(params map[string]any, chatParams map[string]any, contract openClawArtifactContract) (string, int) {
@ -87,7 +100,6 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
"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),
@ -95,12 +107,6 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
if record.PreparedArtifact != nil {
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
}
if len(record.ExpectedArtifactDirs) > 0 {
result["expectedArtifactDirs"] = append([]string(nil), record.ExpectedArtifactDirs...)
}
if len(record.RequiredArtifactExts) > 0 {
result["requiredArtifactExtensions"] = append([]string(nil), record.RequiredArtifactExts...)
}
return result
}
@ -115,11 +121,12 @@ 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(),
"terminal": false,
"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,
}
}
@ -129,3 +136,530 @@ 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, false)
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
record := sess.openClaw
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)
cleanupOpenClawTurnAttachments(record)
return result
}
func (o *SessionOrchestrator) probeOpenClawTask(ctx context.Context, sess *session, notify func(map[string]any), waitForArtifacts bool) 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()
waitParams := map[string]any{
"runId": runID,
"timeoutMs": openClawTaskProbeTimeoutMs,
}
if waitForArtifacts {
waitParams["waitForArtifacts"] = true
}
waitResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"agent.wait",
waitParams,
openClawTaskProbeTimeout,
notifyWithCollection,
)
logOpenClawGatewayTiming(
gatewayProvider,
"agent.wait.probe",
sessionID,
runID,
time.Since(waitStarted),
waitResult.OK,
)
if !waitResult.OK {
if openClawProbeStillRunning(waitResult.Error) {
now := time.Now()
sess.mu.Lock()
if sess.openClaw != nil {
if sess.openClaw.FirstSilentFailureAt.IsZero() {
sess.openClaw.FirstSilentFailureAt = now
}
if openClawSilentFailureExceeded(o.server.config, sess.openClaw.FirstSilentFailureAt, now) {
sess.openClaw.ProbeInFlight = false
sess.mu.Unlock()
return o.failOpenClawTask(sess, "OPENCLAW_GATEWAY_LOST", "OpenClaw gateway stayed unreachable beyond the allowed silent duration")
}
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)
}
sess.mu.Lock()
if sess.openClaw != nil {
sess.openClaw.FirstSilentFailureAt = time.Time{}
}
sess.mu.Unlock()
waitPayload := shared.AsMap(waitResult.Payload)
if !openClawWaitPayloadTerminal(waitPayload) && !collector.isTerminal() {
return openClawMarkProbeRunning(sess)
}
return o.completeOpenClawTask(sess, waitPayload, collector, notify)
}
func openClawMarkProbeRunning(sess *session) map[string]any {
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
}
func openClawWaitPayloadTerminal(payload map[string]any) bool {
if payload == nil {
return false
}
if value, ok := payload["terminal"].(bool); ok {
return value
}
for _, key := range []string{"status", "state", "phase"} {
switch strings.TrimSpace(strings.ToLower(shared.StringArg(payload, key, ""))) {
case "complete", "completed", "done", "final", "success", "succeeded", "failed", "failure", "error", "timeout", "timed_out", "cancelled", "canceled":
return true
}
}
return false
}
func openClawSilentFailureExceeded(config *BridgeConfig, firstFailureAt time.Time, now time.Time) bool {
if firstFailureAt.IsZero() {
return false
}
return now.Sub(firstFailureAt) >= openClawMaxAllowedSilentDuration(config)
}
func openClawMaxAllowedSilentDuration(config *BridgeConfig) time.Duration {
raw := strings.TrimSpace(shared.EnvOrDefault("XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_SILENT_DURATION", ""))
if raw == "" && config != nil {
raw = strings.TrimSpace(config.OpenClawGateway.MaxAllowedSilentDuration)
}
if raw != "" {
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
return parsed
}
}
return openClawDefaultMaxAllowedSilentDuration
}
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")
if output == "" {
output = firstNonEmptyString(shared.AsMap(waitPayload["result"]), "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,
"appThreadKey": record.ThreadID,
"openclawSessionKey": 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 nestedResult := shared.AsMap(waitPayload["result"]); len(nestedResult) > 0 {
mergeOpenClawArtifactPayload(result, nestedResult)
}
if collector != nil {
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
}
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
artifactPayload := o.openClawArtifactExport(
record.GatewayProviderID,
record.ChatParams,
record.ArtifactContract,
record.RunID,
record.ArtifactSinceUnixMs,
record.PreparedArtifact,
notify,
)
mergeOpenClawArtifactPayload(result, artifactPayload)
snapshotPayload := o.openClawArtifactCollectAndSnapshot(
record.GatewayProviderID,
record.ChatParams,
record.ArtifactContract,
record.RunID,
record.ArtifactSinceUnixMs,
record.PreparedArtifact,
notify,
)
mergeOpenClawArtifactPayload(result, snapshotPayload)
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)
cleanupOpenClawTurnAttachments(record)
if notify != nil {
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(record.SessionID, record.ThreadID, record.TurnID, result)))
}
return result
}
func cleanupOpenClawTurnAttachments(record *OpenClawTaskRecord) {
if record == nil {
return
}
workingDirectory := strings.TrimSpace(shared.StringArg(record.ChatParams, "workingDirectory", ""))
if workingDirectory == "" {
return
}
attachmentDirectory := filepath.Join(
workingDirectory,
".xworkmate",
"attachments",
safeOpenClawAttachmentPathSegment(record.TurnID, "turn"),
)
if !openClawSafeAttachmentCleanupPath(workingDirectory, attachmentDirectory) {
return
}
_ = os.RemoveAll(attachmentDirectory)
}
func openClawSafeAttachmentCleanupPath(workingDirectory string, attachmentDirectory string) bool {
workingRoot, err := filepath.Abs(strings.TrimSpace(workingDirectory))
if err != nil || workingRoot == "" {
return false
}
attachmentRoot := filepath.Join(workingRoot, ".xworkmate", "attachments")
target, err := filepath.Abs(strings.TrimSpace(attachmentDirectory))
if err != nil || target == "" {
return false
}
rel, err := filepath.Rel(attachmentRoot, target)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return false
}
return true
}
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,
"appThreadKey": task.ThreadID,
"openclawSessionKey": 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
}

View File

@ -1,197 +0,0 @@
package acp
import (
"log"
"strings"
"time"
"xworkmate-bridge/internal/shared"
)
// 持久 run 仓 / run 关联与 WS 解耦T7/T8/T9
//
// 背景OpenClaw gateway turn 采用异步模型——chat.send 快速返回 runIdbridge 把
// 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.lastResultgateway 之后查不到也不丢;
// 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)
}

View File

@ -1,154 +0,0 @@
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"])
}
}

View File

@ -308,12 +308,14 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
releaseAdmission func(),
notify func(map[string]any),
) (map[string]any, *shared.RPCError) {
collector := newOpenClawChatCollector()
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
if sessionID == "" {
sessionID = threadID
}
notifyWithCollection := func(message map[string]any) {
collector.observe(message)
if notify == nil {
return
}
@ -323,7 +325,12 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
}
sessionKey := o.openClawSessionKey(params, turnID)
params = withOpenClawWritableWorkspace(params, openClawAppThreadKey(params))
artifactContract := openClawArtifactContractForParams(params, nil)
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
if rpcErr != nil {
return nil, rpcErr
}
artifactContract := openClawArtifactContractForParams(params, chatParams)
artifactSinceUnixMs := time.Now().Add(-1 * time.Second).UnixMilli()
preparedArtifact, prepareErr := o.openClawArtifactPrepare(
gatewayProvider,
params,
@ -336,19 +343,13 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
return nil, prepareErr
}
logOpenClawArtifactSync(gatewayProvider, sessionKey, turnID, "prepare", true, false, false)
params = withOpenClawPreparedArtifactWorkspace(params, preparedArtifact)
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
if rpcErr != nil {
return nil, rpcErr
}
applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, turnID, artifactContract)
chatSendTimeout := openClawAgentWaitTimeout(params, chatParams)
sendStarted := time.Now()
sendResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"chat.send",
chatParams,
chatSendTimeout,
2*time.Minute,
notifyWithCollection,
)
logOpenClawGatewayTiming(
@ -385,24 +386,25 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
taskLoadClass, runtimeBudgetMinutes := openClawTaskRuntimePolicy(params, chatParams, artifactContract)
startedAt := time.Now()
record := &OpenClawTaskRecord{
SessionID: sessionID,
ThreadID: threadID,
TurnID: turnID,
RunID: runID,
SessionKey: sessionKey,
GatewayProviderID: gatewayProvider,
TaskLoadClass: taskLoadClass,
RuntimeBudgetMinutes: runtimeBudgetMinutes,
StartedAt: startedAt,
DeadlineAt: startedAt.Add(time.Duration(runtimeBudgetMinutes) * time.Minute),
ProgressStage: "running",
ProgressMessage: "OpenClaw task accepted",
PreparedArtifact: preparedArtifact,
RequiresArtifactExport: artifactContract.RequiresArtifactExport,
ExpectedArtifactDirs: append([]string(nil), artifactContract.ExpectedArtifactDirs...),
RequiredArtifactExts: append([]string(nil), artifactContract.RequiredArtifactExts...),
ResolvedModel: routing.Model,
ResolvedSkills: append([]string(nil), routing.Skills...),
SessionID: sessionID,
ThreadID: threadID,
TurnID: turnID,
RunID: runID,
SessionKey: sessionKey,
GatewayProviderID: gatewayProvider,
TaskLoadClass: taskLoadClass,
ArtifactSinceUnixMs: artifactSinceUnixMs,
RuntimeBudgetMinutes: runtimeBudgetMinutes,
StartedAt: startedAt,
DeadlineAt: startedAt.Add(time.Duration(runtimeBudgetMinutes) * time.Minute),
ProgressStage: "running",
ProgressMessage: "OpenClaw task accepted",
ChatParams: cloneMap(chatParams),
PreparedArtifact: preparedArtifact,
ArtifactContract: artifactContract,
ResolvedModel: routing.Model,
ResolvedSkills: append([]string(nil), routing.Skills...),
AdmissionRelease: releaseAdmission,
}
sess := o.server.getOrCreateSession(sessionID, threadID)
sess.mu.Lock()
@ -417,17 +419,11 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
sess.task.DeadlineAt = record.DeadlineAt
sess.task.ProgressStage = "running"
sess.task.ProgressMessage = "OpenClaw task accepted"
// 新一轮 turn 复用同一 session 时,必须重置上一轮可能留下的终态标记,
// 否则持久 run 仓(T8)会把旧 runId 的终态错配给新 run。
sess.task.State = TaskStateRunning
sess.task.ProgressTerminal = false
sess.openClaw = record
running := openClawRunningTaskResult(record)
sess.lastResult = cloneMap(running)
sess.mu.Unlock()
if releaseAdmission != nil {
releaseAdmission()
}
o.startOpenClawTaskMonitor(sess)
if notify != nil {
notify(shared.NotificationEnvelope("session.update", map[string]any{
"sessionId": sessionID,
@ -444,6 +440,7 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
"progress": running["progress"],
}))
}
_ = collector
return running, nil
}
@ -527,6 +524,14 @@ func logOpenClawArtifactSync(
)
}
func openClawArtifactPayloadCount(payload map[string]any) int {
if payload == nil {
return 0
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
return len(extractArtifactPayloads(payload, remoteWorkingDirectory))
}
func isSessionTaskMethod(method string) bool {
switch strings.TrimSpace(method) {
case "session.start", "session.message":
@ -577,24 +582,9 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
sessionKey = strings.TrimSpace(sessionKey)
runID = strings.TrimSpace(runID)
if sessionKey == "" || runID == "" {
log.Printf(
"level=warn component=openclaw_gateway event=artifact_prepare_missing_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t appThreadKey=%q",
gatewayProvider,
sessionKey != "",
runID != "",
openClawAppThreadKey(params),
)
return nil, &shared.RPCError{Code: -32602, Message: "openclaw artifact prepare requires openclawSessionKey and runId"}
}
prepareParams := openClawSessionPrepareParams(params, sessionKey, runID, artifactContract)
log.Printf(
"level=info component=openclaw_gateway event=artifact_prepare_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t hasWorkspaceDir=%t expectedArtifactDirs=%d",
gatewayProvider,
strings.TrimSpace(shared.StringArg(prepareParams, "openclawSessionKey", "")) != "",
strings.TrimSpace(shared.StringArg(prepareParams, "runId", "")) != "",
strings.TrimSpace(shared.StringArg(prepareParams, "workspaceDir", "")) != "",
len(shared.ListArg(prepareParams, "expectedArtifactDirs")),
)
prepareResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"xworkmate.session.prepare",
@ -603,8 +593,16 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
notify,
)
if !prepareResult.OK {
if isOpenClawUnknownMethodError(prepareResult.Error, "xworkmate.session.prepare") {
return openClawPreparedArtifactScopeFromPayload(openClawFallbackSessionPreparePayload(prepareParams)), nil
if openClawPrepareUnsupported(prepareResult.Error) {
prepared := openClawLegacyPreparedArtifactScope(params, sessionKey, runID)
log.Printf(
"level=warn component=openclaw_gateway event=session_prepare_legacy_fallback provider=%q sessionId=%q runId=%q artifactScope=%q",
gatewayProvider,
sessionKey,
runID,
prepared.ArtifactScope,
)
return prepared, nil
}
return nil, gatewayRPCError(prepareResult.Error, "openclaw artifact prepare failed")
}
@ -615,58 +613,46 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
return prepared, nil
}
func isOpenClawUnknownMethodError(errorPayload map[string]any, method string) bool {
func openClawPrepareUnsupported(errorPayload map[string]any) bool {
code := strings.ToUpper(strings.TrimSpace(shared.StringArg(errorPayload, "code", "")))
message := strings.ToLower(strings.TrimSpace(shared.StringArg(errorPayload, "message", "")))
if message == "" {
if !strings.Contains(message, "xworkmate.session.prepare") {
return false
}
// 消息形如「unknown method: <method>」已明确指向「网关不认识该方法」,足以判定,
// 据此走 graceful fallback如 openClawFallbackSessionPreparePayload
//
// 注意:不能再用严格的 code 白名单来 gate。真实网关常以数字 JSON-RPC code
// (-32601 method not found / -32600 invalid request / -32002 等) 回传,
// 经 shared.StringArg(fmt.Sprint) 会被字符串化为 "-32601"/"-32002"
// 旧实现只接受 {"", INVALID_REQUEST, METHOD_NOT_FOUND},导致 fallback 失效、
// session.prepare 直接以 -32002 硬失败整轮任务。
return strings.Contains(message, "unknown method") &&
strings.Contains(message, strings.ToLower(strings.TrimSpace(method)))
return code == "INVALID_REQUEST" ||
code == "METHOD_NOT_FOUND" ||
code == "UNKNOWN_METHOD" ||
strings.Contains(message, "unknown method") ||
strings.Contains(message, "method not found")
}
func openClawFallbackSessionPreparePayload(params map[string]any) map[string]any {
sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
if sessionKey == "" {
sessionKey = strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
func openClawLegacyPreparedArtifactScope(params map[string]any, sessionKey string, runID string) *openClawPreparedArtifactScope {
sessionKey = strings.TrimSpace(sessionKey)
runID = strings.TrimSpace(runID)
artifactScope := "tasks/" + sessionKey + "/" + runID
workspaceRoot := openClawLegacyArtifactWorkspaceRoot(params)
return &openClawPreparedArtifactScope{
RemoteWorkingDirectory: workspaceRoot,
RemoteWorkspaceRefKind: "remotePath",
ArtifactScope: artifactScope,
ArtifactDirectory: filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)),
RelativeArtifactDirectory: artifactScope,
ScopeKind: "task",
}
if sessionKey == "" {
sessionKey = "main"
}
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
if runID == "" {
runID = strings.TrimSpace(shared.StringArg(params, "taskId", ""))
}
if runID == "" {
runID = strings.TrimSpace(shared.StringArg(params, "requestId", ""))
}
if runID == "" {
runID = "default"
}
relativeArtifactDirectory := filepath.Join("tasks", sessionKey, runID)
workspaceDir := openClawArtifactWorkspaceDir(params)
artifactDirectory := filepath.Join(workspaceDir, relativeArtifactDirectory)
return map[string]any{
"ok": true,
"fallback": true,
"compatibilityMode": "local-session-prepare",
"runId": runID,
"sessionKey": sessionKey,
"openclawSessionKey": sessionKey,
"remoteWorkingDirectory": workspaceDir,
"remoteWorkspaceRefKind": "path",
"artifactScope": relativeArtifactDirectory,
"artifactDirectory": artifactDirectory,
"relativeArtifactDirectory": relativeArtifactDirectory,
"scopeKind": "task",
}
func openClawLegacyArtifactWorkspaceRoot(params map[string]any) string {
for _, key := range []string{"remoteWorkingDirectoryHint", "remoteWorkingDirectory"} {
value := strings.TrimSpace(shared.StringArg(params, key, ""))
if value == "" {
continue
}
cleaned := filepath.Clean(value)
if strings.HasPrefix(cleaned, "/home/ubuntu/.openclaw/workspace") {
return strings.TrimRight(cleaned, string(os.PathSeparator))
}
}
return "/home/ubuntu/.openclaw/workspace"
}
func openClawSessionPrepareParams(params map[string]any, openClawSessionKey string, runID string, artifactContract openClawArtifactContract) map[string]any {
@ -682,45 +668,15 @@ func openClawSessionPrepareParams(params map[string]any, openClawSessionKey stri
if len(artifactContract.ExpectedArtifactDirs) > 0 {
result["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
}
if artifactContract.RequiresArtifactExport {
result["requiresArtifactExport"] = true
if sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", "")); sessionID != "" {
result["sessionId"] = sessionID
}
if len(artifactContract.RequiredArtifactExts) > 0 {
result["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...)
}
if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" {
result["workspaceDir"] = workspaceDir
if threadID := strings.TrimSpace(shared.StringArg(params, "threadId", "")); threadID != "" {
result["threadId"] = threadID
}
return result
}
func openClawArtifactWorkspaceDir(params map[string]any) string {
if value := strings.TrimSpace(shared.StringArg(params, "workspaceDir", "")); value != "" {
return value
}
for _, key := range []string{"remoteWorkingDirectoryHint", "remoteWorkingDirectory", "workingDirectory"} {
if value := strings.TrimSpace(shared.StringArg(params, key, "")); isOpenClawWorkspacePath(value) {
return value
}
}
if configured := strings.TrimSpace(os.Getenv("OPENCLAW_WORKSPACE_DIR")); configured != "" {
return configured
}
return "~/.openclaw/workspace"
}
func isOpenClawWorkspacePath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
normalized := filepath.ToSlash(filepath.Clean(path))
return strings.HasPrefix(normalized, "/home/ubuntu/.openclaw/workspace") ||
strings.HasPrefix(normalized, "/Users/") && strings.Contains(normalized, "/.openclaw/workspace") ||
strings.HasPrefix(normalized, "~/.openclaw/workspace") ||
strings.HasPrefix(normalized, "$HOME/.openclaw/workspace")
}
func openClawAppThreadKey(params map[string]any) string {
if value := strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")); value != "" {
return value
@ -841,13 +797,10 @@ func shellSingleQuote(value string) string {
}
type openClawArtifactContract struct {
TaskLoadClass string
ComplexLongChain bool
RequiresArtifactExport bool
ExpectedArtifactDirs []string
RequiredArtifactExts []string
ExpectedFileCounts map[string]int
SourceMessage string
TaskLoadClass string
ComplexLongChain bool
ExpectedArtifactDirs []string
SourceMessage string
}
func openClawArtifactContractForParams(params map[string]any, chatParams map[string]any) openClawArtifactContract {
@ -860,75 +813,15 @@ func openClawArtifactContractForParams(params map[string]any, chatParams map[str
lowerMessage := strings.ToLower(message)
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
expectedDirs := normalizeOpenClawDirList(shared.ListArg(contract, "expectedArtifactDirs"))
requiresExport := parseBool(contract["requiresExportBeforeFinalResponse"]) || len(expectedDirs) > 0
complex := taskLoadClass == "complex_long_chain_task" || isOpenClawLongArtifactTask(lowerMessage)
requiredExts := normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "requiredArtifactExtensions"))
if len(requiredExts) == 0 {
requiredExts = normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "expectedArtifactExtensions"))
}
if len(requiredExts) == 0 {
requiredExts = inferOpenClawRequiredArtifactExts(lowerMessage)
}
expectedFileCounts := normalizeOpenClawArtifactExtCountMap(shared.AsMap(contract["expectedFileCountByExtension"]))
if len(expectedFileCounts) == 0 {
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(metadata["expectedFileCountByExtension"]))
}
if len(expectedFileCounts) == 0 {
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(shared.AsMap(metadata["xworkmateArtifactConstraints"])["expectedFileCountByExtension"]))
}
return openClawArtifactContract{
TaskLoadClass: taskLoadClass,
ComplexLongChain: complex,
RequiresArtifactExport: requiresExport,
ExpectedArtifactDirs: expectedDirs,
RequiredArtifactExts: requiredExts,
ExpectedFileCounts: expectedFileCounts,
SourceMessage: message,
TaskLoadClass: taskLoadClass,
ComplexLongChain: complex,
ExpectedArtifactDirs: expectedDirs,
SourceMessage: message,
}
}
func normalizeOpenClawArtifactExtCountMap(values map[string]any) map[string]int {
if len(values) == 0 {
return nil
}
result := map[string]int{}
for key, raw := range values {
ext := strings.ToLower(strings.TrimSpace(key))
ext = strings.TrimPrefix(ext, ".")
if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") {
continue
}
count := openClawPositiveInt(raw)
if count <= 0 {
continue
}
result[ext] = count
}
if len(result) == 0 {
return nil
}
return result
}
func openClawPositiveInt(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
case string:
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &parsed); err == nil {
return parsed
}
}
return 0
}
func normalizeOpenClawDirList(values []any) []string {
if len(values) == 0 {
return nil
@ -946,44 +839,11 @@ func normalizeOpenClawDirList(values []any) []string {
return result
}
func normalizeOpenClawArtifactExtList(values []any) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
ext := strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
ext = strings.TrimPrefix(ext, ".")
if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") || seen[ext] {
continue
}
seen[ext] = true
result = append(result, ext)
}
return result
}
func inferOpenClawRequiredArtifactExts(lowerMessage string) []string {
switch {
case openClawMessageContainsAny(lowerMessage, []string{"pdf", "输出 pdf", "生成 pdf"}):
return []string{"pdf"}
case openClawMessageContainsAny(lowerMessage, []string{"视频", "video", "mp4", "渲染"}):
return []string{"mp4"}
case openClawMessageContainsAny(lowerMessage, []string{"图片", "图像", "png", "jpg", "jpeg", "webp", "生成图"}):
return []string{"png", "jpg", "jpeg", "webp"}
case openClawMessageContainsAny(lowerMessage, []string{"markdown", "md文件", ".md", "文案", "资讯"}):
return []string{"md"}
default:
return nil
}
}
func openClawChatSendParams(
params map[string]any,
turnID string,
) (map[string]any, *shared.RPCError) {
return openClawChatSendParamsWithSessionKey(params, turnID, openClawAgentMainSessionKey(openClawAppThreadKey(params)))
return openClawChatSendParamsWithSessionKey(params, turnID, fallbackOpenClawSessionKey(params, turnID))
}
func openClawChatSendParamsWithSessionKey(
@ -1007,6 +867,7 @@ func openClawChatSendParamsWithSessionKey(
}
attachments = append(attachments, inlineAttachments...)
if len(attachments) > 0 {
chatParams["attachments"] = attachments
chatParams["message"] = shared.AugmentPromptWithAttachments(
message,
map[string]any{"attachments": attachments},
@ -1047,66 +908,6 @@ func withOpenClawWritableWorkspace(params map[string]any, appThreadKey string) m
return next
}
func withOpenClawPreparedArtifactWorkspace(params map[string]any, prepared *openClawPreparedArtifactScope) map[string]any {
if prepared == nil {
return params
}
artifactDirectory := strings.TrimSpace(prepared.ArtifactDirectory)
if artifactDirectory == "" {
return params
}
replacements := openClawWorkspacePromptReplacementValues(params)
next := make(map[string]any, len(params)+2)
for key, value := range params {
next[key] = value
}
next["workingDirectory"] = artifactDirectory
next["remoteWorkingDirectoryHint"] = artifactDirectory
for _, key := range []string{"taskPrompt", "prompt", "message"} {
value, ok := next[key].(string)
if !ok || strings.TrimSpace(value) == "" {
continue
}
next[key] = rewriteOpenClawWorkspaceReferences(value, artifactDirectory, replacements)
}
return next
}
func openClawWorkspacePromptReplacementValues(params map[string]any) []string {
values := []string{
shared.StringArg(params, "workingDirectory", ""),
shared.StringArg(params, "remoteWorkingDirectoryHint", ""),
shared.StringArg(params, "remoteWorkingDirectory", ""),
}
metadata := shared.AsMap(params["metadata"])
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
values = append(values,
shared.StringArg(contract, "currentTaskWorkspace", ""),
shared.StringArg(contract, "remoteWorkspaceHint", ""),
)
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
result = append(result, trimmed)
}
return result
}
func rewriteOpenClawWorkspaceReferences(message string, artifactDirectory string, replacements []string) string {
result := message
for _, value := range replacements {
if value != artifactDirectory {
result = strings.ReplaceAll(result, value, artifactDirectory)
}
}
return result
}
func firstOwnerScopedWorkspace(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
@ -1120,7 +921,7 @@ func firstOwnerScopedWorkspace(values ...string) string {
func openClawWritableWorkspaceForOwnerPath(ownerPath string, sessionKey string) string {
root := strings.TrimSpace(os.Getenv("OPENCLAW_WRITABLE_WORKSPACE_ROOT"))
if root == "" {
return ""
root = "/home/ubuntu/.openclaw/workspace/task_artifacts"
}
root = strings.TrimRight(filepath.Clean(root), string(os.PathSeparator))
if root == "" || root == "." || root == string(os.PathSeparator) {
@ -1138,28 +939,17 @@ func openClawNonEmptyPathAttachments(params map[string]any) []any {
if len(rawAttachments) == 0 {
return nil
}
inlineAttachmentNames := map[string]bool{}
for _, raw := range shared.ListArg(params, "inlineAttachments") {
name := strings.TrimSpace(shared.StringArg(shared.AsMap(raw), "name", ""))
if name != "" {
inlineAttachmentNames[name] = true
}
}
attachments := make([]any, 0, len(rawAttachments))
for _, raw := range rawAttachments {
attachment := shared.AsMap(raw)
if len(attachment) == 0 {
continue
}
name := strings.TrimSpace(shared.StringArg(attachment, "name", "attachment"))
if inlineAttachmentNames[name] {
continue
}
if strings.TrimSpace(shared.StringArg(attachment, "path", "")) == "" {
continue
}
attachments = append(attachments, map[string]any{
"name": name,
"name": strings.TrimSpace(shared.StringArg(attachment, "name", "attachment")),
"description": strings.TrimSpace(shared.StringArg(attachment, "description", "")),
"path": strings.TrimSpace(shared.StringArg(attachment, "path", "")),
})
@ -1478,15 +1268,18 @@ func (o *SessionOrchestrator) openClawSessionKey(params map[string]any, turnID s
if explicit := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")); explicit != "" {
return explicit
}
return openClawAgentMainSessionKey(openClawAppThreadKey(params))
if appThreadKey := openClawAppThreadKey(params); appThreadKey != "" {
return openClawAgentMainSessionKey(appThreadKey)
}
return fallbackOpenClawSessionKey(params, turnID)
}
func openClawAgentMainSessionKey(appThreadKey string) string {
appThreadKey = strings.TrimSpace(appThreadKey)
if appThreadKey == "" {
appThreadKey = "main"
return "main"
}
return "agent:main:" + appThreadKey
return appThreadKey
}
func validateOpenClawAcceptedSessionKey(payload map[string]any, expectedSessionKey string) *shared.RPCError {
@ -1512,6 +1305,18 @@ func validateOpenClawAcceptedSessionKey(payload map[string]any, expectedSessionK
}
}
func fallbackOpenClawSessionKey(params map[string]any, turnID string) string {
for _, key := range []string{"threadId", "sessionId"} {
if value := strings.TrimSpace(shared.StringArg(params, key, "")); value != "" {
return value
}
}
if trimmed := strings.TrimSpace(turnID); trimmed != "" {
return trimmed
}
return "main"
}
func (o *SessionOrchestrator) openClawArtifactExport(
gatewayProvider string,
chatParams map[string]any,
@ -1539,20 +1344,54 @@ func (o *SessionOrchestrator) openClawArtifactExport(
if len(artifactContract.ExpectedArtifactDirs) > 0 {
exportParams["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
}
if len(artifactContract.RequiredArtifactExts) > 0 {
exportParams["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...)
}
if len(artifactContract.ExpectedFileCounts) > 0 {
counts := map[string]int{}
for ext, count := range artifactContract.ExpectedFileCounts {
counts[ext] = count
}
exportParams["expectedFileCountByExtension"] = counts
}
payload := o.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
return payload
}
func (o *SessionOrchestrator) openClawArtifactCollectAndSnapshot(
gatewayProvider string,
chatParams map[string]any,
artifactContract openClawArtifactContract,
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
notify func(map[string]any),
) map[string]any {
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
if sessionKey == "" || strings.TrimSpace(runID) == "" || preparedArtifact == nil {
return nil
}
snapshotParams := map[string]any{
"openclawSessionKey": sessionKey,
"runId": strings.TrimSpace(runID),
"sinceUnixMs": sinceUnixMs,
"maxFiles": 64,
}
if strings.TrimSpace(preparedArtifact.ArtifactScope) != "" {
snapshotParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
}
if len(artifactContract.ExpectedArtifactDirs) > 0 {
snapshotParams["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
}
snapshotResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"xworkmate.artifacts.collect-and-snapshot",
snapshotParams,
30*time.Second,
notify,
)
if snapshotResult.OK {
return shared.AsMap(snapshotResult.Payload)
}
message := strings.TrimSpace(shared.StringArg(snapshotResult.Error, "message", ""))
if message == "" {
message = "openclaw artifact snapshot unavailable"
}
return map[string]any{
"artifactWarnings": []any{message},
}
}
func (o *SessionOrchestrator) openClawArtifactExportRequest(
gatewayProvider string,
exportParams map[string]any,
@ -1577,6 +1416,61 @@ func (o *SessionOrchestrator) openClawArtifactExportRequest(
}
}
func openClawSessionKeyFromArtifactScope(scope string) string {
parts := strings.Split(strings.TrimSpace(scope), "/")
if len(parts) != 3 || parts[0] != "tasks" {
return ""
}
return strings.TrimSpace(parts[1])
}
func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput bool) {
if !noDisplayableOutput || result == nil || !parseBool(result["success"]) {
return
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
return
}
result["success"] = false
result["status"] = "failed"
result["code"] = "OPENCLAW_NO_DISPLAYABLE_OUTPUT"
result["error"] = "openclaw returned no displayable output"
result["message"] = openClawNoDisplayableText
result["output"] = openClawNoDisplayableText
result["summary"] = openClawNoDisplayableText
}
func guardOpenClawAgentFailedBeforeReplyResult(result map[string]any) {
if result == nil || !parseBool(result["success"]) {
return
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
return
}
output := firstNonEmptyString(result, "output", "message", "summary")
if !strings.Contains(strings.ToLower(output), "agent failed before reply") {
return
}
result["success"] = false
result["status"] = "failed"
result["code"] = "OPENCLAW_AGENT_FAILED_BEFORE_REPLY"
result["error"] = output
result["message"] = output
result["output"] = output
result["summary"] = output
}
func applyOpenClawArtifactContractResult(result map[string]any, contract openClawArtifactContract) {
if result == nil {
return
}
if strings.TrimSpace(contract.TaskLoadClass) != "" {
result["taskLoadClass"] = contract.TaskLoadClass
}
}
func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any) {
if result == nil || len(source) == 0 {
return
@ -1611,41 +1505,6 @@ func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any)
result[key] = merged
}
}
if value, ok := source["constraintSatisfied"]; ok {
result["constraintSatisfied"] = parseBool(value)
}
if _, ok := source["missingRequiredExtensions"]; ok {
result["missingRequiredExtensions"] = appendStringList(result["missingRequiredExtensions"], source["missingRequiredExtensions"])
}
if value, ok := source["missingRequiredFileCounts"]; ok {
result["missingRequiredFileCounts"] = value
}
}
func appendStringList(existing any, incoming any) []any {
seen := map[string]bool{}
merged := make([]any, 0)
add := func(value any) {
item := strings.TrimSpace(fmt.Sprint(value))
if item == "" || seen[item] {
return
}
seen[item] = true
merged = append(merged, item)
}
for _, values := range []any{existing, incoming} {
switch typed := values.(type) {
case []any:
for _, item := range typed {
add(item)
}
case []string:
for _, item := range typed {
add(item)
}
}
}
return merged
}
func appendArtifactList(existing any, incoming any) []any {
@ -1669,34 +1528,13 @@ func appendArtifactList(existing any, incoming any) []any {
return merged
}
func applyOpenClawConstraintDeliveryStatus(result map[string]any) {
if result == nil || !parseBool(result["success"]) {
return
}
if value, ok := result["constraintSatisfied"]; !ok || parseBool(value) {
return
}
switch strings.ToLower(strings.TrimSpace(shared.StringArg(result, "status", ""))) {
case string(TaskStateRunning), string(TaskStateFailed), string(TaskStateCancelled):
return
default:
result["status"] = "partially_delivered"
result["artifactSyncStatus"] = "partial"
}
}
func gatewayRPCError(errorPayload map[string]any, fallback string) *shared.RPCError {
if isOpenClawRetryableGatewayError(errorPayload) {
metricGatewaySocketClosedInc() // T12
// T10连接断属「可重试 / run 可能仍在后台、可续轮询」语义,而非 run 确实失败。
// 带 retryable/poll 提示,客户端据此降级为「后台续跑·重连中」(T5) 续轮询 tasks.get而非硬失败。
return &shared.RPCError{
Code: -32002,
Message: "OPENCLAW_GATEWAY_SOCKET_CLOSED: OpenClaw gateway connection closed during task execution",
Data: map[string]any{
"code": "OPENCLAW_GATEWAY_SOCKET_CLOSED",
"retryable": true,
"poll": true,
"originalCode": strings.TrimSpace(shared.StringArg(errorPayload, "code", "")),
"originalError": strings.TrimSpace(shared.StringArg(errorPayload, "message", "")),
},
@ -1779,6 +1617,71 @@ func firstNonEmptyString(values map[string]any, keys ...string) string {
return ""
}
type openClawChatCollector struct {
parts []string
final string
terminal bool
artifactPayloads []map[string]any
}
func newOpenClawChatCollector() *openClawChatCollector {
return &openClawChatCollector{}
}
func (c *openClawChatCollector) observe(notification map[string]any) {
if c == nil {
return
}
event := shared.AsMap(shared.AsMap(notification["params"])["event"])
if len(event) == 0 {
return
}
payload := shared.AsMap(event["payload"])
if hasArtifactPayload(payload) {
c.artifactPayloads = append(c.artifactPayloads, payload)
}
if strings.TrimSpace(shared.StringArg(event, "event", "")) != "chat.run" {
return
}
if isTerminalGatewayPayload(payload) {
c.terminal = true
}
text := firstNonEmptyString(payload, "assistantText", "text", "message", "output", "summary")
if text == "" {
return
}
if isTerminalGatewayPayload(payload) {
c.final = text
return
}
c.parts = append(c.parts, text)
}
func (c *openClawChatCollector) output() string {
if c == nil {
return ""
}
if strings.TrimSpace(c.final) != "" {
return strings.TrimSpace(c.final)
}
return strings.TrimSpace(strings.Join(c.parts, ""))
}
func (c *openClawChatCollector) isTerminal() bool {
return c != nil && c.terminal
}
func (c *openClawChatCollector) artifactPayload() map[string]any {
if c == nil || len(c.artifactPayloads) == 0 {
return nil
}
result := map[string]any{}
for _, payload := range c.artifactPayloads {
mergeOpenClawArtifactPayload(result, payload)
}
return result
}
func openClawGatewaySessionUpdate(notification map[string]any, sessionID string, threadID string, turnID string) map[string]any {
params := shared.AsMap(notification["params"])
event := shared.AsMap(params["event"])
@ -1813,6 +1716,18 @@ func openClawGatewaySessionUpdate(notification map[string]any, sessionID string,
return shared.NotificationEnvelope("session.update", update)
}
func hasArtifactPayload(payload map[string]any) bool {
if len(payload) == 0 {
return false
}
for _, key := range []string{"artifacts", "files", "attachments", "remoteWorkingDirectory", "remoteWorkspaceRefKind"} {
if _, ok := payload[key]; ok {
return true
}
}
return false
}
func isTerminalGatewayPayload(payload map[string]any) bool {
if payload == nil {
return false
@ -1901,12 +1816,7 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
delete(result, openClawArtifactExportAttemptedField)
successValue, hasSuccess := result["success"]
successSource := "explicit"
success := parseBool(successValue)
if !hasSuccess {
successSource = "absent"
success = true
}
success := !hasSuccess || parseBool(successValue)
output := strings.TrimSpace(shared.StringArg(result, "output", ""))
if output == "" {
@ -1915,19 +1825,6 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
if output == "" && success {
output = strings.TrimSpace(shared.StringArg(result, "message", ""))
}
if routing.TargetID == "gateway" && successSource == "absent" {
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
if output == "" && len(extractArtifactPayloads(result, remoteWorkingDirectory)) == 0 {
result["success"] = false
result["status"] = string(TaskStateFailed)
result["code"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
result["error"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
result["message"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
} else {
result["success"] = true
result["successSource"] = "inferred"
}
}
sess.mu.Lock()
if output != "" {
@ -1942,12 +1839,7 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
result["status"] = "completed"
}
if !hasSuccess {
if _, ok := result["success"]; !ok {
result["success"] = true
}
}
if !parseBool(result["success"]) && strings.TrimSpace(shared.StringArg(result, "status", "")) == string(TaskStateCompleted) {
result["status"] = string(TaskStateFailed)
result["success"] = true
}
result["resolvedExecutionTarget"] = routing.TargetID
result["resolvedProviderId"] = routing.ProviderID
@ -1974,7 +1866,6 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
sess.task.UpdatedAt = time.Now()
sess.mu.Unlock()
}
applyOpenClawConstraintDeliveryStatus(result)
artifactRecord := buildArtifactRecord(sess, result, output)
if artifactRecord.RemoteWorkingDirectory != "" {

View File

@ -1,374 +0,0 @@
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)
}
})
}
}

View File

@ -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,24 +109,9 @@ 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"]
@ -140,7 +125,6 @@ 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

View File

@ -76,9 +76,6 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
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
@ -93,533 +90,46 @@ 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 {
return next
sess = s.reassociateOpenClawTask(params)
}
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 nil
return map[string]any{"status": "not_found"}
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.task.State != TaskStateRunning {
return nil
waitForArtifacts := shared.BoolArg(shared.StringArg(params, "waitForArtifacts", ""), false)
if val, ok := params["waitForArtifacts"].(bool); ok {
waitForArtifacts = val
}
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
return s.orchestrator.probeOpenClawTask(ctx, sess, notify, waitForArtifacts)
}
func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
sess := s.findTaskSession(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 {
sess = s.reassociateOpenClawTask(params)
}
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()
if sess == nil {
return map[string]any{"accepted": false, "status": "not_found"}
}
if gatewayProvider == "" {
gatewayProvider = "openclaw"
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 strings.TrimSpace(runID) != "" && s.gateway != nil {
snapshot := openClawSessionSnapshotLocked(sess)
sess.mu.Unlock()
s.orchestrator.releaseOpenClawAdmission(sess)
if strings.TrimSpace(gatewayProvider) != "" && strings.TrimSpace(runID) != "" && s.gateway != nil {
_ = s.gateway.RequestByMode(
gatewayProvider,
"agent.cancel",
@ -628,7 +138,8 @@ func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, no
notify,
)
}
return map[string]any{"accepted": strings.TrimSpace(runID) != "", "runId": runID}
snapshot["accepted"] = true
return snapshot
}
func (s *Server) findTaskSession(params map[string]any) *session {
@ -636,6 +147,7 @@ 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 {
@ -648,7 +160,8 @@ 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)
(runID != "" && candidate.task.RunID == runID) ||
(artifactScope != "" && candidate.task.ArtifactScope == artifactScope)
candidate.mu.Unlock()
if matches {
return candidate
@ -657,31 +170,93 @@ func (s *Server) findTaskSession(params map[string]any) *session {
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
}
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
}
if strings.TrimSpace(shared.StringArg(result, "workspaceDir", "")) == "" {
if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" {
result["workspaceDir"] = workspaceDir
}
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
if sessionID == "" {
sessionID = threadID
}
return result
if sessionID == "" {
sessionID = "openclaw:" + runID
}
if threadID == "" {
threadID = sessionID
}
turnID := strings.TrimSpace(shared.StringArg(params, "turnId", runID))
sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
if sessionKey == "" {
sessionKey = openClawAgentMainSessionKey(strings.TrimSpace(shared.StringArg(params, "appThreadKey", threadID)))
}
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", "openclaw"))
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", "")),
ComplexLongChain: shared.BoolArg(shared.StringArg(params, "complexLongChain", ""), false),
}
taskLoadClass, budget := openClawTaskRuntimePolicy(params, map[string]any{"sessionKey": sessionKey}, contract)
if explicitBudget := shared.IntArg(shared.StringArg(params, "runtimeBudgetMinutes", ""), 0); explicitBudget > 0 {
budget = explicitBudget
}
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: 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: 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
}
func (s *Server) cancelSession(ctx context.Context, sessionID string) {
@ -763,10 +338,6 @@ func (s *Server) handleDesktopMethod(ctx context.Context, method string, params
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,

View File

@ -43,20 +43,13 @@ 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(
authToken,
authExtraTokens...,
shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""),
shared.EnvOrDefault("BRIDGE_REVIEW_AUTH_TOKEN", ""),
),
openClawGate: newOpenClawGatewayAdmissionGate(config),
taskRouter: newDistributedTaskRouter(distributedTaskRouterConfig{

View File

@ -1,158 +0,0 @@
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

View File

@ -52,6 +52,7 @@ type QueuedTask struct {
StartedAt time.Time
UpdatedAt time.Time
DeadlineAt time.Time
LastProbeAt time.Time
ProgressStage string
ProgressMessage string
ProgressTerminal bool
@ -95,11 +96,11 @@ type Server struct {
orchestrator *SessionOrchestrator
memoryService memory.Service
providerOrder []string
gateway *gatewayruntime.Manager
openClawGate *openClawGatewayAdmissionGate
jobs *jobManager
taskRouter *distributedTaskRouter
providerOrder []string
gateway *gatewayruntime.Manager
openClawGate *openClawGatewayAdmissionGate
jobs *jobManager
taskRouter *distributedTaskRouter
// Legacy / Common
authService interface{} // Minimal auth dependency

View File

@ -40,11 +40,20 @@ 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":{"runId":%q,"appThreadKey":%q,"openclawSessionKey":%q,"gatewayProviderId":%q,"includeArtifacts":true}}`,
`{"jsonrpc":"2.0","id":"task-get","method":"xworkmate.tasks.get","params":{"sessionId":%q,"threadId":%q,"turnId":%q,"runId":%q,"appThreadKey":%q,"openclawSessionKey":%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", ""),
shared.StringArg(handle, "runId", ""),
shared.StringArg(handle, "appThreadKey", ""),
shared.StringArg(handle, "openclawSessionKey", ""),
shared.StringArg(handle, "artifactScope", ""),
shared.StringArg(handle, "artifactDirectory", ""),
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))
@ -77,6 +86,18 @@ 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", "")
@ -231,7 +252,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","openclawSessionKey":"t1","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","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
)
if err != nil {
t.Fatalf("build request: %v", err)
@ -296,7 +317,7 @@ func TestHTTPHandlerGatewayOpenClawReturnsRunningEnvelopeAndDone(t *testing.T) {
}
}
func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(t *testing.T) {
func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
gateway.agentWaitDelayMs.Store(1500)
@ -326,7 +347,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(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)+`","openclawSessionKey":"t`+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)+`","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}
@ -355,25 +376,36 @@ func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(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 admission to release after accepted native chat.send, got %d chat.send calls", got)
t.Fatalf("expected queued request to run after a slot releases, got %d chat.send calls", got)
}
}
@ -394,11 +426,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输出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",
"参考附件模版制作 ,围绕\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 生成图 -> 汇总排版 -> 制作视频",
}
type result struct {
body string
@ -413,8 +445,7 @@ 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","openclawSessionKey":"e2e-t%d","threadId":"e2e-t%d","taskPrompt":%q,"workingDirectory":%q,"routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`,
index,
`{"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"}}}`,
index,
index,
index,
@ -499,12 +530,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 != 0 {
t.Fatalf("expected task polling to use native task-registry without Bridge-owned agent.wait, got %d", got)
if got := gateway.AgentWaitCount(); got != expectedGatewayTurns {
t.Fatalf("expected five primary agent.wait calls without model repair turns, got %d", got)
}
}
func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *testing.T) {
func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
gateway.agentWaitDelayMs.Store(300)
@ -522,7 +553,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
firstRequest, err := http.NewRequest(
http.MethodPost,
httpServer.URL+"/acp/rpc",
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"}}}`),
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"}}}`),
)
if err != nil {
t.Fatalf("build first request: %v", err)
@ -546,7 +577,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
secondRequest, err := http.NewRequest(
http.MethodPost,
httpServer.URL+"/acp/rpc",
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"}}}`),
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"}}}`),
)
if err != nil {
t.Fatalf("build second request: %v", err)
@ -564,13 +595,14 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
t.Fatalf("read second response: %v", err)
}
bodyText := string(body)
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, openClawGatewayBusyErrorCode) {
t.Fatalf("expected busy error, got %s", bodyText)
}
if got := gateway.ChatSendCount(); got != 2 {
t.Fatalf("accepted native task must not hold admission slot, got %d chat.send calls", got)
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 err := <-firstDone; err != nil {
t.Fatalf("first request failed: %v", err)
@ -593,7 +625,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","openclawSessionKey":"thread-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","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)
@ -688,8 +720,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.session.prepare", "chat.send", "xworkmate.tasks.get"}) {
t.Fatalf("expected prepare, chat.send, then native task lookup, got %#v", got)
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export", "xworkmate.artifacts.collect-and-snapshot"}) {
t.Fatalf("expected artifact workflow methods to prepare before chat.send, got %#v", got)
}
}
@ -707,7 +739,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","openclawSessionKey":"t1","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","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")
@ -734,8 +766,8 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
if got := result["status"]; got != "completed" {
t.Fatalf("expected completed task result, got %#v", result)
}
if gateway.AgentWaitCount() != 0 {
t.Fatalf("expected native task-registry lookup without Bridge-owned agent.wait, got %d", gateway.AgentWaitCount())
if gateway.AgentWaitCount() != 1 {
t.Fatalf("expected one OpenClaw agent.wait, got %d", gateway.AgentWaitCount())
}
}
@ -753,7 +785,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","openclawSessionKey":"t1","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","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")
@ -875,7 +907,6 @@ 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,27 +921,7 @@ 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")
@ -971,10 +982,8 @@ 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()
@ -993,7 +1002,6 @@ 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()
@ -1002,7 +1010,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","openclawSessionKey":"test","threadId":"test"}}`),
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`),
)
request.Header.Set("Content-Type", "application/json")
@ -1014,7 +1022,6 @@ 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()
@ -1034,7 +1041,6 @@ 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")
@ -1243,7 +1249,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","openclawSessionKey":"t1","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","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")

View File

@ -97,8 +97,9 @@ func (xi *XdotoolInjector) Start() error {
// Inject sends a command to the persistent xdotool process
func (xi *XdotoolInjector) Inject(event InputEvent) error {
xi.mu.Lock()
defer xi.mu.Unlock()
if !xi.isStarted || xi.stdin == nil {
xi.mu.Unlock()
return fmt.Errorf("injector is not running")
}
@ -109,7 +110,6 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
xi.moveMu.Lock()
xi.pendingMove = &event
xi.moveMu.Unlock()
xi.mu.Unlock()
return nil
case "mouse_down":
@ -135,7 +135,6 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
}
default:
xi.mu.Unlock()
return fmt.Errorf("unsupported input type: %s", event.Type)
}
@ -145,22 +144,14 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
// 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()
_ = xi.stdin.Close()
if restartErr := xi.Start(); restartErr == nil {
xi.mu.Lock()
_, _ = xi.stdin.Write([]byte(cmdStr))
xi.mu.Unlock()
}
return err
}
}
xi.mu.Unlock()
return nil
}

View File

@ -20,32 +20,6 @@ func normalizeRTPPort(port int) int {
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
@ -84,7 +58,19 @@ func (pm *PipelineManager) Start(cfg PipelineConfig) error {
cfg.Display = ":0.0"
}
}
cfg = normalizePipelineConfig(cfg)
cfg.Port = normalizeRTPPort(cfg.Port)
if cfg.Width <= 0 {
cfg.Width = 1280
}
if cfg.Height <= 0 {
cfg.Height = 720
}
if cfg.FPS <= 0 {
cfg.FPS = 30
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 2000
}
tool, args, err := pm.resolvePipeline(cfg)
if err != nil {
@ -186,21 +172,15 @@ func (pm *PipelineManager) hasExecutable(name string) bool {
}
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, "video/x-raw,framerate=30/1")
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"
encoderStr := "x264enc speed-preset=ultrafast tune=zerolatency bitrate=" + fmt.Sprintf("%d", cfg.Bitrate)
if cfg.UseGPU {
// Detect if nvcodec is present by calling gst-inspect-1.0 or simply attempting it.
// We'll default to nvh264enc.
@ -209,7 +189,6 @@ func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string,
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))
@ -230,7 +209,6 @@ func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string,
}
func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, error) {
cfg = normalizePipelineConfig(cfg)
args := []string{
"-f", "x11grab",
"-draw_mouse", "1",
@ -246,16 +224,12 @@ func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, er
"-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",
)
}

View File

@ -1,85 +0,0 @@
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)
}
}
}

View File

@ -11,7 +11,6 @@ import (
type DesktopSession struct {
SessionID string
Port int
Config PipelineConfig
Pipeline *PipelineManager
Injector *XdotoolInjector
WebRTC *WebRTCServer
@ -73,11 +72,18 @@ func (s *Service) StartSession(sessionID string, cfg PipelineConfig, iceServers
return nil, fmt.Errorf("failed to start RTP receiver: %w", err)
}
// 3. Initialize screen capture pipeline
pipeline := NewPipelineManager()
if err := pipeline.Start(cfg); err != nil {
webrtcSrv.Close()
_ = injector.Close()
return nil, fmt.Errorf("failed to start capture pipeline: %w", err)
}
sess := &DesktopSession{
SessionID: sessionID,
Port: cfg.Port,
Config: cfg,
Pipeline: NewPipelineManager(),
Pipeline: pipeline,
Injector: injector,
WebRTC: webrtcSrv,
}
@ -86,23 +92,6 @@ func (s *Service) StartSession(sessionID string, cfg PipelineConfig, iceServers
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()

View File

@ -5,20 +5,11 @@ import (
"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
@ -26,19 +17,6 @@ type WebRTCServer struct {
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) {
@ -94,24 +72,18 @@ func (w *WebRTCServer) InitPeerConnection(iceServers []string) error {
// 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
if d.Label() == "input" {
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 err := w.inputInjector.Inject(event); err != nil {
log.Printf("Failed to inject input event: %v", err)
}
})
}
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
@ -120,10 +92,6 @@ func (w *WebRTCServer) InitPeerConnection(iceServers []string) error {
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)
@ -139,36 +107,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
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 {
@ -180,8 +118,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
}
break
}
atomic.AddUint64(&w.rtpPackets, 1)
atomic.AddUint64(&w.rtpBytes, uint64(n))
// Forward packet directly to WebRTC track (zero-copy)
w.mu.Lock()
@ -190,7 +126,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
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)
}
}
@ -231,9 +166,7 @@ func (w *WebRTCServer) ProcessOffer(sdpOffer string) (string, error) {
return "", fmt.Errorf("failed to set local description: %w", err)
}
if err := waitForICEGatheringComplete(gatherComplete, desktopICEGatheringTimeout); err != nil {
return "", err
}
<-gatherComplete
localDesc := pc.LocalDescription()
if localDesc == nil {
@ -243,15 +176,6 @@ func (w *WebRTCServer) ProcessOffer(sdpOffer string) (string, error) {
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()
@ -290,10 +214,4 @@ func (w *WebRTCServer) 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),
)
}

View File

@ -1,40 +0,0 @@
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)
}
}

View File

@ -360,11 +360,7 @@ 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) &&
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)
normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path)
}
func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
@ -416,6 +412,11 @@ 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 == "" {

View File

@ -0,0 +1,50 @@
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)
}

View File

@ -0,0 +1,53 @@
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)
}
}

View File

@ -0,0 +1,40 @@
package handler
import (
"encoding/json"
"net/http"
"xworkmate-bridge/internal/service"
"xworkmate-bridge/internal/shared"
)
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"))
}

View File

@ -0,0 +1,34 @@
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)
}
}

146
internal/mounts/config.go Normal file
View File

@ -0,0 +1,146 @@
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)
}

View File

@ -0,0 +1,444 @@
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)
}

View File

@ -0,0 +1,115 @@
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)
}
}

View File

@ -92,3 +92,22 @@ func NotificationEnvelope(method string, params map[string]any) map[string]any {
"seq": 0,
}
}
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)
}

View File

@ -342,6 +342,30 @@ 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,

View File

@ -2,10 +2,10 @@
set -euo pipefail
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
exit 1
fi

View File

@ -3,10 +3,10 @@
set -euo pipefail
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
exit 1
fi

View File

@ -2,11 +2,11 @@
set -euo pipefail
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
HERMES_RPC_URL="${HERMES_RPC_URL:-${BRIDGE_SERVER_URL%/}/acp/rpc}"
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
exit 1
fi

View File

@ -4,33 +4,16 @@ 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:-/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}}"
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"
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
@ -41,84 +24,34 @@ if [[ ! -f "${BINARY_PATH}" ]]; then
exit 1
fi
escape_systemd_env() {
python3 - "$1" <<'PY'
import sys
value = sys.argv[1]
print(value.replace("\\", "\\\\").replace('"', '\\"'))
PY
}
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
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
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}"
scp -q "${BINARY_PATH}" "root@${TARGET_HOST}:${REMOTE_TMP}"
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'
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
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
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
}
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}"
fi
fi
install -o root -g root -m 0755 "${REMOTE_TMP}" "${REMOTE_BINARY}"
restore_immutable
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'
@ -133,76 +66,14 @@ if [[ "${actual_commit}" != "${EXPECTED_COMMIT}" ]]; then
exit 1
fi
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}"
systemctl daemon-reload
systemctl restart "${SERVICE_NAME}"
deadline=$((SECONDS + 20))
actual_exe=""
pid=""
while (( SECONDS < deadline )); do
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
pid="$(systemctl show -p MainPID --value "${SERVICE_NAME}")"
if [[ -n "${pid}" && "${pid}" != "0" && -e "/proc/${pid}/exe" ]]; then
actual_exe="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)"
if [[ "${actual_exe}" == "${REMOTE_BINARY}" ]]; then
exit 0
@ -211,7 +82,7 @@ while (( SECONDS < deadline )); do
sleep 1
done
if [[ -z "${pid}" || "${pid}" == "0" ]]; then
echo "${SERVICE_NAME} did not start as a user service" >&2
echo "${SERVICE_NAME} did not start" >&2
exit 1
fi
echo "${SERVICE_NAME} is not running ${REMOTE_BINARY}; pid=${pid}; actual=${actual_exe:-unknown}" >&2

View File

@ -20,6 +20,6 @@ if [[ "${RUN_APPLY}" != "true" ]]; then
fi
ANSIBLE_CONFIG="${PWD}/ansible.cfg" \
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}" \
BRIDGE_AUTH_TOKEN="${INTERNAL_SERVICE_TOKEN:-}" \
BRIDGE_REVIEW_AUTH_TOKEN="${BRIDGE_REVIEW_AUTH_TOKEN:-}" \
"${args[@]}"

View File

@ -5,4 +5,3 @@ go mod download
go mod verify
golangci-lint run ./...
go test ./...
bash scripts/github-actions/test-deploy-native-binary.sh

View File

@ -4,19 +4,12 @@ set -euo pipefail
TARGET_HOST="${1:?target host is required}"
SSH_KNOWN_HOSTS_PAYLOAD="${2:-}"
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
test -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}"
mkdir -p "${HOME}/.ssh"
chmod 700 "${HOME}/.ssh"
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
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
chmod 600 "${HOME}/.ssh/id_rsa"
ssh-keygen -y -f "${HOME}/.ssh/id_rsa" >/dev/null

View File

@ -31,9 +31,8 @@ curl_args=(
--max-time 20
)
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
if [[ -n "${AUTH_TOKEN}" ]]; then
curl_args+=(-H "Authorization: Bearer ${AUTH_TOKEN}")
if [[ -n "${BRIDGE_AUTH_TOKEN:-}" ]]; then
curl_args+=(-H "Authorization: Bearer ${BRIDGE_AUTH_TOKEN}")
fi
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
@ -84,3 +83,4 @@ print(f"production_tag={deployed_tag}")
print(f"production_commit={deployed_commit}")
print(f"production_version={deployed_version}")
PY

View File

@ -1,243 +0,0 @@
#!/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 "$@"

View File

@ -28,11 +28,7 @@ fi
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")"
RPC_URL="${BASE_URL%/}/acp/rpc"
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
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:?BRIDGE_AUTH_TOKEN is required}"
fast_http_curl_common=(
--silent

View File

@ -2,14 +2,14 @@
set -euo pipefail
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
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
echo "BRIDGE_AUTH_TOKEN is required" >&2
exit 1
fi
@ -147,39 +147,24 @@ def first_nonempty(payload, *keys):
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])
if isinstance(payload.get("result"), dict):
candidates.append(payload["result"])
if isinstance(payload.get("payload"), dict):
candidates.append(payload["payload"])
if isinstance(payload.get("params"), dict):
candidates.append(payload["params"])
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),
}
if first_nonempty(candidate, "sessionId") and first_nonempty(candidate, "threadId") and (
first_nonempty(candidate, "turnId") or first_nonempty(candidate, "runId")
):
return candidate
return {}
@ -207,19 +192,6 @@ def is_valid_no_displayable_contract(payload):
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,
@ -228,27 +200,16 @@ 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]}")
session_id = first_nonempty(handle, "sessionId")
thread_id = first_nonempty(handle, "threadId")
turn_id = first_nonempty(handle, "turnId")
run_id = first_nonempty(handle, "runId")
deadline = time.time() + poll_timeout
while time.time() < deadline:
@ -257,8 +218,9 @@ if handle.get("status") == "running" or (not result and handle):
"id": "poll-task",
"method": "xworkmate.tasks.get",
"params": {
"appThreadKey": app_thread_key,
"openclawSessionKey": openclaw_session_key,
"sessionId": session_id,
"threadId": thread_id,
"turnId": turn_id,
"runId": run_id,
},
}).encode("utf-8")
@ -276,10 +238,6 @@ if handle.get("status") == "running" or (not result and handle):
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
@ -316,9 +274,6 @@ 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}")

View File

@ -2,12 +2,12 @@
set -euo pipefail
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
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 "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
echo "BRIDGE_AUTH_TOKEN is required" >&2
exit 1
fi