Add Gemini ACP adapter shim

This commit is contained in:
Haitao Pan 2026-04-09 14:09:18 +08:00
parent 7cd0bdfbc4
commit 6ebcdd6f5b
5 changed files with 1093 additions and 0 deletions

378
docs/gemini-acp-adapter.md Normal file
View File

@ -0,0 +1,378 @@
# Gemini ACP Adapter
This document records the verified local behavior of `gemini --experimental-acp` and the recommended adapter design for integrating Gemini into `xworkmate-bridge` as a single-agent ACP backend.
## Goal
Keep the bridge semantics unchanged:
- `openclaw gateway` stays on gateway runtime forwarding
- `codex ACP` stays a single-agent ACP backend
- `opencode ACP` stays a single-agent ACP backend
- `gemini ACP` is added as another single-agent ACP backend
Gemini should therefore be integrated through an adapter layer, not through the gateway runtime path.
## Verified Local Findings
The local Gemini CLI binary supports an experimental ACP stdio mode:
```bash
gemini --experimental-acp
```
The bridge's current ACP RPC surface is not directly compatible with Gemini's ACP mode.
The following bridge-style methods were verified to be unsupported by Gemini ACP:
- `acp.capabilities`
- `session.start`
- `tools/list`
Example response:
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"\"Method not found\": acp.capabilities","data":{"method":"acp.capabilities"}}}
```
Gemini ACP does respond to `initialize`, and `protocolVersion` is required:
```bash
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' \
| gemini --experimental-acp
```
Observed result:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": 1,
"authMethods": [
{
"id": "oauth-personal",
"name": "Log in with Google",
"description": null
},
{
"id": "gemini-api-key",
"name": "Use Gemini API key",
"description": "Requires setting the `GEMINI_API_KEY` environment variable"
},
{
"id": "vertex-ai",
"name": "Vertex AI",
"description": null
}
],
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": {
"image": true,
"audio": true,
"embeddedContext": true
},
"mcpCapabilities": {
"http": true,
"sse": true
}
}
}
}
```
## Conclusion
Gemini ACP is reachable over stdio JSON-RPC, but it does not expose the same RPC methods expected by `xworkmate-bridge` today.
This means Gemini should be connected behind a small ACP adapter that:
1. speaks the bridge-facing ACP surface already used by `xworkmate-bridge`
2. speaks Gemini's experimental ACP surface on stdio
## Recommended Topology
```text
xworkmate app / agent manager
-> xworkmate-bridge
-> external provider: gemini
-> gemini ACP adapter
-> stdio child process: gemini --experimental-acp
```
The adapter should be registered in bridge provider sync as an external ACP provider:
- `providerId: "gemini"`
- `label: "Gemini"`
- `endpoint: http://127.0.0.1:<port>/acp/rpc` or `ws://127.0.0.1:<port>/acp`
- `enabled: true`
`xworkmate-bridge` already supports this provider shape through `xworkmate.providers.sync`.
## Adapter Responsibilities
The Gemini ACP adapter should own all protocol translation.
### Bridge-facing side
Expose the same single-agent ACP methods the bridge already forwards:
- `acp.capabilities`
- `session.start`
- `session.message`
- `session.cancel`
- `session.close`
### Gemini-facing side
Launch one Gemini ACP stdio child process:
```bash
gemini --experimental-acp
```
Then initialize it first:
```json
{
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientInfo": {
"name": "xworkmate-gemini-adapter",
"version": "0.1.0"
}
}
}
```
The adapter should cache the initialization result and derive bridge-side capability information from it.
## Bridge-to-Gemini Mapping
The exact post-`initialize` Gemini method surface still needs to be enumerated. Until that discovery is finished, the adapter contract should be structured as follows.
### `acp.capabilities`
Return a bridge-compatible synthesized response. Suggested response:
```json
{
"singleAgent": true,
"multiAgent": false,
"providers": ["gemini"],
"capabilities": {
"single_agent": true,
"multi_agent": false,
"providers": ["gemini"]
},
"upstream": {
"protocolVersion": 1,
"promptCapabilities": {
"image": true,
"audio": true,
"embeddedContext": true
},
"mcpCapabilities": {
"http": true,
"sse": true
},
"authMethods": [
"oauth-personal",
"gemini-api-key",
"vertex-ai"
]
}
}
```
### `session.start`
Suggested adapter behavior:
1. Ensure Gemini stdio process is started
2. Ensure `initialize` has succeeded
3. Create adapter-local session state keyed by `sessionId`
4. Translate the bridge prompt and metadata into the Gemini request format
5. Return a bridge-compatible response envelope
If Gemini requires a different request method than bridge `session.start`, the translation should remain fully internal to the adapter.
### `session.message`
Suggested adapter behavior:
1. Reuse adapter-local session state
2. Translate `taskPrompt`, attachments, and working directory metadata as supported
3. Stream or collect Gemini output
4. Repackage into the bridge's current single-agent result shape
### `session.cancel` and `session.close`
Because Gemini reported `loadSession: false`, the first adapter version should assume sessions are adapter-local, not durable upstream sessions.
Recommended behavior:
- `session.cancel`: cancel the current in-flight Gemini request or kill and restart the child process if fine-grained cancellation is unavailable
- `session.close`: drop adapter-local state and optionally recycle the child process
## Startup Configuration
## Environment
At minimum, support these startup modes:
### API key mode
```bash
export GEMINI_API_KEY=your_api_key
gemini --experimental-acp
```
### OAuth mode
Use the Gemini CLI's own authenticated environment, then run:
```bash
gemini --experimental-acp
```
### Vertex AI mode
Run Gemini CLI in an environment already configured for Vertex AI auth, then launch:
```bash
gemini --experimental-acp
```
## Recommended adapter process contract
Example adapter startup:
```bash
./build/bin/xworkmate-go-core gemini-acp-adapter \
--listen 127.0.0.1:8791 \
--gemini-bin /opt/homebrew/bin/gemini \
--gemini-args="--experimental-acp"
```
Recommended adapter environment:
```bash
XWORKMATE_GEMINI_BIN=/opt/homebrew/bin/gemini
XWORKMATE_GEMINI_ARGS=--experimental-acp
XWORKMATE_GEMINI_INIT_PROTOCOL_VERSION=1
```
The implemented first version exposes:
- `POST /acp/rpc`
- `GET /acp` WebSocket ACP
Supported adapter methods:
- `acp.capabilities`
- `session.start`
- `session.message`
- `session.cancel`
- `session.close`
- `gemini.initialize`
- `gemini.raw`
`session.start` and `session.message` currently behave as a shim skeleton:
- the adapter always initializes Gemini ACP first
- then it forwards to the configured upstream method
- by default, the upstream method name is the same as the incoming bridge method
- if Gemini returns an upstream error, the adapter converts that into a bridge-compatible `success: false` result payload instead of failing the HTTP transport
You can override the forwarded method name with:
```bash
export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method
```
## Bridge Provider Sync Example
Once the adapter is running, register it as a normal external provider:
```json
{
"jsonrpc": "2.0",
"id": "providers-sync-1",
"method": "xworkmate.providers.sync",
"params": {
"providers": [
{
"providerId": "gemini",
"label": "Gemini",
"endpoint": "http://127.0.0.1:8791/acp/rpc",
"enabled": true
}
]
}
}
```
Example local startup and sync flow:
```bash
./build/bin/xworkmate-go-core gemini-acp-adapter \
--listen 127.0.0.1:8791 \
--gemini-bin /opt/homebrew/bin/gemini \
--gemini-args="--experimental-acp"
```
Then sync this provider into the bridge:
```json
{
"jsonrpc": "2.0",
"id": "providers-sync-gemini",
"method": "xworkmate.providers.sync",
"params": {
"providers": [
{
"providerId": "gemini",
"label": "Gemini",
"endpoint": "http://127.0.0.1:8791",
"enabled": true
}
]
}
}
```
## Recommended First Implementation Scope
The first adapter milestone should stay intentionally small:
1. Start `gemini --experimental-acp`
2. Send `initialize`
3. Expose bridge-compatible `acp.capabilities`
4. Translate one synchronous prompt path for `session.start` and `session.message`
5. Return bridge-compatible `output`, `provider`, `mode`, and `turnId`
Do not block the first version on:
- durable upstream session restore
- multimodal attachment parity
- complex streaming semantics
- full cancellation fidelity
## Next Validation Tasks
Before implementing the adapter, enumerate Gemini's post-`initialize` callable methods and request schema.
Recommended next probes:
- inspect whether Gemini sends notifications after `initialize`
- test likely conversation methods after `initialize`
- determine whether the protocol requires explicit auth selection or out-of-band auth only
- verify whether one process supports multiple sequential requests safely
- verify whether one process supports concurrent requests or must be serialized

