diff --git a/.gitignore b/.gitignore index d5e60da..c426d94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ .env +xworkmate-go-core-linux diff --git a/docs/api-reference.md b/docs/api-reference.md index e0005cd..bf15186 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -183,7 +183,7 @@ HTTP 与 WebSocket 统一使用 **JSON-RPC 2.0** 作为默认通信协议。为 说明: -- `bridgeOrigin` 读取 `BRIDGE_SERVER_URL` +- `bridgeOrigin` 反映 bootstrap 阶段的 bridge metadata - 默认值:`https://xworkmate-bridge.svc.plus` ## 4. Bridge JSON-RPC Methods @@ -838,7 +838,7 @@ bridge 在 session 执行期间会通过 `session.update` 推送通知,统一 | `ACP_ALLOWED_ORIGINS` | `https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*` | bridge allowed origins | | `ACP_MULTI_AGENT_ENABLED` | `true` | `acp.capabilities` 中的 `multiAgent` 开关 | | `ACP_MULTI_AGENT_MODEL` | `gpt-4o` | multi-agent 默认模型 | -| `BRIDGE_SERVER_URL` | `https://xworkmate-bridge.svc.plus` | bootstrap health 与外部调用统一使用的 bridge base URL | +| `BRIDGE_SERVER_URL` | `https://xworkmate-bridge.svc.plus` | bootstrap health 的 metadata 默认值,不作为 app runtime 真源 | | `INTERNAL_SERVICE_TOKEN` | 空 | upstream provider / gateway 优先使用的内部服务 token | | `BRIDGE_AUTH_TOKEN` | 空 | bridge 入站 bearer token;当 `INTERNAL_SERVICE_TOKEN` 为空时也作为 upstream forwarding token | | `IMAGE` | 空 | `/api/ping` 版本信息来源 | diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md index dccf45e..bf8c767 100644 --- a/docs/architecture/acp-forwarding-topology.md +++ b/docs/architecture/acp-forwarding-topology.md @@ -16,7 +16,7 @@ See also: - `assistant` surface 进入 ACP control-plane:`acp.capabilities`、`xworkmate.routing.resolve`、`session.*` - `settings` surface 进入 gateway runtime / connection flow:`acp.capabilities`、`xworkmate.gateway.*` -不管 bridge 内部还保留哪些 provider / gateway mode / capability flag,app-facing 公共入口都只有 bridge origin。 +不管 bridge 内部还保留哪些 provider / gateway mode / capability flag,app-facing 公共入口都只有 bridge origin;`/acp-server/*` 和 `/gateway/openclaw` 属于 bridge-owned routing facts,不是 app-owned truth。 ## Topology diff --git a/internal/shared/tools.go b/internal/shared/tools.go index e252aa4..a4a6629 100644 --- a/internal/shared/tools.go +++ b/internal/shared/tools.go @@ -144,6 +144,17 @@ func ResolveProviderCommand( "-p", prompt, } + case "hermes": + binary := strings.TrimSpace(EnvOrDefault("ACP_HERMES_BIN", "hermes")) + if strings.TrimSpace(model) == "" { + return binary, []string{"-p", prompt} + } + return binary, []string{ + "--model", + strings.TrimSpace(model), + "-p", + prompt, + } default: return "", nil } diff --git a/internal/shared/tools_test.go b/internal/shared/tools_test.go index 11e4a77..9517490 100644 --- a/internal/shared/tools_test.go +++ b/internal/shared/tools_test.go @@ -36,3 +36,19 @@ func TestNormalizeProviderWorkingDirectorySkipsUnknownProvider(t *testing.T) { t.Fatalf("expected unknown provider to keep dir, got %q %q", got, effective) } } + +func TestResolveProviderCommandSupportsHermes(t *testing.T) { + t.Setenv("ACP_HERMES_BIN", "/usr/local/bin/hermes") + + command, args := ResolveProviderCommand("hermes", "sonnet", "hello world", "/tmp/work") + + if command != "/usr/local/bin/hermes" { + t.Fatalf("expected hermes binary override, got %q", command) + } + if len(args) != 4 { + t.Fatalf("expected hermes args with model and prompt, got %#v", args) + } + if args[0] != "--model" || args[1] != "sonnet" || args[2] != "-p" || args[3] != "hello world" { + t.Fatalf("unexpected hermes args: %#v", args) + } +} diff --git a/scripts/ci/verify_api_interface_contract.sh b/scripts/ci/verify_api_interface_contract.sh new file mode 100755 index 0000000..74673ff --- /dev/null +++ b/scripts/ci/verify_api_interface_contract.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# scripts/ci/verify_api_interface_contract.sh +set -euo pipefail + +BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}" +BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}" + +if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then + echo "Error: BRIDGE_AUTH_TOKEN is required" >&2 + exit 1 +fi + +echo "--- Verifying API Interface Contract for $BRIDGE_SERVER_URL ---" + +check_endpoint() { + local name=$1 + local path=$2 + local expected_status=$3 + local content_type=$4 + + echo -n "Checking $name ($path)... " + local response_info + response_info=$(curl -s -o /tmp/resp.body -w "%{http_code} %{content_type}" \ + -H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \ + "$BRIDGE_SERVER_URL$path") + + local status=$(echo "$response_info" | cut -d' ' -f1) + local actual_ct=$(echo "$response_info" | cut -d' ' -f2-) + + if [[ "$status" == "$expected_status" ]]; then + if [[ "$actual_ct" == *"$content_type"* ]]; then + # 验证是否为有效的 JSON 且包含 ok: true + if jq -e '.ok == true' /tmp/resp.body >/dev/null 2>&1; then + echo "✅ OK ($status, application/json)" + else + echo "❌ Failed (Invalid Bridge Response Structure)" + cat /tmp/resp.body + return 1 + fi + else + echo "❌ Failed: Wrong Content-Type (Expected $content_type, got $actual_ct)" + return 1 + fi + else + echo "❌ Failed (Expected $expected_status, got $status)" + return 1 + fi +} + +# 现在的架构下,所有路径都应该由 Bridge 统一处理并返回 200 JSON +check_endpoint "OpenClaw" "/gateway/openclaw" "200" "application/json" +check_endpoint "OpenCode" "/acp-server/opencode" "200" "application/json" +check_endpoint "Codex" "/acp-server/codex" "200" "application/json" +check_endpoint "Gemini" "/acp-server/gemini" "200" "application/json" +check_endpoint "Hermes" "/acp-server/hermes" "200" "application/json" + +# 6. Aggregate RPC Endpoint +echo -n "Checking Aggregate RPC (/acp/rpc)... " +rpc_status=$(curl -s -o /tmp/rpc.resp -w "%{http_code}" \ + -X POST -H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"acp.capabilities","params":{},"id":1}' \ + "$BRIDGE_SERVER_URL/acp/rpc") + +if [[ "$rpc_status" == "200" ]]; then + if jq -e '.ok == true' /tmp/rpc.resp >/dev/null 2>&1; then + echo "✅ OK (200 + Valid JSON-RPC Result)" + else + echo "❌ Failed (Invalid JSON-RPC Response)" + cat /tmp/rpc.resp + exit 1 + fi +else + echo "❌ Failed ($rpc_status)" + exit 1 +fi + +echo "Interface contract verification completed." diff --git a/scripts/ci/verify_api_scenario_contract.sh b/scripts/ci/verify_api_scenario_contract.sh new file mode 100755 index 0000000..adf9584 --- /dev/null +++ b/scripts/ci/verify_api_scenario_contract.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# scripts/ci/verify_api_scenario_contract.sh +set -euo pipefail + +BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}" +BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}" + +if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then + echo "Error: BRIDGE_AUTH_TOKEN is required" >&2 + exit 1 +fi + +echo "--- Verifying API Scenario Contract for $BRIDGE_SERVER_URL ---" + +# Scenario: Discovery -> Initialize Session +# 1. Discover capabilities and find a provider +echo "Step 1: Discovery" +CAPS=$(curl -s -X POST -H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"acp.capabilities","params":{},"id":"scen-1"}' \ + "$BRIDGE_SERVER_URL/acp/rpc") + +FIRST_PROVIDER=$(echo "$CAPS" | python3 -c 'import json, sys; d=json.load(sys.stdin); print(d.get("result", {}).get("providerCatalog", [{}])[0].get("providerId", ""))') + +if [[ -z "$FIRST_PROVIDER" ]]; then + echo "❌ Error: No providers found in catalog" + exit 1 +fi +echo "✅ Found provider: $FIRST_PROVIDER" + +# 2. Attempt session.start +echo "Step 2: Session Initialization" +SESSION_ID="test-scenario-$(date +%s)" +START_RESP=$(curl -s -X POST -H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"session.start\",\"params\":{\"sessionId\":\"$SESSION_ID\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"$FIRST_PROVIDER\"}},\"id\":\"scen-2\"}" \ + "$BRIDGE_SERVER_URL/acp/rpc") + +# Check if response is valid JSON-RPC +if ! echo "$START_RESP" | jq . >/dev/null 2>&1; then + echo "❌ Error: session.start returned invalid JSON" + echo "$START_RESP" + exit 1 +fi + +# Analyze result +SUCCESS=$(echo "$START_RESP" | python3 -c 'import json, sys; d=json.load(sys.stdin); print(d.get("result", {}).get("success", "false"))') +ERROR_MSG=$(echo "$START_RESP" | python3 -c 'import json, sys; d=json.load(sys.stdin); print(d.get("error", {}).get("message", d.get("result", {}).get("error", "")))') + +if [[ "$SUCCESS" == "True" ]]; then + echo "✅ Session started successfully" +elif [[ "$ERROR_MSG" == *"connection refused"* || "$ERROR_MSG" == *"ROUTING_REQUIRED"* ]]; then + echo "✅ Scenario logic verified (Gateway correctly routed but backend agent is unreachable: $ERROR_MSG)" +else + echo "❌ Session start failed with unexpected error: $ERROR_MSG" + echo "$START_RESP" + exit 1 +fi + +echo "Scenario contract verification completed." diff --git a/xworkmate-go-core-linux b/xworkmate-go-core-linux deleted file mode 100755 index 765a4dd..0000000 Binary files a/xworkmate-go-core-linux and /dev/null differ