feat(providers): add ACP provider for external coding agents (#190)

* feat(providers): add ACP provider for orchestrating external coding agents (#189)

Implement native Go ACP (Agent Client Protocol) client as a new Provider.
Enables GoClaw to orchestrate any ACP-compatible agent (Claude Code, Codex
CLI, Gemini CLI) as a subprocess via JSON-RPC 2.0 over stdio.

- Add bidirectional JSON-RPC 2.0 transport over stdio pipes
- Add subprocess process pool with idle TTL reaping and crash recovery
- Add ACP session lifecycle (initialize, session/new, session/prompt)
- Add tool bridge for agent-initiated fs/terminal/permission requests
- Add workspace sandboxing, shell deny patterns, and env var filtering
- Wire config-based and DB-based provider registration paths
- Export DefaultDenyPatterns from tools package for reuse

* feat(providers): add changelog entry for ACP provider integration

* fix(tools): prevent workspace traversal bypass via /tmp/ fallback in resolveMediaPath

Reject paths containing ".." in the isInTempDir fallback to prevent
workspace escape where traversal path still resolves inside /tmp/.

* fix(tools): block workspace-sibling paths in resolveMediaPath /tmp/ fallback

When workspace is inside /tmp/, traversal paths like workspace/../X
resolve to /tmp/ siblings that pass isInTempDir. Reject paths inside
the workspace parent directory to prevent this escape.

* feat(providers): add ACP provider web UI and live reload via pubsub

Web UI for creating/editing ACP providers with dedicated form fields
(binary, args, idle TTL, permission mode, work directory). ACP providers
now update immediately without gateway restart via cache invalidation
pubsub pattern.

Frontend:
- New ACPSection form component with i18n (en/vi/zh)
- Provider form dialog integration with ACP state management
- ACP type badge on providers list page
- Settings field added to provider TypeScript types

Backend:
- ACP models handler (claude/codex/gemini) without API key requirement
- Binary path validation + LookPath verification in verify handler
- Provider CRUD emits cache.invalidate events via msgBus
- Subscriber in gateway_managed.go re-registers ACP providers from DB
- ACP core improvements from code review (helpers, jsonrpc, process,
  terminal, tool_bridge)

---------

Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
Goonandviettranx authored and GitHub committed 2026-03-14 16:16:08 +07:00
1 parent 16d726d5a2
commit 5e2fa395c7
30 files changed
+2117 -17

No files matched your search

+28
View File
@@ -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
}
+95
View File
@@ -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")
}
+3 -1
View File
@@ -42,6 +42,7 @@ flowchart TD
subgraph Providers["LLM Providers"]
ANTH[Anthropic -- Native HTTP + SSE]
OAI["OpenAI-Compatible -- HTTP + SSE<br/>(OpenAI, Gemini, DeepSeek, DashScope, +8)"]
ACP["ACP -- JSON-RPC 2.0 stdio<br/>(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 |
+170 -2
View File
@@ -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<br/>json-rpc 2.0 stdio"]
PROC -->|initialize| AGT["Agent<br/>(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 |
---
+63
View File
@@ -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
+2
View File
@@ -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"
+14 -1
View File
@@ -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.
+15
View File
@@ -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)
+22
View File
@@ -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}
+30
View File
@@ -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"})
+81
View File
@@ -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)
+217
View File
@@ -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
}
+237
View File
@@ -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
}
+71
View File
@@ -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,
})
}
+212
View File
@@ -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
}
+204
View File
@@ -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
}
+189
View File
@@ -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"
}
+227
View File
@@ -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"
}
}
+2
View File
@@ -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.
+2 -2
View File
@@ -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()))
}
+7 -3
View File
@@ -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
}
+4 -3
View File
@@ -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,
}
+1
View File
@@ -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" },
];
+21 -1
View File
@@ -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...",
+21 -1
View File
@@ -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...",
+21 -1
View File
@@ -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": "正在检查认证状态...",
@@ -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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("acp.description")}</p>
<div className="space-y-2">
<Label htmlFor="acpBinary">{t("acp.binary")}</Label>
<Input
id="acpBinary"
value={binary}
onChange={(e) => onBinaryChange(e.target.value)}
placeholder={t("acp.binaryPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("acp.binaryHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="acpArgs">{t("acp.args")}</Label>
<Input
id="acpArgs"
value={args}
onChange={(e) => onArgsChange(e.target.value)}
placeholder={t("acp.argsPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("acp.argsHint")}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="acpIdleTTL">{t("acp.idleTTL")}</Label>
<Input
id="acpIdleTTL"
value={idleTTL}
onChange={(e) => onIdleTTLChange(e.target.value)}
placeholder={t("acp.idleTTLPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("acp.idleTTLHint")}</p>
</div>
<div className="space-y-2">
<Label>{t("acp.permMode")}</Label>
<Select value={permMode} onValueChange={onPermModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approve-all">{t("acp.permModeApproveAll")}</SelectItem>
<SelectItem value="approve-reads">{t("acp.permModeApproveReads")}</SelectItem>
<SelectItem value="deny-all">{t("acp.permModeDenyAll")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="acpWorkDir">{t("acp.workDir")}</Label>
<Input
id="acpWorkDir"
value={workDir}
onChange={(e) => onWorkDirChange(e.target.value)}
placeholder={t("acp.workDirPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("acp.workDirHint")}</p>
</div>
</div>
);
}
@@ -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<string, unknown> | 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<string, unknown> = {};
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 && <CLISection open={open} />}
{/* Standard provider fields (not shown for Claude CLI) */}
{!isCLI && (
{/* ACP section */}
{isACP && (
<ACPSection
binary={acpBinary}
onBinaryChange={setAcpBinary}
args={acpArgs}
onArgsChange={setAcpArgs}
idleTTL={acpIdleTTL}
onIdleTTLChange={setAcpIdleTTL}
permMode={acpPermMode}
onPermModeChange={setAcpPermMode}
workDir={acpWorkDir}
onWorkDirChange={setAcpWorkDir}
/>
)}
{/* Standard provider fields (not shown for Claude CLI or ACP) */}
{!isCLI && !isACP && (
<>
<div className="space-y-2">
<Label htmlFor="apiBase">{t("form.apiBase")}</Label>
@@ -29,6 +29,7 @@ const typeBadge: Record<string, { label: string; variant: "default" | "secondary
perplexity: { label: "Perplexity", variant: "secondary" },
chatgpt_oauth: { label: "ChatGPT (OAuth)", variant: "default" },
yescale: { label: "YesScale", variant: "secondary" },
acp: { label: "ACP", variant: "outline" },
};
export function ProvidersPage() {
+2
View File
@@ -6,6 +6,7 @@ export interface ProviderData {
api_base: string;
api_key: string; // masked "***" from server
enabled: boolean;
settings?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
@@ -17,6 +18,7 @@ export interface ProviderInput {
api_base?: string;
api_key?: string;
enabled?: boolean;
settings?: Record<string, unknown>;
}
export interface ModelInfo {