diff --git a/internal/sandbox/wrapper.go b/internal/sandbox/wrapper.go index 299c4c9..4831229 100644 --- a/internal/sandbox/wrapper.go +++ b/internal/sandbox/wrapper.go @@ -19,7 +19,7 @@ var ( ) // ErrNotAvailable is returned when landrun is not found on the system. -var ErrNotAvailable = fmt.Errorf("SANDBOX FAILURE: landrun is not installed or not in PATH — execute_code CANNOT run without it") +var ErrNotAvailable = fmt.Errorf("SANDBOX FAILURE: landrun is not installed or not in PATH — shell CANNOT run without it") // WrapCommand wraps a command with landrun based on the configuration. // Returns an error if landrun is not available. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 531aba6..01ac608 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -717,7 +717,7 @@ func parseTextToolCalls(text string, tools []backend.Tool) []backend.ToolCall { } var calls []backend.ToolCall - for _, name := range []string{"execute_code"} { + for _, name := range []string{"shell"} { if !toolNames[name] { continue } diff --git a/pkg/agent/types.go b/pkg/agent/types.go index 9f5fdb2..4dfff4e 100644 --- a/pkg/agent/types.go +++ b/pkg/agent/types.go @@ -123,7 +123,7 @@ type Core interface { // SetCompactionModel changes the model used for context compaction. SetCompactionModel(model string) - // SetEnv injects a session-scoped environment variable into execute_code + // SetEnv injects a session-scoped environment variable into the shell // subprocesses. Does not affect the daemon process environment. SetEnv(key, value string) diff --git a/pkg/tools/execute/code.go b/pkg/tools/execute/code.go index 469ee84..a0dc432 100644 --- a/pkg/tools/execute/code.go +++ b/pkg/tools/execute/code.go @@ -16,44 +16,7 @@ import ( "strings" ) -// CodeStep is one stage in an execution pipeline. -// Set Code/Language for inline code, Tool/Args for a named script, or Parallel for concurrent fan-out. -// Set Elevated to run the step outside the sandbox via the configured elevation backend. -// Set Detach to immediately background the process and return its PID. -type CodeStep struct { - Code string `json:"code"` - Language string `json:"language"` - Tool string `json:"tool"` - Args []string `json:"args"` - Parallel []CodeStep `json:"parallel"` - Elevated bool `json:"elevated"` - Detach bool `json:"detach"` -} - -// resolveCodeStep loads a CodeStep into executable (code, language, trusted). -// Tool steps are read from the tools directory and treated as trusted. -// Inline code steps default to bash and are validated by the caller. -func resolveCodeStep(s CodeStep) (code, language string, trusted bool, err error) { - if s.Tool != "" { - toolCode, terr := ReadTool(s.Tool) - if terr != nil { - return "", "", false, terr - } - language = "bash" - code = toolCode - if len(s.Args) > 0 { - code = injectArgs(language, s.Tool, s.Args, toolCode) - } - return code, language, true, nil - } - language = s.Language - if language == "" { - language = "bash" - } - return s.Code, language, false, nil -} - -// universalPatterns apply to all general-purpose languages. +// universalPatterns apply to all code. var universalPatterns = []*regexp.Regexp{ regexp.MustCompile(`\bmkfs\b`), regexp.MustCompile(`\bdd\b.*\bif=/dev/`), @@ -61,7 +24,7 @@ var universalPatterns = []*regexp.Regexp{ regexp.MustCompile(`/etc/(shadow|sudoers)`), } -// bashPatterns apply only to bash (flag syntax, redirects, shell-specific constructs). +// bashPatterns apply to bash (flag syntax, redirects, shell-specific constructs). var bashPatterns = []*regexp.Regexp{ regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`), regexp.MustCompile(`rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`), @@ -80,22 +43,21 @@ var languagePatterns = map[string][]*regexp.Regexp{ "": bashPatterns, } -// Dispatch routes a named execute tool call. +// Dispatch routes a shell tool call. func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) { if e.OnPreDispatch != nil { e.OnPreDispatch() } switch name { - case "execute_code": - return dispatchExecuteCode(ctx, e, args) - + case "shell": + return dispatchShell(ctx, e, args) default: return "", fmt.Errorf("unknown execute tool: %s", name) } } -// ValidateCode checks code against dangerous patterns for the given language. +// ValidateCode checks code against dangerous patterns. func (e *Server) ValidateCode(code, language string) error { if err := e.checkRateLimit(); err != nil { return err @@ -162,116 +124,26 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) { return len(p), nil } -// dispatchExecuteCode handles the execute_code tool: inline code steps only. -// The legacy "pipe" field is no longer accepted here; use the pipe tool instead. -func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) (string, error) { - steps, timeout, sandboxName, err := execCodeOnlyArgs(args) - if err != nil { - return "", fmt.Errorf("execute_code: bad args: %w", err) - } - if len(steps) == 0 { - return "", fmt.Errorf("execute_code: steps is required. Format: {\"steps\": [{\"code\": \"your code here\"}]}") - } - return e.dispatchSteps(ctx, steps, timeout, sandboxName) -} - - - -// dispatchSteps runs steps in parallel when safe (annotation-based), serially otherwise. -func (e *Server) dispatchSteps(ctx context.Context, steps []CodeStep, timeout int, sandboxName string) (string, error) { - if e.Strict { - if err := enforceStrict(steps); err != nil { - return "", err - } - } - - // Single step: run directly. - if len(steps) == 1 && len(steps[0].Parallel) == 0 { - if steps[0].Elevated { - e.wdMu.RLock() - dir := e.cwd - e.wdMu.RUnlock() - return e.executeElevated(ctx, steps[0].Code, dir, timeout, steps[0].Detach) - } - code, lang, trusted, err := resolveCodeStep(steps[0]) - if err != nil { - return "", err - } - return e.executeWithStdin(ctx, code, lang, timeout, sandboxName, trusted, "", steps[0].Detach) - } - - // Multiple steps: auto-batch consecutive read-safe steps in parallel; - // write/global steps run serially between batches. - var output string - for i := 0; i < len(steps); { - if classifyStep(steps[i]) == lockClassRead { - j := i + 1 - for j < len(steps) && classifyStep(steps[j]) == lockClassRead { - j++ - } - out, err := e.runReadBatch(ctx, i, steps[i:j], timeout, sandboxName, "") - if err != nil { - return out, err - } - output += out - i = j - } else { - lf, err := acquireFlock(e.lockDir, "rw", true) - if err != nil { - return "", fmt.Errorf("step %d: lock: %w", i, err) - } - out, runErr := e.runStage(ctx, i, steps[i], timeout, sandboxName, "") - if lf != nil { - lf.Close() - } - if runErr != nil { - return output + out, fmt.Errorf("step %d: %w", i, runErr) - } - output += out - i++ - } - } - return output, nil -} - - - -// enforceStrict rejects any step that uses inline code rather than a named tool. -func enforceStrict(stages []CodeStep) error { - for i, s := range stages { - if len(s.Parallel) > 0 { - if err := enforceStrict(s.Parallel); err != nil { - return err - } - continue - } - if s.Tool == "" { - return fmt.Errorf("execute_code: step %d rejected: inline code not allowed in strict mode", i) - } - } - return nil -} - -// execCodeOnlyArgs parses args for the execute_code tool (steps only). -func execCodeOnlyArgs(args json.RawMessage) (steps []CodeStep, timeout int, sandboxName string, err error) { +// dispatchShell handles the shell tool: a single bash command. +func dispatchShell(ctx context.Context, e *Server, args json.RawMessage) (string, error) { var a struct { - Steps []CodeStep `json:"steps"` - Timeout int `json:"timeout"` - Sandbox string `json:"sandbox"` + Cmd string `json:"cmd"` + Timeout int `json:"timeout"` + Sandbox string `json:"sandbox"` } - if err = json.Unmarshal(args, &a); err != nil { - return + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("shell: bad args: %w", err) } - steps = a.Steps - timeout = a.Timeout + if a.Cmd == "" { + return "", fmt.Errorf("shell: cmd is required") + } + timeout := a.Timeout if timeout <= 0 { timeout = 30 } - sandboxName = a.Sandbox + sandboxName := a.Sandbox if sandboxName == "" { sandboxName = "default" } - return + return e.executeWithStdin(ctx, a.Cmd, "bash", timeout, sandboxName, false, "") } - - diff --git a/pkg/tools/execute/flock.go b/pkg/tools/execute/flock.go index 09f091d..48326e0 100644 --- a/pkg/tools/execute/flock.go +++ b/pkg/tools/execute/flock.go @@ -7,58 +7,6 @@ import ( "syscall" ) -// lockClass classifies a CodeStep by its concurrency profile. -type lockClass int - -const ( - lockClassRead lockClass = iota // file_read, file_glob, file_grep — safe to parallelize - lockClassWrite // file_write, file_edit — exclusive per path - lockClassGlobal // inline code, bash, unknown tools — global serialize -) - -// classifyStep returns the lock class for a single CodeStep. -// Inline code, parallel groups, and elevated steps are always lockClassGlobal. -// Tool steps are classified by reading the script and checking for an -// ollie:parallel annotation in the first 10 lines (see detectParallelClass). -// Unknown or unreadable tools default to lockClassGlobal. -func classifyStep(step CodeStep) lockClass { - if step.Code != "" || len(step.Parallel) > 0 || step.Elevated || step.Tool == "" { - return lockClassGlobal - } - code, err := ReadTool(step.Tool) - if err != nil { - return lockClassGlobal - } - return detectParallelClass(code) -} - -// detectParallelClass scans the first 10 lines of a tool script for an -// ollie:parallel annotation. The annotation is comment-syntax-agnostic: -// it matches "ollie:parallel read" or "ollie:parallel write" anywhere in -// the line, so it works with # (bash/python), -- (lua), // (go), etc. -// -// Absence of the annotation → lockClassGlobal (serialize). -func detectParallelClass(code string) lockClass { - lines := strings.SplitN(code, "\n", 11) - if len(lines) > 10 { - lines = lines[:10] - } - for _, line := range lines { - idx := strings.Index(line, "ollie:parallel") - if idx < 0 { - continue - } - rest := strings.TrimSpace(line[idx+len("ollie:parallel"):]) - switch { - case rest == "read" || strings.HasPrefix(rest, "read "): - return lockClassRead - case rest == "write" || strings.HasPrefix(rest, "write "): - return lockClassWrite - } - } - return lockClassGlobal -} - // acquireFlock opens (or creates) a lock file in dir named name and acquires // LOCK_SH (exclusive=false) or LOCK_EX (exclusive=true). // Returns nil, nil when dir is empty (locking disabled). @@ -86,15 +34,6 @@ func acquireFlock(dir, name string, exclusive bool) (*os.File, error) { return f, nil } -// IsParallelRead implements tools.ParallelClassifier. Returns true when the -// named tool script carries an "ollie:parallel read" annotation. -func (e *Server) IsParallelRead(name string) bool { - code, err := ReadTool(name) - if err != nil { - return false - } - return detectParallelClass(code) == lockClassRead -} func sanitizeLockName(s string) string { var b strings.Builder diff --git a/pkg/tools/execute/pipe.go b/pkg/tools/execute/pipe.go deleted file mode 100644 index 66c1057..0000000 --- a/pkg/tools/execute/pipe.go +++ /dev/null @@ -1,112 +0,0 @@ -package execute - -import ( - "context" - "fmt" - "sync" -) - -// runStage executes one stage of an execute_code pipeline, feeding stdinData as stdin. -// Parallel stages fan out concurrently and concatenate results in submission order. -func (e *Server) runStage(ctx context.Context, idx int, stage CodeStep, timeout int, sandboxName, stdinData string) (string, error) { - if len(stage.Parallel) > 0 { - results := make([]string, len(stage.Parallel)) - errs := make([]error, len(stage.Parallel)) - var wg sync.WaitGroup - for i, step := range stage.Parallel { - wg.Add(1) - go func(j int, s CodeStep) { - defer wg.Done() - if s.Elevated { - e.wdMu.RLock() - dir := e.cwd - e.wdMu.RUnlock() - results[j], errs[j] = e.executeElevated(ctx, s.Code, dir, timeout, s.Detach) - return - } - code, lang, trusted, err := resolveCodeStep(s) - if err != nil { - errs[j] = err - return - } - results[j], errs[j] = e.executeWithStdin(ctx, code, lang, timeout, sandboxName, trusted, stdinData) - }(i, step) - } - wg.Wait() - var out string - for i, r := range results { - out += r - if errs[i] != nil { - return out, errs[i] - } - } - return out, nil - } - - if stage.Elevated { - e.wdMu.RLock() - dir := e.cwd - e.wdMu.RUnlock() - return e.executeElevated(ctx, stage.Code, dir, timeout, stage.Detach) - } - - if stage.Tool != "" || stage.Code != "" { - code, lang, trusted, err := resolveCodeStep(stage) - if err != nil { - return "", err - } - return e.executeWithStdin(ctx, code, lang, timeout, sandboxName, trusted, stdinData, stage.Detach) - } - - return "", fmt.Errorf("stage %d: requires code, tool, or parallel", idx) -} - -// runReadBatch runs a contiguous slice of read-safe stages concurrently, -// concatenating their outputs in submission order. All stages receive the -// same stdinData (read-safe tools don't depend on stdin chaining). -// If e.lockDir is set, each goroutine acquires LOCK_SH before running. -func (e *Server) runReadBatch(ctx context.Context, startIdx int, stages []CodeStep, timeout int, sandboxName, stdinData string) (string, error) { - if len(stages) == 1 { - lf, err := acquireFlock(e.lockDir, "rw", false) - if err != nil { - return "", fmt.Errorf("step %d: lock: %w", startIdx, err) - } - if lf != nil { - defer lf.Close() - } - out, err := e.runStage(ctx, startIdx, stages[0], timeout, sandboxName, stdinData) - if err != nil { - return out, fmt.Errorf("step %d: %w", startIdx, err) - } - return out, nil - } - - results := make([]string, len(stages)) - errs := make([]error, len(stages)) - var wg sync.WaitGroup - for i, stage := range stages { - wg.Add(1) - go func(j int, s CodeStep) { - defer wg.Done() - lf, lerr := acquireFlock(e.lockDir, "rw", false) - if lerr != nil { - errs[j] = fmt.Errorf("step %d: lock: %w", startIdx+j, lerr) - return - } - if lf != nil { - defer lf.Close() - } - results[j], errs[j] = e.runStage(ctx, startIdx+j, s, timeout, sandboxName, stdinData) - }(i, stage) - } - wg.Wait() - - var out string - for i, r := range results { - out += r - if errs[i] != nil { - return out, errs[i] - } - } - return out, nil -} diff --git a/pkg/tools/execute/server.go b/pkg/tools/execute/server.go index a76e72a..c548a70 100644 --- a/pkg/tools/execute/server.go +++ b/pkg/tools/execute/server.go @@ -132,64 +132,28 @@ func Decl(cwd string, opts ...Option) func() tools.Server { } } -// ListTools implements tools.Server, returning execute_code plus any +// ListTools implements tools.Server, returning shell plus any // tools promoted in the session's tool registry. func (e *Server) ListTools() ([]tools.ToolInfo, error) { all := []tools.ToolInfo{ { - Name: "execute_code", - Description: `Run one or more inline code steps in a sandboxed environment. + Name: "shell", + Description: `Execute a single bash command in a sandboxed environment. -Steps run in parallel when consecutive steps carry an ollie:parallel annotation; -otherwise they run serially. Outputs are concatenated in submission order. -Each step is one of: -- {code, language} — inline code (default language: bash) -- {elevated: true, code} — run outside sandbox via elevation backend (bash only) -- {detach: true, code} — start process and immediately background it; returns PID -- {parallel: [{code/language}...]} — concurrent fan-out; outputs concatenated in submission order +Usage: {"cmd": "your command here"} -Only bash is supported. -timeout applies to each step independently (default: 30s). A failed step aborts. - -Examples: -- Single step: steps=[{code: "date"}] -- Two steps: steps=[{code: "echo hello"}, {code: "echo world"}] -- Fan-out: steps=[{parallel: [{code: "cat a.txt"}, {code: "cat b.txt"}]}]`, +Sandbox prevents dangerous operations. Use for computation, builds, scripting. +timeout applies to each call (default: 30s). A non-zero exit is an error.`, InputSchema: json.RawMessage(`{ "type": "object", - "required": ["steps"], + "required": ["cmd"], "properties": { - "steps": { - "type": "array", - "description": "Inline code steps. Run in parallel when safe (ollie:parallel annotation), serially otherwise.", - "items": { - "type": "object", - "properties": { - "code": {"type": "string", "description": "Inline code to execute."}, - "language": {"type": "string", "description": "Language interpreter (default: bash)."}, - "elevated": {"type": "boolean", "description": "Run outside the sandbox via the elevation backend. Only bash is supported."}, - "detach": {"type": "boolean", "description": "Start the process and immediately background it. Returns the PID. Use process_list/process_output to inspect."}, - "parallel": { - "type": "array", - "description": "Fan-out: steps run concurrently, outputs concatenated in submission order.", - "items": { - "type": "object", - "properties": { - "code": {"type": "string"}, - "language": {"type": "string"}, - "elevated": {"type": "boolean"} - } - } - } - } - } - }, - "timeout": {"type": "integer", "description": "Timeout in seconds per step (default: 30). Use 0 for no timeout."}, - "sandbox": {"type": "string", "description": "Sandbox name (default: default)."} + "cmd": {"type": "string", "description": "Bash command to execute."}, + "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30). Use 0 for no timeout."}, + "sandbox": {"type": "string", "description": "Sandbox profile name (default: default)."} } }`), }, - } if e.toolRegistry != nil && e.sessionID != "" { diff --git a/pkg/tools/execute/tier.go b/pkg/tools/execute/tier.go index 6cf49b5..a92f6a4 100644 --- a/pkg/tools/execute/tier.go +++ b/pkg/tools/execute/tier.go @@ -37,11 +37,11 @@ func (e *Server) ResultTier(name string) string { } // ResultTierArgs classifies the tier using both the outer tool name and its -// arguments. For execute_code, it returns warm. For promoted tools, it +// arguments. For shell, it returns warm. For promoted tools, it // delegates to ResultTier. func (e *Server) ResultTierArgs(name string, args json.RawMessage) string { switch name { - case "execute_code": + case "shell": return "warm" default: return e.ResultTier(name)