From 6ebcdd6f5bed5c89d67ae0e0ef4531ab28a9a956 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 9 Apr 2026 14:09:18 +0800 Subject: [PATCH] Add Gemini ACP adapter shim --- docs/gemini-acp-adapter.md | 378 +++++++++++++++++++++++++ internal/geminiadapter/client.go | 184 ++++++++++++ internal/geminiadapter/server.go | 385 ++++++++++++++++++++++++++ internal/geminiadapter/server_test.go | 138 +++++++++ main.go | 8 + 5 files changed, 1093 insertions(+) create mode 100644 docs/gemini-acp-adapter.md create mode 100644 internal/geminiadapter/client.go create mode 100644 internal/geminiadapter/server.go create mode 100644 internal/geminiadapter/server_test.go diff --git a/docs/gemini-acp-adapter.md b/docs/gemini-acp-adapter.md new file mode 100644 index 0000000..1ff9896 --- /dev/null +++ b/docs/gemini-acp-adapter.md @@ -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:/acp/rpc` or `ws://127.0.0.1:/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 diff --git a/internal/geminiadapter/client.go b/internal/geminiadapter/client.go new file mode 100644 index 0000000..7498a66 --- /dev/null +++ b/internal/geminiadapter/client.go @@ -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 +} diff --git a/internal/geminiadapter/server.go b/internal/geminiadapter/server.go new file mode 100644 index 0000000..d497639 --- /dev/null +++ b/internal/geminiadapter/server.go @@ -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)) +} diff --git a/internal/geminiadapter/server_test.go b/internal/geminiadapter/server_test.go new file mode 100644 index 0000000..6170874 --- /dev/null +++ b/internal/geminiadapter/server_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 463abf1..dc3b981 100644 --- a/main.go +++ b/main.go @@ -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) }