Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ api_key_env = "DEEPSEEK_API_KEY"

[tools]
enabled = [] # omit/empty = all built-ins
bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap

[skills]
# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ api_key_env = "DEEPSEEK_API_KEY"

[tools]
enabled = [] # 省略/为空 = 全部内置工具
bash_timeout_seconds = 120 # 前台安全上限;设为 0 表示不设工具层超时

[skills]
# paths = ["~/my-skills", "../shared/skills"] # 额外的自定义技能目录
Expand Down
1 change: 1 addition & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ api_key_env = "MIMO_API_KEY"

[tools]
enabled = [] # omit/empty = all built-ins
bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap

[skills]
# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots
Expand Down
10 changes: 6 additions & 4 deletions internal/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"reasonix/internal/agent"
"reasonix/internal/codegraph"
Expand Down Expand Up @@ -193,7 +194,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
fmt.Fprintln(stderr, "warning: bash not found on PATH; the shell tool will run commands under Windows PowerShell. Install Git for Windows or WSL to use bash.")
}
searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr)
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, searchSpec, stderr, root)
bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second
addBuiltins(reg, cfg.Tools.Enabled, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root)
// Always construct a host, even with no plugins configured, so the controller's
// host pointer is stable for the session and `/mcp add` can hot-add into it.
pluginHost := plugin.NewHost()
Expand Down Expand Up @@ -664,12 +666,12 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// instance bound to writeRoots (preserving registry order).
// When workDir is non-empty, tools resolve relative paths against it instead of
// the process cwd, enabling concurrent multi-project sessions.
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string) {
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string) {
// If a workspace directory is set, use workspace-bound tools that resolve
// paths relative to that directory. Otherwise fall back to the process-cwd
// compile-time builtins.
if workDir != "" {
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, Search: searchSpec}
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec}
for _, t := range ws.Tools(enabled...) {
reg.Add(t)
}
Expand All @@ -692,7 +694,7 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
// Replace the unconfined defaults with confined instances (registry order is
// preserved on replace): file-writers bound to the workspace, bash to the OS
// sandbox. Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec), builtin.ConfineSearch(searchSpec))
confined := append(builtin.ConfineWriters(writeRoots), builtin.ConfineBash(bashSpec, bashTimeout), builtin.ConfineSearch(searchSpec))
for _, t := range confined {
if _, ok := reg.Get(t.Name()); ok {
reg.Add(t)
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/signal"
"time"

"reasonix/internal/acp"
"reasonix/internal/agent"
Expand Down Expand Up @@ -106,7 +107,7 @@ func (f *acpFactory) NewSession(ctx context.Context, p acp.SessionParams) (*cont
writeRoots = []string{p.Cwd}
}
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, Network: cfg.Sandbox.Network}
ws := builtin.Workspace{Dir: p.Cwd, WriteRoots: writeRoots, Bash: bashSpec, Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil)}
ws := builtin.Workspace{Dir: p.Cwd, WriteRoots: writeRoots, Bash: bashSpec, BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second, Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil)}
for _, t := range ws.Tools(cfg.Tools.Enabled...) {
reg.Add(t)
}
Expand Down
18 changes: 16 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,22 @@ func (e *ProviderEntry) HasModel(m string) bool {

// ToolsConfig selects which built-in tools are enabled. Empty means all of them.
type ToolsConfig struct {
Enabled []string `toml:"enabled"`
Search SearchConfig `toml:"search"`
Enabled []string `toml:"enabled"`
BashTimeoutSeconds *int `toml:"bash_timeout_seconds"`
Search SearchConfig `toml:"search"`
}

const defaultBashTimeoutSeconds = 120

// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted
// config keeps the historical 120s safety cap, explicit 0 disables the
// tool-local cap, and positive values set a custom cap. Negative values fall
// back to the default so a typo cannot silently remove the safety net.
func (c *Config) BashTimeoutSeconds() int {
if c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {
return defaultBashTimeoutSeconds
}
return *c.Tools.BashTimeoutSeconds
}

// SearchConfig tunes the grep tool's engine. Engine is "auto" (default — use
Expand Down
5 changes: 3 additions & 2 deletions internal/config/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func RenderTOML(c *Config) string {

b.WriteString("[tools]\n")
if len(c.Tools.Enabled) == 0 {
b.WriteString("enabled = [] # empty = all built-in tools\n\n")
b.WriteString("enabled = [] # empty = all built-in tools\n")
} else {
b.WriteString("enabled = [")
for i, t := range c.Tools.Enabled {
Expand All @@ -170,8 +170,9 @@ func RenderTOML(c *Config) string {
}
fmt.Fprintf(&b, "%q", t)
}
b.WriteString("]\n\n")
b.WriteString("]\n")
}
fmt.Fprintf(&b, "bash_timeout_seconds = %d # foreground safety cap; set 0 for no tool-local cap\n\n", c.BashTimeoutSeconds())

b.WriteString("[codegraph]\n")
fmt.Fprintf(&b, "enabled = %v # built-in MCP server; off by default for first-run sessions\n", c.Codegraph.Enabled)
Expand Down
6 changes: 6 additions & 0 deletions internal/config/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
orig.Agent.AutoPlanClassifier = "deepseek-flash"
orig.Agent.SubagentModel = "mimo-pro"
orig.Agent.SubagentModels = map[string]string{"review": "deepseek-pro"}
orig.Tools.BashTimeoutSeconds = intPtr(900)
orig.Permissions = PermissionsConfig{
Mode: "deny",
Deny: []string{"bash(rm -rf*)"},
Expand Down Expand Up @@ -106,6 +107,9 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.Agent.SubagentModels["review"] != "deepseek-pro" {
t.Errorf("subagent_models.review = %q, want deepseek-pro", got.Agent.SubagentModels["review"])
}
if got.Tools.BashTimeoutSeconds == nil || *got.Tools.BashTimeoutSeconds != 900 {
t.Errorf("tools.bash_timeout_seconds = %v, want 900", got.Tools.BashTimeoutSeconds)
}
if g, _ := got.Provider("mimo-pro"); g == nil || g.BaseURL != "http://localhost:8000/v1" {
t.Errorf("mimo-pro base_url not preserved: %+v", g)
}
Expand Down Expand Up @@ -152,3 +156,5 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
}

func boolPtr(v bool) *bool { return &v }

func intPtr(v int) *int { return &v }
46 changes: 46 additions & 0 deletions internal/config/tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package config

import (
"testing"

"github.com/BurntSushi/toml"
)

func TestBashTimeoutSecondsDefaultsToSafetyCap(t *testing.T) {
cfg := Default()
if cfg.Tools.BashTimeoutSeconds != nil {
t.Fatalf("default raw bash timeout = %v, want nil", *cfg.Tools.BashTimeoutSeconds)
}
if got := cfg.BashTimeoutSeconds(); got != 120 {
t.Fatalf("BashTimeoutSeconds() = %d, want 120", got)
}
}

func TestBashTimeoutSecondsAllowsExplicitZero(t *testing.T) {
cfg := Default()
cfg.Tools.BashTimeoutSeconds = intPtr(0)
if got := cfg.BashTimeoutSeconds(); got != 0 {
t.Fatalf("BashTimeoutSeconds() = %d, want 0", got)
}
}

func TestBashTimeoutSecondsParsesExplicitZero(t *testing.T) {
cfg := Default()
if _, err := toml.Decode("[tools]\nbash_timeout_seconds = 0\n", cfg); err != nil {
t.Fatalf("decode explicit zero: %v", err)
}
if cfg.Tools.BashTimeoutSeconds == nil {
t.Fatal("explicit zero decoded as nil")
}
if got := cfg.BashTimeoutSeconds(); got != 0 {
t.Fatalf("BashTimeoutSeconds() = %d, want 0", got)
}
}

func TestBashTimeoutSecondsFallsBackForNegative(t *testing.T) {
cfg := Default()
cfg.Tools.BashTimeoutSeconds = intPtr(-1)
if got := cfg.BashTimeoutSeconds(); got != 120 {
t.Fatalf("BashTimeoutSeconds() = %d, want 120", got)
}
}
2 changes: 1 addition & 1 deletion internal/skill/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const builtinTestBody = `This skill is INLINED — you run in the parent loop. T

How to operate:
1. Detect the test command. Look at the project: go.mod → ` + "`go test ./...`" + `; package.json scripts.test → ` + "`npm test`" + ` (or pnpm/yarn); pyproject.toml/requirements.txt → ` + "`pytest`" + `; Cargo.toml → ` + "`cargo test`" + `. If you can't tell, ASK — don't guess.
2. Run it via bash (timeout ~120s, more for a big suite). Capture stdout + stderr.
2. Run it via bash. Capture stdout + stderr; for intentionally long-running commands, start them in the background and use wait/bash_output.
3. Read the failures: which tests failed, the actual error, the file + line that threw. Locate the exact assertion or stack frame.
4. Fix each distinct failure:
- Production bug (test caught a real defect) → fix the production code.
Expand Down
45 changes: 31 additions & 14 deletions internal/tool/builtin/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
Expand All @@ -16,22 +17,26 @@ import (
)

const (
bashTimeout = 120 * time.Second
bashWaitDelay = 5 * time.Second
)

var errBashTimeout = errors.New("bash foreground timeout")

func init() { tool.RegisterBuiltin(bash{}) }

// bash runs a shell command with a timeout to avoid hangs. sb, when it enforces,
// wraps the command in an OS sandbox; the zero value registered at init runs
// unconfined and is overridden per run by ConfineBash. shell is the resolved
// interpreter (real bash, or PowerShell on a Windows host without bash); the
// zero value resolves lazily. workDir, when non-empty, is the directory the
// command runs in (cmd.Dir); empty uses the process cwd.
// bash runs a shell command. sb, when it enforces, wraps the command in an OS
// sandbox; the zero value registered at init runs unconfined and is overridden
// per run by ConfineBash. shell is the resolved interpreter (real bash, or
// PowerShell on a Windows host without bash); the zero value resolves lazily.
// workDir, when non-empty, is the directory the command runs in (cmd.Dir);
// empty uses the process cwd. timeout optionally caps foreground commands;
// zero or negative means no tool-local cap, while parent context cancellation
// still kills the process tree.
type bash struct {
sb sandbox.Spec
shell sandbox.Shell
workDir string
timeout time.Duration
}

func (bash) Name() string { return "bash" }
Expand Down Expand Up @@ -65,7 +70,7 @@ func (b bash) resolved() sandbox.Shell {
}

func (bash) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"},"run_in_background":{"type":"boolean","description":"Run detached: returns a job id immediately and keeps running across turns (no timeout). Read new output with bash_output, wait for it with wait, stop it with kill_shell. Use for long-running commands like servers, watchers, or builds you don't need to block on."}},"required":["command"]}`)
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"},"run_in_background":{"type":"boolean","description":"Run detached: returns a job id immediately and keeps running across turns. Read new output with bash_output, wait for it with wait, stop it with kill_shell. Use for long-running commands like servers, watchers, or builds you don't need to block on."}},"required":["command"]}`)
}

