Fix ACP auth and Gemini session compatibility

This commit is contained in:
Haitao Pan 2026-04-09 19:21:12 +08:00
parent 6ebcdd6f5b
commit 98a076cd65
7 changed files with 465 additions and 18 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
build/
.env

View File

@ -0,0 +1,173 @@
# ACP Public Validation - 2026-04-09
This document records the post-deployment public validation for `xworkmate-bridge.svc.plus` and the unified ACP ingress at `acp-server.svc.plus`.
It is intended as an app-integration reference so future clients use the verified public endpoints and expected JSON-RPC methods.
## Verified Public Endpoints
### Bridge root
- URL: `https://xworkmate-bridge.svc.plus/`
- Result: `200 OK`
- Body: `xworkmate-bridge is running`
### ACP public ingress
The public ACP JSON-RPC endpoint is the `.../acp/rpc` path.
Do not send JSON-RPC requests to `.../acp` for HTTP clients.
Verified public HTTP JSON-RPC endpoints:
- Codex: `https://acp-server.svc.plus/codex/acp/rpc`
- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc`
- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc`
The `.../acp` path remains reserved for WebSocket ACP.
## Auth Contract
All verified public ACP HTTP requests used:
- header: `Authorization: Bearer <INTERNAL_SERVICE_TOKEN>`
- header: `Content-Type: application/json`
Missing bearer auth returns a JSON-RPC error envelope with code `-32001`.
## Public Validation Results
### Codex
Verified `acp.capabilities` over the public ingress:
```json
{
"method": "acp.capabilities",
"result": {
"providers": ["codex", "gemini", "opencode"],
"singleAgent": true,
"multiAgent": true
}
}
```
Verified `session.start` reached the Codex execution layer, but the task failed upstream.
Observed upstream failure summary:
- repeated `wss://api.openai.com/v1/responses` `500 Internal Server Error`
- final `https://api.openai.com/v1/responses` `401 Unauthorized`
- message: `Missing bearer or basic authentication in header`
### OpenCode
Verified `acp.capabilities` over the public ingress:
```json
{
"method": "acp.capabilities",
"result": {
"providers": ["opencode"],
"singleAgent": true,
"multiAgent": true
}
}
```
Verified `session.start` end to end with prompt `Reply with exactly pong`.
Observed result:
```json
{
"success": true,
"provider": "opencode",
"output": "pong"
}
```
### Gemini
Verified `acp.capabilities` over the public ingress:
```json
{
"method": "acp.capabilities",
"result": {
"providers": ["gemini"],
"singleAgent": true,
"multiAgent": false
}
}
```
Before the compatibility layer landed, the upstream Gemini ACP returned:
```json
{
"success": false,
"error": "\"Method not found\": session.start"
}
```
The adapter has now been updated so `session.start` and `session.message` default to adapter-local prompt compatibility instead of forwarding unsupported upstream methods.
## App Integration Notes
### Recommended request shape
Use JSON-RPC `POST` requests against `.../acp/rpc`.
For capability discovery:
```json
{
"jsonrpc": "2.0",
"id": "cap-1",
"method": "acp.capabilities"
}
```
For single-agent task execution:
```json
{
"jsonrpc": "2.0",
"id": "task-1",
"method": "session.start",
"params": {
"sessionId": "session-1",
"threadId": "thread-1",
"taskPrompt": "Reply with exactly pong",
"workingDirectory": "/tmp",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": "opencode"
}
}
}
```
### Provider-specific notes
- `opencode` is the currently verified public task path.
- `gemini` now depends on the adapter compatibility layer, not an upstream Gemini ACP conversation method.
- `codex` public routing is healthy, but task execution requires upstream OpenAI auth to be present in the runtime environment.
## Codex Runtime Root Cause
Remote inspection on `jp-xhttp-contabo.svc.plus` showed:
- `codex-app-server` only had `HOME=/root TERM=xterm-256color NODE_NO_WARNINGS=1`
- `/root/.codex` existed, but no auth/config JSON files were present
- `codex --version` was `0.117.0`
That means the deployed Codex runtime was able to start, but it did not have a usable OpenAI auth source.
For future deployments, the systemd units should provide:
- `CODEX_HOME`
- `OPENAI_API_KEY` when API-key auth is used
- optional custom base URL variables when running against a non-default upstream

View File

@ -269,7 +269,7 @@ XWORKMATE_GEMINI_ARGS=--experimental-acp
XWORKMATE_GEMINI_INIT_PROTOCOL_VERSION=1
```
The implemented first version exposes:
The implemented adapter exposes:
- `POST /acp/rpc`
- `GET /acp` WebSocket ACP
@ -284,14 +284,19 @@ Supported adapter methods:
- `gemini.initialize`
- `gemini.raw`
`session.start` and `session.message` currently behave as a shim skeleton:
`session.start` and `session.message` now use a compatibility layer by default:
- 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
- the adapter still initializes Gemini ACP first so `acp.capabilities` remains grounded in the real upstream ACP surface
- if `GEMINI_ADAPTER_UPSTREAM_METHOD` is unset, session traffic runs through adapter-local prompt mode
- the adapter keeps session-local history keyed by `sessionId`
- `session.start` resets adapter-local history for that session
- `session.message` replays prior user turns plus the new turn as one prompt to the Gemini CLI
- the adapter returns a bridge-compatible single-agent payload with `output`, `provider`, `mode`, and `upstreamMethod: "prompt"`
- `session.close` drops adapter-local state
You can override the forwarded method name with:
This default exists because the verified Gemini ACP upstream did not expose bridge-compatible `session.start` / `session.message` methods during testing.
If Gemini ACP later gains a compatible conversation method, you can override the forwarded method name with:
```bash
export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method

