diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 2481b8fd..86a60cfb 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -544,6 +544,34 @@ func wireExtras( }) } + // Provider cache: re-register ACP providers on create/update/delete + msgBus.Subscribe(bus.TopicCacheProvider, func(event bus.Event) { + if event.Name != protocol.EventCacheInvalidate { + return + } + payload, ok := event.Payload.(bus.CacheInvalidatePayload) + if !ok || payload.Kind != bus.CacheKindProvider { + return + } + if payload.Key == "" { + return + } + // Re-register from DB if provider still exists and is ACP type + p, err := stores.Providers.GetProviderByName(context.Background(), payload.Key) + if err != nil { + // Provider was deleted or not found — already unregistered by handler + return + } + if p.ProviderType != store.ProviderACP { + return + } + // Unregister old instance (closes ProcessPool) then re-register + providerReg.Unregister(p.Name) + if p.Enabled { + registerACPFromDB(providerReg, *p) + } + }) + slog.Info("resolver + interceptors + cache subscribers wired") return contextFileInterceptor, delegateMgr, mcpPool, mediaStore } diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 395b4259..78ef896b 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -5,9 +5,11 @@ import ( "encoding/json" "log/slog" "net" + "os" "os/exec" "path/filepath" "strconv" + "time" "github.com/google/uuid" @@ -15,6 +17,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" ) // loopbackAddr normalizes a gateway address for local connections. @@ -157,6 +160,11 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...)) slog.Info("registered provider", "name", "claude-cli") } + + // ACP provider (config-based) — orchestrates any ACP-compatible agent binary + if cfg.Providers.ACP.Binary != "" { + registerACPFromConfig(registry, cfg.Providers.ACP) + } } // buildMCPServerLookup creates an MCPServerLookup from an MCPServerStore. @@ -261,6 +269,11 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Info("registered provider from DB", "name", p.Name) continue } + // ACP provider — no API key needed (agents manage their own auth). + if p.ProviderType == store.ProviderACP { + registerACPFromDB(registry, p) + continue + } // Local Ollama requires no API key — handle before the key guard (same pattern as ClaudeCLI). if p.ProviderType == store.ProviderOllama { host := p.APIBase @@ -329,3 +342,85 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Info("registered provider from DB", "name", p.Name) } } + +// registerACPFromConfig registers an ACP provider from config file settings. +func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig) { + if _, err := exec.LookPath(cfg.Binary); err != nil { + slog.Warn("acp: binary not found, skipping", "binary", cfg.Binary, "error", err) + return + } + idleTTL := 5 * time.Minute + if cfg.IdleTTL != "" { + if d, err := time.ParseDuration(cfg.IdleTTL); err == nil { + idleTTL = d + } + } + workDir := cfg.WorkDir + if workDir == "" { + workDir = defaultACPWorkDir() + } + var opts []providers.ACPOption + if cfg.Model != "" { + opts = append(opts, providers.WithACPModel(cfg.Model)) + } + if cfg.PermMode != "" { + opts = append(opts, providers.WithACPPermMode(cfg.PermMode)) + } + registry.Register(providers.NewACPProvider( + cfg.Binary, cfg.Args, workDir, idleTTL, tools.DefaultDenyPatterns, opts..., + )) + slog.Info("registered provider", "name", "acp", "binary", cfg.Binary) +} + +// registerACPFromDB registers an ACP provider from a DB provider row. +func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { + binary := p.APIBase // repurpose api_base as binary path + if binary == "" { + slog.Warn("acp: no binary specified in DB provider", "name", p.Name) + return + } + if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) { + slog.Warn("security.acp: invalid binary path from DB", "path", binary) + return + } + if _, err := exec.LookPath(binary); err != nil { + slog.Warn("acp: binary not found, skipping", "binary", binary, "error", err) + return + } + // Parse settings JSONB for extra config + var settings struct { + Args []string `json:"args"` + IdleTTL string `json:"idle_ttl"` + PermMode string `json:"perm_mode"` + WorkDir string `json:"work_dir"` + } + if p.Settings != nil { + if err := json.Unmarshal(p.Settings, &settings); err != nil { + slog.Warn("acp: invalid settings JSON, using defaults", "name", p.Name, "error", err) + } + } + idleTTL := 5 * time.Minute + if settings.IdleTTL != "" { + if d, err := time.ParseDuration(settings.IdleTTL); err == nil { + idleTTL = d + } + } + workDir := settings.WorkDir + if workDir == "" { + workDir = defaultACPWorkDir() + } + registry.Register(providers.NewACPProvider( + binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns, + providers.WithACPModel(p.Name), + )) + slog.Info("registered provider from DB", "name", p.Name, "type", "acp") +} + +// defaultACPWorkDir returns the default workspace directory for ACP agents. +func defaultACPWorkDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "goclaw-acp-workspaces") + } + return filepath.Join(home, ".goclaw", "acp-workspaces") +} diff --git a/docs/00-architecture-overview.md b/docs/00-architecture-overview.md index f4eee937..84d72ca7 100644 --- a/docs/00-architecture-overview.md +++ b/docs/00-architecture-overview.md @@ -42,6 +42,7 @@ flowchart TD subgraph Providers["LLM Providers"] ANTH[Anthropic -- Native HTTP + SSE] OAI["OpenAI-Compatible -- HTTP + SSE
(OpenAI, Gemini, DeepSeek, DashScope, +8)"] + ACP["ACP -- JSON-RPC 2.0 stdio
(Claude Code, Codex, Gemini CLI)"] end subgraph Tools["Tool Registry"] @@ -109,7 +110,8 @@ flowchart TD | `internal/gateway/` | WebSocket + HTTP server, client handling, method router | | `internal/gateway/methods/` | RPC method handlers: chat, agents, agent_links, teams, delegations, sessions, config, skills, cron, pairing, exec approval, usage, send | | `internal/agent/` | Agent loop (think, act, observe), router, resolver, system prompt builder, sanitization, pruning, tracing, memory flush, DELEGATION.md + TEAM.md injection | -| `internal/providers/` | LLM providers: Anthropic (native HTTP + SSE streaming), OpenAI-compatible (HTTP + SSE, 12+ providers), DashScope (Qwen), extended thinking support, retry logic | +| `internal/providers/` | LLM providers: Anthropic (native HTTP + SSE), OpenAI-compatible (HTTP + SSE, 12+ providers), DashScope (Qwen), ACP (JSON-RPC 2.0 subprocess), extended thinking support, retry logic | +| `internal/providers/acp/` | ACP protocol implementation: ProcessPool (subprocess lifecycle), ToolBridge (fs/terminal), session management | | `internal/tools/` | Tool registry, filesystem ops, exec/shell, policy engine, subagent, delegation manager, team tools, evaluate loop, handoff, context file + memory interceptors, credential scrubbing, rate limiting, PathDenyable | | `internal/tools/dynamic_loader.go` | Custom tool loader: LoadGlobal (startup), LoadForAgent (per-agent clone), ReloadGlobal (cache invalidation) | | `internal/tools/dynamic_tool.go` | Custom tool executor: command template rendering, shell escaping, encrypted env vars | diff --git a/docs/02-providers.md b/docs/02-providers.md index 39533f78..3b0deb18 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -30,7 +30,7 @@ The Anthropic provider uses `x-api-key` header authentication and the `anthropic ## 2. Supported Providers -| Provider | Type | API Base | Default Model | +| Provider | Type | API Base / Binary | Default Model | |----------|------|----------|---------------| | anthropic | Native HTTP + SSE | `https://api.anthropic.com/v1` | `claude-sonnet-4-5-20250929` | | openai | OpenAI-compatible | `https://api.openai.com/v1` | `gpt-4o` | @@ -47,6 +47,7 @@ The Anthropic provider uses `x-api-key` header authentication and the `anthropic | bailian | OpenAI-compatible | `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.5-plus` | | zai | OpenAI-compatible | `https://api.z.ai/api/paas/v4` | `glm-5` | | zai_coding | OpenAI-compatible | `https://api.z.ai/api/coding/paas/v4` | `glm-5` | +| acp | ACP (JSON-RPC 2.0 stdio) | `claude`, `codex`, `gemini` (binary name) | `claude` | --- @@ -291,7 +292,167 @@ Standard OpenAI-compatible provider targeting the Alibaba Coding API. --- -## 10. Agent Evaluators (Hook System) +## 10. ACP Provider (Agent Client Protocol) + +The ACP provider enables GoClaw to orchestrate external coding agents (Claude Code, Codex CLI, Gemini CLI, or any ACP-compatible agent) as subprocesses via JSON-RPC 2.0 over stdio. This allows delegating complex code generation tasks to specialized agents while maintaining GoClaw's unified interface. + +### Architecture Overview + +```mermaid +flowchart TD + AL["Agent Loop"] -->|Chat / ChatStream| ACP["ACPProvider"] + ACP --> PP["ProcessPool"] + PP -->|spawn| PROC["Subprocess
json-rpc 2.0 stdio"] + PROC -->|initialize| AGT["Agent
(Claude Code, Codex, etc.)"] + + AGT -->|fs/readTextFile| TB["ToolBridge"] + AGT -->|fs/writeTextFile| TB + AGT -->|terminal/*| TB + AGT -->|permission/request| TB + + TB -->|enforce| SB["Workspace Sandbox"] + TB -->|check| DEN["Deny Patterns"] + TB -->|handle| PERM["Permission Mode"] +``` + +### Configuration + +ACPConfig struct fields: + +```go +type ACPConfig struct { + Binary string // agent binary name or path (e.g. "claude", "codex") + Args []string // extra spawn args + Model string // default model/agent name (e.g. "claude") + WorkDir string // base workspace dir + IdleTTL string // process idle TTL (e.g. "5m") + PermMode string // "approve-all" (default), "approve-reads", "deny-all" +} +``` + +Example config.json: + +```json5 +{ + "providers": { + "acp": { + "binary": "claude", + "args": ["--profile", "goclaw"], + "model": "claude", + "work_dir": "/tmp/workspace", + "idle_ttl": "5m", + "perm_mode": "approve-all" + } + } +} +``` + +Database-based provider registration: + +- `provider_type = "acp"` +- `api_base = "claude"` (binary name) +- `settings = { "args": [...], "idle_ttl": "5m", "perm_mode": "approve-all", "work_dir": "..." }` + +### Session Management + +#### ProcessPool + +Manages subprocess lifecycle with idle TTL reaping and crash recovery: + +1. **GetOrSpawn** — Retrieve existing session or spawn new subprocess +2. **Idle TTL** — Reap idle processes after configured duration (default 5m) +3. **Crash Recovery** — Restart failed subprocesses transparently + +#### ToolBridge + +Handles agent → client requests for filesystem and terminal operations: + +- **fs/readTextFile** — Read file within workspace sandbox +- **fs/writeTextFile** — Write file within workspace sandbox +- **terminal/createTerminal** — Spawn terminal subprocess +- **terminal/terminalOutput** — Fetch terminal output + exit status +- **terminal/waitForTerminalExit** — Block until terminal exit +- **terminal/releaseTerminal** — Clean up terminal resources +- **terminal/killTerminal** — Force-terminate terminal +- **permission/request** — Request user approval (approve-all, approve-reads, deny-all) + +### Content Handling + +ACP messages use `ContentBlock` with three types: + +```go +type ContentBlock struct { + Type string // "text", "image", "audio" + Text string // text content + Data string // base64 for image/audio + MimeType string // e.g., "image/png", "audio/wav" +} +``` + +Request extraction: + +1. Extract system prompt + user message from GoClaw `ChatRequest.Messages` +2. Prepend system prompt to first user message (ACP agents lack separate system API) +3. Attach images as separate blocks + +Response collection: + +1. Accumulate `SessionUpdate` notifications during prompt execution +2. Collect text blocks into response content +3. Return finish reason mapped from `stopReason` ("maxContextLength" → "length", others → "stop") + +### Security & Sandboxing + +#### Workspace Isolation + +All file operations are scoped to `WorkDir`. Attempts to escape (e.g., `../../../etc/passwd`) are rejected. + +#### Deny Patterns + +Regex patterns (from config or tools policy) prevent access to sensitive paths: + +``` +[ + "^/etc/", + "^\\.env", + "^secret", + "^[Cc]redentials" +] +``` + +Each agent request is validated against deny patterns before execution. + +#### Permission Modes + +| Mode | Behavior | +|------|----------| +| `approve-all` | All requests approved (default) | +| `approve-reads` | Read-only; filesystem writes denied | +| `deny-all` | All requests denied | + +### Session Sequencing + +Per-session requests are serialized via `sessionMu` mutex to prevent concurrent tool access that could corrupt file state: + +```go +unlock := p.lockSession(sessionKey) +defer unlock() +// ... execute Chat or ChatStream with guaranteed serial access +``` + +### Streaming vs Non-Streaming + +#### Chat (Non-Streaming) + +Returns complete response after agent execution finishes. Collects all text blocks and returns single `ChatResponse`. + +#### ChatStream + +Emits `StreamChunk` for each text delta via callback. Supports context cancellation by sending `session/cancel` notification. Returns combined response when complete. + +--- + +## 11. Agent Evaluators (Hook System) Agent evaluators in the quality gate / hook system (see [03-tools-system.md](./03-tools-system.md)) use the same provider resolution as normal agent runs. When a quality gate is configured with `"type": "agent"`, the hook engine delegates to the specified reviewer agent, which resolves its own provider through the standard provider registry. No separate provider configuration is needed for evaluator agents. @@ -307,6 +468,13 @@ Agent evaluators in the quality gate / hook system (see [03-tools-system.md](./0 | `internal/providers/retry.go` | RetryDo[T] generic function, RetryConfig, IsRetryableError, backoff computation | | `internal/providers/schema_cleaner.go` | CleanSchemaForProvider, CleanToolSchemas, recursive schema field removal | | `internal/providers/dashscope.go` | DashScope provider: thinking budget, tools+streaming fallback | +| `internal/providers/acp_provider.go` | ACPProvider implementation: orchestrates ACP agents as subprocesses | +| `internal/providers/acp/types.go` | ACP protocol types: InitializeRequest, SessionUpdate, ContentBlock, etc. | +| `internal/providers/acp/process.go` | ProcessPool: subprocess lifecycle, idle TTL reaping, crash recovery | +| `internal/providers/acp/jsonrpc.go` | JSON-RPC 2.0 request/response marshaling over stdio | +| `internal/providers/acp/tool_bridge.go` | ToolBridge: handles fs and terminal requests, workspace sandboxing | +| `internal/providers/acp/terminal.go` | Terminal lifecycle: create, output, exit, release, kill | +| `internal/providers/acp/session.go` | Session state tracking per ACP agent | | `cmd/gateway_providers.go` | Provider registration from config and database during gateway startup | --- diff --git a/docs/17-changelog.md b/docs/17-changelog.md new file mode 100644 index 00000000..8e41bfb7 --- /dev/null +++ b/docs/17-changelog.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to GoClaw Gateway are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [Unreleased] + +### Added + +#### ACP Provider (Agent Client Protocol) +- **New provider**: ACP provider enables orchestration of external coding agents (Claude Code, Codex CLI, Gemini CLI) as JSON-RPC 2.0 subprocesses over stdio +- **ProcessPool**: Manages subprocess lifecycle with idle TTL reaping and automatic crash recovery +- **ToolBridge**: Handles agent→client requests for filesystem operations and terminal spawning with workspace sandboxing +- **Security features**: Workspace isolation, deny pattern matching, configurable permission modes (approve-all, approve-reads, deny-all) +- **Streaming support**: Both streaming and non-streaming modes supported with context cancellation +- **Config integration**: New `ACPConfig` struct in configuration with binary, args, model, work_dir, idle_ttl, perm_mode +- **Database providers**: ACP providers can be registered in `llm_providers` table with encrypted credentials +- **Files added**: + - `internal/providers/acp_provider.go` — ACPProvider implementation + - `internal/providers/acp/types.go` — ACP protocol types + - `internal/providers/acp/process.go` — Process pool management + - `internal/providers/acp/jsonrpc.go` — JSON-RPC 2.0 marshaling + - `internal/providers/acp/tool_bridge.go` — Request handling + - `internal/providers/acp/terminal.go` — Terminal lifecycle + - `internal/providers/acp/session.go` — Session tracking + +### Changed + +- Updated `02-providers.md` to document ACP provider architecture, configuration, session management, security, and streaming +- Updated `00-architecture-overview.md` component diagram to include ACP provider +- Updated Module Map in architecture overview to reference `internal/providers/acp/` package + +### Documentation + +- Added comprehensive ACP provider documentation with architecture diagrams, configuration examples, security model, and file reference +- Added `17-changelog.md` for tracking project changes + +--- + +## [Previous Releases] + +### v1.0.0 and Earlier + +- Initial release of GoClaw Gateway with Anthropic and OpenAI-compatible providers +- WebSocket RPC v3 protocol and HTTP API +- PostgreSQL multi-tenant backend with pgvector embeddings +- Agent loop with think→act→observe cycle +- Tool system: filesystem, exec, web, memory, browser, MCP bridge, custom tools +- Channel adapters: Telegram, Discord, Feishu, Zalo, WhatsApp +- Extended thinking support for Anthropic and select OpenAI models +- Scheduler with lane-based concurrency control +- Cron scheduling system +- Agent teams with task delegation +- Skills system with hot-reload +- Tracing and observability with optional OpenTelemetry export +- Browser automation via Rod +- Code sandbox with Docker +- Text-to-speech (OpenAI, ElevenLabs, Edge, MiniMax) +- i18n support (English, Vietnamese, Chinese) +- RBAC permission system +- Device pairing with 8-character codes +- MCP server integration with stdio, SSE, streamable-HTTP transports diff --git a/internal/bus/types.go b/internal/bus/types.go index 981094d3..3b4d0c42 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -64,6 +64,7 @@ const ( CacheKindGroupFileWriters = "group_file_writers" CacheKindSkillGrants = "skill_grants" CacheKindMCP = "mcp" + CacheKindProvider = "provider" ) // Topic constants for msgBus.Subscribe() / Broadcast(). @@ -80,6 +81,7 @@ const ( TopicCacheGroupFileWriters = "cache:group_file_writers" TopicCacheSkillGrants = "cache:skill_grants" TopicCacheMCP = "cache:mcp" + TopicCacheProvider = "cache:provider" TopicAudit = "audit" TopicChannelStreaming = "channel-streaming" TopicConfigChanged = "config:changed" diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 18236c60..4936fe4e 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -202,6 +202,7 @@ type ProvidersConfig struct { Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed) OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required) ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` + ACP ACPConfig `json:"acp"` } // OllamaConfig configures a local (or self-hosted) Ollama instance. @@ -218,6 +219,17 @@ type ClaudeCLIConfig struct { PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions") } +// ACPConfig configures the ACP (Agent Client Protocol) provider. +// Orchestrates any ACP-compatible coding agent (Claude Code, Codex CLI, Gemini CLI) as a subprocess. +type ACPConfig struct { + Binary string `json:"binary"` // agent binary name or path (e.g. "claude", "codex") + Args []string `json:"args"` // extra spawn args + Model string `json:"model"` // default model/agent name + WorkDir string `json:"work_dir"` // base workspace dir + IdleTTL string `json:"idle_ttl"` // process idle TTL (e.g. "5m") + PermMode string `json:"perm_mode"` // "approve-all" (default), "approve-reads", "deny-all" +} + type ProviderConfig struct { APIKey string `json:"api_key"` APIBase string `json:"api_base,omitempty"` @@ -243,7 +255,8 @@ func (c *Config) HasAnyProvider() bool { p.ZaiCoding.APIKey != "" || p.Ollama.Host != "" || p.OllamaCloud.APIKey != "" || - p.ClaudeCLI.CLIPath != "" + p.ClaudeCLI.CLIPath != "" || + p.ACP.Binary != "" } // QuotaWindow defines request limits per time window. Zero means unlimited. diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index f42d1986..2199c867 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -45,6 +45,12 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht return } + // ACP agents don't need an API key — return hardcoded models + if p.ProviderType == store.ProviderACP { + writeJSON(w, http.StatusOK, map[string]interface{}{"models": acpModels()}) + return + } + if p.APIKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")}) return @@ -239,6 +245,15 @@ func claudeCLIModels() []ModelInfo { } } +// acpModels returns the model aliases for ACP-compatible coding agents. +func acpModels() []ModelInfo { + return []ModelInfo{ + {ID: "claude", Name: "Claude"}, + {ID: "codex", Name: "Codex"}, + {ID: "gemini", Name: "Gemini"}, + } +} + // fetchOpenAIModels calls an OpenAI-compatible /models endpoint. func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil) diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 7b3cb4af..3d254aa9 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "net/http" + "os/exec" + "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // handleVerifyProvider tests a provider+model combination with a minimal LLM call. @@ -44,6 +47,25 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } + // ACP: verify binary exists on the server (no LLM call needed) + if p.ProviderType == store.ProviderACP { + binary := p.APIBase + if binary == "" { + binary = "claude" + } + // Validate binary against known allowlist (same check as registerACPFromDB) + if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) { + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "invalid binary path"}) + return + } + if _, err := exec.LookPath(binary); err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "binary not found: " + binary}) + } else { + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) + } + return + } + // Claude CLI: validate model alias locally (no LLM call needed) if p.ProviderType == "claude_cli" { validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true} diff --git a/internal/http/providers.go b/internal/http/providers.go index ae7eaa14..e8b7056f 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // ProvidersHandler handles LLM provider CRUD endpoints. @@ -44,6 +45,18 @@ func (h *ProvidersHandler) SetMCPServerLookup(lookup providers.MCPServerLookup) h.mcpLookup = lookup } +// emitProviderCacheInvalidate broadcasts a provider cache invalidation event. +// Subscribers (e.g. ACP re-registration in gateway_managed.go) react to reload from DB. +func (h *ProvidersHandler) emitProviderCacheInvalidate(name string) { + if h.msgBus == nil { + return + } + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventCacheInvalidate, + Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindProvider, Key: name}, + }) +} + // RegisterRoutes registers all provider management routes on the given mux. func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { // Provider CRUD @@ -89,6 +102,11 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { if h.providerReg == nil || !p.Enabled { return } + // ACP agents don't need an API key — skip in-memory registration + // (ACP providers are registered via gateway_providers.go on startup or restart) + if p.ProviderType == store.ProviderACP { + return + } // Claude CLI doesn't need an API key — register immediately if p.ProviderType == store.ProviderClaudeCLI { cliPath := p.APIBase // reuse APIBase field for CLI path @@ -196,6 +214,7 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R // Register in-memory so verify/chat work without restart h.registerInMemory(&p) + h.emitProviderCacheInvalidate(p.Name) emitAudit(h.msgBus, r, "provider.created", "provider", p.ID.String()) maskAPIKey(&p) @@ -293,6 +312,14 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R } } + // Notify subscribers (e.g. ACP re-registration) about the change + if updated, err := h.store.GetProvider(r.Context(), id); err == nil { + h.emitProviderCacheInvalidate(updated.Name) + if oldName != "" && oldName != updated.Name { + h.emitProviderCacheInvalidate(oldName) + } + } + emitAudit(h.msgBus, r, "provider.updated", "provider", id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } @@ -320,6 +347,9 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R if h.providerReg != nil && providerName != "" { h.providerReg.Unregister(providerName) } + if providerName != "" { + h.emitProviderCacheInvalidate(providerName) + } emitAudit(h.msgBus, r, "provider.deleted", "provider", id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) diff --git a/internal/providers/acp/helpers.go b/internal/providers/acp/helpers.go new file mode 100644 index 00000000..fade9700 --- /dev/null +++ b/internal/providers/acp/helpers.go @@ -0,0 +1,81 @@ +package acp + +import ( + "io" + "strings" + "sync" +) + +// sensitiveEnvPrefixes lists env var prefixes stripped from ACP subprocesses. +var sensitiveEnvPrefixes = []string{ + "GOCLAW", "CLAUDE", "ANTHROPIC", "OPENAI", + "DATABASE", "POSTGRES", "MYSQL", "REDIS", "MONGO", + "AWS_", "GOOGLE_", "AZURE_", "GCP_", + "GITHUB_", "GH_", "GITLAB_", "BITBUCKET_", + "DOCKER_", "REGISTRY_", + "STRIPE_", "TWILIO_", "SENDGRID_", + "SSH_", "GPG_", +} + +// 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. +func filterACPEnv(environ []string) []string { + var filtered []string + for _, e := range environ { + key, _, _ := strings.Cut(e, "=") + upper := strings.ToUpper(key) + 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) diff --git a/internal/providers/acp/jsonrpc.go b/internal/providers/acp/jsonrpc.go new file mode 100644 index 00000000..1386a054 --- /dev/null +++ b/internal/providers/acp/jsonrpc.go @@ -0,0 +1,217 @@ +package acp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "sync" + "sync/atomic" +) + +// jsonrpcMessage is the wire format for JSON-RPC 2.0 messages. +type jsonrpcMessage struct { + JSONRPC string `json:"jsonrpc"` + ID *int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpcError `json:"error,omitempty"` +} + +type jsonrpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *jsonrpcError) Error() string { + return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message) +} + +// RequestHandler handles agent→client requests (fs, terminal, permission). +// Context is derived from the connection lifetime and cancelled when the read loop exits. +type RequestHandler func(ctx context.Context, method string, params json.RawMessage) (any, error) + +// NotifyHandler handles agent→client notifications (session/update). +type NotifyHandler func(method string, params json.RawMessage) + +// Conn manages bidirectional JSON-RPC 2.0 over stdio pipes. +type Conn struct { + writer io.Writer + reader io.Reader + nextID atomic.Int64 + pending sync.Map // id → chan *jsonrpcMessage + handler RequestHandler + notify NotifyHandler + done chan struct{} + mu sync.Mutex // protects writes +} + +// NewConn creates a JSON-RPC connection over the given reader/writer pair. +func NewConn(writer io.Writer, reader io.Reader, handler RequestHandler, notify NotifyHandler) *Conn { + return &Conn{ + writer: writer, + reader: reader, + handler: handler, + notify: notify, + done: make(chan struct{}), + } +} + +// Start begins the read loop goroutine that dispatches incoming messages. +func (c *Conn) Start() { + go c.readLoop() +} + +// readLoop reads newline-delimited JSON messages from stdout and dispatches them. +func (c *Conn) readLoop() { + scanner := bufio.NewScanner(c.reader) + scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024) // 256KB initial, 10MB max + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var msg jsonrpcMessage + if err := json.Unmarshal(line, &msg); err != nil { + slog.Debug("acp.jsonrpc: skip malformed line", "error", err) + continue + } + + switch { + case msg.ID != nil && msg.Method == "": + // Response to our request — dispatch to pending caller + if ch, ok := c.pending.LoadAndDelete(*msg.ID); ok { + ch.(chan *jsonrpcMessage) <- &msg + } + + case msg.ID != nil && msg.Method != "": + // Agent→client request — handle and respond + go c.handleRequest(&msg) + + case msg.ID == nil && msg.Method != "": + // Notification — dispatch to handler + if c.notify != nil { + c.notify(msg.Method, msg.Params) + } + } + } + + if err := scanner.Err(); err != nil { + slog.Debug("acp.jsonrpc: read loop ended", "error", err) + } + close(c.done) +} + +// handleRequest processes an agent→client request and sends back the response. +// Uses a context derived from the connection lifetime so long-running handlers +// (e.g. waitForExit) are cancelled when the connection closes. +func (c *Conn) handleRequest(msg *jsonrpcMessage) { + var resp jsonrpcMessage + resp.JSONRPC = "2.0" + resp.ID = msg.ID + + // Create a context that cancels when the connection's read loop exits. + ctx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-c.done: + cancel() + case <-ctx.Done(): + } + }() + defer cancel() + + if c.handler == nil { + resp.Error = &jsonrpcError{Code: -32601, Message: "no handler registered"} + } else { + result, err := c.handler(ctx, msg.Method, msg.Params) + if err != nil { + resp.Error = &jsonrpcError{Code: -32000, Message: err.Error()} + } else { + data, marshalErr := json.Marshal(result) + if marshalErr != nil { + resp.Error = &jsonrpcError{Code: -32603, Message: "internal: " + marshalErr.Error()} + } else { + resp.Result = data + } + } + } + + c.writeMessage(&resp) +} + +// Call sends a JSON-RPC request and blocks until the response arrives. +func (c *Conn) Call(ctx context.Context, method string, params any, result any) error { + id := c.nextID.Add(1) + ch := make(chan *jsonrpcMessage, 1) + c.pending.Store(id, ch) + defer c.pending.Delete(id) + + paramsData, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("marshal params: %w", err) + } + + msg := &jsonrpcMessage{ + JSONRPC: "2.0", + ID: &id, + Method: method, + Params: paramsData, + } + if err := c.writeMessage(msg); err != nil { + return fmt.Errorf("write request: %w", err) + } + + select { + case resp := <-ch: + if resp.Error != nil { + return resp.Error + } + if result != nil && len(resp.Result) > 0 { + return json.Unmarshal(resp.Result, result) + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.done: + return fmt.Errorf("connection closed") + } +} + +// Notify sends a JSON-RPC notification (no response expected). +func (c *Conn) Notify(method string, params any) error { + paramsData, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("marshal params: %w", err) + } + msg := &jsonrpcMessage{ + JSONRPC: "2.0", + Method: method, + Params: paramsData, + } + return c.writeMessage(msg) +} + +// writeMessage marshals and writes a JSON-RPC message followed by a newline. +func (c *Conn) writeMessage(msg *jsonrpcMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + data = append(data, '\n') + + c.mu.Lock() + defer c.mu.Unlock() + _, err = c.writer.Write(data) + return err +} + +// Done returns a channel that is closed when the read loop exits. +func (c *Conn) Done() <-chan struct{} { + return c.done +} diff --git a/internal/providers/acp/process.go b/internal/providers/acp/process.go new file mode 100644 index 00000000..80012d15 --- /dev/null +++ b/internal/providers/acp/process.go @@ -0,0 +1,237 @@ +package acp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "sync" + "sync/atomic" + "time" +) + +// ACPProcess represents a running ACP agent subprocess. +type ACPProcess struct { + cmd *exec.Cmd + conn *Conn + sessionID string // ACP session ID from session/new + agentCaps AgentCaps + lastActive time.Time + inUse atomic.Int32 // >0 means prompt active — reaper must skip + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + exited chan struct{} // closed when process exits + + // updateFn is called for session/update notifications during a prompt. + updateFn func(SessionUpdate) + updateMu sync.Mutex +} + +// setUpdateFn sets the callback for session/update notifications (thread-safe). +func (p *ACPProcess) setUpdateFn(fn func(SessionUpdate)) { + p.updateMu.Lock() + p.updateFn = fn + p.updateMu.Unlock() +} + +// dispatchUpdate routes a session/update to the active prompt callback. +func (p *ACPProcess) dispatchUpdate(update SessionUpdate) { + p.updateMu.Lock() + fn := p.updateFn + p.updateMu.Unlock() + if fn != nil { + fn(update) + } +} + +// ProcessPool manages a pool of ACP agent subprocesses keyed by session. +type ProcessPool struct { + processes sync.Map // sessionKey → *ACPProcess + spawnMu sync.Map // sessionKey → *sync.Mutex — prevents concurrent spawn + agentBinary string + agentArgs []string + workDir string + idleTTL time.Duration + mu sync.RWMutex // protects toolHandler + toolHandler RequestHandler + done chan struct{} + closeOnce sync.Once +} + +// NewProcessPool creates a pool that spawns ACP agents as subprocesses. +func NewProcessPool(binary string, args []string, workDir string, idleTTL time.Duration) *ProcessPool { + pp := &ProcessPool{ + agentBinary: binary, + agentArgs: args, + workDir: workDir, + idleTTL: idleTTL, + done: make(chan struct{}), + } + go pp.reapLoop() + return pp +} + +// SetToolHandler sets the agent→client request handler (tool bridge). +// Must be called before any GetOrSpawn calls. +func (pp *ProcessPool) SetToolHandler(h RequestHandler) { + pp.mu.Lock() + defer pp.mu.Unlock() + pp.toolHandler = h +} + +// getToolHandler returns the current tool handler (thread-safe). +func (pp *ProcessPool) getToolHandler() RequestHandler { + pp.mu.RLock() + defer pp.mu.RUnlock() + return pp.toolHandler +} + +// GetOrSpawn returns an existing process for the session key or spawns a new one. +// Uses per-key mutex to prevent concurrent spawn for the same session. +func (pp *ProcessPool) GetOrSpawn(ctx context.Context, sessionKey string) (*ACPProcess, error) { + // Acquire per-key spawn lock to prevent concurrent spawns + actual, _ := pp.spawnMu.LoadOrStore(sessionKey, &sync.Mutex{}) + mu := actual.(*sync.Mutex) + mu.Lock() + defer mu.Unlock() + + if val, ok := pp.processes.Load(sessionKey); ok { + proc := val.(*ACPProcess) + select { + case <-proc.exited: + // Process crashed — remove and respawn + pp.processes.Delete(sessionKey) + slog.Info("acp: respawning crashed process", "session_key", sessionKey) + default: + return proc, nil + } + } + return pp.spawn(ctx, sessionKey) +} + +// spawn creates a new ACP subprocess, initializes it, and creates a session. +func (pp *ProcessPool) spawn(ctx context.Context, sessionKey string) (*ACPProcess, error) { + procCtx, cancel := context.WithCancel(context.Background()) + + cmd := exec.CommandContext(procCtx, pp.agentBinary, pp.agentArgs...) + cmd.Dir = pp.workDir + cmd.Env = filterACPEnv(os.Environ()) + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("acp: stdin pipe: %w", err) + } + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("acp: stdout pipe: %w", err) + } + // Capture stderr for diagnostics (capped via limitedWriter) + cmd.Stderr = &limitedWriter{max: 4096} + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("acp: start %s: %w", pp.agentBinary, err) + } + + proc := &ACPProcess{ + cmd: cmd, + lastActive: time.Now(), + ctx: procCtx, + cancel: cancel, + exited: make(chan struct{}), + } + + // Notification handler: route session/update to active prompt callback + notifyHandler := func(method string, params json.RawMessage) { + if method == "session/update" { + var update SessionUpdate + if err := json.Unmarshal(params, &update); err != nil { + slog.Warn("acp: failed to parse session/update", "error", err) + return + } + proc.dispatchUpdate(update) + } + } + + proc.conn = NewConn(stdinPipe, stdoutPipe, pp.getToolHandler(), notifyHandler) + proc.conn.Start() + + // Monitor process exit and log stderr + stderrWriter := cmd.Stderr.(*limitedWriter) + go func() { + _ = cmd.Wait() + if s := stderrWriter.String(); s != "" { + slog.Debug("acp: process stderr", "session_key", sessionKey, "stderr", s) + } + close(proc.exited) + }() + + // ACP handshake: initialize + session/new + if err := proc.Initialize(ctx); err != nil { + cancel() + return nil, err + } + if err := proc.NewSession(ctx); err != nil { + cancel() + return nil, err + } + + pp.processes.Store(sessionKey, proc) + slog.Info("acp: process spawned", "session_key", sessionKey, "binary", pp.agentBinary) + return proc, nil +} + +// reapLoop periodically checks for idle processes and kills them. +func (pp *ProcessPool) reapLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + pp.processes.Range(func(key, value any) bool { + proc := value.(*ACPProcess) + // Skip processes with active prompts + if proc.inUse.Load() > 0 { + return true + } + proc.mu.Lock() + idle := time.Since(proc.lastActive) > pp.idleTTL + proc.mu.Unlock() + if idle { + slog.Info("acp: reaping idle process", "session_key", key) + proc.cancel() + pp.processes.Delete(key) + } + return true + }) + case <-pp.done: + return + } + } +} + +// Close shuts down all processes gracefully. +func (pp *ProcessPool) Close() error { + pp.closeOnce.Do(func() { + close(pp.done) + pp.processes.Range(func(key, value any) bool { + proc := value.(*ACPProcess) + proc.cancel() + // Wait briefly for process to exit + select { + case <-proc.exited: + case <-time.After(5 * time.Second): + slog.Warn("acp: process did not exit in time", "session_key", key) + } + pp.processes.Delete(key) + return true + }) + }) + return nil +} + diff --git a/internal/providers/acp/session.go b/internal/providers/acp/session.go new file mode 100644 index 00000000..ba0fa361 --- /dev/null +++ b/internal/providers/acp/session.go @@ -0,0 +1,71 @@ +package acp + +import ( + "context" + "fmt" + "time" +) + +// Initialize sends the ACP initialize request to establish capabilities. +func (p *ACPProcess) Initialize(ctx context.Context) error { + req := InitializeRequest{ + ClientInfo: ClientInfo{Name: "goclaw", Version: "1.0"}, + Capabilities: ClientCaps{ + Fs: &FsCaps{ReadTextFile: true, WriteTextFile: true}, + Terminal: &TerminalCaps{Enabled: true}, + }, + } + var resp InitializeResponse + if err := p.conn.Call(ctx, "initialize", req, &resp); err != nil { + return fmt.Errorf("acp initialize: %w", err) + } + p.agentCaps = resp.Capabilities + return nil +} + +// NewSession creates a new ACP session on this process. +func (p *ACPProcess) NewSession(ctx context.Context) error { + var resp NewSessionResponse + if err := p.conn.Call(ctx, "session/new", NewSessionRequest{}, &resp); err != nil { + return fmt.Errorf("acp session/new: %w", err) + } + p.sessionID = resp.SessionID + return nil +} + +// Prompt sends user content and blocks until the agent responds. +// onUpdate is called for each session/update notification (streaming). +func (p *ACPProcess) Prompt(ctx context.Context, content []ContentBlock, onUpdate func(SessionUpdate)) (*PromptResponse, error) { + p.inUse.Add(1) + defer p.inUse.Add(-1) + + p.mu.Lock() + p.lastActive = time.Now() + p.mu.Unlock() + + // Install the update callback for this prompt + p.setUpdateFn(onUpdate) + defer p.setUpdateFn(nil) + + req := PromptRequest{ + SessionID: p.sessionID, + Content: content, + } + var resp PromptResponse + if err := p.conn.Call(ctx, "session/prompt", req, &resp); err != nil { + return nil, fmt.Errorf("acp session/prompt: %w", err) + } + + p.mu.Lock() + p.lastActive = time.Now() + p.mu.Unlock() + + return &resp, nil +} + +// Cancel sends a session/cancel notification for cooperative cancellation. +func (p *ACPProcess) Cancel() error { + return p.conn.Notify("session/cancel", CancelNotification{ + SessionID: p.sessionID, + }) +} diff --git a/internal/providers/acp/terminal.go b/internal/providers/acp/terminal.go new file mode 100644 index 00000000..282d09e4 --- /dev/null +++ b/internal/providers/acp/terminal.go @@ -0,0 +1,212 @@ +package acp + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// Terminal represents a running command spawned by an ACP agent. +type Terminal struct { + id string + cmd *exec.Cmd + output *cappedBuffer + mu sync.Mutex + exited chan struct{} + exitCode int + cancel context.CancelFunc +} + +// cappedBuffer is a thread-safe buffer that caps output at a maximum size. +type cappedBuffer struct { + buf bytes.Buffer + mu sync.Mutex + max int +} + +func (cb *cappedBuffer) Write(p []byte) (int, error) { + cb.mu.Lock() + defer cb.mu.Unlock() + // If writing would exceed cap, truncate by keeping only the tail + if cb.buf.Len()+len(p) > cb.max { + overflow := cb.buf.Len() + len(p) - cb.max + if overflow >= cb.buf.Len() { + cb.buf.Reset() + // Only write the last max bytes of p + if len(p) > cb.max { + p = p[len(p)-cb.max:] + } + } else { + data := cb.buf.Bytes()[overflow:] + cb.buf.Reset() + cb.buf.Write(data) + } + } + return cb.buf.Write(p) +} + +func (cb *cappedBuffer) String() string { + cb.mu.Lock() + defer cb.mu.Unlock() + return cb.buf.String() +} + +// allowedTerminalBinaries lists binaries that ACP agents may execute. +// All commands run via exec.CommandContext (no shell interpretation), +// so this restricts which programs can be spawned as subprocesses. +var allowedTerminalBinaries = map[string]bool{ + "sh": true, "bash": true, "zsh": true, "fish": true, + "node": true, "python": true, "python3": true, "ruby": true, "perl": true, + "go": true, "cargo": true, "rustc": true, "gcc": true, "g++": true, "make": true, + "git": true, "ls": true, "cat": true, "head": true, "tail": true, + "grep": true, "rg": true, "find": true, "wc": true, "sort": true, "uniq": true, + "diff": true, "patch": true, "mkdir": true, "cp": true, "mv": true, "touch": true, + "echo": true, "printf": true, "env": true, "which": true, "whoami": true, + "npm": true, "npx": true, "pnpm": true, "yarn": true, "bun": true, + "pip": true, "pip3": true, "uv": true, "pipx": true, + "docker": true, "kubectl": true, + "curl": true, "wget": true, "jq": true, "yq": true, + "tar": true, "gzip": true, "unzip": true, + "sed": true, "awk": true, "xargs": true, "tee": true, "tr": true, "cut": true, + "test": true, "true": true, "false": true, +} + +// createTerminal spawns a command and tracks it in the terminal registry. +func (tb *ToolBridge) createTerminal(req CreateTerminalRequest) (*CreateTerminalResponse, error) { + // Validate binary against allowlist — extract base name for PATH-based commands + binaryBase := filepath.Base(req.Command) + if !allowedTerminalBinaries[binaryBase] { + slog.Warn("security.acp_terminal_denied", "command", req.Command, "binary", binaryBase) + return nil, fmt.Errorf("command %q not in allowed binary list", binaryBase) + } + + // Validate command + args against deny patterns + fullCmd := req.Command + if len(req.Args) > 0 { + fullCmd += " " + strings.Join(req.Args, " ") + } + for _, pat := range tb.denyPatterns { + if pat.MatchString(fullCmd) { + slog.Warn("security.acp_terminal_deny_pattern", "command", fullCmd) + return nil, fmt.Errorf("command denied by safety policy") + } + } + + // Resolve cwd (default to workspace) + cwd := tb.workspace + if req.Cwd != "" { + resolved, err := tb.resolvePath(req.Cwd) + if err != nil { + return nil, err + } + cwd = resolved + } + + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, req.Command, req.Args...) + cmd.Dir = cwd + + output := &cappedBuffer{max: tb.maxOutputBytes} + cmd.Stdout = output + cmd.Stderr = output + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start failed: %w", err) + } + + termID := fmt.Sprintf("term-%d", tb.nextTermID.Add(1)) + term := &Terminal{ + id: termID, + cmd: cmd, + output: output, + exited: make(chan struct{}), + cancel: cancel, + } + + // Wait for process exit in background + go func() { + err := cmd.Wait() + term.mu.Lock() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + term.exitCode = exitErr.ExitCode() + } else { + term.exitCode = -1 + } + } + term.mu.Unlock() + close(term.exited) + }() + + tb.terminals.Store(termID, term) + return &CreateTerminalResponse{TerminalID: termID}, nil +} + +// terminalOutput returns the current output and exit status if exited. +func (tb *ToolBridge) terminalOutput(req TerminalOutputRequest) (*TerminalOutputResponse, error) { + val, ok := tb.terminals.Load(req.TerminalID) + if !ok { + return nil, fmt.Errorf("terminal not found: %s", req.TerminalID) + } + t := val.(*Terminal) + resp := &TerminalOutputResponse{Output: t.output.String()} + select { + case <-t.exited: + t.mu.Lock() + code := t.exitCode + t.mu.Unlock() + resp.ExitStatus = &code + default: + } + return resp, nil +} + +// releaseTerminal kills (if running) and removes a terminal. +func (tb *ToolBridge) releaseTerminal(req ReleaseTerminalRequest) (*ReleaseTerminalResponse, error) { + val, ok := tb.terminals.LoadAndDelete(req.TerminalID) + if !ok { + return &ReleaseTerminalResponse{}, nil + } + t := val.(*Terminal) + t.cancel() + return &ReleaseTerminalResponse{}, nil +} + +// waitForExit blocks until the terminal command exits, with a 10-minute timeout. +// Respects context cancellation so callers are not blocked indefinitely. +func (tb *ToolBridge) waitForExit(ctx context.Context, req WaitForTerminalExitRequest) (*WaitForTerminalExitResponse, error) { + val, ok := tb.terminals.Load(req.TerminalID) + if !ok { + return nil, fmt.Errorf("terminal not found: %s", req.TerminalID) + } + t := val.(*Terminal) + select { + case <-t.exited: + t.mu.Lock() + code := t.exitCode + t.mu.Unlock() + return &WaitForTerminalExitResponse{ExitStatus: code}, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(10 * time.Minute): + return nil, fmt.Errorf("terminal %s: wait timed out after 10m", req.TerminalID) + } +} + +// killTerminal sends a kill signal without removing the terminal. +func (tb *ToolBridge) killTerminal(req KillTerminalRequest) (*KillTerminalResponse, error) { + val, ok := tb.terminals.Load(req.TerminalID) + if !ok { + return nil, fmt.Errorf("terminal not found: %s", req.TerminalID) + } + t := val.(*Terminal) + t.cancel() + return &KillTerminalResponse{}, nil +} diff --git a/internal/providers/acp/tool_bridge.go b/internal/providers/acp/tool_bridge.go new file mode 100644 index 00000000..ba36aa58 --- /dev/null +++ b/internal/providers/acp/tool_bridge.go @@ -0,0 +1,204 @@ +package acp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" +) + +// ToolBridge handles agent→client requests (fs, terminal, permission). +// It enforces workspace sandboxing and shell deny patterns. +type ToolBridge struct { + workspace string + terminals sync.Map // string → *Terminal + denyPatterns []*regexp.Regexp + permMode string // "approve-all" (default), "approve-reads", "deny-all" + nextTermID atomic.Int64 + maxOutputBytes int +} + +// ToolBridgeOption configures a ToolBridge. +type ToolBridgeOption func(*ToolBridge) + +// WithDenyPatterns sets shell deny patterns. +func WithDenyPatterns(patterns []*regexp.Regexp) ToolBridgeOption { + return func(tb *ToolBridge) { tb.denyPatterns = patterns } +} + +// WithPermMode sets the permission handling mode. +func WithPermMode(mode string) ToolBridgeOption { + return func(tb *ToolBridge) { + if mode != "" { + tb.permMode = mode + } + } +} + +// NewToolBridge creates a tool bridge sandboxed to the given workspace. +func NewToolBridge(workspace string, opts ...ToolBridgeOption) *ToolBridge { + tb := &ToolBridge{ + workspace: workspace, + permMode: "approve-all", + maxOutputBytes: 10 * 1024 * 1024, // 10MB + } + for _, opt := range opts { + opt(tb) + } + return tb +} + +// Handle dispatches agent→client requests by method name. +// Implements the RequestHandler signature for Conn. +func (tb *ToolBridge) Handle(ctx context.Context, method string, params json.RawMessage) (any, error) { + switch method { + case "fs/readTextFile": + if tb.permMode == "deny-all" { + return nil, fmt.Errorf("read denied by permission mode: %s", tb.permMode) + } + var req ReadTextFileRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.readFile(req) + case "fs/writeTextFile": + if tb.permMode == "deny-all" || tb.permMode == "approve-reads" { + return nil, fmt.Errorf("write denied by permission mode: %s", tb.permMode) + } + var req WriteTextFileRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.writeFile(req) + case "terminal/create": + if tb.permMode == "deny-all" || tb.permMode == "approve-reads" { + return nil, fmt.Errorf("terminal denied by permission mode: %s", tb.permMode) + } + var req CreateTerminalRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.createTerminal(req) + case "terminal/output": + var req TerminalOutputRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.terminalOutput(req) + case "terminal/release": + var req ReleaseTerminalRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.releaseTerminal(req) + case "terminal/waitForExit": + var req WaitForTerminalExitRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.waitForExit(ctx, req) + case "terminal/kill": + if tb.permMode == "deny-all" { + return nil, fmt.Errorf("terminal kill denied by permission mode: %s", tb.permMode) + } + var req KillTerminalRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.killTerminal(req) + case "permission/request": + var req RequestPermissionRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.handlePermission(req) + default: + return nil, fmt.Errorf("unknown method: %s", method) + } +} + +// readFile reads a file validated against the workspace boundary. +func (tb *ToolBridge) readFile(req ReadTextFileRequest) (*ReadTextFileResponse, error) { + resolved, err := tb.resolvePath(req.Path) + if err != nil { + return nil, err + } + data, err := os.ReadFile(resolved) + if err != nil { + return nil, fmt.Errorf("read failed: %w", err) + } + return &ReadTextFileResponse{Content: string(data)}, nil +} + +// writeFile writes a file validated against the workspace boundary. +func (tb *ToolBridge) writeFile(req WriteTextFileRequest) (*WriteTextFileResponse, error) { + resolved, err := tb.resolvePath(req.Path) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil { + return nil, fmt.Errorf("mkdir failed: %w", err) + } + if err := os.WriteFile(resolved, []byte(req.Content), 0644); err != nil { + return nil, fmt.Errorf("write failed: %w", err) + } + return &WriteTextFileResponse{}, nil +} + +// handlePermission responds to permission requests based on configured mode. +func (tb *ToolBridge) handlePermission(req RequestPermissionRequest) (*RequestPermissionResponse, error) { + switch tb.permMode { + case "deny-all": + return &RequestPermissionResponse{Outcome: "denied"}, nil + case "approve-reads": + // Approve read-only tools, deny write/exec tools + lower := strings.ToLower(req.ToolName) + if strings.Contains(lower, "read") || strings.Contains(lower, "glob") || + strings.Contains(lower, "grep") || strings.Contains(lower, "search") || + strings.Contains(lower, "list") || strings.Contains(lower, "view") { + return &RequestPermissionResponse{Outcome: "approved"}, nil + } + return &RequestPermissionResponse{Outcome: "denied"}, nil + default: // "approve-all" or unknown → approve + return &RequestPermissionResponse{Outcome: "approved"}, nil + } +} + +// resolvePath validates that a path stays within the workspace boundary. +func (tb *ToolBridge) resolvePath(path string) (string, error) { + cleaned := filepath.Clean(path) + if !filepath.IsAbs(cleaned) { + cleaned = filepath.Join(tb.workspace, cleaned) + } + // Resolve symlinks for the target (may not exist yet for writes) + real, err := filepath.EvalSymlinks(cleaned) + if err != nil { + real = cleaned // file may not exist yet — validate parent + } + wsReal, _ := filepath.EvalSymlinks(tb.workspace) + if wsReal == "" { + wsReal = tb.workspace + } + if real != wsReal && !strings.HasPrefix(real, wsReal+string(filepath.Separator)) { + slog.Warn("security.acp_path_escape", "path", path, "resolved", real, "workspace", wsReal) + return "", fmt.Errorf("access denied: path outside workspace") + } + return real, nil +} + +// Close kills all active terminals. +func (tb *ToolBridge) Close() error { + tb.terminals.Range(func(key, value any) bool { + t := value.(*Terminal) + t.cancel() + tb.terminals.Delete(key) + return true + }) + return nil +} diff --git a/internal/providers/acp/types.go b/internal/providers/acp/types.go new file mode 100644 index 00000000..216bf5a4 --- /dev/null +++ b/internal/providers/acp/types.go @@ -0,0 +1,189 @@ +package acp + +// ACP protocol types — client-side subset for GoClaw as ACP client. +// Covers: initialize, session lifecycle, content blocks, and agent→client requests. + +// --- Client → Agent Requests --- + +// InitializeRequest starts the ACP handshake. +type InitializeRequest struct { + ClientInfo ClientInfo `json:"clientInfo"` + Capabilities ClientCaps `json:"capabilities"` +} + +// ClientInfo identifies the ACP client. +type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ClientCaps declares what the client can handle (fs, terminal, etc.). +type ClientCaps struct { + Fs *FsCaps `json:"fs,omitempty"` + Terminal *TerminalCaps `json:"terminal,omitempty"` +} + +// FsCaps declares filesystem capabilities. +type FsCaps struct { + ReadTextFile bool `json:"readTextFile"` + WriteTextFile bool `json:"writeTextFile"` +} + +// TerminalCaps declares terminal capabilities. +type TerminalCaps struct { + Enabled bool `json:"enabled"` +} + +// InitializeResponse carries the agent's identity and capabilities. +type InitializeResponse struct { + AgentInfo AgentInfo `json:"agentInfo"` + Capabilities AgentCaps `json:"capabilities"` +} + +// AgentInfo identifies the ACP agent. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// AgentCaps declares agent capabilities. +type AgentCaps struct { + LoadSession bool `json:"loadSession"` + PromptCapabilities *PromptCaps `json:"promptCapabilities,omitempty"` + SessionCapabilities *SessionCaps `json:"sessionCapabilities,omitempty"` +} + +// PromptCaps describes what content types the agent accepts. +type PromptCaps struct { + Audio bool `json:"audio"` + Image bool `json:"image"` + EmbeddedContext bool `json:"embeddedContext"` +} + +// SessionCaps describes session-level capabilities. +type SessionCaps struct{} + +// --- Session Methods --- + +// NewSessionRequest creates a new ACP session. +type NewSessionRequest struct{} + +// NewSessionResponse carries the new session ID. +type NewSessionResponse struct { + SessionID string `json:"sessionId"` +} + +// PromptRequest sends user content to the agent. +type PromptRequest struct { + SessionID string `json:"sessionId"` + Content []ContentBlock `json:"content"` +} + +// PromptResponse is the final response after the agent completes. +type PromptResponse struct { + StopReason string `json:"stopReason,omitempty"` +} + +// CancelNotification requests cooperative cancellation. +type CancelNotification struct { + SessionID string `json:"sessionId"` +} + +// --- Content Blocks --- + +// ContentBlock represents a piece of content (text, image, audio). +type ContentBlock struct { + Type string `json:"type"` // "text", "image", "audio" + Text string `json:"text,omitempty"` + Data string `json:"data,omitempty"` // base64 for image/audio + MimeType string `json:"mimeType,omitempty"` +} + +// --- Agent → Client Notifications --- + +// SessionUpdate carries incremental updates during prompt execution. +type SessionUpdate struct { + Kind string `json:"kind"` // "message", "toolCall", "plan" + StopReason string `json:"stopReason,omitempty"` // "endTurn", "cancelled" + Message *MessageUpdate `json:"message,omitempty"` + ToolCall *ToolCallUpdate `json:"toolCall,omitempty"` +} + +// MessageUpdate carries an assistant text delta. +type MessageUpdate struct { + Role string `json:"role"` + Content []ContentBlock `json:"content"` +} + +// ToolCallUpdate carries tool call progress. +type ToolCallUpdate struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // "running", "completed" + Content []ContentBlock `json:"content,omitempty"` +} + +// --- Agent → Client Requests (fs/terminal/permission) --- + +type ReadTextFileRequest struct { + Path string `json:"path"` +} + +type ReadTextFileResponse struct { + Content string `json:"content"` +} + +type WriteTextFileRequest struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type WriteTextFileResponse struct{} + +type CreateTerminalRequest struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +type CreateTerminalResponse struct { + TerminalID string `json:"terminalId"` +} + +type TerminalOutputRequest struct { + TerminalID string `json:"terminalId"` +} + +type TerminalOutputResponse struct { + Output string `json:"output"` + ExitStatus *int `json:"exitStatus,omitempty"` +} + +type ReleaseTerminalRequest struct { + TerminalID string `json:"terminalId"` +} + +type ReleaseTerminalResponse struct{} + +type WaitForTerminalExitRequest struct { + TerminalID string `json:"terminalId"` +} + +type WaitForTerminalExitResponse struct { + ExitStatus int `json:"exitStatus"` +} + +type KillTerminalRequest struct { + TerminalID string `json:"terminalId"` +} + +type KillTerminalResponse struct{} + +type RequestPermissionRequest struct { + ToolName string `json:"toolName"` + Description string `json:"description"` +} + +type RequestPermissionResponse struct { + Outcome string `json:"outcome"` // "approved", "denied", "cancelled" +} diff --git a/internal/providers/acp_provider.go b/internal/providers/acp_provider.go new file mode 100644 index 00000000..03d9f0c4 --- /dev/null +++ b/internal/providers/acp_provider.go @@ -0,0 +1,227 @@ +package providers + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + "sync" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/providers/acp" +) + +// ACPProvider implements Provider by orchestrating ACP-compatible agent subprocesses. +// It delegates to a ProcessPool that manages agent lifecycle over JSON-RPC 2.0 stdio. +type ACPProvider struct { + pool *acp.ProcessPool + bridge *acp.ToolBridge + defaultModel string + permMode string // permission mode for tool bridge + sessionMu sync.Map // sessionKey → *sync.Mutex +} + +// ACPOption configures an ACPProvider. +type ACPOption func(*ACPProvider) + +// WithACPModel sets the default model/agent name. +func WithACPModel(model string) ACPOption { + return func(p *ACPProvider) { + if model != "" { + p.defaultModel = model + } + } +} + +// WithACPPermMode sets the permission mode for the tool bridge. +func WithACPPermMode(mode string) ACPOption { + return func(p *ACPProvider) { + if mode != "" { + p.permMode = mode + } + } +} + +// NewACPProvider creates a provider that orchestrates ACP agents as subprocesses. +func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider { + p := &ACPProvider{ + defaultModel: "claude", + } + for _, opt := range opts { + opt(p) + } + + // Create tool bridge with workspace sandboxing, deny patterns, and permission mode + var bridgeOpts []acp.ToolBridgeOption + if len(denyPatterns) > 0 { + bridgeOpts = append(bridgeOpts, acp.WithDenyPatterns(denyPatterns)) + } + if p.permMode != "" { + bridgeOpts = append(bridgeOpts, acp.WithPermMode(p.permMode)) + } + p.bridge = acp.NewToolBridge(workDir, bridgeOpts...) + + // Create process pool with tool bridge wired in + p.pool = acp.NewProcessPool(binary, args, workDir, idleTTL) + p.pool.SetToolHandler(p.bridge.Handle) + + return p +} + +func (p *ACPProvider) Name() string { return "acp" } +func (p *ACPProvider) DefaultModel() string { return p.defaultModel } + +// Chat sends a prompt and returns the complete response (non-streaming). +func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + sessionKey := extractStringOpt(req.Options, OptSessionKey) + if sessionKey == "" { + return nil, fmt.Errorf("acp: session_key required in options") + } + + unlock := p.lockSession(sessionKey) + defer unlock() + + proc, err := p.pool.GetOrSpawn(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("acp: spawn failed: %w", err) + } + + content := extractACPContent(req) + if len(content) == 0 { + return nil, fmt.Errorf("acp: no user message in request") + } + + // Collect all text from session/update notifications + var buf strings.Builder + promptResp, err := proc.Prompt(ctx, content, func(update acp.SessionUpdate) { + if update.Message != nil { + for _, block := range update.Message.Content { + if block.Type == "text" { + buf.WriteString(block.Text) + } + } + } + }) + if err != nil { + return nil, fmt.Errorf("acp: prompt failed: %w", err) + } + + return &ChatResponse{ + Content: buf.String(), + FinishReason: mapStopReason(promptResp), + Usage: &Usage{}, + }, nil +} + +// ChatStream sends a prompt and streams response chunks via onChunk callback. +func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + sessionKey := extractStringOpt(req.Options, OptSessionKey) + if sessionKey == "" { + return nil, fmt.Errorf("acp: session_key required in options") + } + + unlock := p.lockSession(sessionKey) + defer unlock() + + proc, err := p.pool.GetOrSpawn(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("acp: spawn failed: %w", err) + } + + content := extractACPContent(req) + if len(content) == 0 { + return nil, fmt.Errorf("acp: no user message in request") + } + + // Handle context cancellation → send session/cancel + cancelCtx, cancelFn := context.WithCancel(ctx) + defer cancelFn() + go func() { + <-cancelCtx.Done() + if ctx.Err() == context.Canceled { + _ = proc.Cancel() + } + }() + + var buf strings.Builder + promptResp, err := proc.Prompt(ctx, content, func(update acp.SessionUpdate) { + if update.Message != nil { + for _, block := range update.Message.Content { + if block.Type == "text" { + onChunk(StreamChunk{Content: block.Text}) + buf.WriteString(block.Text) + } + } + } + if update.ToolCall != nil && update.ToolCall.Status == "running" { + slog.Debug("acp: tool call", "name", update.ToolCall.Name) + } + }) + if err != nil { + return nil, fmt.Errorf("acp: prompt failed: %w", err) + } + + onChunk(StreamChunk{Done: true}) + + return &ChatResponse{ + Content: buf.String(), + FinishReason: mapStopReason(promptResp), + Usage: &Usage{}, + }, nil +} + +// Close shuts down all subprocesses and cleans up terminals. +func (p *ACPProvider) Close() error { + _ = p.bridge.Close() + return p.pool.Close() +} + +// lockSession acquires a per-session mutex (same pattern as ClaudeCLIProvider). +func (p *ACPProvider) lockSession(sessionKey string) func() { + actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{}) + m := actual.(*sync.Mutex) + m.Lock() + return m.Unlock +} + +// extractACPContent extracts user message + images from ChatRequest into ACP ContentBlocks. +func extractACPContent(req ChatRequest) []acp.ContentBlock { + systemPrompt, userMsg, images := extractFromMessages(req.Messages) + if userMsg == "" { + return nil + } + + var blocks []acp.ContentBlock + + // Prepend system prompt to first user message (ACP agents don't have separate system prompt API) + text := userMsg + if systemPrompt != "" { + text = systemPrompt + "\n\n" + userMsg + } + blocks = append(blocks, acp.ContentBlock{Type: "text", Text: text}) + + // Add images + for _, img := range images { + blocks = append(blocks, acp.ContentBlock{ + Type: "image", + Data: img.Data, + MimeType: img.MimeType, + }) + } + + return blocks +} + +// mapStopReason converts ACP stopReason to GoClaw finish reason. +func mapStopReason(resp *acp.PromptResponse) string { + if resp == nil { + return "stop" + } + switch resp.StopReason { + case "maxContextLength": + return "length" + default: + return "stop" + } +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 06d94e5d..e597d017 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -30,6 +30,7 @@ const ( ProviderZaiCoding = "zai_coding" ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) + ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess ) // ValidProviderTypes lists all accepted provider_type values. @@ -55,6 +56,7 @@ var ValidProviderTypes = map[string]bool{ ProviderZaiCoding: true, ProviderOllama: true, ProviderOllamaCloud: true, + ProviderACP: true, } // LLMProviderData represents an LLM provider configuration. diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go index f37f0bc9..ae9b291b 100644 --- a/internal/tools/dynamic_tool.go +++ b/internal/tools/dynamic_tool.go @@ -43,8 +43,8 @@ func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result // Render command template with shell-escaped args command := renderCommand(t.def.Command, args) - // Check deny patterns (same package — direct access to defaultDenyPatterns) - for _, pattern := range defaultDenyPatterns { + // Check deny patterns (same package — direct access to DefaultDenyPatterns) + for _, pattern := range DefaultDenyPatterns { if pattern.MatchString(command) { return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) } diff --git a/internal/tools/message.go b/internal/tools/message.go index 44669e19..51d5bf74 100644 --- a/internal/tools/message.go +++ b/internal/tools/message.go @@ -189,9 +189,13 @@ func (t *MessageTool) resolveMediaPath(ctx context.Context, s string) (string, b // resolvePath handles relative→absolute, symlink, hardlink, boundary checks. resolved, err := resolvePath(raw, workspace, restrict) if err != nil { - // When restricted, also allow /tmp/ (used by create_image, create_audio, etc.) - if restrict && isInTempDir(raw) { - return filepath.Clean(raw), true + // When restricted, also allow /tmp/ paths (used by create_image, create_audio, etc.) + // But reject paths that are siblings of the workspace — these are likely traversal + // attacks where workspace/../X resolves inside /tmp/ because workspace itself is in /tmp/. + cleaned := filepath.Clean(raw) + wsParent := filepath.Dir(filepath.Clean(workspace)) + if restrict && isInTempDir(cleaned) && !isPathInside(cleaned, wsParent) { + return cleaned, true } return "", false } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index ae143416..7a2646ce 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -20,7 +20,8 @@ import ( // read-only rootfs, no-new-privileges, pids-limit, memory limit). // Sources: OWASP Agentic AI Top 10, Claude Code CVE-2025-66032, MITRE ATT&CK, // PayloadsAllTheThings, Trail of Bits prompt-injection-to-RCE research. -var defaultDenyPatterns = []*regexp.Regexp{ +// DefaultDenyPatterns contains dangerous command patterns to deny by default. +var DefaultDenyPatterns = []*regexp.Regexp{ // ── Destructive file operations ── regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\brm\s+.*--recursive`), @@ -145,7 +146,7 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { return &ExecTool{ workingDir: workingDir, timeout: 60 * time.Second, - denyPatterns: defaultDenyPatterns, + denyPatterns: DefaultDenyPatterns, restrict: restrict, } } @@ -155,7 +156,7 @@ func NewSandboxedExecTool(workingDir string, restrict bool, mgr sandbox.Manager) return &ExecTool{ workingDir: workingDir, timeout: 300 * time.Second, // sandbox allows longer timeout - denyPatterns: defaultDenyPatterns, + denyPatterns: DefaultDenyPatterns, restrict: restrict, sandboxMgr: mgr, } diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index edbb1158..29984e26 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -26,4 +26,5 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" }, { value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" }, { value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" }, + { value: "acp", label: "ACP Agent (Subprocess)", apiBase: "", placeholder: "claude" }, ]; diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index 5491b713..990d379c 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -64,7 +64,27 @@ "cohere": "Cohere", "perplexity": "Perplexity", "chatgpt_oauth": "ChatGPT (OAuth)", - "yescale": "YesScale" + "yescale": "YesScale", + "acp": "ACP" + }, + "acp": { + "description": "Orchestrate ACP-compatible coding agents as subprocesses. No API key needed.", + "binary": "Binary", + "binaryPlaceholder": "claude", + "binaryHint": "Path or name of the ACP agent binary (e.g. claude, codex, gemini)", + "args": "Arguments", + "argsPlaceholder": "--profile goclaw", + "argsHint": "Space-separated arguments passed to the binary", + "idleTTL": "Idle TTL", + "idleTTLPlaceholder": "5m", + "idleTTLHint": "Kill idle processes after this duration (e.g. 5m, 1h)", + "permMode": "Permission Mode", + "permModeApproveAll": "Approve All", + "permModeApproveReads": "Approve Reads Only", + "permModeDenyAll": "Deny All", + "workDir": "Work Directory", + "workDirPlaceholder": "/tmp/workspace", + "workDirHint": "Working directory for agent subprocesses (optional)" }, "oauth": { "checkingStatus": "Checking authentication status...", diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index e3df961f..8aac9019 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -64,7 +64,27 @@ "cohere": "Cohere", "perplexity": "Perplexity", "chatgpt_oauth": "ChatGPT (OAuth)", - "yescale": "YesScale" + "yescale": "YesScale", + "acp": "ACP" + }, + "acp": { + "description": "Điều phối các agent lập trình tương thích ACP dưới dạng tiến trình con. Không cần API key.", + "binary": "Tệp nhị phân", + "binaryPlaceholder": "claude", + "binaryHint": "Đường dẫn hoặc tên tệp nhị phân agent ACP (vd: claude, codex, gemini)", + "args": "Tham số", + "argsPlaceholder": "--profile goclaw", + "argsHint": "Các tham số cách nhau bởi dấu cách truyền vào tệp nhị phân", + "idleTTL": "Thời gian chờ", + "idleTTLPlaceholder": "5m", + "idleTTLHint": "Dừng tiến trình nhàn rỗi sau khoảng thời gian này (vd: 5m, 1h)", + "permMode": "Chế độ quyền", + "permModeApproveAll": "Chấp nhận tất cả", + "permModeApproveReads": "Chỉ chấp nhận đọc", + "permModeDenyAll": "Từ chối tất cả", + "workDir": "Thư mục làm việc", + "workDirPlaceholder": "/tmp/workspace", + "workDirHint": "Thư mục làm việc cho tiến trình con agent (tùy chọn)" }, "oauth": { "checkingStatus": "Đang kiểm tra trạng thái xác thực...", diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index 01a48e6b..cf42911e 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -64,7 +64,27 @@ "cohere": "Cohere", "perplexity": "Perplexity", "chatgpt_oauth": "ChatGPT (OAuth)", - "yescale": "YesScale" + "yescale": "YesScale", + "acp": "ACP" + }, + "acp": { + "description": "将兼容 ACP 的编程代理作为子进程进行编排。无需 API 密钥。", + "binary": "二进制文件", + "binaryPlaceholder": "claude", + "binaryHint": "ACP 代理二进制文件的路径或名称(例如 claude、codex、gemini)", + "args": "参数", + "argsPlaceholder": "--profile goclaw", + "argsHint": "传递给二进制文件的以空格分隔的参数", + "idleTTL": "空闲超时", + "idleTTLPlaceholder": "5m", + "idleTTLHint": "在此时间后终止空闲进程(例如 5m、1h)", + "permMode": "权限模式", + "permModeApproveAll": "全部批准", + "permModeApproveReads": "仅批准读取", + "permModeDenyAll": "全部拒绝", + "workDir": "工作目录", + "workDirPlaceholder": "/tmp/workspace", + "workDirHint": "代理子进程的工作目录(可选)" }, "oauth": { "checkingStatus": "正在检查认证状态...", diff --git a/ui/web/src/pages/providers/provider-acp-section.tsx b/ui/web/src/pages/providers/provider-acp-section.tsx new file mode 100644 index 00000000..9d2bf285 --- /dev/null +++ b/ui/web/src/pages/providers/provider-acp-section.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface ACPSectionProps { + binary: string; + onBinaryChange: (v: string) => void; + args: string; + onArgsChange: (v: string) => void; + idleTTL: string; + onIdleTTLChange: (v: string) => void; + permMode: string; + onPermModeChange: (v: string) => void; + workDir: string; + onWorkDirChange: (v: string) => void; +} + +export function ACPSection({ + binary, onBinaryChange, + args, onArgsChange, + idleTTL, onIdleTTLChange, + permMode, onPermModeChange, + workDir, onWorkDirChange, +}: ACPSectionProps) { + const { t } = useTranslation("providers"); + + return ( +
+

