Files
goclaw/internal/tools/dynamic_tool.go
T
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

134 lines
3.5 KiB
Go

package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// DynamicTool wraps a CustomToolDef from the database and implements the Tool interface.
// Command templates use {{.key}} placeholders — all LLM-provided values are shell-escaped.
type DynamicTool struct {
def store.CustomToolDef
workspace string
params map[string]any
}
// NewDynamicTool creates a Tool from a DB-stored custom tool definition.
func NewDynamicTool(def store.CustomToolDef, workspace string) *DynamicTool {
var params map[string]any
if len(def.Parameters) > 0 {
json.Unmarshal(def.Parameters, &params)
}
if params == nil {
params = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return &DynamicTool{def: def, workspace: workspace, params: params}
}
func (t *DynamicTool) Name() string { return t.def.Name }
func (t *DynamicTool) Description() string { return t.def.Description }
func (t *DynamicTool) Parameters() map[string]any { return t.params }
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 {
if pattern.MatchString(command) {
return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String()))
}
}
// Timeout
timeout := time.Duration(t.def.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 60 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Working directory — per-user workspace from context, fallback to tool's workspace.
// Explicit WorkingDir on the tool definition still overrides everything.
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if t.def.WorkingDir != "" {
cwd = t.def.WorkingDir
}
cmd := exec.CommandContext(ctx, "sh", "-c", command)
cmd.Dir = cwd
// Decrypt and apply env vars
if len(t.def.Env) > 0 {
var envMap map[string]string
if json.Unmarshal(t.def.Env, &envMap) == nil {
for k, v := range envMap {
cmd.Env = append(cmd.Env, k+"="+v)
}
}
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var result string
if stdout.Len() > 0 {
result = stdout.String()
}
if stderr.Len() > 0 {
if result != "" {
result += "\n"
}
result += "STDERR:\n" + stderr.String()
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ErrorResult(fmt.Sprintf("command timed out after %s", timeout))
}
if result == "" {
result = err.Error()
}
return ErrorResult(result)
}
if result == "" {
result = "(command completed with no output)"
}
return SilentResult(result)
}
// renderCommand replaces {{.key}} placeholders with shell-escaped arg values.
// Uses simple string replacement (NOT Go text/template) for security.
func renderCommand(tmpl string, args map[string]any) string {
result := tmpl
for key, val := range args {
placeholder := "{{." + key + "}}"
escaped := shellEscape(fmt.Sprint(val))
result = strings.ReplaceAll(result, placeholder, escaped)
}
return result
}
// shellEscape wraps a value in single quotes, escaping embedded single quotes.
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}