mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
460 lines
15 KiB
Go
460 lines
15 KiB
Go
package providers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
)
|
|
|
|
// cliNativeToolGoclawEquivalent maps Claude CLI native built-in tool names to
|
|
// their GoClaw canonical registry equivalents. A native tool is only allowed
|
|
// (omitted from --disallowedTools) when its GoClaw equivalent is present in
|
|
// the agent's policy-filtered allowed tool set.
|
|
var cliNativeToolGoclawEquivalent = map[string]string{
|
|
"Bash": "exec",
|
|
"Edit": "edit",
|
|
"Write": "write_file",
|
|
"Read": "read_file",
|
|
"WebFetch": "web_fetch",
|
|
"WebSearch": "web_search",
|
|
}
|
|
|
|
// cliNativeToolsAlwaysBlocked lists Claude CLI native tools with no GoClaw
|
|
// policy equivalent. They are always disallowed so agent tool policy cannot
|
|
// be bypassed through them.
|
|
//
|
|
// TodoRead and NotebookRead were removed here: current Claude CLI releases
|
|
// (>= 2.x) no longer register them, and a deny rule naming an unknown tool
|
|
// makes the CLI print "Permission deny rule ... matches no known tool" on
|
|
// every invocation. Their write-side counterparts TodoWrite/NotebookEdit
|
|
// still exist in the CLI and remain blocked.
|
|
var cliNativeToolsAlwaysBlocked = []string{
|
|
"Glob", "Grep", "TodoWrite", "NotebookEdit",
|
|
}
|
|
|
|
var unknownDisallowedToolRe = regexp.MustCompile(`Permission deny rule "([^"]+)" matches no known tool`)
|
|
|
|
// disallowedCLITools computes the --disallowedTools value for the Claude CLI
|
|
// subprocess from the agent's policy-filtered allowed GoClaw tool names.
|
|
// allowedToolNames == nil is treated as "no tools allowed" (fail closed):
|
|
// every native tool with a GoClaw equivalent is blocked, same as an empty set.
|
|
func disallowedCLITools(allowedToolNames []string) []string {
|
|
allowed := make(map[string]bool, len(allowedToolNames))
|
|
for _, name := range allowedToolNames {
|
|
allowed[name] = true
|
|
}
|
|
|
|
blocked := make([]string, 0, len(cliNativeToolGoclawEquivalent)+len(cliNativeToolsAlwaysBlocked))
|
|
for native, equivalent := range cliNativeToolGoclawEquivalent {
|
|
if !allowed[equivalent] {
|
|
blocked = append(blocked, native)
|
|
}
|
|
}
|
|
blocked = append(blocked, cliNativeToolsAlwaysBlocked...)
|
|
slices.Sort(blocked)
|
|
return blocked
|
|
}
|
|
|
|
// filterInvalidDisallowedTools removes deny rules that the local Claude CLI has
|
|
// already rejected as unknown. Claude Code occasionally changes native tool
|
|
// names; retrying without rejected names keeps the provider usable while still
|
|
// denying every rule the installed CLI recognizes.
|
|
func (p *ClaudeCLIProvider) filterInvalidDisallowedTools(tools []string) []string {
|
|
if len(tools) == 0 {
|
|
return tools
|
|
}
|
|
filtered := tools[:0]
|
|
for _, tool := range tools {
|
|
if _, invalid := p.invalidDisallowedToolNames.Load(tool); invalid {
|
|
continue
|
|
}
|
|
filtered = append(filtered, tool)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// noteInvalidDisallowedTool records a Claude CLI deny rule rejected by the
|
|
// installed CLI. It returns true only the first time the tool is observed so
|
|
// callers can bound retries.
|
|
func (p *ClaudeCLIProvider) noteInvalidDisallowedTool(stderr string) (string, bool) {
|
|
match := unknownDisallowedToolRe.FindStringSubmatch(stderr)
|
|
if len(match) != 2 {
|
|
return "", false
|
|
}
|
|
tool := match[1]
|
|
if tool == "" {
|
|
return "", false
|
|
}
|
|
if _, loaded := p.invalidDisallowedToolNames.LoadOrStore(tool, struct{}{}); loaded {
|
|
return tool, false
|
|
}
|
|
return tool, true
|
|
}
|
|
|
|
// validCLIModels lists accepted model aliases for the Claude CLI.
|
|
var validCLIModels = map[string]bool{
|
|
"sonnet": true, "opus": true, "haiku": true,
|
|
}
|
|
|
|
// validateCLIModel checks if a model alias is supported by the Claude CLI.
|
|
func validateCLIModel(model string) error {
|
|
if !validCLIModels[model] {
|
|
return fmt.Errorf("claude-cli: unsupported model %q (valid: sonnet, opus, haiku)", model)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildArgs constructs CLI arguments.
|
|
// mcpConfigPath is the resolved per-session MCP config file (may differ per call).
|
|
// effort is the reasoning effort level (low/medium/high); empty or "off" omits the flag.
|
|
// allowedToolNames is the agent's policy-filtered canonical GoClaw tool set for this
|
|
// turn; it drives which Claude CLI native built-in tools are permitted (see
|
|
// disallowedCLITools). nil means "no tools allowed" (fail closed).
|
|
func (p *ClaudeCLIProvider) buildArgs(model, workDir, mcpConfigPath string, cliSessionID uuid.UUID, outputFormat string, hasImages, disableTools bool, effort string, allowedToolNames []string) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--output-format", outputFormat,
|
|
"--model", model,
|
|
"--permission-mode", p.permMode,
|
|
"--verbose",
|
|
}
|
|
|
|
if effort != "" && effort != "off" {
|
|
effort = strings.ToLower(strings.TrimSpace(effort))
|
|
if isAlphaOnly(effort) {
|
|
args = append(args, "--effort", effort)
|
|
} else {
|
|
slog.Warn("claude-cli: invalid effort value, skipping --effort flag", "effort", effort)
|
|
}
|
|
}
|
|
|
|
if mcpConfigPath != "" {
|
|
args = append(args, "--mcp-config", mcpConfigPath)
|
|
}
|
|
|
|
// Session persistence: check if CLI session file exists on disk.
|
|
// If exists → --resume (continue conversation). If not → --session-id (create new).
|
|
// Session files live at ~/.claude/projects/<sanitized-workdir>/<uuid>.jsonl
|
|
sid := cliSessionID.String()
|
|
if sessionFileExists(workDir, cliSessionID) {
|
|
args = append(args, "--resume", sid)
|
|
} else {
|
|
args = append(args, "--session-id", sid)
|
|
}
|
|
|
|
if hasImages {
|
|
args = append(args, "--input-format", "stream-json")
|
|
}
|
|
|
|
// --disallowedTools is always computed, regardless of whether an MCP config
|
|
// path was resolved: when disableTools is set (e.g. summoner), no native
|
|
// tools are permitted; otherwise the agent's policy-filtered allowed tool
|
|
// set determines which native CLI tools (with a GoClaw equivalent) may run.
|
|
// This must never be skipped — omitting it previously let the CLI
|
|
// subprocess run with its full native toolset unrestricted whenever no MCP
|
|
// config was resolved.
|
|
var blockedTools []string
|
|
if disableTools {
|
|
blockedTools = disallowedCLITools(nil)
|
|
} else {
|
|
blockedTools = disallowedCLITools(allowedToolNames)
|
|
}
|
|
blockedTools = p.filterInvalidDisallowedTools(blockedTools)
|
|
args = append(args, "--disallowedTools", strings.Join(blockedTools, ","))
|
|
|
|
if p.hooksSettingsPath != "" {
|
|
args = append(args, "--settings", p.hooksSettingsPath)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
// resolveMCPConfigPath writes a per-session MCP config with agent context and returns its path.
|
|
func (p *ClaudeCLIProvider) resolveMCPConfigPath(ctx context.Context, sessionKey string, bc BridgeContext) string {
|
|
if p.mcpConfigData == nil {
|
|
return ""
|
|
}
|
|
path := p.mcpConfigData.WriteMCPConfig(ctx, sessionKey, bc)
|
|
if path != "" {
|
|
p.mcpConfigDirs.Store(filepath.Dir(path), struct{}{})
|
|
}
|
|
return path
|
|
}
|
|
|
|
// ensureWorkDir creates and returns a stable work directory for the given session key.
|
|
func (p *ClaudeCLIProvider) ensureWorkDir(sessionKey string) string {
|
|
// Sanitize session key for filesystem safety (path traversal, null bytes, length)
|
|
safe := sanitizePathSegment(sessionKey)
|
|
dir := filepath.Join(p.baseWorkDir, safe)
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
slog.Warn("claude-cli: failed to create workdir", "dir", dir, "error", err)
|
|
return os.TempDir()
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// writeClaudeMD writes the system prompt to CLAUDE.md in the work directory.
|
|
// CLI reads this file automatically on every run (including --resume).
|
|
// Skips write if content is unchanged to avoid unnecessary disk I/O.
|
|
func (p *ClaudeCLIProvider) writeClaudeMD(workDir, systemPrompt string) {
|
|
path := filepath.Join(workDir, "CLAUDE.md")
|
|
if existing, err := os.ReadFile(path); err == nil && string(existing) == systemPrompt {
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, []byte(systemPrompt), 0600); err != nil {
|
|
slog.Warn("claude-cli: failed to write CLAUDE.md", "path", path, "error", err)
|
|
}
|
|
}
|
|
|
|
// extractFromMessages extracts system prompt, last user message, and images from the messages array.
|
|
func extractFromMessages(msgs []Message) (systemPrompt, userMsg string, images []ImageContent) {
|
|
for _, m := range msgs {
|
|
if m.Role == "system" {
|
|
systemPrompt = m.Content
|
|
}
|
|
}
|
|
// Find last user message
|
|
for i := len(msgs) - 1; i >= 0; i-- {
|
|
if msgs[i].Role == "user" {
|
|
userMsg = msgs[i].Content
|
|
images = msgs[i].Images
|
|
break
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// extractStringOpt gets a string value from Options map by key.
|
|
func extractStringOpt(opts map[string]any, key string) string {
|
|
if opts == nil {
|
|
return ""
|
|
}
|
|
if v, ok := opts[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractBoolOpt gets a bool value from Options map by key.
|
|
func extractBoolOpt(opts map[string]any, key string) bool {
|
|
if opts == nil {
|
|
return false
|
|
}
|
|
if v, ok := opts[key]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
return b
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractStringSliceOpt gets a []string value from Options map by key.
|
|
// Returns nil if absent or of an unexpected type (fail closed: callers must
|
|
// treat nil as "no tools allowed", not "unrestricted").
|
|
func extractStringSliceOpt(opts map[string]any, key string) []string {
|
|
if opts == nil {
|
|
return nil
|
|
}
|
|
v, ok := opts[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
names, ok := v.([]string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return names
|
|
}
|
|
|
|
// bridgeContextFromOpts builds a BridgeContext from the Options map.
|
|
func bridgeContextFromOpts(opts map[string]any) BridgeContext {
|
|
return BridgeContext{
|
|
AgentID: extractStringOpt(opts, OptAgentID),
|
|
UserID: extractStringOpt(opts, OptUserID),
|
|
Channel: extractStringOpt(opts, OptChannel),
|
|
ChatID: extractStringOpt(opts, OptChatID),
|
|
PeerKind: extractStringOpt(opts, OptPeerKind),
|
|
Workspace: extractStringOpt(opts, OptWorkspace),
|
|
TenantID: extractStringOpt(opts, OptTenantID),
|
|
LocalKey: extractStringOpt(opts, OptLocalKey),
|
|
DelegationID: extractStringOpt(opts, OptDelegationID),
|
|
DelegationInputs: extractStringOpt(opts, OptDelegationInputs),
|
|
}
|
|
}
|
|
|
|
// defaultCLIWorkDir returns dataDir/cli-workspaces.
|
|
func defaultCLIWorkDir() string {
|
|
return filepath.Join(config.ResolvedDataDirFromEnv(), "cli-workspaces")
|
|
}
|
|
|
|
// deriveSessionUUID creates a deterministic UUID v5 from a session key string.
|
|
func deriveSessionUUID(sessionKey string) uuid.UUID {
|
|
if sessionKey == "" {
|
|
return uuid.New() // fallback: random
|
|
}
|
|
return uuid.NewSHA1(uuid.NameSpaceDNS, []byte(sessionKey))
|
|
}
|
|
|
|
// sessionFileExists checks if a Claude CLI session file exists for the given work directory.
|
|
// Claude CLI resolves symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
// before encoding the path, so we must do the same.
|
|
func sessionFileExists(workDir string, sessionID uuid.UUID) bool {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Resolve symlinks to match CLI's path encoding (macOS: /var → /private/var)
|
|
resolved, err := filepath.EvalSymlinks(workDir)
|
|
if err != nil {
|
|
resolved = workDir
|
|
}
|
|
// Claude CLI stores sessions at: ~/.claude/projects/<encoded-path>/<session-id>.jsonl
|
|
// CLI replaces path separators, "_", ".", and ":" with "-" in the path encoding.
|
|
// On Windows: C:\Users\foo → C--Users-foo (backslash + colon both become "-")
|
|
// On macOS/Linux: /home/foo → -home-foo (forward slash becomes "-")
|
|
encoded := strings.NewReplacer(string(filepath.Separator), "-", "_", "-", ".", "-", ":", "-").Replace(resolved)
|
|
sessionFile := filepath.Join(home, ".claude", "projects", encoded, sessionID.String()+".jsonl")
|
|
_, err = os.Stat(sessionFile)
|
|
return err == nil
|
|
}
|
|
|
|
// buildStreamJSONInput creates stream-json stdin for multimodal input
|
|
// (images/documents + text). The content block type is chosen from MIME:
|
|
// - application/pdf → "document" (Anthropic PDF support, Claude 3.5+)
|
|
// - image/* → "image"
|
|
//
|
|
// MIME types that fit neither are emitted as "image" for backwards
|
|
// compatibility; the upstream Anthropic API will reject them explicitly
|
|
// rather than silently misroute.
|
|
func buildStreamJSONInput(text string, images []ImageContent) *bytes.Reader {
|
|
var contentBlocks []map[string]any
|
|
|
|
for _, img := range images {
|
|
blockType := "image"
|
|
if img.MimeType == "application/pdf" {
|
|
blockType = "document"
|
|
}
|
|
contentBlocks = append(contentBlocks, map[string]any{
|
|
"type": blockType,
|
|
"source": map[string]any{
|
|
"type": "base64",
|
|
"media_type": img.MimeType,
|
|
"data": img.Data,
|
|
},
|
|
})
|
|
}
|
|
|
|
if text != "" {
|
|
contentBlocks = append(contentBlocks, map[string]any{
|
|
"type": "text",
|
|
"text": text,
|
|
})
|
|
}
|
|
|
|
msg := map[string]any{
|
|
"type": "user",
|
|
"message": map[string]any{
|
|
"role": "user",
|
|
"content": contentBlocks,
|
|
},
|
|
}
|
|
|
|
data, _ := json.Marshal(msg)
|
|
return bytes.NewReader(data)
|
|
}
|
|
|
|
// ResetCLISession deletes the Claude CLI session file and CLAUDE.md for a given session key.
|
|
// Called on /reset to ensure the CLI starts fresh instead of --resume-ing poisoned history.
|
|
// Safe to call even if CLI provider is not in use (no-op if files don't exist).
|
|
func ResetCLISession(baseWorkDir, sessionKey string) {
|
|
if baseWorkDir == "" {
|
|
baseWorkDir = defaultCLIWorkDir()
|
|
}
|
|
safe := sanitizePathSegment(sessionKey)
|
|
workDir := filepath.Join(baseWorkDir, safe)
|
|
sessionID := deriveSessionUUID(sessionKey)
|
|
|
|
// Delete CLI session .jsonl file from ~/.claude/projects/
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
resolved, err := filepath.EvalSymlinks(workDir)
|
|
if err != nil {
|
|
resolved = workDir
|
|
}
|
|
encoded := strings.NewReplacer(string(filepath.Separator), "-", "_", "-", ".", "-", ":", "-").Replace(resolved)
|
|
sessionFile := filepath.Join(home, ".claude", "projects", encoded, sessionID.String()+".jsonl")
|
|
if err := os.Remove(sessionFile); err == nil {
|
|
slog.Info("claude-cli: deleted session file on /reset", "path", sessionFile)
|
|
}
|
|
}
|
|
|
|
// Delete CLAUDE.md from workdir so it regenerates fresh
|
|
claudeMD := filepath.Join(workDir, "CLAUDE.md")
|
|
if err := os.Remove(claudeMD); err == nil {
|
|
slog.Info("claude-cli: deleted CLAUDE.md on /reset", "path", claudeMD)
|
|
}
|
|
}
|
|
|
|
// filterCLIEnv removes CLAUDE* env vars to prevent nested session conflicts.
|
|
// Behavioral tuning vars are whitelisted so they can be set system-wide
|
|
// and picked up by claude-cli subprocesses.
|
|
func filterCLIEnv(environ []string) []string {
|
|
allowed := map[string]bool{
|
|
"CLAUDE_CODE_OAUTH_TOKEN": true, // auth
|
|
"CLAUDE_CODE_EFFORT_LEVEL": true, // behavioral tuning
|
|
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING": true, // behavioral tuning
|
|
}
|
|
var filtered []string
|
|
for _, e := range environ {
|
|
key := e
|
|
if before, _, ok := strings.Cut(e, "="); ok {
|
|
key = before
|
|
}
|
|
if strings.HasPrefix(key, "CLAUDE") && !allowed[key] {
|
|
continue
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// isAlphaOnly returns true if s is non-empty and contains only a-z characters.
|
|
func isAlphaOnly(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < 'a' || c > 'z' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// removeEnvKey returns a copy of environ with all entries for the given key removed.
|
|
func removeEnvKey(environ []string, key string) []string {
|
|
result := make([]string, 0, len(environ))
|
|
for _, e := range environ {
|
|
if k, _, _ := strings.Cut(e, "="); k != key {
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
return result
|
|
}
|