Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ jobs:
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Run docs tests
run: task test:docs
Expand Down
176 changes: 164 additions & 12 deletions api/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,26 @@ type CapturedFile struct {
}

// HandleExecRequest handles the execution of scripts and streams output via SSE
func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExecutableRegistry bool, cliOutputPath string) gin.HandlerFunc {
func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExecutableRegistry bool, cliOutputPath string, sessionManager *SessionManager) gin.HandlerFunc {
return func(c *gin.Context) {
var req ExecRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Validate session token if Authorization header is provided
var session *Session
token := extractBearerToken(c)
if token != "" {
var valid bool
session, valid = sessionManager.ValidateToken(token)
if !valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session token. Try refreshing the page or restarting Runbooks."})
return
}
}

var executable *Executable
var err error

Expand Down Expand Up @@ -137,6 +149,28 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")

// Create temp files for environment capture (used to capture env changes after script execution)
var envCapturePath, pwdCapturePath string
if session != nil {
envFile, err := os.CreateTemp("", "runbook-env-capture-*.txt")
if err != nil {
sendSSEError(c, fmt.Sprintf("Failed to create env capture file: %v", err))
return
}
envCapturePath = envFile.Name()
envFile.Close()
defer os.Remove(envCapturePath)

pwdFile, err := os.CreateTemp("", "runbook-pwd-capture-*.txt")
if err != nil {
sendSSEError(c, fmt.Sprintf("Failed to create pwd capture file: %v", err))
return
}
pwdCapturePath = pwdFile.Name()
pwdFile.Close()
defer os.Remove(pwdCapturePath)
}

// Create a temporary file for the script
tmpFile, err := os.CreateTemp("", "runbook-check-*.sh")
if err != nil {
Expand All @@ -145,8 +179,23 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
}
defer os.Remove(tmpFile.Name())

// Detect interpreter from shebang or use language from executable
// We need this BEFORE deciding whether to wrap, so we can skip wrapping for non-bash scripts
interpreter, args := detectInterpreter(scriptContent, executable.Language)

// Write script content to temp file
if _, err := tmpFile.WriteString(scriptContent); err != nil {
// If we have a session AND the script is bash-compatible, wrap to capture environment changes.
// Non-bash scripts (Python, Ruby, etc.) cannot have their environment changes captured because:
// 1. The wrapper is bash code that wouldn't be valid in other interpreters
// 2. Even if we ran non-bash scripts separately, their os.environ changes only affect
// their own subprocess and wouldn't propagate back to the session
scriptToWrite := scriptContent
isBashCompatible := isBashInterpreter(interpreter)
if session != nil && isBashCompatible {
scriptToWrite = wrapScriptForEnvCapture(scriptContent, envCapturePath, pwdCapturePath)
}

if _, err := tmpFile.WriteString(scriptToWrite); err != nil {
tmpFile.Close()
sendSSEError(c, fmt.Sprintf("Failed to write script: %v", err))
return
Expand All @@ -160,9 +209,6 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
}
tmpFile.Close()

// Detect interpreter from shebang or use language from executable
interpreter, args := detectInterpreter(scriptContent, executable.Language)

// Create context with 5 minute timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
Expand All @@ -171,16 +217,20 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
cmdArgs := append(args, tmpFile.Name())
cmd := exec.CommandContext(ctx, interpreter, cmdArgs...)

// Pass through all environment variables
// This feels dirty, but the point of a Runbook is to streamline what a user would otherwise do
// in their local environment! For users who want more security/control here, a commercial version
// of Runbooks is probably the answer.
cmd.Env = os.Environ()
// Set environment variables
// If we have a session, use the session's environment; otherwise use the process environment
if session != nil {
cmd.Env = session.EnvSlice()
} else {
cmd.Env = os.Environ()
}

// Set working directory if capturing files
// This isolates the script so all relative file writes are captured
// Set working directory
// Priority: captureFiles workDir > session workDir > default
if req.CaptureFiles && workDir != "" {
cmd.Dir = workDir
} else if session != nil {
cmd.Dir = session.WorkingDir
}

// Get stdout and stderr pipes
Expand Down Expand Up @@ -270,6 +320,30 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
sendSSEStatus(c, status, exitCode)
flusher.Flush()

// Update session environment if we have a session, the script is bash-compatible, and execution succeeded.
// We capture env even on warnings since the script may have made partial changes.
// Non-bash scripts (Python, Ruby, etc.) don't get environment capture - their env changes
// only affect their own subprocess and can't propagate back to the session.
if session != nil && isBashCompatible && (status == "success" || status == "warn") {
capturedEnv, capturedPwd := parseEnvCapture(envCapturePath, pwdCapturePath)
if capturedEnv != nil {
// Filter out shell internals
filteredEnv := FilterCapturedEnv(capturedEnv)
// Determine new working directory
newWorkDir := session.WorkingDir
if capturedPwd != "" {
newWorkDir = capturedPwd
}
// Update session (ignore errors, non-critical)
if err := sessionManager.UpdateSessionEnv(filteredEnv, newWorkDir); err != nil {
// If the session was deleted concurrently, we can't update it.
// Log a warning to the user's console.
// TODO: Surface this warning in the UI, perhaps with a toaster notification
sendSSELog(c, fmt.Sprintf("Warning: could not persist environment changes: %v", err))
}
}
}

// Capture files if enabled and execution was successful (or warning)
if req.CaptureFiles && workDir != "" && (status == "success" || status == "warn") {
capturedFiles, captureErr := captureFilesFromWorkDir(workDir, captureOutputDir, cliOutputPath)
Expand All @@ -291,6 +365,72 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
}
}

// wrapScriptForEnvCapture wraps a script to capture environment changes after execution.
// The wrapper appends commands to dump the environment and working directory to temp files.
func wrapScriptForEnvCapture(script, envCapturePath, pwdCapturePath string) string {
// We wrap the script to capture environment after it runs
// The wrapper:
// 1. Sources/executes the original script
// 2. Captures the resulting environment to a temp file
// 3. Captures the working directory to another temp file
//
// We use a subshell to run the user script so that 'exit' calls don't skip our capture
// but we need the environment changes to propagate, so we use 'source' instead
//
// The wrapper preserves the exit code of the original script
wrapper := fmt.Sprintf(`#!/bin/bash
# Runbooks environment capture wrapper
# This wrapper captures environment changes after the user script runs

__RUNBOOKS_ENV_CAPTURE_PATH=%q
__RUNBOOKS_PWD_CAPTURE_PATH=%q

# Run the user script in the current shell context so env changes propagate
# We use a function and trap to ensure we capture env even if script calls 'exit'
__runbooks_capture_env() {
env > "$__RUNBOOKS_ENV_CAPTURE_PATH" 2>/dev/null
pwd > "$__RUNBOOKS_PWD_CAPTURE_PATH" 2>/dev/null
}

trap __runbooks_capture_env EXIT

# Execute the user script inline (not sourced, to preserve proper error handling)
# --- BEGIN USER SCRIPT ---
%s
# --- END USER SCRIPT ---
`, envCapturePath, pwdCapturePath, script)

return wrapper
}

// parseEnvCapture reads the captured environment and working directory from temp files.
func parseEnvCapture(envCapturePath, pwdCapturePath string) (map[string]string, string) {
env := make(map[string]string)

// Read environment capture
if envData, err := os.ReadFile(envCapturePath); err == nil {
for _, line := range strings.Split(string(envData), "\n") {
if idx := strings.Index(line, "="); idx != -1 {
key := line[:idx]
value := line[idx+1:]
env[key] = value
}
}
}

// Read working directory capture
var pwd string
if pwdData, err := os.ReadFile(pwdCapturePath); err == nil {
pwd = strings.TrimSpace(string(pwdData))
}

if len(env) == 0 {
return nil, pwd
}

return env, pwd
}

// detectInterpreter detects the interpreter from the shebang line or uses provided language
func detectInterpreter(script string, providedLang string) (string, []string) {
// If language is explicitly provided, use it
Expand Down Expand Up @@ -328,6 +468,18 @@ func detectInterpreter(script string, providedLang string) (string, []string) {
return "bash", []string{}
}

// isBashInterpreter returns true if the interpreter is bash or sh compatible.
// Only bash-compatible scripts can have their environment changes captured,
// because the environment capture wrapper is written in bash.
func isBashInterpreter(interpreter string) bool {
switch interpreter {
case "bash", "sh", "/bin/bash", "/bin/sh", "/usr/bin/bash", "/usr/bin/sh":
return true
default:
return false
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bash wrapper executed with sh fails on non-bash systems

High Severity

The isBashInterpreter function incorrectly treats sh as bash-compatible, but the wrapped script is executed with the original detected interpreter. When a script has #!/bin/sh, the bash wrapper (containing bash-specific syntax like [[ ]] and builtin trap) is applied at line 174, but the script is executed with sh at line 197. On systems where /bin/sh is not bash (Debian/Ubuntu with dash, Alpine with busybox ash, BSD systems), the bash-specific syntax causes immediate syntax errors, completely breaking environment persistence for sh scripts. The interpreter should be overridden to bash when the wrapper is applied, or sh should not be considered bash-compatible.

Additional Locations (1)

Fix in Cursor Fix in Web


// streamOutput reads from a pipe and sends lines to the output channel
func streamOutput(pipe io.ReadCloser, outputChan chan<- string) {
scanner := bufio.NewScanner(pipe)
Expand Down
33 changes: 28 additions & 5 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

// setupCommonRoutes sets up the common routes for both server modes
func setupCommonRoutes(r *gin.Engine, runbookPath string, outputPath string, registry *ExecutableRegistry, useExecutableRegistry bool) {
func setupCommonRoutes(r *gin.Engine, runbookPath string, outputPath string, registry *ExecutableRegistry, sessionManager *SessionManager, useExecutableRegistry bool) {
// Get embedded filesystems for serving static assets
distFS, err := web.GetDistFS()
if err != nil {
Expand Down Expand Up @@ -51,8 +51,22 @@ func setupCommonRoutes(r *gin.Engine, runbookPath string, outputPath string, reg
// API endpoint to get registered executables
r.GET("/api/runbook/executables", HandleExecutablesRequest(registry))

// Session management endpoints (single session per runbook server)
// Public session endpoints (no auth required)
r.POST("/api/session", HandleCreateSession(sessionManager, runbookPath))
r.POST("/api/session/join", HandleJoinSession(sessionManager))

// Protected session endpoints (require Bearer token)
sessionAuth := r.Group("/api/session")
sessionAuth.Use(SessionAuthMiddleware(sessionManager))
{
sessionAuth.GET("", HandleGetSession(sessionManager))
sessionAuth.POST("/reset", HandleResetSession(sessionManager))
sessionAuth.DELETE("", HandleDeleteSession(sessionManager))
}

// API endpoint to execute check scripts
r.POST("/api/exec", HandleExecRequest(registry, runbookPath, useExecutableRegistry, outputPath))
r.POST("/api/exec", HandleExecRequest(registry, runbookPath, useExecutableRegistry, outputPath, sessionManager))

// API endpoints for managing generated files
r.GET("/api/generated-files/check", HandleGeneratedFilesCheck(outputPath))
Expand Down Expand Up @@ -95,6 +109,9 @@ func StartServer(runbookPath string, port int, outputPath string) error {
return fmt.Errorf("failed to create executable registry: %w", err)
}

// Create session manager for persistent environment
sessionManager := NewSessionManager()

// Use release mode for end-users (quieter logs, better performance)
// Use gin.New() instead of gin.Default() to skip the default logger middleware
// This keeps the logs clean for end-users while still including recovery middleware
Expand All @@ -109,7 +126,7 @@ func StartServer(runbookPath string, port int, outputPath string) error {
r.GET("/api/runbook", HandleRunbookRequest(resolvedPath, false, true))

// Set up common routes
setupCommonRoutes(r, resolvedPath, outputPath, registry, true)
setupCommonRoutes(r, resolvedPath, outputPath, registry, sessionManager, true)

// listen and serve on localhost:$port only (security: prevent remote access)
return r.Run("127.0.0.1:" + fmt.Sprintf("%d", port))
Expand All @@ -129,6 +146,9 @@ func StartBackendServer(runbookPath string, port int, outputPath string) error {
return fmt.Errorf("failed to create executable registry: %w", err)
}

// Create session manager for persistent environment
sessionManager := NewSessionManager()

// Keep debug mode for development (default behavior)
r := gin.Default()

Expand All @@ -149,7 +169,7 @@ func StartBackendServer(runbookPath string, port int, outputPath string) error {
r.GET("/api/runbook", HandleRunbookRequest(resolvedPath, false, true))

// Set up common routes (includes all other endpoints)
setupCommonRoutes(r, resolvedPath, outputPath, registry, true)
setupCommonRoutes(r, resolvedPath, outputPath, registry, sessionManager, true)

// listen and serve on localhost:$port only (security: prevent remote access)
return r.Run("127.0.0.1:" + fmt.Sprintf("%d", port))
Expand All @@ -172,6 +192,9 @@ func StartServerWithWatch(runbookPath string, port int, outputPath string, useEx
}
}

// Create session manager for persistent environment
sessionManager := NewSessionManager()

// Create file watcher
fileWatcher, err := NewFileWatcher(resolvedPath)
if err != nil {
Expand All @@ -192,7 +215,7 @@ func StartServerWithWatch(runbookPath string, port int, outputPath string, useEx
r.GET("/api/watch", HandleWatchSSE(fileWatcher))

// Set up common routes
setupCommonRoutes(r, resolvedPath, outputPath, registry, useExecutableRegistry)
setupCommonRoutes(r, resolvedPath, outputPath, registry, sessionManager, useExecutableRegistry)

// listen and serve on localhost:$port only (security: prevent remote access)
return r.Run("127.0.0.1:" + fmt.Sprintf("%d", port))
Expand Down
Loading