Files
040b0f1944 fix(acp): Gemini ACP protocol fixes and multi-session architecture (#901)
* fix(cli): add missing X-GoClaw-User-Id header to gateway client

* feat(acp): comprehensive integration with Gemini ACP protocol

- Support nested JSON-RPC notification structures
- Add robust streaming text collection and mapping
- Increase handshake timeout to 60s for heavy model initialization
- Fix WebSocket user_id authentication and schema v47 compatibility
- Allow Google/GCP environment variables for ACP subprocesses

* refactor(acp): multi-session architecture with session tracing and temp session cleanup

- One shared Gemini process, multiple ACP sessions per process (one per goclaw conversation)
- resolveSession: per-key mutex prevents TOCTOU race on concurrent session creation
- Respawn detection via proc pointer comparison; session/load fallback after crash
- sessionReaper: purges ACP sessions idle >30min; temp- sessions purged immediately on completion
- WithGoclawSession context propagation: goclaw session key appears alongside ACP sid in all logs
- dispatchUpdate: Gemini agent_message_chunk protocol mapping normalized here
- session_test.go: full coverage of multi-session API (Initialize, NewSession, Prompt, Cancel, dispatch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): align with ACP protocol standard (SDK v0.16.1)

- InitializeRequest: "capabilities" → "clientCapabilities" (standard field name)
- LoadSessionRequest: add mcpServers field (required by standard)
- mapStopReason: add standard stop reasons (max_tokens, cancelled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): address review findings from PR #901

- B1: Restore RequiredSchemaVersion to 55 (was incorrectly set to 47)
- S1: Move X-GoClaw-User-Id header outside token check
- S2: Add env gate (ACP_GEMINI_E2E) to Gemini E2E test
- S3: Fix tab indentation in jsonrpc.go writeMessage
- Build: Extract Pdeathsig to platform-specific files for cross-platform build

* fix(acp): address S4-S7 review findings

S4/S5: Send session/cancel before purging sessions locally
- purgeSession() now cancels ACP session before deleting map entry
- sessionReaper() sends cancel notification for idle sessions
- Updated comments to reflect actual behavior

S6: Tighten GOOGLE_/GCP_ env var filtering
- Add GOOGLE_, GCP_ back to sensitiveEnvPrefixes
- Add allowedEnvExact for safe vars: GOOGLE_API_KEY,
  GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_CLOUD_PROJECT, GCP_PROJECT

S7: Propagate ACP errors to callers
- Chat/ChatStream now return err alongside ChatResponse
- Enables upstream retry logic and error metrics

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-18 12:17:23 +07:00

115 lines
3.1 KiB
Go

package acp
import (
"context"
"io"
"strings"
"sync"
)
// contextKey is the unexported type for context values set by this package.
type contextKey string
const goclawSessionCtxKey contextKey = "goclaw_session"
// WithGoclawSession attaches the goclaw conversation session key to ctx so
// that ACP-level logs can correlate the ACP session ID with the goclaw session.
func WithGoclawSession(ctx context.Context, key string) context.Context {
return context.WithValue(ctx, goclawSessionCtxKey, key)
}
// goclawSessionFromCtx extracts the goclaw session key injected by WithGoclawSession.
// Returns "" if not set.
func goclawSessionFromCtx(ctx context.Context) string {
v, _ := ctx.Value(goclawSessionCtxKey).(string)
return v
}
// sensitiveEnvPrefixes lists env var prefixes stripped from ACP subprocesses.
var sensitiveEnvPrefixes = []string{
"GOCLAW", "CLAUDE", "ANTHROPIC", "OPENAI",
"DATABASE", "POSTGRES", "MYSQL", "REDIS", "MONGO",
"AWS_", "AZURE_", "GOOGLE_", "GCP_",
"GITHUB_", "GH_", "GITLAB_", "BITBUCKET_",
"DOCKER_", "REGISTRY_",
"STRIPE_", "TWILIO_", "SENDGRID_",
"SSH_", "GPG_",
}
// allowedEnvExact lists env vars explicitly allowed even if they match sensitive prefixes.
// These are required for Google/GCP authentication in ACP subprocesses (e.g., Gemini).
var allowedEnvExact = map[string]bool{
"GOOGLE_API_KEY": true,
"GOOGLE_APPLICATION_CREDENTIALS": true,
"GOOGLE_CLOUD_PROJECT": true,
"GCP_PROJECT": true,
}
// sensitiveEnvExact lists exact env var names stripped from ACP subprocesses.
var sensitiveEnvExact = map[string]bool{
"DB_DSN": true, "PGPASSWORD": true, "PGUSER": true, "PGHOST": true,
"NPM_TOKEN": true, "NPM_CONFIG_TOKEN": true,
"HOMEBREW_GITHUB_API_TOKEN": true,
"CODECOV_TOKEN": true, "COVERALLS_REPO_TOKEN": true,
"SENTRY_DSN": true, "SENTRY_AUTH_TOKEN": true,
"SECRET_KEY": true, "JWT_SECRET": true,
}
// filterACPEnv strips sensitive env vars from the subprocess environment.
// Explicitly allowed vars (allowedEnvExact) pass through even if they match sensitive prefixes.
func filterACPEnv(environ []string) []string {
var filtered []string
for _, e := range environ {
key, _, _ := strings.Cut(e, "=")
upper := strings.ToUpper(key)
if allowedEnvExact[upper] {
filtered = append(filtered, e)
continue
}
if sensitiveEnvExact[upper] {
continue
}
skip := false
for _, prefix := range sensitiveEnvPrefixes {
if strings.HasPrefix(upper, prefix) {
skip = true
break
}
}
if skip {
continue
}
filtered = append(filtered, e)
}
return filtered
}
// limitedWriter captures up to max bytes of output for diagnostics.
type limitedWriter struct {
mu sync.Mutex
buf []byte
max int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
remaining := w.max - len(w.buf)
if remaining > 0 {
if len(p) > remaining {
p = p[:remaining]
}
w.buf = append(w.buf, p...)
}
return len(p), nil
}
func (w *limitedWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return string(w.buf)
}
// Ensure limitedWriter satisfies io.Writer.
var _ io.Writer = (*limitedWriter)(nil)