View File

@ -101,7 +101,7 @@ func NewServer() *Server {
queues: make(map[string]chan task),
gateway: gatewayruntime.NewManager(),
providerCatalog: make(map[string]syncedProvider),
authService: service.NewStaticTokenAuthService(""),
authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("ACP_AUTH_TOKEN", ""))),
}
}

View File

@ -1,6 +1,7 @@
package geminiadapter
import (
"context"
"encoding/json"
"errors"
"flag"
@ -24,12 +25,15 @@ const (
)
type Server struct {
client rpcClient
authService *service.StaticTokenAuthService
providerID string
providerLabel string
allowedOrigins []string
upstreamMethod string
client rpcClient
authService *service.StaticTokenAuthService
providerID string
providerLabel string
allowedOrigins []string
upstreamMethod string
sessionRunner func(context.Context, string, string, string) (string, error)
sessionsMu sync.Mutex
sessions map[string]*adapterSession
}
var adapterWSUpgrader = websocket.Upgrader{
@ -40,6 +44,14 @@ var adapterWSUpgrader = websocket.Upgrader{
},
}
type adapterSession struct {
history []string
model string
workingDirectory string
lastOutput string
lastUpstreamMethod string
}
func Serve(args []string) error {
flags := flag.NewFlagSet("gemini-acp-adapter", flag.ExitOnError)
listen := flags.String(
@ -98,6 +110,16 @@ func NewServer(client rpcClient) *Server {
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", "")),
sessionRunner: func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
return shared.RunProviderCommand(
ctx,
defaultProviderID,
model,
prompt,
workingDirectory,
)
},
sessions: make(map[string]*adapterSession),
}
}
@ -188,7 +210,8 @@ func (s *Server) handleRequest(request shared.RPCRequest) map[string]any {
case "session.cancel":
return map[string]any{"accepted": true, "cancelled": false}
case "session.close":
return map[string]any{"accepted": true, "closed": true}
sessionID := strings.TrimSpace(shared.StringArg(request.Params, "sessionId", ""))
return map[string]any{"accepted": true, "closed": s.closeSession(sessionID)}
case "gemini.initialize":
return s.handleInitialize()
case "gemini.raw":
@ -275,9 +298,13 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
}
}
upstreamMethod := s.upstreamMethod
if upstreamMethod == "" {
upstreamMethod = strings.TrimSpace(method)
if upstreamMethod != "" {
return s.handleConfiguredUpstreamSessionRequest(upstreamMethod, params)
}
return s.handleCompatSessionRequest(method, params)
}
func (s *Server) handleConfiguredUpstreamSessionRequest(upstreamMethod string, params map[string]any) map[string]any {
response, err := s.client.Call(upstreamMethod, params)
if err != nil {
return map[string]any{
@ -317,6 +344,130 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
}
}
func (s *Server) handleCompatSessionRequest(method string, params map[string]any) map[string]any {
if s.sessionRunner == nil {
return map[string]any{
"success": false,
"provider": s.providerID,
"mode": "single-agent",
"error": "gemini session runner is not configured",
}
}
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
if sessionID == "" {
return map[string]any{
"success": false,
"provider": s.providerID,
"mode": "single-agent",
"error": "sessionId is required",
}
}
state := s.getOrCreateSession(sessionID)
if method == "session.start" {
state = s.resetSession(sessionID)
}
taskPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
taskPrompt = shared.AugmentPromptWithAttachments(taskPrompt, params)
if taskPrompt == "" {
return map[string]any{
"success": false,
"provider": s.providerID,
"mode": "single-agent",
"error": "taskPrompt is required",
}
}
model := strings.TrimSpace(shared.StringArg(params, "model", ""))
if model == "" {
model = state.model
}
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
if workingDirectory == "" {
workingDirectory = state.workingDirectory
}
sessionsHistory := append([]string(nil), state.history...)
sessionsHistory = append(sessionsHistory, taskPrompt)
composedPrompt := shared.ComposeHistoryPrompt(sessionsHistory)
output, err := s.sessionRunner(context.Background(), model, composedPrompt, workingDirectory)
if err != nil {
return map[string]any{
"success": false,
"provider": s.providerID,
"mode": "single-agent",
"error": err.Error(),
}
}
s.sessionsMu.Lock()
state = s.sessions[sessionID]
if state == nil {
state = &adapterSession{}
s.sessions[sessionID] = state
}
state.history = sessionsHistory
state.model = model
state.workingDirectory = workingDirectory
state.lastOutput = output
state.lastUpstreamMethod = "prompt"
s.sessionsMu.Unlock()
result := map[string]any{
"success": true,
"provider": s.providerID,
"mode": "single-agent",
"output": output,
"sessionId": sessionID,
"upstreamMethod": "prompt",
}
if workingDirectory != "" {
result["effectiveWorkingDirectory"] = workingDirectory
}
if model != "" {
result["resolvedModel"] = model
}
return result
}
func (s *Server) getOrCreateSession(sessionID string) *adapterSession {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
state := s.sessions[sessionID]
if state == nil {
state = &adapterSession{}
s.sessions[sessionID] = state
}
return &adapterSession{
history: append([]string(nil), state.history...),
model: state.model,
workingDirectory: state.workingDirectory,
lastOutput: state.lastOutput,
lastUpstreamMethod: state.lastUpstreamMethod,
}
}
func (s *Server) resetSession(sessionID string) *adapterSession {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
state := &adapterSession{}
s.sessions[sessionID] = state
return state
}
func (s *Server) closeSession(sessionID string) bool {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return false
}
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
if _, ok := s.sessions[sessionID]; !ok {
return false
}
delete(s.sessions, sessionID)
return true
}
func parseAllowedOrigins(raw string) []string {
if raw == "" {
return nil

View File

@ -2,6 +2,7 @@ package geminiadapter
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -72,12 +73,14 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
},
}
server := NewServer(stub)
server.upstreamMethod = "session.start"
body, _ := json.Marshal(shared.RPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "session.start",
Params: map[string]any{
"sessionId": "s1",
"taskPrompt": "hello",
},
})
@ -102,6 +105,114 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
}
}
func TestHandleSessionStartFallsBackToPromptRunner(t *testing.T) {
stub := &stubClient{
initResult: initializeResult{ProtocolVersion: 1},
}
server := NewServer(stub)
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
if model != "gemini-2.5-pro" {
t.Fatalf("expected model gemini-2.5-pro, got %q", model)
}
if workingDirectory != "/tmp/demo" {
t.Fatalf("expected workingDirectory /tmp/demo, got %q", workingDirectory)
}
expectedPrompt := "## User Turn 1\nReply with exactly pong"
if prompt != expectedPrompt {
t.Fatalf("unexpected prompt %q", prompt)
}
return "pong", nil
}
result := server.handleRequest(shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "s1",
"taskPrompt": "Reply with exactly pong",
"model": "gemini-2.5-pro",
"workingDirectory": "/tmp/demo",
},
})
if got := result["output"]; got != "pong" {
t.Fatalf("expected output pong, got %#v", result)
}
if got := result["upstreamMethod"]; got != "prompt" {
t.Fatalf("expected prompt upstream method, got %#v", result)
}
}
func TestHandleSessionMessageReusesAdapterLocalHistory(t *testing.T) {
stub := &stubClient{
initResult: initializeResult{ProtocolVersion: 1},
}
server := NewServer(stub)
callCount := 0
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
callCount++
if callCount == 1 {
expected := "## User Turn 1\nFirst turn"
if prompt != expected {
t.Fatalf("unexpected first prompt %q", prompt)
}
return "first-reply", nil
}
expected := "## User Turn 1\nFirst turn\n\n## User Turn 2\nSecond turn"
if prompt != expected {
t.Fatalf("unexpected second prompt %q", prompt)
}
if workingDirectory != "/tmp/demo" {
t.Fatalf("expected inherited workingDirectory, got %q", workingDirectory)
}
if model != "gemini-2.5-flash" {
t.Fatalf("expected inherited model, got %q", model)
}
return "second-reply", nil
}
server.handleRequest(shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "s1",
"taskPrompt": "First turn",
"model": "gemini-2.5-flash",
"workingDirectory": "/tmp/demo",
},
})
result := server.handleRequest(shared.RPCRequest{
Method: "session.message",
Params: map[string]any{
"sessionId": "s1",
"taskPrompt": "Second turn",
},
})
if got := result["output"]; got != "second-reply" {
t.Fatalf("expected second reply, got %#v", result)
}
}
func TestSessionCloseDropsAdapterLocalState(t *testing.T) {
server := NewServer(&stubClient{initResult: initializeResult{ProtocolVersion: 1}})
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
return "ok", nil
}
server.handleRequest(shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "s1",
"taskPrompt": "hello",
},
})
result := server.handleRequest(shared.RPCRequest{
Method: "session.close",
Params: map[string]any{
"sessionId": "s1",
},
})
if got := result["closed"]; got != true {
t.Fatalf("expected closed true, got %#v", result)
}
}
func TestHandleWebSocketCapabilities(t *testing.T) {
server := NewServer(&stubClient{
initResult: initializeResult{ProtocolVersion: 1},

View File

@ -27,5 +27,11 @@ func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool
}
return strings.TrimSpace(header[len("Bearer "):]) != ""
}
return header == s.expectedToken
if header == s.expectedToken {
return true
}
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return false
}
return strings.TrimSpace(header[len("Bearer "):]) == s.expectedToken
}