mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
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:
1 parent
16d726d5a2
commit
5e2fa395c7
30 files changed
+2117
-17
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user