execute_code -> shell: simplify to single bash command, remove steps/parallel/detach/language
This commit is contained in:
parent
1b05433a5b
commit
2c36c023aa
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Reference in New Issue