{t("acp.description")}

+ +
+ + onBinaryChange(e.target.value)} + placeholder={t("acp.binaryPlaceholder")} + /> +

{t("acp.binaryHint")}

+
+ +
+ + onArgsChange(e.target.value)} + placeholder={t("acp.argsPlaceholder")} + /> +

{t("acp.argsHint")}

+
+ +
+
+ + onIdleTTLChange(e.target.value)} + placeholder={t("acp.idleTTLPlaceholder")} + /> +

{t("acp.idleTTLHint")}

+
+ +
+ + +
+
+ +
+ + onWorkDirChange(e.target.value)} + placeholder={t("acp.workDirPlaceholder")} + /> +

{t("acp.workDirHint")}

+
+
+ ); +} diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index d9207468..79f7a7a3 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -25,6 +25,7 @@ import { slugify, isValidSlug } from "@/lib/slug"; import { PROVIDER_TYPES } from "@/constants/providers"; import { OAuthSection } from "./provider-oauth-section"; import { CLISection } from "./provider-cli-section"; +import { ACPSection } from "./provider-acp-section"; interface ProviderFormDialogProps { open: boolean; @@ -47,11 +48,19 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // ACP fields + const [acpBinary, setAcpBinary] = useState(""); + const [acpArgs, setAcpArgs] = useState(""); + const [acpIdleTTL, setAcpIdleTTL] = useState(""); + const [acpPermMode, setAcpPermMode] = useState("approve-all"); + const [acpWorkDir, setAcpWorkDir] = useState(""); + // Only one Claude CLI provider allowed per instance const hasClaudeCLI = !isEdit && existingProviders.some((p) => p.provider_type === "claude_cli"); const isOAuth = providerType === "chatgpt_oauth"; const isCLI = providerType === "claude_cli"; + const isACP = providerType === "acp"; useEffect(() => { if (open) { @@ -63,6 +72,15 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi setApiBase(provider.api_base || ""); setApiKey(provider.api_key || ""); setEnabled(provider.enabled); + // Load ACP settings from provider + if (provider.provider_type === "acp") { + const s = provider.settings as Record | undefined; + setAcpBinary(provider.api_base || ""); + setAcpArgs(Array.isArray(s?.args) ? (s.args as string[]).join(" ") : ""); + setAcpIdleTTL((s?.idle_ttl as string) || ""); + setAcpPermMode((s?.perm_mode as string) || "approve-all"); + setAcpWorkDir((s?.work_dir as string) || ""); + } } else { setName(""); setDisplayName(""); @@ -70,6 +88,11 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi setApiBase(""); setApiKey(""); setEnabled(true); + setAcpBinary(""); + setAcpArgs(""); + setAcpIdleTTL(""); + setAcpPermMode("approve-all"); + setAcpWorkDir(""); } } }, [open, provider]); @@ -86,6 +109,21 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi enabled, }; + // ACP: serialize fields into api_base (binary) + settings JSON + if (isACP) { + data.api_base = acpBinary.trim() || undefined; + const settings: Record = {}; + if (acpArgs.trim()) { + settings.args = acpArgs.trim().split(/\s+/); + } + if (acpIdleTTL.trim()) settings.idle_ttl = acpIdleTTL.trim(); + if (acpPermMode) settings.perm_mode = acpPermMode; + if (acpWorkDir.trim()) settings.work_dir = acpWorkDir.trim(); + if (Object.keys(settings).length > 0) { + data.settings = settings; + } + } + // Only include api_key if it's a real value (not the mask) if (apiKey && apiKey !== "***") { data.api_key = apiKey; @@ -190,8 +228,24 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi {/* Claude CLI section */} {isCLI && } - {/* Standard provider fields (not shown for Claude CLI) */} - {!isCLI && ( + {/* ACP section */} + {isACP && ( + + )} + + {/* Standard provider fields (not shown for Claude CLI or ACP) */} + {!isCLI && !isACP && ( <>
diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx index 777976e5..c78c945f 100644 --- a/ui/web/src/pages/providers/providers-page.tsx +++ b/ui/web/src/pages/providers/providers-page.tsx @@ -29,6 +29,7 @@ const typeBadge: Record; created_at: string; updated_at: string; } @@ -17,6 +18,7 @@ export interface ProviderInput { api_base?: string; api_key?: string; enabled?: boolean; + settings?: Record; } export interface ModelInfo {