Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
145 changes: 136 additions & 9 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 @@ -146,7 +180,13 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
defer os.Remove(tmpFile.Name())

// Write script content to temp file
if _, err := tmpFile.WriteString(scriptContent); err != nil {
// If we have a session, wrap the script to capture environment changes
scriptToWrite := scriptContent
if session != nil {
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 @@ -171,16 +211,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 +314,23 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec
sendSSEStatus(c, status, exitCode)
flusher.Flush()

// Update session environment if we have a session and execution succeeded
// We capture env even on warnings since the script may have made partial changes
if session != nil && (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)
_ = sessionManager.UpdateSessionEnv(filteredEnv, newWorkDir)
}
}

// 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 +352,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
26 changes: 21 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,15 @@ 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)
r.POST("/api/session", HandleCreateSession(sessionManager, runbookPath))
r.POST("/api/session/join", HandleJoinSession(sessionManager))
r.GET("/api/session", HandleGetSession(sessionManager))
r.POST("/api/session/reset", HandleResetSession(sessionManager))
r.DELETE("/api/session", 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 +102,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 +119,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 +139,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 +162,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 +185,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 +208,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