View File

@ -0,0 +1,184 @@
package geminiadapter
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-gemini-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("gemini 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("gemini 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 gemini acp response: %w", err)
}
return response, nil
}

View File

@ -0,0 +1,385 @@
package geminiadapter
import (
"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:8791"
defaultProviderID = "gemini"
defaultLabel = "Gemini"
)
type Server struct {
client rpcClient
authService *service.StaticTokenAuthService
providerID string
providerLabel string
allowedOrigins []string
upstreamMethod string
}
var adapterWSUpgrader = websocket.Upgrader{
ReadBufferSize: 16 * 1024,
WriteBufferSize: 16 * 1024,
CheckOrigin: func(*http.Request) bool {
return true
},
}
func Serve(args []string) error {
flags := flag.NewFlagSet("gemini-acp-adapter", flag.ExitOnError)
listen := flags.String(
"listen",
strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_LISTEN_ADDR", defaultListenAddr)),
"Gemini ACP adapter listen address",
)
binary := flags.String(
"gemini-bin",
strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_BIN", shared.EnvOrDefault("ACP_GEMINI_BIN", "gemini"))),
"Gemini CLI binary path",
)
rawArgs := flags.String(
"gemini-args",
strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_ARGS", "--experimental-acp")),
"Gemini CLI arguments",
)
_ = flags.Parse(args)
client := newStdioRPCClient(
*binary,
strings.Fields(strings.TrimSpace(*rawArgs)),
nil,
shared.IntArg(shared.EnvOrDefault("GEMINI_ADAPTER_PROTOCOL_VERSION", "1"), 1),
)
defer 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("gemini adapter failed: %w", err)
}
return nil
}
func NewServer(client rpcClient) *Server {
return &Server{
client: client,
authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_AUTH_TOKEN", ""))),
providerID: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_PROVIDER_ID", defaultProviderID)),
providerLabel: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_PROVIDER_LABEL", defaultLabel)),
allowedOrigins: parseAllowedOrigins(strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*"))),
upstreamMethod: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_UPSTREAM_METHOD", "")),
}
}
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 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":
return map[string]any{"accepted": true, "closed": true}
case "gemini.initialize":
return s.handleInitialize()
case "gemini.raw":
return s.handleRaw(request.Params)
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) handleInitialize() map[string]any {
result, err := s.client.Initialize()
if err != nil {
return map[string]any{"success": false, "error": err.Error()}
}
return map[string]any{
"success": true,
"result": result,
}
}
func (s *Server) handleRaw(params map[string]any) map[string]any {
method := strings.TrimSpace(shared.StringArg(params, "method", ""))
upstreamParams, _ := params["params"].(map[string]any)
if method == "" {
return map[string]any{"success": false, "error": "method is required"}
}
if _, err := s.client.Initialize(); err != nil {
return map[string]any{"success": false, "error": err.Error()}
}
response, err := s.client.Call(method, upstreamParams)
if err != nil {
return map[string]any{"success": false, "error": err.Error()}
}
return map[string]any{"success": true, "response": response}
}
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 == "" {
upstreamMethod = strings.TrimSpace(method)
}
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 gemini acp error")),
"upstreamMethod": upstreamMethod,
"upstreamError": errPayload,
}
}
return map[string]any{
"success": true,
"provider": s.providerID,
"mode": "single-agent",
"upstreamMethod": upstreamMethod,
"upstream": response,
}
}
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")
headers.Add("Vary", "Access-Control-Request-Method")
headers.Add("Vary", "Access-Control-Request-Headers")
}
func (s *Server) authorized(r *http.Request) bool {
if s == nil || s.authService == nil {
return true
}
expected := strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_AUTH_TOKEN", ""))
if expected == "" {
return true
}
return s.authService.ValidateAuthorizationHeader(r.Header.Get("Authorization"))
}
func (s *Server) writeJSONError(w http.ResponseWriter, requestID any, statusCode int, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(requestID, code, message))
}

