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")}
+ +{t("acp.binaryHint")}
+{t("acp.argsHint")}
+{t("acp.idleTTLHint")}
+{t("acp.workDirHint")}
+