Files
goclaw/internal/providers/acp/jsonrpc.go
T
Goon 5e2fa395c7 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>
2026-03-14 16:16:08 +07:00

218 lines
5.5 KiB
Go

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
}