// ReadOnly is false: bash's effect cannot be inferred from args (rm, curl,
Expand Down Expand Up @@ -101,7 +106,7 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
return "", fmt.Errorf("background execution is not available in this context")
}
workDir := b.workDir
// The job runs under the manager's session context (no 120s timeout), so it
// The job runs under the manager's session context (no foreground timeout), so it
// survives this turn; its combined output streams to the job buffer.
job := jm.Start("bash", commandPreview(p.Command), func(jobCtx context.Context, out io.Writer) (string, error) {
cmd := exec.CommandContext(jobCtx, argv[0], argv[1:]...)
Expand All @@ -115,10 +120,15 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
return fmt.Sprintf("Started background job %q. It keeps running across turns; read new output with bash_output(job_id=%q), wait for it with wait, or stop it with kill_shell(job_id=%q).", job.ID, job.ID, job.ID), nil
}

ctx, cancel := context.WithTimeout(ctx, bashTimeout)
defer cancel()
runCtx := ctx
timeout := b.foregroundTimeout()
if timeout > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeoutCause(ctx, timeout, errBashTimeout)
defer cancel()
}

cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...)
cmd.Dir = b.workDir // "" lets exec use the process working directory
setKillTree(cmd)
cmd.WaitDelay = bashWaitDelay
Expand All @@ -132,8 +142,8 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
err := cmd.Run()
out := buf.String()

if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("command timed out (> %s)", bashTimeout)
if errors.Is(context.Cause(runCtx), errBashTimeout) {
return out, fmt.Errorf("command timed out (> %s)", timeout)
}
if err != nil {
// Non-zero exit: feed output and error back so the model can self-correct.
Expand All @@ -142,6 +152,13 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
return out, nil
}

func (b bash) foregroundTimeout() time.Duration {
if b.timeout <= 0 {
return 0
}
return b.timeout
}

// progressWriter forwards each chunk the command writes to a tool.ProgressFunc,
// so foreground bash output streams to the frontend as it is produced.
type progressWriter struct{ emit tool.ProgressFunc }
Expand Down
Loading
Loading