View File

@ -0,0 +1,138 @@
package geminiadapter
import (
"bytes"
"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,
AuthMethods: []map[string]any{
{"id": "gemini-api-key"},
},
AgentCapabilities: map[string]any{
"mcpCapabilities": map[string]any{"http": true},
},
},
})
result := server.handleRequest(shared.RPCRequest{
Method: "acp.capabilities",
Params: map[string]any{},
})
if got := result["singleAgent"]; got != true {
t.Fatalf("expected singleAgent true, got %#v", result)
}
providers, _ := result["providers"].([]string)
if len(providers) != 1 || providers[0] != "gemini" {
t.Fatalf("expected gemini 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)
body, _ := json.Marshal(shared.RPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "session.start",
Params: map[string]any{
"taskPrompt": "hello",
},
})
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/acp/rpc", bytes.NewReader(body))
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 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"):]
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
defer 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] != "gemini" {
t.Fatalf("expected gemini provider over websocket, got %#v", result)
}
}

View File

@ -5,6 +5,7 @@ import (
"os"
"xworkmate-bridge/internal/acp"
"xworkmate-bridge/internal/geminiadapter"
"xworkmate-bridge/internal/toolbridge"
)
@ -20,6 +21,13 @@ func main() {
acp.RunStdio(os.Stdin, os.Stdout)
return
}
if len(os.Args) > 1 && os.Args[1] == "gemini-acp-adapter" {
if err := geminiadapter.Serve(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
return
}
toolbridge.Run(os.Stdin, os.Stdout)
}