From 7f6a9fdb63a40b304ea219ab7e413f0455d94023 Mon Sep 17 00:00:00 2001 From: Jared Pleva Date: Sun, 29 Mar 2026 16:46:31 +0000 Subject: [PATCH] feat: add OpenClaw and NemoClaw as first-class execution engines OpenClaw provides browser automation + integrations as a governed runtime. NemoClaw wraps OpenClaw with Nemotron model defaults and OpenShell sandbox. Both engines integrate with ShellForge governance via govern-shell.sh and are available as drivers: `shellforge run openclaw` / `shellforge run nemoclaw`. Closes #80, #81 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/shellforge/main.go | 28 ++++-- cmd/shellforge/status.go | 2 + internal/engine/nemoclaw.go | 130 ++++++++++++++++++++++++++++ internal/engine/openclaw.go | 166 ++++++++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 internal/engine/nemoclaw.go create mode 100644 internal/engine/openclaw.go diff --git a/cmd/shellforge/main.go b/cmd/shellforge/main.go index efd7b96..55f2bea 100644 --- a/cmd/shellforge/main.go +++ b/cmd/shellforge/main.go @@ -47,7 +47,7 @@ cmdReport(repo) case "run": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "Usage: shellforge run \"prompt\"") -fmt.Fprintln(os.Stderr, "Drivers: goose, claude, copilot, codex, gemini") +fmt.Fprintln(os.Stderr, "Drivers: goose, claude, copilot, codex, gemini, openclaw, nemoclaw") os.Exit(1) } driver := os.Args[2] @@ -91,7 +91,7 @@ func printUsage() { fmt.Printf(`ShellForge %s — local governed agent runtime Usage: - shellforge run "prompt" Run a governed agent (claude, copilot, codex, gemini, crush) + shellforge run "prompt" Run a governed agent (claude, copilot, codex, gemini, openclaw, nemoclaw) shellforge setup Install Ollama, pull model, verify stack shellforge qa [target] QA analysis with tool use + governance shellforge report [repo] Weekly status report from git + logs @@ -510,13 +510,31 @@ var drivers = map[string]driverConfig{ hasHooks: false, initHint: "", }, + "openclaw": { + binary: "openclaw", + buildCmd: func(p string) []string { + return []string{"--non-interactive", "-p", p} + }, + interactive: []string{}, + hasHooks: false, + initHint: "agentguard openclaw-init", + }, + "nemoclaw": { + binary: "openclaw", + buildCmd: func(p string) []string { + return []string{"--non-interactive", "--model", "nemotron", "--sandbox", "openshell", "-p", p} + }, + interactive: []string{}, + hasHooks: false, + initHint: "agentguard nemoclaw-init", + }, } func cmdRun(driver, prompt string) { dc, ok := drivers[driver] if !ok { fmt.Fprintf(os.Stderr, "Unknown driver: %s\n", driver) - fmt.Fprintln(os.Stderr, "Available drivers: claude, copilot, codex, gemini, crush") + fmt.Fprintln(os.Stderr, "Available drivers: claude, copilot, codex, gemini, openclaw, nemoclaw") os.Exit(1) } @@ -559,8 +577,8 @@ func cmdRun(driver, prompt string) { cmd.Stderr = os.Stderr cmd.Env = os.Environ() - // For Goose: set governed shell so ALL commands go through AgentGuard - if driver == "goose" { + // For Goose/OpenClaw/NemoClaw: set governed shell so ALL commands go through AgentGuard + if driver == "goose" || driver == "openclaw" || driver == "nemoclaw" { sfBin, _ := exec.LookPath("shellforge") if sfBin != "" { // Find govern-shell.sh next to the shellforge binary or in known locations diff --git a/cmd/shellforge/status.go b/cmd/shellforge/status.go index b56eae8..c96e260 100644 --- a/cmd/shellforge/status.go +++ b/cmd/shellforge/status.go @@ -68,6 +68,8 @@ func cmdStatusFull() { {"copilot", "github-copilot-cli", "GitHub Copilot CLI", "gh extension install github/gh-copilot"}, {"codex", "codex", "OpenAI Codex CLI", "npm i -g @openai/codex"}, {"gemini", "gemini", "Google Gemini CLI", "npm i -g @anthropic-ai/gemini-cli"}, + {"openclaw", "openclaw", "OpenClaw browser automation (Anthropic)", "npm i -g @anthropic-ai/openclaw"}, + {"nemoclaw", "openclaw", "NemoClaw (OpenClaw + Nemotron sandbox)", "npm i -g @anthropic-ai/openclaw"}, } driverCount := 0 for _, d := range drivers { diff --git a/internal/engine/nemoclaw.go b/internal/engine/nemoclaw.go new file mode 100644 index 0000000..8f25477 --- /dev/null +++ b/internal/engine/nemoclaw.go @@ -0,0 +1,130 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// NemoClawEngine is a thin wrapper around OpenClaw that additionally: +// - Defaults to Nemotron as the model (instead of Claude) +// - Enables OpenShell sandbox (if available) +// - Sets additional security flags for hardened execution +// +// NemoClaw is an optional adapter — it never introduces a hard dependency. +type NemoClawEngine struct{} + +func (e *NemoClawEngine) Name() string { return "nemoclaw" } + +func (e *NemoClawEngine) Available() bool { + // NemoClaw requires OpenClaw to be installed + oc := &OpenClawEngine{} + return oc.Available() +} + +func (e *NemoClawEngine) Run(task Task) (*Result, error) { + start := time.Now() + + if !e.Available() { + return nil, fmt.Errorf("nemoclaw requires openclaw. Install: npm i -g @anthropic-ai/openclaw") + } + + // Determine binary: standalone or npx (reuse OpenClaw resolution) + binary, args := openclawCommand() + + // NemoClaw defaults to Nemotron model + model := "nemotron" + if task.Model != "" { + model = task.Model + } + if v := os.Getenv("NEMOCLAW_MODEL"); v != "" { + model = v + } + args = append(args, "--model", model) + + // Always headless for NemoClaw (security-oriented, no browser UI) + args = append(args, "--headless") + + // Enable sandbox mode if OpenShell is available + if _, err := exec.LookPath("openshell"); err == nil { + args = append(args, "--sandbox", "openshell") + } + + // Additional security flags + args = append(args, "--no-network-write") + args = append(args, "--read-only-fs") + + // Timeout + if task.Timeout > 0 { + args = append(args, "--timeout", fmt.Sprintf("%d", task.Timeout)) + } + + // Max turns + if task.MaxTurns > 0 { + args = append(args, "--max-turns", fmt.Sprintf("%d", task.MaxTurns)) + } + + // Non-interactive for headless execution + args = append(args, "--non-interactive") + + // Set up AgentGuard governance + NemoClaw-specific env + env := append(os.Environ(), + "AGENTGUARD_POLICY=agentguard.yaml", + "AGENTGUARD_MODE=enforce", + "NEMOCLAW_SANDBOX=1", + ) + + // Wrap shell commands through govern-shell.sh + governShell := findGovernShell() + if governShell != "" { + env = append(env, + "SHELL="+governShell, + "SHELLFORGE_REAL_SHELL=/bin/bash", + ) + } + + cmd := exec.Command(binary, args...) + cmd.Dir = task.WorkDir + cmd.Env = env + cmd.Stdin = strings.NewReader(task.Prompt) + + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + + duration := time.Since(start).Milliseconds() + + if err != nil && output == "" { + return nil, fmt.Errorf("nemoclaw failed: %w", err) + } + + // Parse structured output if available + result := &Result{ + Success: err == nil, + Output: output, + DurationMs: duration, + } + + // Try to extract metrics from OpenClaw's JSON output + if idx := strings.LastIndex(output, "\n{"); idx >= 0 { + var metrics struct { + Turns int `json:"turns"` + ToolCalls int `json:"tool_calls"` + Tokens struct { + Prompt int `json:"prompt"` + Response int `json:"response"` + } `json:"tokens"` + } + if json.Unmarshal([]byte(output[idx+1:]), &metrics) == nil { + result.Turns = metrics.Turns + result.ToolCalls = metrics.ToolCalls + result.PromptTok = metrics.Tokens.Prompt + result.ResponseTok = metrics.Tokens.Response + result.Output = strings.TrimSpace(output[:idx]) + } + } + + return result, nil +} diff --git a/internal/engine/openclaw.go b/internal/engine/openclaw.go new file mode 100644 index 0000000..3f39787 --- /dev/null +++ b/internal/engine/openclaw.go @@ -0,0 +1,166 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// OpenClawEngine wraps the OpenClaw CLI with AgentGuard governance. +// OpenClaw is a browser automation + integrations runtime by Anthropic. +// Installed via npm: npx @anthropic-ai/openclaw, or as a standalone binary. +// Supports headless mode (for server/RunPod) and Extension Relay mode (local Mac with browser). +type OpenClawEngine struct{} + +func (e *OpenClawEngine) Name() string { return "openclaw" } + +func (e *OpenClawEngine) Available() bool { + // Check for standalone binary first + if _, err := exec.LookPath("openclaw"); err == nil { + return true + } + // Check for npx availability (npm-installed) + cmd := exec.Command("npx", "@anthropic-ai/openclaw", "--version") + return cmd.Run() == nil +} + +func (e *OpenClawEngine) Run(task Task) (*Result, error) { + start := time.Now() + + if !e.Available() { + return nil, fmt.Errorf("openclaw not installed. Install: npm i -g @anthropic-ai/openclaw") + } + + // Determine binary: standalone or npx + binary, args := openclawCommand() + + // Headless mode: default on for server (no DISPLAY), override via env + headless := os.Getenv("DISPLAY") == "" + if v := os.Getenv("OPENCLAW_HEADLESS"); v != "" { + headless = v == "1" || v == "true" + } + + if headless { + args = append(args, "--headless") + } + + // Model override + model := task.Model + if v := os.Getenv("OPENCLAW_MODEL"); v != "" { + model = v + } + if model != "" { + args = append(args, "--model", model) + } + + // Timeout + if task.Timeout > 0 { + args = append(args, "--timeout", fmt.Sprintf("%d", task.Timeout)) + } + + // Max turns + if task.MaxTurns > 0 { + args = append(args, "--max-turns", fmt.Sprintf("%d", task.MaxTurns)) + } + + // Non-interactive mode for headless execution + args = append(args, "--non-interactive") + + // Set up AgentGuard governance via env + env := append(os.Environ(), + "AGENTGUARD_POLICY=agentguard.yaml", + "AGENTGUARD_MODE=enforce", + ) + + // Wrap shell commands through govern-shell.sh + governShell := findGovernShell() + if governShell != "" { + env = append(env, + "SHELL="+governShell, + "SHELLFORGE_REAL_SHELL=/bin/bash", + ) + } + + cmd := exec.Command(binary, args...) + cmd.Dir = task.WorkDir + cmd.Env = env + cmd.Stdin = strings.NewReader(task.Prompt) + + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + + duration := time.Since(start).Milliseconds() + + if err != nil && output == "" { + return nil, fmt.Errorf("openclaw failed: %w", err) + } + + // Parse structured output if available + result := &Result{ + Success: err == nil, + Output: output, + DurationMs: duration, + } + + // Try to extract metrics from OpenClaw's JSON output + if idx := strings.LastIndex(output, "\n{"); idx >= 0 { + var metrics struct { + Turns int `json:"turns"` + ToolCalls int `json:"tool_calls"` + Tokens struct { + Prompt int `json:"prompt"` + Response int `json:"response"` + } `json:"tokens"` + } + if json.Unmarshal([]byte(output[idx+1:]), &metrics) == nil { + result.Turns = metrics.Turns + result.ToolCalls = metrics.ToolCalls + result.PromptTok = metrics.Tokens.Prompt + result.ResponseTok = metrics.Tokens.Response + result.Output = strings.TrimSpace(output[:idx]) + } + } + + return result, nil +} + +// openclawCommand returns the binary and base args for invoking OpenClaw. +// Prefers the standalone binary; falls back to npx. +func openclawCommand() (string, []string) { + if _, err := exec.LookPath("openclaw"); err == nil { + return "openclaw", nil + } + return "npx", []string{"@anthropic-ai/openclaw"} +} + +// findGovernShell locates the govern-shell.sh wrapper for governance integration. +func findGovernShell() string { + sfBin, _ := exec.LookPath("shellforge") + if sfBin == "" { + // Try common locations even without shellforge on PATH + for _, path := range []string{ + "scripts/govern-shell.sh", + "/usr/local/share/shellforge/govern-shell.sh", + } { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" + } + for _, path := range []string{ + filepath.Join(filepath.Dir(sfBin), "..", "share", "shellforge", "govern-shell.sh"), + filepath.Join(filepath.Dir(sfBin), "govern-shell.sh"), + "scripts/govern-shell.sh", + "/usr/local/share/shellforge/govern-shell.sh", + } { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +}