Add Hermes ACP adapter

This commit is contained in:
Haitao Pan 2026-04-20 16:54:40 +08:00
parent 729da27179
commit 1e6c644a92
18 changed files with 926 additions and 35 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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 而言:

View File

@ -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 容器

View File

@ -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 桥接。

View File

@ -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:

View File

@ -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 {

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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) {

View File

@ -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)

View 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
}

View 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))
}

View 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)
}
}

View File

@ -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)
}

View File

@ -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
)"

View File

@ -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"

View File

@ -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]