Add Hermes ACP adapter
This commit is contained in:
parent
729da27179
commit
1e6c644a92
3
.github/workflows/pipeline.yml
vendored
3
.github/workflows/pipeline.yml
vendored
@ -278,6 +278,7 @@ jobs:
|
||||
CODEX_RPC_URL: https://xworkmate-bridge.svc.plus/acp-server/codex
|
||||
OPENCODE_RPC_URL: https://xworkmate-bridge.svc.plus/acp-server/opencode
|
||||
GEMINI_RPC_URL: https://xworkmate-bridge.svc.plus/acp-server/gemini
|
||||
HERMES_RPC_URL: https://xworkmate-bridge.svc.plus/acp-server/hermes
|
||||
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@ -286,7 +287,7 @@ jobs:
|
||||
- name: Validate deployed endpoints
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" "${BRIDGE_SERVER_URL}" "${OPENCLAW_URL}" "${CODEX_RPC_URL}" "${OPENCODE_RPC_URL}" "${GEMINI_RPC_URL}"
|
||||
run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" "${BRIDGE_SERVER_URL}" "${OPENCLAW_URL}" "${CODEX_RPC_URL}" "${OPENCODE_RPC_URL}" "${GEMINI_RPC_URL}" "${HERMES_RPC_URL}"
|
||||
|
||||
- name: Validate public ACP contract
|
||||
env:
|
||||
|
||||
@ -49,8 +49,8 @@ Missing bearer auth returns a JSON-RPC error envelope with code `-32001`.
|
||||
|
||||
The ingress returned `200 OK` on all public routes after re-apply, and the deployment response confirmed the active upstream mappings:
|
||||
|
||||
- `codex` -> `127.0.0.1:9010`
|
||||
- `opencode` -> `127.0.0.1:3910`
|
||||
- `codex` -> `127.0.0.1:9001`
|
||||
- `opencode` -> `127.0.0.1:38992`
|
||||
- `gemini` -> `127.0.0.1:8791`
|
||||
- `openclaw` -> `127.0.0.1:18789` (Host process)
|
||||
|
||||
|
||||
@ -141,18 +141,23 @@ Important distinction:
|
||||
`https://xworkmate-bridge.svc.plus/gateway/openclaw/`
|
||||
## Production Truth
|
||||
|
||||
当前 production forwarding 事实:
|
||||
当前 production forwarding 事实(内部直连架构):
|
||||
|
||||
- canonical app-facing origin: `https://xworkmate-bridge.svc.plus`
|
||||
- canonical app-facing ACP paths:
|
||||
- `POST /acp/rpc`
|
||||
- `GET /acp`
|
||||
- current built-in single-agent provider catalog:
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
- current production gateway forwarding target:
|
||||
- `openclaw -> https://xworkmate-bridge.svc.plus/gateway/openclaw/`
|
||||
|
||||
### 核心真源映射 (Final Source of Truth)
|
||||
|
||||
为了消除冗余层(Bridge-on-Bridge)并提高就绪性响应速度,中心 Bridge 已配置为绕过 9010/3910 转发层,直接对接各核心服务端口:
|
||||
|
||||
| 服务名 | 核心端口 | 协议路径 | 角色定义 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`openclaw-gateway.service`** | **`18789`** | **`ws://127.0.0.1:18789/`** | **OpenClaw 独立网关服务(不使用 /acp)** |
|
||||
| **`acp-codex.service`** | **`9001`** | **`http://127.0.0.1:9001/acp/rpc`** | **Codex 核心 ACP 实现** |
|
||||
| **`acp-opencode.service`** | **`38992`** | **`http://127.0.0.1:38992/acp/rpc`** | **Opencode 核心 ACP 实现** |
|
||||
| **`acp-gemini.service`** | **`8791`** | **`http://127.0.0.1:8791/acp/rpc`** | **Gemini 协议转换适配器 (Category: protocol-adapter)** |
|
||||
|
||||
对 app 而言:
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
│ Bridge │ xworkmate-bridge.svc.plus/ │ 127.0.0.1:8787 │ Docker 容器 │
|
||||
│ OpenClaw │ xworkmate-bridge.svc.plus/gateway/openclaw/ │ 127.0.0.1:18789 │ 主机进程 │
|
||||
│ Codex │ xworkmate-bridge.svc.plus/acp-server/codex/ │ acp-server-codex:3911 │ Docker 容器 │
|
||||
│ OpenCode │ xworkmate-bridge.svc.plus/acp-server/opencode/ │ acp-server-opencode:3910 │ Docker 容器 │
|
||||
│ OpenCode │ http://127.0.0.1:38992/acp/rpc │ acp-opencode:38992 │ Docker 容器 │
|
||||
│ Gemini │ xworkmate-bridge.svc.plus/acp-server/gemini/ │ acp-server-gemini:3912 │ Docker 容器
|
||||
|
||||
|
||||
|
||||
@ -10,6 +10,17 @@
|
||||
|
||||
## internal/acp
|
||||
|
||||
### 核心真源映射 (Core Service Mapping)
|
||||
|
||||
为确保高性能与就绪性可靠性,本包默认配置直连以下核心真源。开发与维护时应确保这些端口在宿主机 `127.0.0.1` 保持可用:
|
||||
|
||||
| 规范服务名 | 监听端口 | 协议模式 | 关键职责 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`acp-codex.service`** | `9001` | HTTP | Codex 核心 ACP 控制面 |
|
||||
| **`acp-opencode.service`** | `38992` | HTTP | Opencode 核心 ACP 控制面 |
|
||||
| **`acp-gemini.service`** | `8791` | HTTP | Gemini 协议适配器 (protocol-adapter) |
|
||||
| **`openclaw-gateway.service`** | `18789` | WS | OpenClaw 独立网关运行时 |
|
||||
|
||||
### 包职责
|
||||
|
||||
APP-facing bridge 主控面。该模块已全面切换至 **JSON-RPC 2.0** 作为默认协议。负责 HTTP / WebSocket 路由、JSON-RPC method dispatch、session / thread 队列、routing resolve、single-agent / multi-agent / gateway 执行分流,以及 provider / gateway runtime 桥接。
|
||||
|
||||
@ -6,10 +6,11 @@
|
||||
# Upstream provider endpoints
|
||||
# Priority: YAML > Environment Variable (e.g. OPENCLAW_CODEX_URL) > Default Constants
|
||||
upstream:
|
||||
gateway_url: "https://xworkmate-bridge.svc.plus/gateway/openclaw/"
|
||||
codex_url: "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"
|
||||
opencode_url: "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"
|
||||
gemini_url: "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"
|
||||
gateway_url: "ws://127.0.0.1:18789/"
|
||||
codex_url: "http://127.0.0.1:9001/acp/rpc"
|
||||
opencode_url: "http://127.0.0.1:38992/acp/rpc"
|
||||
gemini_url: "http://127.0.0.1:8791/acp/rpc"
|
||||
hermes_url: "http://127.0.0.1:3920/acp/rpc"
|
||||
|
||||
# Legacy/Reference structure (Normally managed via code constants or environment)
|
||||
bridge:
|
||||
|
||||
@ -16,9 +16,10 @@ func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedEndpoints := map[string]string{
|
||||
"codex": "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc",
|
||||
"opencode": "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc",
|
||||
"gemini": "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc",
|
||||
"codex": "http://127.0.0.1:9001/acp/rpc",
|
||||
"opencode": "http://127.0.0.1:38992/acp/rpc",
|
||||
"gemini": "http://127.0.0.1:8791/acp/rpc",
|
||||
"hermes": "http://127.0.0.1:3920/acp/rpc",
|
||||
}
|
||||
|
||||
for _, id := range order {
|
||||
@ -32,7 +33,7 @@ func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) {
|
||||
if !provider.Enabled {
|
||||
t.Errorf("Provider %s should be enabled in example config", id)
|
||||
}
|
||||
|
||||
|
||||
want := expectedEndpoints[id]
|
||||
got := resolveSingleAgentForwardEndpoint(provider)
|
||||
if got != want {
|
||||
|
||||
@ -20,7 +20,7 @@ func TestResolveGatewayReportedRemoteAddressUsesBuiltInOpenClawEndpoint(t *testi
|
||||
},
|
||||
})
|
||||
|
||||
const want = "xworkmate-bridge.svc.plus:443"
|
||||
const want = "127.0.0.1:18789"
|
||||
if got != want {
|
||||
t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want)
|
||||
}
|
||||
@ -42,7 +42,7 @@ func TestResolveGatewayReportedRemoteAddressNormalizesExplicitPublicRemoteHost(
|
||||
},
|
||||
})
|
||||
|
||||
const want = "xworkmate-bridge.svc.plus:443"
|
||||
const want = "127.0.0.1:18789"
|
||||
if got != want {
|
||||
t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
|
||||
// Default production endpoints for XWorkmate managed bridge environment.
|
||||
const (
|
||||
productionGatewayEndpointURL = "https://xworkmate-bridge.svc.plus/gateway/openclaw/"
|
||||
productionGatewayEndpointURL = "ws://127.0.0.1:18789/"
|
||||
)
|
||||
|
||||
type syncedProvider struct {
|
||||
@ -27,6 +27,7 @@ type BridgeConfig struct {
|
||||
CodexURL string `yaml:"codex_url"`
|
||||
OpenCodeURL string `yaml:"opencode_url"`
|
||||
GeminiURL string `yaml:"gemini_url"`
|
||||
HermesURL string `yaml:"hermes_url"`
|
||||
} `yaml:"upstream"`
|
||||
}
|
||||
|
||||
@ -83,21 +84,28 @@ func newProductionProviderCatalog() (map[string]syncedProvider, []string) {
|
||||
label: "Codex",
|
||||
yaml: config.Upstream.CodexURL,
|
||||
envKeys: []string{"CODEX_RPC_URL"},
|
||||
defaultURL: "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc",
|
||||
defaultURL: "http://127.0.0.1:9001/acp/rpc",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
yaml: config.Upstream.OpenCodeURL,
|
||||
envKeys: []string{"OPENCODE_RPC_URL"},
|
||||
defaultURL: "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc",
|
||||
defaultURL: "http://127.0.0.1:38992/acp/rpc",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
yaml: config.Upstream.GeminiURL,
|
||||
envKeys: []string{"GEMINI_RPC_URL"},
|
||||
defaultURL: "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc",
|
||||
defaultURL: "http://127.0.0.1:8791/acp/rpc",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
label: "Hermes",
|
||||
yaml: config.Upstream.HermesURL,
|
||||
envKeys: []string{"HERMES_RPC_URL"},
|
||||
defaultURL: "http://127.0.0.1:3920/acp/rpc",
|
||||
},
|
||||
}
|
||||
|
||||
@ -133,10 +141,15 @@ func (s *Server) availableProviderCatalog() []Provider {
|
||||
var catalog []Provider
|
||||
for _, id := range s.providerOrder {
|
||||
if p, ok := s.providerCatalog[id]; ok && p.Enabled {
|
||||
category := "native"
|
||||
if id == "gemini" || id == "hermes" {
|
||||
category = "protocol-adapter"
|
||||
}
|
||||
catalog = append(catalog, Provider{
|
||||
ProviderID: p.ProviderID,
|
||||
Label: p.Label,
|
||||
Targets: []string{"agent"},
|
||||
Category: category,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -164,6 +177,7 @@ type Provider struct {
|
||||
ProviderID string `json:"providerId"`
|
||||
Label string `json:"label"`
|
||||
Targets []string `json:"targets"`
|
||||
Category string `json:"category,omitempty"`
|
||||
ProviderDisplay *ProviderDisplay `json:"providerDisplay,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -56,8 +56,8 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(catalog) < 3 {
|
||||
t.Fatalf("expected at least 3 production providers, got %d", len(catalog))
|
||||
if len(catalog) < 4 {
|
||||
t.Fatalf("expected at least 4 production providers, got %d", len(catalog))
|
||||
}
|
||||
|
||||
providers := make(map[string]Provider)
|
||||
@ -71,6 +71,12 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
|
||||
if _, ok := providers["opencode"]; !ok {
|
||||
t.Error("missing opencode provider")
|
||||
}
|
||||
if _, ok := providers["gemini"]; !ok {
|
||||
t.Error("missing gemini provider")
|
||||
}
|
||||
if _, ok := providers["hermes"]; !ok {
|
||||
t.Error("missing hermes provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogFallsBackToBridgeAuthToken(t *testing.T) {
|
||||
|
||||
@ -264,18 +264,20 @@ func TestHandleRPCCapabilitiesReturnsCanonicalProviderContract(t *testing.T) {
|
||||
}
|
||||
|
||||
result := asMap(envelope["result"])
|
||||
if got := result["singleAgent"]; got != true { t.Fatalf("expected singleAgent true, got %v", got) }
|
||||
if got := result["singleAgent"]; got != true {
|
||||
t.Fatalf("expected singleAgent true, got %v", got)
|
||||
}
|
||||
availableTargets := mustStringList(t, result["availableExecutionTargets"])
|
||||
if !reflect.DeepEqual(availableTargets, []string{"agent", "gateway"}) {
|
||||
t.Fatalf("expected canonical execution targets, got %#v", availableTargets)
|
||||
}
|
||||
|
||||
providerCatalog := mustObjectList(t, result["providerCatalog"])
|
||||
if len(providerCatalog) != 3 {
|
||||
t.Fatalf("expected 3 providers, got %#v", providerCatalog)
|
||||
if len(providerCatalog) != 4 {
|
||||
t.Fatalf("expected 4 providers, got %#v", providerCatalog)
|
||||
}
|
||||
wantAgentIDs := []string{"codex", "opencode", "gemini"}
|
||||
wantAgentLabels := []string{"Codex", "OpenCode", "Gemini"}
|
||||
wantAgentIDs := []string{"codex", "opencode", "gemini", "hermes"}
|
||||
wantAgentLabels := []string{"Codex", "OpenCode", "Gemini", "Hermes"}
|
||||
for index, wantID := range wantAgentIDs {
|
||||
if got := providerCatalog[index]["providerId"]; got != wantID {
|
||||
t.Fatalf("expected provider %q at index %d, got %#v", wantID, index, providerCatalog)
|
||||
|
||||
179
internal/hermesadapter/client.go
Normal file
179
internal/hermesadapter/client.go
Normal file
@ -0,0 +1,179 @@
|
||||
package hermesadapter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type rpcClient interface {
|
||||
Initialize() (initializeResult, error)
|
||||
Call(method string, params map[string]any) (map[string]any, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type initializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AuthMethods []map[string]any `json:"authMethods"`
|
||||
AgentCapabilities map[string]any `json:"agentCapabilities"`
|
||||
}
|
||||
|
||||
type stdioRPCClient struct {
|
||||
mu sync.Mutex
|
||||
command string
|
||||
args []string
|
||||
env []string
|
||||
protocolVersion int
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Reader
|
||||
stderr io.ReadCloser
|
||||
nextID atomic.Int64
|
||||
initialized bool
|
||||
initResult initializeResult
|
||||
}
|
||||
|
||||
func newStdioRPCClient(command string, args []string, env []string, protocolVersion int) *stdioRPCClient {
|
||||
return &stdioRPCClient{
|
||||
command: strings.TrimSpace(command),
|
||||
args: append([]string(nil), args...),
|
||||
env: append([]string(nil), env...),
|
||||
protocolVersion: protocolVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) Initialize() (initializeResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureStartedLocked(); err != nil {
|
||||
return initializeResult{}, err
|
||||
}
|
||||
if c.initialized {
|
||||
return c.initResult, nil
|
||||
}
|
||||
result, err := c.callLocked("initialize", map[string]any{
|
||||
"protocolVersion": c.protocolVersion,
|
||||
"clientInfo": map[string]any{
|
||||
"name": "xworkmate-hermes-adapter",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return initializeResult{}, err
|
||||
}
|
||||
payload, _ := result["result"].(map[string]any)
|
||||
data, _ := json.Marshal(payload)
|
||||
var parsed initializeResult
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return initializeResult{}, fmt.Errorf("decode initialize result: %w", err)
|
||||
}
|
||||
c.initialized = true
|
||||
c.initResult = parsed
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) Call(method string, params map[string]any) (map[string]any, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureStartedLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.callLocked(method, params)
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closeLocked()
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) ensureStartedLocked() error {
|
||||
if c.cmd != nil {
|
||||
return nil
|
||||
}
|
||||
if c.command == "" {
|
||||
return fmt.Errorf("hermes command is empty")
|
||||
}
|
||||
cmd := exec.Command(c.command, c.args...)
|
||||
cmd.Env = append(os.Environ(), c.env...)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cmd = cmd
|
||||
c.stdin = stdin
|
||||
c.stdout = bufio.NewReader(stdout)
|
||||
c.stderr = stderr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) closeLocked() error {
|
||||
var firstErr error
|
||||
if c.stdin != nil {
|
||||
if err := c.stdin.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
if err := c.cmd.Process.Kill(); err != nil && firstErr == nil && !strings.Contains(strings.ToLower(err.Error()), "finished") {
|
||||
firstErr = err
|
||||
}
|
||||
_, _ = c.cmd.Process.Wait()
|
||||
}
|
||||
c.cmd = nil
|
||||
c.stdin = nil
|
||||
c.stdout = nil
|
||||
c.stderr = nil
|
||||
c.initialized = false
|
||||
c.initResult = initializeResult{}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (c *stdioRPCClient) callLocked(method string, params map[string]any) (map[string]any, error) {
|
||||
requestID := fmt.Sprintf("req-%d", c.nextID.Add(1))
|
||||
request := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": requestID,
|
||||
"method": strings.TrimSpace(method),
|
||||
"params": params,
|
||||
}
|
||||
encoded, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.stdin.Write(append(encoded, '\n')); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := c.stdout.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if stderr, stderrErr := io.ReadAll(c.stderr); stderrErr == nil {
|
||||
trimmed := strings.TrimSpace(string(stderr))
|
||||
if trimmed != "" {
|
||||
return nil, fmt.Errorf("hermes acp read failed: %s", trimmed)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return nil, fmt.Errorf("decode hermes acp response: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
500
internal/hermesadapter/server.go
Normal file
500
internal/hermesadapter/server.go
Normal file
@ -0,0 +1,500 @@
|
||||
package hermesadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenAddr = "127.0.0.1:3920"
|
||||
defaultProviderID = "hermes"
|
||||
defaultLabel = "Hermes"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
client rpcClient
|
||||
authService *service.StaticTokenAuthService
|
||||
providerID string
|
||||
providerLabel string
|
||||
allowedOrigins []string
|
||||
upstreamMethod string
|
||||
sessionRunner func(context.Context, string, string, string) (string, error)
|
||||
sessionsMu sync.Mutex
|
||||
sessions map[string]*adapterSession
|
||||
}
|
||||
|
||||
var adapterWSUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 16 * 1024,
|
||||
WriteBufferSize: 16 * 1024,
|
||||
CheckOrigin: func(*http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type adapterSession struct {
|
||||
history []string
|
||||
model string
|
||||
workingDirectory string
|
||||
lastOutput string
|
||||
lastUpstreamMethod string
|
||||
}
|
||||
|
||||
func Serve(args []string) error {
|
||||
flags := flag.NewFlagSet("hermes-acp-adapter", flag.ExitOnError)
|
||||
listen := flags.String(
|
||||
"listen",
|
||||
strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_LISTEN_ADDR", defaultListenAddr)),
|
||||
"Hermes ACP adapter listen address",
|
||||
)
|
||||
binary := flags.String(
|
||||
"hermes-bin",
|
||||
strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_BIN", shared.EnvOrDefault("ACP_HERMES_BIN", "hermes"))),
|
||||
"Hermes CLI binary path",
|
||||
)
|
||||
rawArgs := flags.String(
|
||||
"hermes-args",
|
||||
strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_ARGS", "acp")),
|
||||
"Hermes CLI arguments",
|
||||
)
|
||||
_ = flags.Parse(args)
|
||||
|
||||
client := newStdioRPCClient(
|
||||
*binary,
|
||||
strings.Fields(strings.TrimSpace(*rawArgs)),
|
||||
nil,
|
||||
shared.IntArg(shared.EnvOrDefault("HERMES_ADAPTER_PROTOCOL_VERSION", "1"), 1),
|
||||
)
|
||||
defer func() {
|
||||
_ = client.Close()
|
||||
}()
|
||||
|
||||
server := NewServer(client)
|
||||
httpServer := &http.Server{
|
||||
Addr: strings.TrimSpace(*listen),
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/acp/rpc":
|
||||
server.HandleRPC(w, r)
|
||||
case "/acp":
|
||||
server.HandleWebSocket(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("hermes adapter failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewServer(client rpcClient) *Server {
|
||||
return &Server{
|
||||
client: client,
|
||||
authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_AUTH_TOKEN", ""))),
|
||||
providerID: strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_PROVIDER_ID", defaultProviderID)),
|
||||
providerLabel: strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_PROVIDER_LABEL", defaultLabel)),
|
||||
allowedOrigins: parseAllowedOrigins(strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*"))),
|
||||
upstreamMethod: strings.TrimSpace(shared.EnvOrDefault("HERMES_ADAPTER_UPSTREAM_METHOD", "")),
|
||||
sessionRunner: func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
return shared.RunProviderCommand(
|
||||
ctx,
|
||||
defaultProviderID,
|
||||
model,
|
||||
prompt,
|
||||
workingDirectory,
|
||||
)
|
||||
},
|
||||
sessions: make(map[string]*adapterSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.originAllowed(r.Header.Get("Origin")) {
|
||||
s.writeJSONError(w, nil, http.StatusForbidden, -32003, fmt.Sprintf("origin not allowed: %s", strings.TrimSpace(r.Header.Get("Origin"))))
|
||||
return
|
||||
}
|
||||
if !s.authorized(r) {
|
||||
s.writeJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
|
||||
return
|
||||
}
|
||||
upgrader := adapterWSUpgrader
|
||||
upgrader.CheckOrigin = func(req *http.Request) bool {
|
||||
return s.originAllowed(req.Header.Get("Origin")) && s.authorized(req)
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
var writeMu sync.Mutex
|
||||
notify := func(message map[string]any) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
_ = conn.WriteJSON(message)
|
||||
}
|
||||
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
request, err := shared.DecodeRPCRequest(payload)
|
||||
if err != nil {
|
||||
notify(shared.ErrorEnvelope(nil, -32700, err.Error()))
|
||||
continue
|
||||
}
|
||||
response := s.handleRequest(request)
|
||||
if request.ID == nil {
|
||||
continue
|
||||
}
|
||||
notify(shared.ResultEnvelope(request.ID, response))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.applyCORS(w, r)
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
s.writeJSONError(w, nil, http.StatusMethodNotAllowed, -32600, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.originAllowed(r.Header.Get("Origin")) {
|
||||
s.writeJSONError(w, nil, http.StatusForbidden, -32003, fmt.Sprintf("origin not allowed: %s", strings.TrimSpace(r.Header.Get("Origin"))))
|
||||
return
|
||||
}
|
||||
if !s.authorized(r) {
|
||||
s.writeJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
|
||||
return
|
||||
}
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
s.writeJSONError(w, nil, http.StatusBadRequest, -32600, "invalid body")
|
||||
return
|
||||
}
|
||||
request, err := shared.DecodeRPCRequest(payload)
|
||||
if err != nil {
|
||||
s.writeJSONError(w, nil, http.StatusBadRequest, -32700, err.Error())
|
||||
return
|
||||
}
|
||||
result := s.handleRequest(request)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(request.ID, result))
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(request shared.RPCRequest) map[string]any {
|
||||
switch strings.TrimSpace(request.Method) {
|
||||
case "acp.capabilities":
|
||||
return s.handleCapabilities()
|
||||
case "session.start", "session.message":
|
||||
return s.handleSessionRequest(request.Method, request.Params)
|
||||
case "session.cancel":
|
||||
return map[string]any{"accepted": true, "cancelled": false}
|
||||
case "session.close":
|
||||
sessionID := strings.TrimSpace(shared.StringArg(request.Params, "sessionId", ""))
|
||||
return map[string]any{"accepted": true, "closed": s.closeSession(sessionID)}
|
||||
default:
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported method: %s", strings.TrimSpace(request.Method)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCapabilities() map[string]any {
|
||||
result, err := s.client.Initialize()
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"singleAgent": false,
|
||||
"multiAgent": false,
|
||||
"providers": []string{},
|
||||
"capabilities": map[string]any{
|
||||
"single_agent": false,
|
||||
"multi_agent": false,
|
||||
"providers": []string{},
|
||||
},
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"singleAgent": true,
|
||||
"multiAgent": false,
|
||||
"providers": []string{s.providerID},
|
||||
"capabilities": map[string]any{
|
||||
"single_agent": true,
|
||||
"multi_agent": false,
|
||||
"providers": []string{s.providerID},
|
||||
},
|
||||
"provider": map[string]any{
|
||||
"id": s.providerID,
|
||||
"label": s.providerLabel,
|
||||
},
|
||||
"upstream": map[string]any{
|
||||
"protocolVersion": result.ProtocolVersion,
|
||||
"authMethods": result.AuthMethods,
|
||||
"agentCapabilities": result.AgentCapabilities,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionRequest(method string, params map[string]any) map[string]any {
|
||||
if _, err := s.client.Initialize(); err != nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
upstreamMethod := s.upstreamMethod
|
||||
if upstreamMethod != "" {
|
||||
return s.handleConfiguredUpstreamSessionRequest(upstreamMethod, params)
|
||||
}
|
||||
return s.handleCompatSessionRequest(method, params)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfiguredUpstreamSessionRequest(upstreamMethod string, params map[string]any) map[string]any {
|
||||
response, err := s.client.Call(upstreamMethod, params)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": err.Error(),
|
||||
"upstreamMethod": upstreamMethod,
|
||||
}
|
||||
}
|
||||
result, _ := response["result"].(map[string]any)
|
||||
if len(result) > 0 {
|
||||
if _, ok := result["provider"]; !ok {
|
||||
result["provider"] = s.providerID
|
||||
}
|
||||
if _, ok := result["mode"]; !ok {
|
||||
result["mode"] = "single-agent"
|
||||
}
|
||||
return result
|
||||
}
|
||||
if errPayload, ok := response["error"].(map[string]any); ok && len(errPayload) > 0 {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": strings.TrimSpace(shared.StringArg(errPayload, "message", "upstream hermes acp error")),
|
||||
"upstreamMethod": upstreamMethod,
|
||||
"upstreamError": errPayload,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"upstreamMethod": upstreamMethod,
|
||||
"upstream": response,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCompatSessionRequest(method string, params map[string]any) map[string]any {
|
||||
if s.sessionRunner == nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "hermes session runner is not configured",
|
||||
}
|
||||
}
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
if sessionID == "" {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "sessionId is required",
|
||||
}
|
||||
}
|
||||
state := s.getOrCreateSession(sessionID)
|
||||
if method == "session.start" {
|
||||
state = s.resetSession(sessionID)
|
||||
}
|
||||
taskPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||
taskPrompt = shared.AugmentPromptWithAttachments(taskPrompt, params)
|
||||
if taskPrompt == "" {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "taskPrompt is required",
|
||||
}
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(shared.StringArg(params, "model", ""))
|
||||
if model == "" {
|
||||
model = state.model
|
||||
}
|
||||
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||||
if workingDirectory == "" {
|
||||
workingDirectory = state.workingDirectory
|
||||
}
|
||||
|
||||
sessionsHistory := append([]string(nil), state.history...)
|
||||
sessionsHistory = append(sessionsHistory, "USER: "+taskPrompt)
|
||||
composedPrompt := shared.ComposeHistoryPrompt(sessionsHistory)
|
||||
output, err := s.sessionRunner(context.Background(), model, composedPrompt, workingDirectory)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
s.sessionsMu.Lock()
|
||||
state = s.sessions[sessionID]
|
||||
if state == nil {
|
||||
state = &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
}
|
||||
state.history = append(sessionsHistory, "ASSISTANT: "+output)
|
||||
state.model = model
|
||||
state.workingDirectory = workingDirectory
|
||||
state.lastOutput = output
|
||||
state.lastUpstreamMethod = "prompt"
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"output": output,
|
||||
"sessionId": sessionID,
|
||||
"upstreamMethod": "prompt",
|
||||
}
|
||||
if workingDirectory != "" {
|
||||
result["effectiveWorkingDirectory"] = workingDirectory
|
||||
}
|
||||
if model != "" {
|
||||
result["resolvedModel"] = model
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) getOrCreateSession(sessionID string) *adapterSession {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
state := s.sessions[sessionID]
|
||||
if state == nil {
|
||||
state = &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
}
|
||||
return &adapterSession{
|
||||
history: append([]string(nil), state.history...),
|
||||
model: state.model,
|
||||
workingDirectory: state.workingDirectory,
|
||||
lastOutput: state.lastOutput,
|
||||
lastUpstreamMethod: state.lastUpstreamMethod,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resetSession(sessionID string) *adapterSession {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
state := &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *Server) closeSession(sessionID string) bool {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
if _, ok := s.sessions[sessionID]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.sessions, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
func parseAllowedOrigins(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) originAllowed(origin string) bool {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range s.allowedOrigins {
|
||||
if strings.HasSuffix(allowed, ":*") {
|
||||
if strings.HasPrefix(origin, strings.TrimSuffix(allowed, "*")) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if origin == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" || !s.originAllowed(origin) {
|
||||
return
|
||||
}
|
||||
headers := w.Header()
|
||||
headers.Set("Access-Control-Allow-Origin", origin)
|
||||
headers.Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
headers.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
|
||||
headers.Set("Access-Control-Max-Age", "600")
|
||||
headers.Add("Vary", "Origin")
|
||||
}
|
||||
|
||||
func (s *Server) authorized(r *http.Request) bool {
|
||||
return s.authService.ValidateAuthorizationHeader(strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
}
|
||||
|
||||
func (s *Server) writeJSONError(w http.ResponseWriter, id any, status int, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(id, code, message))
|
||||
}
|
||||
155
internal/hermesadapter/server_test.go
Normal file
155
internal/hermesadapter/server_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
package hermesadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
type stubClient struct {
|
||||
initResult initializeResult
|
||||
initErr error
|
||||
callResult map[string]any
|
||||
callErr error
|
||||
lastMethod string
|
||||
lastParams map[string]any
|
||||
}
|
||||
|
||||
func (s *stubClient) Initialize() (initializeResult, error) {
|
||||
return s.initResult, s.initErr
|
||||
}
|
||||
|
||||
func (s *stubClient) Call(method string, params map[string]any) (map[string]any, error) {
|
||||
s.lastMethod = method
|
||||
s.lastParams = params
|
||||
return s.callResult, s.callErr
|
||||
}
|
||||
|
||||
func (s *stubClient) Close() error { return nil }
|
||||
|
||||
func TestHandleCapabilitiesSynthesizesProviderResponse(t *testing.T) {
|
||||
server := NewServer(&stubClient{
|
||||
initResult: initializeResult{ProtocolVersion: 1},
|
||||
})
|
||||
result := server.handleRequest(shared.RPCRequest{Method: "acp.capabilities"})
|
||||
if got := result["singleAgent"]; got != true {
|
||||
t.Fatalf("expected singleAgent true, got %#v", result)
|
||||
}
|
||||
providers, _ := result["providers"].([]string)
|
||||
if len(providers) != 1 || providers[0] != "hermes" {
|
||||
t.Fatalf("expected hermes provider, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
|
||||
stub := &stubClient{
|
||||
initResult: initializeResult{ProtocolVersion: 1},
|
||||
callResult: map[string]any{
|
||||
"result": map[string]any{
|
||||
"success": true,
|
||||
"output": "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
server := NewServer(stub)
|
||||
server.upstreamMethod = "session.start"
|
||||
|
||||
body, _ := json.Marshal(shared.RPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "hello",
|
||||
},
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/acp/rpc", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer test-token")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.HandleRPC(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
result := envelope["result"].(map[string]any)
|
||||
if got := result["output"]; got != "hello" {
|
||||
t.Fatalf("expected output hello, got %#v", result)
|
||||
}
|
||||
if stub.lastMethod != "session.start" {
|
||||
t.Fatalf("expected upstream method session.start, got %q", stub.lastMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessionStartFallsBackToPromptRunner(t *testing.T) {
|
||||
stub := &stubClient{initResult: initializeResult{ProtocolVersion: 1}}
|
||||
server := NewServer(stub)
|
||||
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
if workingDirectory != "/tmp/demo" {
|
||||
t.Fatalf("expected workingDirectory /tmp/demo, got %q", workingDirectory)
|
||||
}
|
||||
expectedPrompt := "## User Turn 1\nReply with exactly pong"
|
||||
if prompt != expectedPrompt {
|
||||
t.Fatalf("unexpected prompt %q", prompt)
|
||||
}
|
||||
return "pong", nil
|
||||
}
|
||||
|
||||
result := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "Reply with exactly pong",
|
||||
"workingDirectory": "/tmp/demo",
|
||||
},
|
||||
})
|
||||
if got := result["output"]; got != "pong" {
|
||||
t.Fatalf("expected output pong, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocketCapabilities(t *testing.T) {
|
||||
server := NewServer(&stubClient{initResult: initializeResult{ProtocolVersion: 1}})
|
||||
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server.HandleWebSocket(w, r)
|
||||
}))
|
||||
defer httpServer.Close()
|
||||
|
||||
wsURL := "ws" + httpServer.URL[len("http"):]
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", "Bearer test-token")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
t.Fatalf("dial websocket: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.WriteJSON(shared.RPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: "cap-1",
|
||||
Method: "acp.capabilities",
|
||||
Params: map[string]any{},
|
||||
}); err != nil {
|
||||
t.Fatalf("write json: %v", err)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := conn.ReadJSON(&envelope); err != nil {
|
||||
t.Fatalf("read json: %v", err)
|
||||
}
|
||||
result := envelope["result"].(map[string]any)
|
||||
providers := result["providers"].([]any)
|
||||
if len(providers) != 1 || providers[0] != "hermes" {
|
||||
t.Fatalf("expected hermes provider over websocket, got %#v", result)
|
||||
}
|
||||
}
|
||||
8
main.go
8
main.go
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"xworkmate-bridge/internal/acp"
|
||||
"xworkmate-bridge/internal/geminiadapter"
|
||||
"xworkmate-bridge/internal/hermesadapter"
|
||||
"xworkmate-bridge/internal/toolbridge"
|
||||
)
|
||||
|
||||
@ -28,6 +29,13 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "hermes-acp-adapter" {
|
||||
if err := hermesadapter.Serve(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
toolbridge.Run(os.Stdin, os.Stdout)
|
||||
}
|
||||
|
||||
@ -151,6 +151,10 @@ case "${scenario}" in
|
||||
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"gemini","capabilities":{"providers":["gemini"]}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${data}" == *'"providerId":"hermes"'* ]]; then
|
||||
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"hermes","capabilities":{"providers":["hermes"]}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf 'unexpected bridge probe payload in retry-success scenario: %s\n' "${data}" >&2
|
||||
exit 1
|
||||
;;
|
||||
@ -194,6 +198,7 @@ run_validate_capture() {
|
||||
CODEX_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc" \
|
||||
OPENCODE_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc" \
|
||||
GEMINI_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc" \
|
||||
HERMES_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/hermes/acp/rpc" \
|
||||
INTERNAL_SERVICE_TOKEN="test-token" \
|
||||
bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1
|
||||
)"
|
||||
|
||||
@ -57,6 +57,7 @@ OPENCLAW_BASE_URL="$(normalize_url "${OPENCLAW_URL:-${3:-${BASE_URL}/gateway/ope
|
||||
CODEX_BASE_URL="$(normalize_url "${CODEX_RPC_URL:-${4:-${BASE_URL}/acp-server/codex/acp/rpc}}")"
|
||||
OPENCODE_BASE_URL="$(normalize_url "${OPENCODE_RPC_URL:-${5:-${BASE_URL}/acp-server/opencode/acp/rpc}}")"
|
||||
GEMINI_BASE_URL="$(normalize_url "${GEMINI_RPC_URL:-${6:-${BASE_URL}/acp-server/gemini/acp/rpc}}")"
|
||||
HERMES_BASE_URL="$(normalize_url "${HERMES_RPC_URL:-${7:-${BASE_URL}/acp-server/hermes/acp/rpc}}")"
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-${7:-}}}"
|
||||
|
||||
ensure_rpc_path() {
|
||||
@ -72,6 +73,7 @@ OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_BASE_URL}")"
|
||||
CODEX_RPC_ENDPOINT="$(ensure_rpc_path "${CODEX_BASE_URL}")"
|
||||
OPENCODE_RPC_ENDPOINT="$(ensure_rpc_path "${OPENCODE_BASE_URL}")"
|
||||
GEMINI_RPC_ENDPOINT="$(ensure_rpc_path "${GEMINI_BASE_URL}")"
|
||||
HERMES_RPC_ENDPOINT="$(ensure_rpc_path "${HERMES_BASE_URL}")"
|
||||
|
||||
fast_http_curl_common=(
|
||||
--silent
|
||||
@ -382,3 +384,4 @@ run_with_retry "capabilities ${GEMINI_RPC_ENDPOINT}" 3 5 "${RETRYABLE_TRANSPORT}
|
||||
run_with_retry "bridge provider probe codex" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "codex"
|
||||
run_with_retry "bridge provider probe opencode" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "opencode"
|
||||
run_with_retry "bridge provider probe gemini" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "gemini"
|
||||
run_with_retry "bridge provider probe hermes" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "hermes"
|
||||
|
||||
@ -122,10 +122,10 @@ if not isinstance(provider_catalog, list):
|
||||
if not isinstance(gateway_providers, list):
|
||||
raise SystemExit("gatewayProviders is missing or invalid")
|
||||
|
||||
expected_agent_ids = ["codex", "opencode", "gemini"]
|
||||
expected_agent_labels = ["Codex", "OpenCode", "Gemini"]
|
||||
expected_agent_ids = ["codex", "opencode", "gemini", "hermes"]
|
||||
expected_agent_labels = ["Codex", "OpenCode", "Gemini", "Hermes"]
|
||||
if len(provider_catalog) != len(expected_agent_ids):
|
||||
raise SystemExit(f"expected 3 agent providers, got {provider_catalog!r}")
|
||||
raise SystemExit(f"expected 4 agent providers, got {provider_catalog!r}")
|
||||
|
||||
for index, (provider_id, label) in enumerate(zip(expected_agent_ids, expected_agent_labels)):
|
||||
item = provider_catalog[index]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user