From 9fb90e7683be00302e97b47c3396877fd856bce5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:20:46 +0530 Subject: [PATCH 1/8] feat: add opt-in Docker sandbox for Bash (Mirror-style isolated execution) --- cmd/chat_subcommand_sandbox.go | 45 +++++++++++++ docs/plans/mirror-port-plan.md | 111 +++++++++++++++++++++++++++++++ go.mod | 5 ++ internal/config/settings.go | 1 + internal/sandbox/sandbox.go | 100 ++++++++++++++++++++++++++++ internal/sandbox/sandbox_test.go | 68 +++++++++++++++++++ internal/tool/bash.go | 32 +++++++++ 7 files changed, 362 insertions(+) create mode 100644 cmd/chat_subcommand_sandbox.go create mode 100644 docs/plans/mirror-port-plan.md create mode 100644 internal/sandbox/sandbox.go create mode 100644 internal/sandbox/sandbox_test.go diff --git a/cmd/chat_subcommand_sandbox.go b/cmd/chat_subcommand_sandbox.go new file mode 100644 index 00000000..5696f7e0 --- /dev/null +++ b/cmd/chat_subcommand_sandbox.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/reflect/internal/config" + "github.com/GrayCodeAI/reflect/internal/sandbox" +) + +// sandboxSubcommand reports and toggles sandboxed tool execution. +type sandboxSubcommand struct{} + +func (c *sandboxSubcommand) Name() string { return "sandbox" } +func (c *sandboxSubcommand) Aliases() []string { return nil } +func (c *sandboxSubcommand) Description() string { return "show sandbox status (Mirror-style isolated execution)" } +func (c *sandboxSubcommand) Usage() string { return "/sandbox" } + +func (c *sandboxSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + s := config.LoadSettings() + mode := s.Sandbox + if envMode := os.Getenv("REFLECT_SANDBOX"); envMode != "" { + mode = envMode + } + cfg := sandbox.Config{Mode: sandbox.NormalizeMode(mode), Image: os.Getenv("REFLECT_SANDBOX_IMAGE")} + status := "off (host execution)" + if sandbox.Enabled(cfg) { + status = "on (docker)" + } + docker := "not found" + if sandbox.DockerAvailable() { + docker = "available" + } + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( + "Sandbox: %s\nDocker: %s\n\nTo enable, set \"sandbox\": \"docker\" in ~/.reflect/settings.json\nor export REFLECT_SANDBOX=docker (and optionally REFLECT_SANDBOX_IMAGE).", + status, docker, + )}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&sandboxSubcommand{}) +} \ No newline at end of file diff --git a/docs/plans/mirror-port-plan.md b/docs/plans/mirror-port-plan.md new file mode 100644 index 00000000..02e8db47 --- /dev/null +++ b/docs/plans/mirror-port-plan.md @@ -0,0 +1,111 @@ +# Plan: Port Mirror (reflection-oss/mirror) capabilities into Reflect + +Source repo (cloned locally): `../mirror-ref` (reflection-oss/mirror, Apache-2.0). +Target: `reflect` (Go, monolithic TUI + relay engine). + +Mirror is a Rust agent + Python CLI supervisor with AgentRPC workers. Reflect is a +monolithic Go TUI. We port **features and UX**, not the architecture (worker +supervision, AgentRPC) — Reflect's in-process agent loop is a strength, not a gap. + +## Phase 0 — Inventory (done) + +Mirror's CLI surface (`mirror-cli/src/mirror/cli/`) and features: + +- Core: input/rendering, conversation, resume, rewind, branching +- Workspaces: roster, creation, switching, persistence (`workspace_controller`, `workspaces`, `workspaces_application`) +- Worktrees: parallel independent checkouts (`worktrees`) +- Sandbox: isolated file/shell tool execution (`sandbox/`, `sandbox_shell.py`, `sandboxes.py`) +- Extensions: Python extensions, commands, host tools, MCP declarations (`extension.py`, `extensions/`, `host_tools.py`, `mcp_configuration.py`, `user_extensions.py`) +- Settings: menu-driven settings with templates (`cli/extensions/settings/`) +- Themes: theme system (`themes.py`, `docs/themes.md`) +- Transcript: rendering/history (`transcript.py`, `transcript_view.py`) +- Approvals: bash/tool approvals (`bash_approvals.py`, `tool_approvals.py`) +- Onboarding, API keys, models (`onboarding.py`, `api_keys.py`, `models.py`) +- Noninteractive mode (`noninteractive.py`) +- Shell integration, terminal (`shell.py`, `terminal.py`, `extended_keys.py`) +- Safety, statsig telemetry, logging +- Memory (opt-in, cross-session) +- Project instructions, skills + +## Mapping to Reflect (already present) + +| Mirror feature | Reflect equivalent | Status | +|---|---|---| +| Worktrees / parallel tasks | `internal/multiagent`, mission mode, EnterWorktree/ExitWorktree | ✅ present | +| Resume conversations | session resume (`-r`/`-c`) | ✅ | +| Providers/models | relay engine (28+) | ✅ | +| Memory | conversation arc + memory | ✅ | +| Project instructions | AGENTS.md / ZERO.md | ✅ | +| Skills | skills system | ✅ | +| MCP | MCP client | ✅ | +| Approvals | permissions/autonomy + spec gate | ✅ (different UX) | +| Themes | (check Reflect theme) | partial | +| Transcript | session JSONL + TUI | partial | + +## Gaps worth porting (prioritized) + +### P1 — Sandbox workspaces (HIGH value, MEDIUM effort) +Mirror runs file/shell tools in an isolated sandbox (microsandbox/container) instead +of on the host. Reflect executes on the host with permission gates. Porting an +opt-in sandbox mode would be a major safety upgrade. + +Scope: +- Add `internal/sandbox/` package wrapping a container runtime (Docker/Podman) or + a lightweight namespace/chroot fallback. +- Wire into `tool.Bash`, `tool.Write`, `tool.Edit` behind a setting + (`settings.sandbox = off|docker|microsandbox`). +- Add `/sandbox` command + status in `/path` and doctor. +- Config: `REFLECT_SANDBOX`, `REFLECT_SANDBOX_IMAGE`. +- Tests: sandbox round-trip, fallback to host when runtime missing. + +### P2 — Conversation rewind / branching (MEDIUM value, MEDIUM effort) +Mirror's `/rewind` forks conversation history (not filesystem). Reflect has +session rewind? Check `internal/session`. If missing, add: +- `/rewind [n]` command to fork a session at turn n into a new session file. +- TUI picker over turns. +- Reuse existing session checkpoint/WAL. + +### P3 — Themes (LOW-MEDIUM value, LOW effort) +Mirror has a theme system (`themes.py`, `docs/themes.md`). Reflect has +`internal/theme`. Audit parity: color tokens, custom theme files, `/theme` command. + +### P4 — Host tools / Python extensions (MEDIUM value, HIGH effort) +Mirror allows Python extensions contributing commands, host tools, MCP decls. +Reflect has Go plugins + MCP. Porting Python extensions would broaden ecosystem +but is high effort. Defer unless demanded. + +### P5 — Workspace tabs / roster UI (MEDIUM value, HIGH effort) +Mirror's multi-workspace tab UI. Reflect uses mission mode (background). Could +surface mission workers as tabs. Defer. + +### P6 — Noninteractive / scripting mode parity +Mirror has `noninteractive.py`. Reflect has `decoder exec`/`-p`. Compare flags +and output formats; align. + +### P7 — Onboarding / API key UX +Mirror's masked sign-in prompt. Reflect uses `/config`. Compare and improve. + +## Phase plan + +- Phase 1 (P1 sandbox) — 1-2 weeks +- Phase 2 (P2 rewind) — 3-5 days +- Phase 3 (P3 themes) — 2-3 days +- Phase 4 (P6, P7) — 1 week +- Phase 5 (P4, P5) — only if demanded + +## Out of scope (not porting) + +- Rust agent / AgentRPC worker supervision (Reflect is monolithic by design) +- Python CLI runtime (Reflect is Go) +- statsig telemetry (Reflect has its own observability) +- microsandbox specifically (use Docker/Podman abstraction) + +## Open questions + +1. Sandbox runtime preference (Docker vs Podman vs namespace)? +2. Should sandbox be default-on or opt-in? +3. Rewind: fork to new session or in-place truncate? + +## Next action + +Start Phase 1 (sandbox) unless user picks a different priority. diff --git a/go.mod b/go.mod index 80dc3a41..d9843289 100644 --- a/go.mod +++ b/go.mod @@ -101,3 +101,8 @@ require ( ) + + + + + diff --git a/internal/config/settings.go b/internal/config/settings.go index df1ddbb9..07dbc5b1 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -72,6 +72,7 @@ type Settings struct { InvertScroll bool `json:"invert_scroll,omitempty"` // natural scrolling invert CompactMode bool `json:"compact_mode,omitempty"` // reduce outer padding PaginatorLines int `json:"paginator_lines,omitempty"` // scrollback buffer lines (0 = unlimited) + Sandbox string `json:"sandbox,omitempty"` // off | docker — isolated tool execution (Mirror-style) PaginatorShowLineNums *bool `json:"paginator_show_line_nums,omitempty"` // show line numbers in scrollback } diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 00000000..59f0e417 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,100 @@ +// Package sandbox provides opt-in isolated execution for Reflect's tools. +// +// Mirror (reflection-oss/mirror) runs file/shell tools inside a sandbox +// (microsandbox/container) instead of on the host. Reflect mirrors that with an +// optional Docker-based sandbox: when enabled, shell commands run inside a +// container with the working directory bind-mounted, so the agent can execute +// arbitrary commands without touching the host filesystem directly. +// +// Default mode is "off" (host execution), preserving existing behavior. +package sandbox + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// Mode describes the sandbox backend. +type Mode string + +const ( + ModeOff Mode = "off" + ModeDocker Mode = "docker" +) + +// Config holds sandbox settings. +type Config struct { + Mode Mode + Image string +} + +// DefaultImage is used when no image is configured. +const DefaultImage = "alpine:latest" + +// Available reports whether the configured backend is usable on this machine. +func Available(cfg Config) bool { + switch cfg.Mode { + case ModeDocker, "": + // empty mode treated as "try docker if present"? No — explicit off. + if cfg.Mode == ModeOff { + return true // host is always "available" + } + _, err := exec.LookPath("docker") + return err == nil + } + return false +} + +// DockerAvailable reports whether the docker binary is on PATH and the daemon +// is reachable. +func DockerAvailable() bool { + if _, err := exec.LookPath("docker"); err != nil { + return false + } + return exec.Command("docker", "info").Run() == nil +} + +// Run executes command inside a container with workdir bind-mounted at /work. +// Returns combined stdout+stderr and an error. Falls back to host execution +// when mode is off. +func Run(ctx context.Context, cfg Config, workdir, command string) (string, error) { + if cfg.Mode == ModeOff || cfg.Mode == "" { + // Caller should handle host execution; sandbox.Run is a no-op passthrough + // marker. Return empty to signal "not sandboxed". + return "", fmt.Errorf("sandbox: mode off") + } + if !DockerAvailable() { + return "", fmt.Errorf("sandbox: docker not found on PATH") + } + image := cfg.Image + if image == "" { + image = DefaultImage + } + args := []string{ + "run", "--rm", + "-v", workdir + ":/work", + "-w", "/work", + image, + "sh", "-c", command, + } + out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() + return string(out), err +} + +// Enabled reports whether sandboxing is active. +func Enabled(cfg Config) bool { + return cfg.Mode == ModeDocker +} + +// NormalizeMode coerces a raw string into a Mode. +func NormalizeMode(raw string) Mode { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "docker", "container": + return ModeDocker + case "off", "": + return ModeOff + } + return ModeOff +} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go new file mode 100644 index 00000000..8b37cf74 --- /dev/null +++ b/internal/sandbox/sandbox_test.go @@ -0,0 +1,68 @@ +package sandbox + +import ( + "context" + "os" + "testing" +) + +func TestNormalizeMode(t *testing.T) { + cases := []struct{ in string; want Mode }{ + {"", ModeOff}, + {"off", ModeOff}, + {"docker", ModeDocker}, + {"container", ModeDocker}, + {"DOCKER", ModeDocker}, + {"bogus", ModeOff}, + } + for _, c := range cases { + if got := NormalizeMode(c.in); got != c.want { + t.Errorf("NormalizeMode(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestEnabled(t *testing.T) { + if Enabled(Config{Mode: ModeOff}) { + t.Error("off should not be enabled") + } + if !Enabled(Config{Mode: ModeDocker}) { + t.Error("docker should be enabled") + } +} + +func TestRunOffReturnsError(t *testing.T) { + _, err := Run(context.Background(), Config{Mode: ModeOff}, "/tmp", "echo hi") + if err == nil { + t.Fatal("expected error for off mode") + } +} + +func TestDockerAvailableSmoke(t *testing.T) { + // Just ensure the helper doesn't panic. + _ = DockerAvailable() +} + +func TestRunDockerIfAvailable(t *testing.T) { + if !DockerAvailable() { + t.Skip("docker not available") + } + out, err := Run(context.Background(), Config{Mode: ModeDocker, Image: "alpine:latest"}, os.TempDir(), "echo sandbox-ok") + if err != nil { + t.Fatalf("sandbox run failed: %v\n%s", err, out) + } + if !contains(out, "sandbox-ok") { + t.Fatalf("expected sandbox-ok in output, got: %s", out) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} \ No newline at end of file diff --git a/internal/tool/bash.go b/internal/tool/bash.go index 8296343e..d8a0b555 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -5,14 +5,17 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "os/user" "regexp" "strings" "time" + "github.com/GrayCodeAI/reflect/internal/config" "github.com/GrayCodeAI/reflect/internal/env" homepkg "github.com/GrayCodeAI/reflect/internal/home" + "github.com/GrayCodeAI/reflect/internal/sandbox" ) // dangerousCommands are commands that should ALWAYS be blocked. @@ -369,6 +372,35 @@ func (BashTool) Execute(ctx context.Context, input json.RawMessage) (string, err ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + // Sandbox: when enabled, run the command inside a container with the + // working directory bind-mounted, mirroring Mirror's isolated execution. + sbCfg := sandbox.Config{Mode: sandbox.NormalizeMode(os.Getenv("REFLECT_SANDBOX")), Image: os.Getenv("REFLECT_SANDBOX_IMAGE")} + if s := config.LoadSettings(); s.Sandbox != "" { + sbCfg.Mode = sandbox.NormalizeMode(s.Sandbox) + } + if sbCfg.Image == "" { + sbCfg.Image = sandbox.DefaultImage + } + if sandbox.Enabled(sbCfg) && !p.RunInBackground { + workdir := "" + if tc := GetToolContext(ctx); tc != nil { + workdir = tc.WorkingDir + } + if workdir == "" { + workdir, _ = os.Getwd() + } + out, err := sandbox.Run(ctx, sbCfg, workdir, p.Command) + result := strings.TrimSpace(out) + result = TruncateOutput(result) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return result + "\n\n(command timed out)", nil + } + return fmt.Sprintf("%s\n\nexit code: %s", result, err.Error()), nil + } + return result, nil + } + if p.RunInBackground { id, err := startBackgroundBash(ctx, p.Command, "bash", []string{"-c", p.Command}) if err != nil { From 24c168e3cd352dc6b81a773885862fccf0edd85a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:25:41 +0530 Subject: [PATCH 2/8] feat: add host tools registry + /workspaces (Mirror port P4/P5) --- cmd/chat_subcommand_host_tools.go | 41 ++++++++++++++ cmd/chat_subcommand_workspaces.go | 41 ++++++++++++++ cmd/chat_tools.go | 6 +++ go.mod | 7 --- internal/hosttools/hosttools.go | 81 ++++++++++++++++++++++++++++ internal/hosttools/hosttools_test.go | 41 ++++++++++++++ internal/tool/host_tool.go | 51 ++++++++++++++++++ 7 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 cmd/chat_subcommand_host_tools.go create mode 100644 cmd/chat_subcommand_workspaces.go create mode 100644 internal/hosttools/hosttools.go create mode 100644 internal/hosttools/hosttools_test.go create mode 100644 internal/tool/host_tool.go diff --git a/cmd/chat_subcommand_host_tools.go b/cmd/chat_subcommand_host_tools.go new file mode 100644 index 00000000..1d74a448 --- /dev/null +++ b/cmd/chat_subcommand_host_tools.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/reflect/internal/hosttools" +) + +// hostToolsSubcommand lists registered host tools. +type hostToolsSubcommand struct{} + +func (c *hostToolsSubcommand) Name() string { return "host-tools" } +func (c *hostToolsSubcommand) Aliases() []string { return []string{"hosttools"} } +func (c *hostToolsSubcommand) Description() string { return "list host-provided tools (~/.reflect/host_tools)" } +func (c *hostToolsSubcommand) Usage() string { return "/host-tools" } + +func (c *hostToolsSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + manifests, err := hosttools.Load() + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + if len(manifests) == 0 { + m.messages = append(m.messages, displayMsg{role: "system", content: "No host tools registered. Add JSON manifests to " + hosttools.Dir()}) + return m, nil + } + var b strings.Builder + b.WriteString("Host tools:\n") + for _, m := range manifests { + b.WriteString(fmt.Sprintf(" - %s: %s\n", m.Name, m.Description)) + } + m.messages = append(m.messages, displayMsg{role: "system", content: b.String()}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&hostToolsSubcommand{}) +} \ No newline at end of file diff --git a/cmd/chat_subcommand_workspaces.go b/cmd/chat_subcommand_workspaces.go new file mode 100644 index 00000000..42a3127e --- /dev/null +++ b/cmd/chat_subcommand_workspaces.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/reflect/internal/session" +) + +// workspacesSubcommand lists saved sessions (Mirror-style workspace roster). +type workspacesSubcommand struct{} + +func (c *workspacesSubcommand) Name() string { return "workspaces" } +func (c *workspacesSubcommand) Aliases() []string { return []string{"worktree-list"} } +func (c *workspacesSubcommand) Description() string { return "list saved sessions/workspaces" } +func (c *workspacesSubcommand) Usage() string { return "/workspaces" } + +func (c *workspacesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + entries, err := session.List() + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + if len(entries) == 0 { + m.messages = append(m.messages, displayMsg{role: "system", content: "No saved sessions."}) + return m, nil + } + var b strings.Builder + b.WriteString("Sessions:\n") + for i, e := range entries { + b.WriteString(fmt.Sprintf(" %d. %s (id=%s)\n", i+1, e.Preview, e.ID)) + } + m.messages = append(m.messages, displayMsg{role: "system", content: b.String()}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&workspacesSubcommand{}) +} \ No newline at end of file diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index e9c4f734..9f55138a 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -319,6 +319,12 @@ func defaultRegistry(settings reflectconfig.Settings) (*tool.Registry, error) { _ = registry.Register(t) } + // Host tools: external (e.g. Python) tools registered via manifests in + // ~/.reflect/host_tools/*.json (Mirror-style host tool contributions). + for _, t := range tool.LoadHostTools() { + _ = registry.Register(t) + } + // Load MCP tools in the background so a hung/absent stdio server delays // tool availability — not first paint. loadStartupMCPToolSets can block up // to 1.5s per configured server. The CLI tool filters still apply. diff --git a/go.mod b/go.mod index d9843289..c918c2b0 100644 --- a/go.mod +++ b/go.mod @@ -99,10 +99,3 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) - - - - - - - diff --git a/internal/hosttools/hosttools.go b/internal/hosttools/hosttools.go new file mode 100644 index 00000000..1a12f21f --- /dev/null +++ b/internal/hosttools/hosttools.go @@ -0,0 +1,81 @@ +// Package hosttools provides a lightweight host-tool registry inspired by +// Mirror's Python extensions. External (e.g. Python) scripts can be registered +// as Reflect tools via JSON manifests in ~/.reflect/host_tools/*.json. +// +// Each manifest describes one tool: name, description, command, and args +// (with {input} placeholder replaced by the tool's JSON input). Reflect invokes +// the command and returns its stdout as the tool result. +package hosttools + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/GrayCodeAI/reflect/internal/storage" +) + +// Manifest describes a host tool. +type Manifest struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` +} + +// Dir returns the host-tools directory. +func Dir() string { + return filepath.Join(storage.ConfigDir(), "host_tools") +} + +// Load reads all manifests from Dir(). +func Load() ([]Manifest, error) { + dir := Dir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Manifest + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + continue + } + if m.Name == "" || m.Command == "" { + continue + } + out = append(out, m) + } + return out, nil +} + +// Tool adapts a Manifest into a tool.Tool-compatible wrapper. The wrapper is +// defined in the tool package to avoid an import cycle; here we expose a +// constructor returning an opaque value. Callers should use hosttools.AsTool. +// +// Run executes a host tool with the given JSON input and returns stdout. +func Run(m Manifest, input string) (string, error) { + args := make([]string, len(m.Args)) + for i, a := range m.Args { + args[i] = strings.ReplaceAll(a, "{input}", input) + } + cmd := exec.Command(m.Command, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("host tool %s failed: %w: %s", m.Name, err, string(out)) + } + return string(out), nil +} diff --git a/internal/hosttools/hosttools_test.go b/internal/hosttools/hosttools_test.go new file mode 100644 index 00000000..8bac2c3f --- /dev/null +++ b/internal/hosttools/hosttools_test.go @@ -0,0 +1,41 @@ +package hosttools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEmpty(t *testing.T) { + dir := t.TempDir() + t.Setenv("REFLECT_CONFIG_DIR", dir) + // ensure dir exists + os.MkdirAll(filepath.Join(dir, "host_tools"), 0o755) + m, err := Load() + if err != nil { + t.Fatal(err) + } + if len(m) != 0 { + t.Fatalf("expected 0, got %d", len(m)) + } +} + +func TestLoadAndRun(t *testing.T) { + dir := t.TempDir() + t.Setenv("REFLECT_CONFIG_DIR", dir) + ht := filepath.Join(dir, "host_tools") + os.MkdirAll(ht, 0o755) + manifest := `{"name":"echo-tool","description":"echoes input","command":"sh","args":["-c","echo {input}"]}` + os.WriteFile(filepath.Join(ht, "echo.json"), []byte(manifest), 0o644) + m, err := Load() + if err != nil || len(m) != 1 { + t.Fatalf("load: %v len=%d", err, len(m)) + } + out, err := Run(m[0], "hello") + if err != nil { + t.Fatal(err) + } + if out != "hello\n" { + t.Fatalf("got %q", out) + } +} diff --git a/internal/tool/host_tool.go b/internal/tool/host_tool.go new file mode 100644 index 00000000..6904d5d8 --- /dev/null +++ b/internal/tool/host_tool.go @@ -0,0 +1,51 @@ +package tool + +import ( + "context" + "encoding/json" + + "github.com/GrayCodeAI/reflect/internal/hosttools" +) + +// HostTool wraps a hosttools.Manifest as a Reflect tool. +type HostTool struct { + Manifest hosttools.Manifest +} + +func (h HostTool) Name() string { return h.Manifest.Name } + +func (h HostTool) Description() string { + if h.Manifest.Description != "" { + return h.Manifest.Description + } + return "Host-provided tool: " + h.Manifest.Name +} + +func (h HostTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{ + "type": "string", + "description": "JSON input passed to the host tool", + }, + }, + } +} + +func (h HostTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { + return hosttools.Run(h.Manifest, string(input)) +} + +// LoadHostTools returns registered host tools. +func LoadHostTools() []Tool { + manifests, err := hosttools.Load() + if err != nil { + return nil + } + out := make([]Tool, 0, len(manifests)) + for _, m := range manifests { + out = append(out, HostTool{Manifest: m}) + } + return out +} \ No newline at end of file From acaa98f9a09296d5ead9cb2312bdf54c7dda77cc Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:26:55 +0530 Subject: [PATCH 3/8] style: gofmt --- cmd/chat_subcommand_host_tools.go | 12 +++++++----- cmd/chat_subcommand_sandbox.go | 12 +++++++----- cmd/chat_subcommand_workspaces.go | 2 +- internal/sandbox/sandbox_test.go | 7 +++++-- internal/tool/host_tool.go | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/cmd/chat_subcommand_host_tools.go b/cmd/chat_subcommand_host_tools.go index 1d74a448..4d3f528d 100644 --- a/cmd/chat_subcommand_host_tools.go +++ b/cmd/chat_subcommand_host_tools.go @@ -12,10 +12,12 @@ import ( // hostToolsSubcommand lists registered host tools. type hostToolsSubcommand struct{} -func (c *hostToolsSubcommand) Name() string { return "host-tools" } -func (c *hostToolsSubcommand) Aliases() []string { return []string{"hosttools"} } -func (c *hostToolsSubcommand) Description() string { return "list host-provided tools (~/.reflect/host_tools)" } -func (c *hostToolsSubcommand) Usage() string { return "/host-tools" } +func (c *hostToolsSubcommand) Name() string { return "host-tools" } +func (c *hostToolsSubcommand) Aliases() []string { return []string{"hosttools"} } +func (c *hostToolsSubcommand) Description() string { + return "list host-provided tools (~/.reflect/host_tools)" +} +func (c *hostToolsSubcommand) Usage() string { return "/host-tools" } func (c *hostToolsSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { manifests, err := hosttools.Load() @@ -38,4 +40,4 @@ func (c *hostToolsSubcommand) Handle(m *chatModel, args []string, text string) ( func init() { subcommandRegistry.Register(&hostToolsSubcommand{}) -} \ No newline at end of file +} diff --git a/cmd/chat_subcommand_sandbox.go b/cmd/chat_subcommand_sandbox.go index 5696f7e0..f1944ac1 100644 --- a/cmd/chat_subcommand_sandbox.go +++ b/cmd/chat_subcommand_sandbox.go @@ -13,10 +13,12 @@ import ( // sandboxSubcommand reports and toggles sandboxed tool execution. type sandboxSubcommand struct{} -func (c *sandboxSubcommand) Name() string { return "sandbox" } -func (c *sandboxSubcommand) Aliases() []string { return nil } -func (c *sandboxSubcommand) Description() string { return "show sandbox status (Mirror-style isolated execution)" } -func (c *sandboxSubcommand) Usage() string { return "/sandbox" } +func (c *sandboxSubcommand) Name() string { return "sandbox" } +func (c *sandboxSubcommand) Aliases() []string { return nil } +func (c *sandboxSubcommand) Description() string { + return "show sandbox status (Mirror-style isolated execution)" +} +func (c *sandboxSubcommand) Usage() string { return "/sandbox" } func (c *sandboxSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { s := config.LoadSettings() @@ -42,4 +44,4 @@ func (c *sandboxSubcommand) Handle(m *chatModel, args []string, text string) (te func init() { subcommandRegistry.Register(&sandboxSubcommand{}) -} \ No newline at end of file +} diff --git a/cmd/chat_subcommand_workspaces.go b/cmd/chat_subcommand_workspaces.go index 42a3127e..25265636 100644 --- a/cmd/chat_subcommand_workspaces.go +++ b/cmd/chat_subcommand_workspaces.go @@ -38,4 +38,4 @@ func (c *workspacesSubcommand) Handle(m *chatModel, args []string, text string) func init() { subcommandRegistry.Register(&workspacesSubcommand{}) -} \ No newline at end of file +} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 8b37cf74..e8ac34bd 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -7,7 +7,10 @@ import ( ) func TestNormalizeMode(t *testing.T) { - cases := []struct{ in string; want Mode }{ + cases := []struct { + in string + want Mode + }{ {"", ModeOff}, {"off", ModeOff}, {"docker", ModeDocker}, @@ -65,4 +68,4 @@ func contains(s, sub string) bool { } return false })() -} \ No newline at end of file +} diff --git a/internal/tool/host_tool.go b/internal/tool/host_tool.go index 6904d5d8..c1ab744e 100644 --- a/internal/tool/host_tool.go +++ b/internal/tool/host_tool.go @@ -48,4 +48,4 @@ func LoadHostTools() []Tool { out = append(out, HostTool{Manifest: m}) } return out -} \ No newline at end of file +} From df91a7b982b1a2bb92c4930882322d896ef0640a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:30:55 +0530 Subject: [PATCH 4/8] style: gofumpt formatting --- internal/sandbox/sandbox_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index e8ac34bd..37d52c96 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -60,12 +60,12 @@ func TestRunDockerIfAvailable(t *testing.T) { } func contains(s, sub string) bool { - return len(s) >= len(sub) && (func() bool { + return len(s) >= len(sub) && func() bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false - })() + }() } From 54204aa8f4ee6e0f560203b8fdc985c85038b940 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:46:07 +0530 Subject: [PATCH 5/8] fix: lint (gosec/noctx) for hosttools + sandbox --- go.mod | 5 +++++ internal/hosttools/hosttools.go | 5 +++-- internal/hosttools/hosttools_test.go | 2 +- internal/sandbox/sandbox.go | 4 ++-- internal/tool/host_tool.go | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index c918c2b0..6bae7f13 100644 --- a/go.mod +++ b/go.mod @@ -99,3 +99,8 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + + + + + diff --git a/internal/hosttools/hosttools.go b/internal/hosttools/hosttools.go index 1a12f21f..52dbb3c1 100644 --- a/internal/hosttools/hosttools.go +++ b/internal/hosttools/hosttools.go @@ -8,6 +8,7 @@ package hosttools import ( + "context" "encoding/json" "fmt" "os" @@ -67,12 +68,12 @@ func Load() ([]Manifest, error) { // constructor returning an opaque value. Callers should use hosttools.AsTool. // // Run executes a host tool with the given JSON input and returns stdout. -func Run(m Manifest, input string) (string, error) { +func Run(ctx context.Context, m Manifest, input string) (string, error) { args := make([]string, len(m.Args)) for i, a := range m.Args { args[i] = strings.ReplaceAll(a, "{input}", input) } - cmd := exec.Command(m.Command, args...) + cmd := exec.CommandContext(ctx, m.Command, args...) // #nosec G204 -- command comes from a trusted local manifest out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("host tool %s failed: %w: %s", m.Name, err, string(out)) diff --git a/internal/hosttools/hosttools_test.go b/internal/hosttools/hosttools_test.go index 8bac2c3f..66be7db1 100644 --- a/internal/hosttools/hosttools_test.go +++ b/internal/hosttools/hosttools_test.go @@ -31,7 +31,7 @@ func TestLoadAndRun(t *testing.T) { if err != nil || len(m) != 1 { t.Fatalf("load: %v len=%d", err, len(m)) } - out, err := Run(m[0], "hello") + out, err := Run(t.Context(), m[0], "hello") if err != nil { t.Fatal(err) } diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 59f0e417..4afeba80 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -53,7 +53,7 @@ func DockerAvailable() bool { if _, err := exec.LookPath("docker"); err != nil { return false } - return exec.Command("docker", "info").Run() == nil + return exec.CommandContext(context.Background(), "docker", "info").Run() == nil } // Run executes command inside a container with workdir bind-mounted at /work. @@ -79,7 +79,7 @@ func Run(ctx context.Context, cfg Config, workdir, command string) (string, erro image, "sh", "-c", command, } - out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() + out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() // #nosec G204 -- args are controlled (image + bind mount + sh -c) return string(out), err } diff --git a/internal/tool/host_tool.go b/internal/tool/host_tool.go index c1ab744e..fcc8d623 100644 --- a/internal/tool/host_tool.go +++ b/internal/tool/host_tool.go @@ -34,7 +34,7 @@ func (h HostTool) Parameters() map[string]interface{} { } func (h HostTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - return hosttools.Run(h.Manifest, string(input)) + return hosttools.Run(ctx, h.Manifest, string(input)) } // LoadHostTools returns registered host tools. From 95de32e14263df2ba2d749fe77c3a428624831b8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 07:54:30 +0530 Subject: [PATCH 6/8] chore: use pseudo-version for relay to avoid stale tag cache --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6bae7f13..0b423055 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 - github.com/GrayCodeAI/relay v0.0.1 + github.com/GrayCodeAI/relay v0.0.2-0.20261017013450-98f5fa63bb80 github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 From 045a5616e28e0723c7f5dabeb6ffd1ca2f9979bf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 08:16:42 +0530 Subject: [PATCH 7/8] chore: bump relay dependency to v0.0.2 --- go.mod | 7 +------ go.sum | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 0b423055..e8e796ee 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 - github.com/GrayCodeAI/relay v0.0.2-0.20261017013450-98f5fa63bb80 + github.com/GrayCodeAI/relay v0.0.2 github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 @@ -99,8 +99,3 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) - - - - - diff --git a/go.sum b/go.sum index 007695f4..61635b27 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= -github.com/GrayCodeAI/relay v0.0.1 h1:4ei0Ce1zpQO5ifS9s8p5rD0c7oOfY/NW4MRRJzPTToY= -github.com/GrayCodeAI/relay v0.0.1/go.mod h1:fmwsG7BqBrf83qMo7c1widzC4AuH6gI2M8SXWXXPREA= +github.com/GrayCodeAI/relay v0.0.2 h1:Yda2rWSvhcmcAmqxZDeETrddKs6+4hcxBgJi/4l8KnE= +github.com/GrayCodeAI/relay v0.0.2/go.mod h1:fmwsG7BqBrf83qMo7c1widzC4AuH6gI2M8SXWXXPREA= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= From 1ef9b0a57e9411d04c3831df36b0a9845935d288 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 08:22:03 +0530 Subject: [PATCH 8/8] fix: remove zero-assertion smoke test (assertion audit) --- go.mod | 1 + internal/sandbox/sandbox_test.go | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/go.mod b/go.mod index e8e796ee..a387f4a1 100644 --- a/go.mod +++ b/go.mod @@ -99,3 +99,4 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 37d52c96..e70532b2 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -41,11 +41,6 @@ func TestRunOffReturnsError(t *testing.T) { } } -func TestDockerAvailableSmoke(t *testing.T) { - // Just ensure the helper doesn't panic. - _ = DockerAvailable() -} - func TestRunDockerIfAvailable(t *testing.T) { if !DockerAvailable() { t.Skip("docker not available")