Add Gemini ACP adapter shim
This commit is contained in:
parent
7cd0bdfbc4
commit
6ebcdd6f5b
378
docs/gemini-acp-adapter.md
Normal file
378
docs/gemini-acp-adapter.md
Normal 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
|
||||
184
internal/geminiadapter/client.go
Normal file
184
internal/geminiadapter/client.go
Normal 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
|
||||
}
|
||||
385
internal/geminiadapter/server.go
Normal file
385
internal/geminiadapter/server.go
Normal 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))
|
||||
}
|
||||
138
internal/geminiadapter/server_test.go
Normal file
138
internal/geminiadapter/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
8
main.go
8
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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user