mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 14:10:52 +00:00
5e2fa395c7
* 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>
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
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
|
|
}
|
|
|