orca.ai/pkg/sandbox/process.go
大森 e18dde7c15 feat: implement TUI with bubbletea and multi-agent collaboration
- Add bubbletea/lipgloss/glamour dependencies for TUI
- Create internal/tui package with EventWriter, styles, and bubbletea Model
- Support streaming output display in conversation window
- Add right panel with statistics and active agent status
- Implement multi-agent collaboration with sub-agents
- Add AgentCallTool for delegating tasks to sub-agents
- Support parallel tool execution with goroutines
- Auto-discover sub-agents from ~/.orca/prompts/ directory
- Fix orchestrator routing based on msg.To field
- Add non-blocking event writer with timeout to prevent blocking
2026-05-10 14:28:17 +08:00

247 lines
6.2 KiB
Go

package sandbox
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
const (
// DefaultOutputLimit is the maximum number of bytes captured from stdout/stderr (512 KB).
DefaultOutputLimit = 512 * 1024
// DefaultWorkingDir is the default working directory for sandboxed commands.
DefaultWorkingDir = "."
)
// AllowedEnvVars is the whitelist of environment variables accessible inside the sandbox.
// Only these variables are passed through from the parent process.
var AllowedEnvVars = []string{
"HOME",
"USER",
"PATH",
"LANG",
"SHELL",
"TMPDIR",
"ORCA_HOME",
}
// ProcessSandbox is a Sandbox implementation that uses os/exec to run commands
// as child processes with resource restrictions.
type ProcessSandbox struct {
// WorkingDir is the directory in which commands execute.
WorkingDir string
// OutputLimit is the maximum bytes to capture from stdout/stderr.
OutputLimit int
// EnvWhitelist controls which environment variables are passed to child processes.
// If nil, AllowedEnvVars is used. If empty, no env vars are passed.
EnvWhitelist []string
}
// NewProcessSandbox creates a ProcessSandbox with sensible defaults.
func NewProcessSandbox() *ProcessSandbox {
return &ProcessSandbox{
WorkingDir: DefaultWorkingDir,
OutputLimit: DefaultOutputLimit,
EnvWhitelist: nil, // uses AllowedEnvVars
}
}
// Execute runs a command as a child process with resource restrictions.
func (ps *ProcessSandbox) Execute(ctx context.Context, cmd string, args ...string) (*Result, error) {
// Ensure working directory exists
if err := os.MkdirAll(ps.WorkingDir, 0755); err != nil {
return nil, fmt.Errorf("sandbox: failed to create working directory %q: %w", ps.WorkingDir, err)
}
// Build the command
c := exec.CommandContext(ctx, cmd, args...)
c.Dir = ps.WorkingDir
// Set up environment variable whitelist
env := ps.buildEnv()
c.Env = env
// Capture stdout and stderr with size limits
stdoutBuf := newLimitedBuffer(ps.outputLimit())
stderrBuf := newLimitedBuffer(ps.outputLimit())
stdoutPipe, err := c.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("sandbox: failed to create stdout pipe: %w", err)
}
stderrPipe, err := c.StderrPipe()
if err != nil {
return nil, fmt.Errorf("sandbox: failed to create stderr pipe: %w", err)
}
// Start the command
if err := c.Start(); err != nil {
return nil, fmt.Errorf("sandbox: failed to start command: %w", err)
}
// Read stdout and stderr concurrently
var readStdout, readStderr error
var wg syncWaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, readStdout = io.Copy(stdoutBuf, stdoutPipe)
// ErrShortWrite is expected when the output limit is reached — not a real error.
if readStdout != nil && readStdout != io.EOF && readStdout != io.ErrShortWrite {
readStdout = fmt.Errorf("sandbox: stdout read error: %w", readStdout)
} else {
readStdout = nil
}
}()
go func() {
defer wg.Done()
_, readStderr = io.Copy(stderrBuf, stderrPipe)
if readStderr != nil && readStderr != io.EOF && readStderr != io.ErrShortWrite {
readStderr = fmt.Errorf("sandbox: stderr read error: %w", readStderr)
} else {
readStderr = nil
}
}()
wg.Wait()
// Wait for the command to finish
err = c.Wait()
exitCode := 0
if err != nil {
// Check if the process was killed due to context cancellation (timeout)
if ctx.Err() != nil {
return nil, fmt.Errorf("sandbox: command timed out: %w", ctx.Err())
}
// Normal non-zero exit
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
err = nil
}
}
// Combine errors: prefer command error, then read errors
if err != nil {
return nil, err
}
if readStdout != nil {
return nil, readStdout
}
if readStderr != nil {
return nil, readStderr
}
return &Result{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: exitCode,
}, nil
}
// buildEnv constructs the environment variable list for the child process
// based on the whitelist configuration.
func (ps *ProcessSandbox) buildEnv() []string {
whitelist := ps.EnvWhitelist
if whitelist == nil {
whitelist = AllowedEnvVars
}
env := make([]string, 0, len(whitelist))
for _, key := range whitelist {
if val, ok := os.LookupEnv(key); ok {
env = append(env, key+"="+val)
}
}
return env
}
// outputLimit returns the effective output size limit.
func (ps *ProcessSandbox) outputLimit() int {
if ps.OutputLimit <= 0 {
return DefaultOutputLimit
}
return ps.OutputLimit
}
// WorkingDirPath returns the absolute path of the sandbox working directory.
func (ps *ProcessSandbox) WorkingDirPath() string {
abs, err := filepath.Abs(ps.WorkingDir)
if err != nil {
return ps.WorkingDir
}
return abs
}
// ---------------------------------------------------------------------------
// limitedBuffer — a writer that stops accepting data after MaxSize bytes.
// Uses a named field (not embedded) to avoid promoting bytes.Buffer.ReadFrom
// which would bypass the size limit when used with io.Copy.
// ---------------------------------------------------------------------------
type limitedBuffer struct {
buf bytes.Buffer
MaxSize int
}
func newLimitedBuffer(maxSize int) *limitedBuffer {
return &limitedBuffer{MaxSize: maxSize}
}
func (lb *limitedBuffer) Write(p []byte) (int, error) {
remaining := lb.MaxSize - lb.buf.Len()
if remaining <= 0 {
return len(p), nil // silently drop excess; io.Copy sees nw==nr, continues draining pipe
}
if len(p) > remaining {
p = p[:remaining]
n, err := lb.buf.Write(p)
// Return n < original len(p) so io.Copy stops with ErrShortWrite.
return n, err
}
return lb.buf.Write(p)
}
func (lb *limitedBuffer) String() string {
return lb.buf.String()
}
func (lb *limitedBuffer) Len() int {
return lb.buf.Len()
}
// ---------------------------------------------------------------------------
// syncWaitGroup — a simple goroutine synchronization mechanism.
// ---------------------------------------------------------------------------
type syncWaitGroup struct {
ch chan struct{}
}
func (wg *syncWaitGroup) Add(n int) {
if wg.ch == nil {
wg.ch = make(chan struct{}, n)
}
}
func (wg *syncWaitGroup) Done() {
wg.ch <- struct{}{}
}
func (wg *syncWaitGroup) Wait() {
for i := 0; i < cap(wg.ch); i++ {
<-wg.ch
}
}
// Compile-time interface check.
var _ Sandbox = (*ProcessSandbox)(nil)