mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-07 21:13:25 +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>
218 lines
5.5 KiB
Go
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
|
|
}
|