elevation: add Elevator interface + superpowers backend; add elevated:true to execute_code steps
Remove session-scoped env injection from frontend (s/{id}/env, -e/-env flags) — superseded
by per-invocation superpowers run-session in the elevator. Keep agentCore env map and
OLLIE_SESSION_ID propagation via pushSessionEnv.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc1d1c6f83
commit
e09a6c1250
|
|
@ -102,10 +102,8 @@ type AgentEnv struct {
|
|||
// BuildAgentEnv constructs an AgentEnv from a pre-configured Dispatcher and
|
||||
// optional agent config. cwd sets the working directory reported in the
|
||||
// system prompt; if empty, the process working directory is used.
|
||||
// sessionEnv provides session-scoped values for ${VAR} expansion in MCP server
|
||||
// configs, without touching the process environment.
|
||||
// The caller is responsible for registering all servers on d before calling this.
|
||||
func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, sessionEnv map[string]string) AgentEnv {
|
||||
func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv {
|
||||
var messages []string
|
||||
|
||||
if cfg != nil {
|
||||
|
|
@ -113,7 +111,7 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string, sessionEn
|
|||
if serverCfg.Disabled || serverCfg.Command == "" {
|
||||
continue
|
||||
}
|
||||
transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env, sessionEnv)
|
||||
transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env)
|
||||
client, err := transport.Connect()
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf("MCP %s: failed to connect: %v", name, err))
|
||||
|
|
@ -281,8 +279,7 @@ type agentCore struct {
|
|||
env map[string]string // session-scoped env vars
|
||||
}
|
||||
|
||||
// SetEnv stores a session-scoped variable in the core env map and propagates
|
||||
// it to the execute server's subprocess environment.
|
||||
// SetEnv stores a session-scoped variable and propagates it to the execute server.
|
||||
func (s *agentCore) SetEnv(key, value string) {
|
||||
s.envMu.Lock()
|
||||
if s.env == nil {
|
||||
|
|
@ -300,8 +297,7 @@ func (s *agentCore) SetEnv(key, value string) {
|
|||
}
|
||||
}
|
||||
|
||||
// pushSessionEnv injects session-scoped vars into the execute server's
|
||||
// subprocess environment without touching the process-global env.
|
||||
// pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env.
|
||||
func (s *agentCore) pushSessionEnv() {
|
||||
if s.dispatcher == nil || s.sessionID == "" {
|
||||
return
|
||||
|
|
@ -313,7 +309,6 @@ func (s *agentCore) pushSessionEnv() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
var _ Core = (*agentCore)(nil) // compile-time interface check
|
||||
|
||||
// NewAgentCore creates an agentCore from the given configuration.
|
||||
|
|
@ -933,7 +928,7 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve
|
|||
s.dispatcher.Close()
|
||||
}
|
||||
d := s.newDispatcher()
|
||||
env := BuildAgentEnv(cfg, d, s.cwd, nil)
|
||||
env := BuildAgentEnv(cfg, d, s.cwd)
|
||||
s.dispatcher = env.dispatcher
|
||||
s.hooks = env.Hooks
|
||||
s.loopcfg.systemPrompt = env.systemPrompt
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package elevation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Elevator runs commands with elevated privileges outside the sandbox.
|
||||
// Returns the exit code; a non-zero exit code is not itself an error.
|
||||
type Elevator interface {
|
||||
Run(ctx context.Context, cmd, dir string, stdout, stderr io.Writer) (int, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Detect returns the best available Elevator, or nil if none is configured.
|
||||
func Detect() Elevator {
|
||||
if sp := newSuperpowers(); sp != nil {
|
||||
return sp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Available reports whether a named elevator binary is present in PATH.
|
||||
func Available(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package elevation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"ollie/pkg/paths"
|
||||
)
|
||||
|
||||
type superpowersElevator struct {
|
||||
script string
|
||||
}
|
||||
|
||||
func newSuperpowers() *superpowersElevator {
|
||||
if _, err := exec.LookPath("superpowers"); err != nil {
|
||||
return nil
|
||||
}
|
||||
script := filepath.Join(paths.CfgDir(), "tools", "elevate_superpowers")
|
||||
if _, err := os.Stat(script); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &superpowersElevator{script: script}
|
||||
}
|
||||
|
||||
func (s *superpowersElevator) Name() string { return "superpowers" }
|
||||
|
||||
func (s *superpowersElevator) Run(ctx context.Context, cmd, dir string, stdout, stderr io.Writer) (int, error) {
|
||||
c := exec.CommandContext(ctx, "superpowers", "run-session", "--", "python3", s.script, cmd)
|
||||
c.Dir = dir
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
if err := c.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
|
@ -8,17 +8,15 @@ import (
|
|||
|
||||
// STDIOTransport launches a subprocess and connects a Client to its stdin/stdout.
|
||||
type STDIOTransport struct {
|
||||
command string
|
||||
args []string
|
||||
env map[string]string
|
||||
extraEnv map[string]string // session-scoped vars used to expand ${VAR} in env values
|
||||
command string
|
||||
args []string
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
// NewSTDIOTransport creates a transport that will launch the given command.
|
||||
// sessionEnv provides values for ${VAR} expansion in env values, taking
|
||||
// precedence over the process environment.
|
||||
func NewSTDIOTransport(command string, args []string, env map[string]string, sessionEnv map[string]string) *STDIOTransport {
|
||||
return &STDIOTransport{command: command, args: args, env: env, extraEnv: sessionEnv}
|
||||
// env values may contain ${VAR} references expanded against the process environment.
|
||||
func NewSTDIOTransport(command string, args []string, env map[string]string) *STDIOTransport {
|
||||
return &STDIOTransport{command: command, args: args, env: env}
|
||||
}
|
||||
|
||||
// Connect launches the subprocess and returns a connected Client.
|
||||
|
|
@ -26,14 +24,8 @@ func (t *STDIOTransport) Connect() (*Client, error) {
|
|||
cmd := exec.Command(t.command, t.args...)
|
||||
if len(t.env) > 0 {
|
||||
cmd.Env = os.Environ()
|
||||
lookup := func(name string) string {
|
||||
if v, ok := t.extraEnv[name]; ok {
|
||||
return v
|
||||
}
|
||||
return os.Getenv(name)
|
||||
}
|
||||
for k, v := range t.env {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.Expand(v, lookup)))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, os.ExpandEnv(v)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import (
|
|||
|
||||
// CodeStep is one stage in an execute_code 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.
|
||||
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"`
|
||||
}
|
||||
|
||||
// resolveCodeStep loads a CodeStep into executable (code, language, trusted).
|
||||
|
|
@ -178,6 +180,12 @@ func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) (
|
|||
|
||||
// Degenerate case: single simple stage, return raw output.
|
||||
if len(stages) == 1 && len(stages[0].Parallel) == 0 {
|
||||
if stages[0].Elevated {
|
||||
e.wdMu.RLock()
|
||||
dir := e.cwd
|
||||
e.wdMu.RUnlock()
|
||||
return e.executeElevated(ctx, stages[0].Code, dir, timeout)
|
||||
}
|
||||
code, lang, trusted, err := resolveCodeStep(stages[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -199,13 +207,17 @@ func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) (
|
|||
|
||||
func buildExecLabel(stages []CodeStep) string {
|
||||
stageLabel := func(s CodeStep) string {
|
||||
prefix := ""
|
||||
if s.Elevated {
|
||||
prefix = "[elevated] "
|
||||
}
|
||||
if len(s.Parallel) > 0 {
|
||||
return fmt.Sprintf("parallel(%d)", len(s.Parallel))
|
||||
return fmt.Sprintf("%sparallel(%d)", prefix, len(s.Parallel))
|
||||
}
|
||||
if s.Tool != "" {
|
||||
return fmt.Sprintf("tool:%s %s", s.Tool, strings.Join(s.Args, " "))
|
||||
return fmt.Sprintf("%stool:%s %s", prefix, s.Tool, strings.Join(s.Args, " "))
|
||||
}
|
||||
return s.Code
|
||||
return prefix + s.Code
|
||||
}
|
||||
if len(stages) == 1 {
|
||||
return fmt.Sprintf("execute_code: %s", stageLabel(stages[0]))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ func (e *Server) runStage(ctx context.Context, idx int, stage CodeStep, timeout
|
|||
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)
|
||||
return
|
||||
}
|
||||
code, lang, trusted, err := resolveCodeStep(s)
|
||||
if err != nil {
|
||||
errs[j] = err
|
||||
|
|
@ -36,6 +43,13 @@ func (e *Server) runStage(ctx context.Context, idx int, stage CodeStep, timeout
|
|||
return out, nil
|
||||
}
|
||||
|
||||
if stage.Elevated {
|
||||
e.wdMu.RLock()
|
||||
dir := e.cwd
|
||||
e.wdMu.RUnlock()
|
||||
return e.executeElevated(ctx, stage.Code, dir, timeout)
|
||||
}
|
||||
|
||||
if stage.Tool != "" || stage.Code != "" {
|
||||
code, lang, trusted, err := resolveCodeStep(stage)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"time"
|
||||
|
||||
"ollie/internal/sandbox"
|
||||
"ollie/pkg/elevation"
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
|
|
@ -23,14 +24,17 @@ type Server struct {
|
|||
// Returns true to allow, false to deny. Trusted tools bypass this check.
|
||||
Confirm func(string) bool
|
||||
|
||||
// Elevator, if set, is used to run steps with elevated:true outside the sandbox.
|
||||
Elevator elevation.Elevator
|
||||
|
||||
// trusted is the set of tool names that bypass Confirm.
|
||||
trustedMu sync.RWMutex
|
||||
trusted map[string]bool
|
||||
|
||||
// cwd is the working directory for sandboxed commands. If empty,
|
||||
// the process working directory is used.
|
||||
wdMu sync.RWMutex
|
||||
cwd string
|
||||
wdMu sync.RWMutex
|
||||
cwd string
|
||||
|
||||
// envExtra holds per-session environment variables injected via SetEnv.
|
||||
envMu sync.RWMutex
|
||||
|
|
@ -43,9 +47,13 @@ type Server struct {
|
|||
blockedUntil time.Time
|
||||
}
|
||||
|
||||
// Decl returns a factory for an execute Server with the given working directory.
|
||||
func Decl(cwd string) func() tools.Server {
|
||||
return func() tools.Server { return New(cwd) }
|
||||
// Decl returns a factory for an execute Server with the given working directory and elevator.
|
||||
func Decl(cwd string, el elevation.Elevator) func() tools.Server {
|
||||
return func() tools.Server {
|
||||
s := New(cwd)
|
||||
s.Elevator = el
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// ListTools implements tools.Server, returning the ToolInfo definitions for the execute_* built-in tools.
|
||||
|
|
@ -88,6 +96,7 @@ Examples:
|
|||
"language": {"type": "string", "description": "Language interpreter (default: bash). Ignored when tool or parallel is set."},
|
||||
"tool": {"type": "string", "description": "Named tool script from the tools directory. Discover available tools: grep -iA2 'keyword' $OLLIE/t/idx"},
|
||||
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
|
||||
"elevated": {"type": "boolean", "description": "Run this step outside the sandbox via the elevation backend (e.g. superpowers). Only bash code is supported. Omit or false for normal sandboxed execution."},
|
||||
"parallel": {
|
||||
"type": "array",
|
||||
"description": "Fan-out: steps run concurrently, outputs concatenated in submission order, result fed to next stage. All parallel steps should produce the same output schema so the concatenated result is coherent.",
|
||||
|
|
@ -97,7 +106,8 @@ Examples:
|
|||
"code": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"tool": {"type": "string"},
|
||||
"args": {"type": "array", "items": {"type": "string"}}
|
||||
"args": {"type": "array", "items": {"type": "string"}},
|
||||
"elevated": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,6 +183,37 @@ func (e *Server) SetEnv(key, value string) {
|
|||
e.envMu.Unlock()
|
||||
}
|
||||
|
||||
// executeElevated runs cmd as a bash command via the configured Elevator.
|
||||
// Returns (output, error); a non-zero exit code is treated as an error.
|
||||
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int) (string, error) {
|
||||
if e.Elevator == nil {
|
||||
return "", fmt.Errorf("elevation not available: no backend configured")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
lw := &limitedWriter{w: &outBuf, limit: 10 * 1024 * 1024}
|
||||
code, err := e.Elevator.Run(ctx, cmd, dir, lw, &errBuf)
|
||||
combined := outBuf.String()
|
||||
if errBuf.Len() > 0 {
|
||||
combined += errBuf.String()
|
||||
}
|
||||
if lw.truncated {
|
||||
combined += "\n[output truncated at 10MB]"
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return combined, fmt.Errorf("execution timeout after %d seconds", timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return combined, fmt.Errorf("elevated execution failed: %v", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return combined, fmt.Errorf("elevated execution failed (exit %d)\nOutput: %s", code, combined)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
var whitespacePattern = regexp.MustCompile(`\s+`)
|
||||
|
||||
const (
|
||||
|
|
@ -292,7 +333,6 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
|
|||
cmd.Stdin = strings.NewReader(stdinData)
|
||||
}
|
||||
|
||||
// Inject per-session env vars so they're visible inside the sandbox.
|
||||
e.envMu.RLock()
|
||||
if len(e.envExtra) > 0 {
|
||||
cmd.Env = os.Environ()
|
||||
|
|
|
|||
Reference in New Issue