diff --git a/.cursorrules b/.cursorrules index 4e5a476..8b8fc45 100644 --- a/.cursorrules +++ b/.cursorrules @@ -8,4 +8,6 @@ Docs are located in /docs The frontend is located in /web -The backend is spread across /api, /browser, and /cmd. When the user requests something, make sure you know if they're asking to update the docs, frontend, or backend. \ No newline at end of file +The backend is spread across /api, /browser, and /cmd. When the user requests something, make sure you know if they're asking to update the docs, frontend, or backend. + +Whenever you plan to use Terraform examples, use OpenTofu instead. \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63a7df7..555e1fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/api/exec.go b/api/exec.go index beedaaa..ac44b8c 100644 --- a/api/exec.go +++ b/api/exec.go @@ -3,6 +3,7 @@ package api import ( "bufio" "context" + "errors" "fmt" "io" "log/slog" @@ -18,11 +19,9 @@ import ( // ExecRequest represents the request to execute a script type ExecRequest struct { - ExecutableID string `json:"executable_id,omitempty"` // Used when useExecutableRegistry=true - ComponentID string `json:"component_id,omitempty"` // Used when useExecutableRegistry=false - TemplateVarValues map[string]string `json:"template_var_values"` // Values for template variables - CaptureFiles bool `json:"capture_files"` // When true, capture files written by the script to the workspace - CaptureFilesOutputPath string `json:"capture_files_output_path,omitempty"` // Relative subdirectory within the output folder for captured files + ExecutableID string `json:"executable_id,omitempty"` // Used when useExecutableRegistry=true + ComponentID string `json:"component_id,omitempty"` // Used when useExecutableRegistry=false + TemplateVarValues map[string]string `json:"template_var_values"` // Values for template variables } // ExecLogEvent represents a log line event sent via SSE @@ -51,7 +50,7 @@ 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 { @@ -59,6 +58,18 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec return } + // Validate session token if Authorization header is provided + var execCtx *SessionExecContext + token := extractBearerToken(c) + if token != "" { + var valid bool + execCtx, 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 @@ -102,41 +113,43 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec scriptContent = rendered } - // Validate captureFilesOutputPath if captureFiles is enabled - var captureOutputDir string - if req.CaptureFiles { - // Validate the output path (reuse validation from boilerplate_render.go) - if err := ValidateRelativePath(req.CaptureFilesOutputPath); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid captureFilesOutputPath: %v", err)}) - return - } + // Create capture directory for RUNBOOKS_OUTPUT + // Scripts can write files here to have them captured to the output directory + captureDir, err := os.MkdirTemp("", "runbook-output-*") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create capture directory: %v", err)}) + return + } + defer os.RemoveAll(captureDir) + + // Set up SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Transfer-Encoding", "chunked") - // Determine the output directory for captured files - captureOutputDir, err = determineOutputDirectory(cliOutputPath, &req.CaptureFilesOutputPath) + // Create temp files for environment capture (used to capture env changes after script execution) + var envCapturePath, pwdCapturePath string + if execCtx != nil { + envFile, err := os.CreateTemp("", "runbook-env-capture-*.txt") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to determine capture output directory: %v", err)}) + sendSSEError(c, fmt.Sprintf("Failed to create env capture file: %v", err)) return } - } + envCapturePath = envFile.Name() + envFile.Close() + defer os.Remove(envCapturePath) - // Create an isolated working directory for the script when captureFiles is enabled - // This ensures all relative file writes are captured - var workDir string - if req.CaptureFiles { - workDir, err = os.MkdirTemp("", "runbook-cmd-workspace-*") + pwdFile, err := os.CreateTemp("", "runbook-pwd-capture-*.txt") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create working directory: %v", err)}) + sendSSEError(c, fmt.Sprintf("Failed to create pwd capture file: %v", err)) return } - defer os.RemoveAll(workDir) // Clean up the working directory when done + pwdCapturePath = pwdFile.Name() + pwdFile.Close() + defer os.Remove(pwdCapturePath) } - // Set up SSE headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("Transfer-Encoding", "chunked") - // Create a temporary file for the script tmpFile, err := os.CreateTemp("", "runbook-check-*.sh") if err != nil { @@ -145,8 +158,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 execCtx != 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 @@ -160,9 +188,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() @@ -171,16 +196,21 @@ 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 execCtx != nil { + cmd.Env = execCtx.Env + } else { + cmd.Env = os.Environ() + } + + // Add RUNBOOKS_OUTPUT environment variable + // Scripts can write files to this directory to have them captured to the output + cmd.Env = append(cmd.Env, "RUNBOOKS_OUTPUT="+captureDir) - // Set working directory if capturing files - // This isolates the script so all relative file writes are captured - if req.CaptureFiles && workDir != "" { - cmd.Dir = workDir + // Set working directory from session if available + if execCtx != nil { + cmd.Dir = execCtx.WorkDir } // Get stdout and stderr pipes @@ -270,9 +300,34 @@ func HandleExecRequest(registry *ExecutableRegistry, runbookPath string, useExec sendSSEStatus(c, status, exitCode) flusher.Flush() - // Capture files if enabled and execution was successful (or warning) - if req.CaptureFiles && workDir != "" && (status == "success" || status == "warn") { - capturedFiles, captureErr := captureFilesFromWorkDir(workDir, captureOutputDir, cliOutputPath) + // 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 execCtx != 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 (use captured pwd, or fall back to the original) + newWorkDir := execCtx.WorkDir + 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 from RUNBOOKS_OUTPUT if execution was successful (or warning) + // Scripts can write files to $RUNBOOKS_OUTPUT to have them captured + if status == "success" || status == "warn" { + capturedFiles, captureErr := copyFilesFromCaptureDir(captureDir, cliOutputPath) if captureErr != nil { sendSSELog(c, fmt.Sprintf("Warning: Failed to capture files: %v", captureErr)) flusher.Flush() @@ -291,6 +346,271 @@ 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. +// +// ## How the wrapper works +// +// The wrapper executes in the same shell context as the user script, allowing environment +// variable changes (export FOO=bar) and directory changes (cd /somewhere) to be captured. +// When the script exits (normally or via explicit `exit`), we dump the environment using +// `env -0` (NUL-terminated to handle values with embedded newlines like RSA keys or JSON) +// and the working directory using `pwd`. +// +// ## The trap override problem +// +// A key challenge is that user scripts may set their own EXIT traps (e.g., for cleanup): +// +// trap "rm -rf $TEMP_DIR" EXIT +// +// In bash, only one trap handler can exist per signal. If we naively set our own EXIT trap +// at the start of the wrapper, the user's trap would override it, and we'd never capture +// the environment. +// +// ## Solution: Intercept the trap builtin +// +// We solve this by defining a shell function named `trap` that shadows the builtin. +// When the user script calls `trap "cleanup" EXIT`, our function intercepts it: +// +// 1. We detect it's an EXIT trap (signal 0 or "EXIT") +// 2. We save the user's handler string to __RUNBOOKS_USER_EXIT_HANDLER +// 3. We return without actually setting the trap (so ours remains active) +// +// For non-EXIT traps (e.g., SIGINT, SIGTERM), we pass through to `builtin trap`. +// +// Our combined exit handler (__runbooks_combined_exit) runs when the script exits and: +// 1. Executes the user's saved handler first (so their cleanup runs) +// 2. Then captures the environment +// 3. Preserves the original exit code +// +// We use `builtin trap` to set our handler, which bypasses our override function. +func wrapScriptForEnvCapture(script, envCapturePath, pwdCapturePath string) string { + // We use `env -0` to output NUL-terminated entries instead of newline-terminated. + // This is critical because environment variable values can contain embedded newlines + // (e.g., RSA keys, JSON, multiline strings). Both GNU and BSD/macOS support `env -0`. + wrapper := fmt.Sprintf(`#!/bin/bash +# ============================================================================= +# Runbooks Environment Capture Wrapper +# ============================================================================= +# This wrapper captures environment changes after the user script runs. +# It intercepts EXIT traps to ensure both user cleanup AND env capture run. +# +# Flow: +# 1. Define our capture function and trap override +# 2. Set our combined EXIT handler (using builtin to bypass override) +# 3. Execute user script (which may call 'trap ... EXIT') +# 4. On exit: run user's handler first, then capture env +# ============================================================================= + +__RUNBOOKS_ENV_CAPTURE_PATH=%q +__RUNBOOKS_PWD_CAPTURE_PATH=%q + +# ----------------------------------------------------------------------------- +# Environment capture function +# Called on exit to dump env vars and working directory to temp files +# ----------------------------------------------------------------------------- +__runbooks_capture_env() { + # Use env -0 for NUL-terminated output to handle values with embedded newlines + # (e.g., RSA keys, JSON, multiline strings) + env -0 > "$__RUNBOOKS_ENV_CAPTURE_PATH" 2>/dev/null + pwd > "$__RUNBOOKS_PWD_CAPTURE_PATH" 2>/dev/null +} + +# ----------------------------------------------------------------------------- +# Trap override mechanism +# ----------------------------------------------------------------------------- +# In bash, only one handler can exist per signal. If the user script sets an +# EXIT trap, it would override ours and we'd lose env capture. To solve this, +# we define a function named 'trap' that shadows the builtin. +# +# When user calls: trap "rm -rf $TEMP_DIR" EXIT +# Our function: +# 1. Detects it's an EXIT trap +# 2. Saves the handler to __RUNBOOKS_USER_EXIT_HANDLER +# 3. Returns without setting the actual trap (ours remains active) +# +# For non-EXIT traps, we pass through to 'builtin trap' so they work normally. +# ----------------------------------------------------------------------------- + +# Store user's EXIT trap handler (if they set one) +__RUNBOOKS_USER_EXIT_HANDLER="" + +# Override the trap builtin to intercept EXIT handlers +trap() { + # Handle query flags (-p, -l) immediately - pass through to builtin + # These are for querying trap state, not setting handlers + if [[ "$1" == "-p" || "$1" == "-l" ]]; then + builtin trap "$@" + return $? + fi + + # Check if EXIT (or signal 0, which is equivalent) is in the arguments + local has_exit=false + local i + for i in "$@"; do + if [[ "$i" == "EXIT" || "$i" == "0" ]]; then + has_exit=true + break + fi + done + + if $has_exit && [[ $# -ge 2 ]]; then + # This is setting an EXIT trap - intercept it + local handler="$1" + if [[ "$handler" == "-" ]]; then + # trap - EXIT: reset to default (clear user handler) + __RUNBOOKS_USER_EXIT_HANDLER="" + elif [[ -z "$handler" ]]; then + # trap '' EXIT: ignore signal (clear user handler) + __RUNBOOKS_USER_EXIT_HANDLER="" + else + # Save user's handler to call during exit + __RUNBOOKS_USER_EXIT_HANDLER="$handler" + fi + return 0 + fi + + # Not an EXIT trap (or just querying) - pass through to builtin + builtin trap "$@" +} + +# ----------------------------------------------------------------------------- +# Combined exit handler +# Runs when script exits to execute user cleanup AND capture environment +# ----------------------------------------------------------------------------- +__runbooks_combined_exit() { + local exit_code=$? + + # Run user's EXIT handler first (if any), so their cleanup happens + if [[ -n "$__RUNBOOKS_USER_EXIT_HANDLER" ]]; then + eval "$__RUNBOOKS_USER_EXIT_HANDLER" || true + fi + + # Then capture environment (after user's changes but before exit) + __runbooks_capture_env + + # Preserve the original exit code + exit $exit_code +} + +# Set our combined exit handler using 'builtin trap' to bypass our override +builtin trap __runbooks_combined_exit EXIT + +# ============================================================================= +# USER SCRIPT BEGIN +# ============================================================================= +%s +# ============================================================================= +# USER SCRIPT END +# ============================================================================= +`, envCapturePath, pwdCapturePath, script) + + return wrapper +} + +// parseEnvCapture reads the captured environment and working directory from temp files. +// The environment file is expected to be NUL-terminated (from `env -0`) to correctly +// handle environment variable values that contain embedded newlines. +// Falls back to newline-delimited parsing if no NUL characters are found (for compatibility +// with systems where `env -0` might not be available). +func parseEnvCapture(envCapturePath, pwdCapturePath string) (map[string]string, string) { + env := make(map[string]string) + + // Read environment capture + envData, err := os.ReadFile(envCapturePath) + if err != nil { + // File not existing is expected if script failed early; other errors should be logged + if !errors.Is(err, os.ErrNotExist) { + slog.Warn("Failed to read environment capture file", "path", envCapturePath, "error", err) + } + } else { + data := string(envData) + + // Auto-detect format: if NUL characters are present, use NUL-delimited parsing + // (from `env -0`), otherwise fall back to newline-delimited (legacy/fallback) + if strings.Contains(data, "\x00") { + // NUL-delimited: each entry is a complete KEY=VALUE pair + for _, entry := range strings.Split(data, "\x00") { + if entry == "" { + continue + } + if idx := strings.Index(entry, "="); idx != -1 { + env[entry[:idx]] = entry[idx+1:] + } + } + } else { + // Newline-delimited fallback: must handle multiline values by detecting + // continuation lines (lines that don't start a new KEY=VALUE pair) + var currentKey string + var valueLines []string + + for _, line := range strings.Split(data, "\n") { + // Check if this line starts a new KEY=VALUE pair + // A new pair has format: VALID_ENV_NAME=value + // where VALID_ENV_NAME starts with letter/underscore and contains only [A-Za-z0-9_] + idx := strings.Index(line, "=") + if idx > 0 && isValidEnvVarName(line[:idx]) { + // Save previous key-value if any + if currentKey != "" { + env[currentKey] = strings.Join(valueLines, "\n") + } + // Start new key + currentKey = line[:idx] + valueLines = []string{line[idx+1:]} + } else if currentKey != "" && line != "" { + // Continuation line - append to current value + valueLines = append(valueLines, line) + } + } + // Don't forget the last key + if currentKey != "" { + env[currentKey] = strings.Join(valueLines, "\n") + } + } + } + + // Read working directory capture + var pwd string + pwdData, pwdErr := os.ReadFile(pwdCapturePath) + if pwdErr != nil { + // File not existing is expected if script failed early; other errors should be logged + if !errors.Is(pwdErr, os.ErrNotExist) { + slog.Warn("Failed to read working directory capture file", "path", pwdCapturePath, "error", pwdErr) + } + } else { + pwd = strings.TrimSpace(string(pwdData)) + } + + if len(env) == 0 { + return nil, pwd + } + + return env, pwd +} + +// isValidEnvVarName checks if a string is a valid environment variable name. +// Valid names start with a letter or underscore and contain only [A-Za-z0-9_]. +// This is used to distinguish new KEY=VALUE pairs from continuation lines in +// multiline values when parsing newline-delimited env output. +func isValidEnvVarName(name string) bool { + if len(name) == 0 { + return false + } + // First character must be letter or underscore + first := name[0] + if !((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_') { + return false + } + // Rest must be alphanumeric or underscore + for i := 1; i < len(name); i++ { + c := name[i] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + // 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 @@ -328,6 +648,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 + } +} + // streamOutput reads from a pipe and sends lines to the output channel func streamOutput(pipe io.ReadCloser, outputChan chan<- string) { scanner := bufio.NewScanner(pipe) @@ -367,35 +699,45 @@ func sendSSEError(c *gin.Context, message string) { } } -// captureFilesFromWorkDir copies all files from the working directory to the output directory -// Returns a list of captured files with their relative paths and sizes -func captureFilesFromWorkDir(workDir, captureOutputDir, cliOutputPath string) ([]CapturedFile, error) { +// copyFilesFromCaptureDir copies all files from the capture directory (RUNBOOKS_OUTPUT) to the output directory. +// Returns a list of captured files with their relative paths and sizes. +// If the capture directory is empty, returns nil with no error. +func copyFilesFromCaptureDir(captureDir, outputDir string) ([]CapturedFile, error) { var capturedFiles []CapturedFile + // Check if capture directory has any files + entries, err := os.ReadDir(captureDir) + if err != nil { + return nil, fmt.Errorf("failed to read capture directory: %w", err) + } + if len(entries) == 0 { + return nil, nil // No files to capture + } + // Create the output directory if it doesn't exist - if err := os.MkdirAll(captureOutputDir, 0755); err != nil { + if err := os.MkdirAll(outputDir, 0755); err != nil { return nil, fmt.Errorf("failed to create output directory: %w", err) } - // Walk the working directory and copy all files - err := filepath.Walk(workDir, func(srcPath string, info os.FileInfo, walkErr error) error { + // Walk the capture directory and copy all files + err = filepath.Walk(captureDir, func(srcPath string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } // Skip the root directory itself - if srcPath == workDir { + if srcPath == captureDir { return nil } - // Get the relative path from the working directory - relPath, err := filepath.Rel(workDir, srcPath) + // Get the relative path from the capture directory + relPath, err := filepath.Rel(captureDir, srcPath) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Construct the destination path - dstPath := filepath.Join(captureOutputDir, relPath) + dstPath := filepath.Join(outputDir, relPath) if info.IsDir() { // Create the directory in the output @@ -412,19 +754,8 @@ func captureFilesFromWorkDir(workDir, captureOutputDir, cliOutputPath string) ([ return fmt.Errorf("failed to copy file %s: %w", relPath, err) } - // Calculate relative path from CLI output path for the response - // This is what the frontend expects to see in the file tree - outputRelPath := relPath - if captureOutputDir != cliOutputPath { - // If we're in a subdirectory, include that in the relative path - subDir, _ := filepath.Rel(cliOutputPath, captureOutputDir) - if subDir != "" && subDir != "." { - outputRelPath = filepath.Join(subDir, relPath) - } - } - capturedFiles = append(capturedFiles, CapturedFile{ - Path: filepath.ToSlash(outputRelPath), // Use forward slashes for consistency + Path: filepath.ToSlash(relPath), // Use forward slashes for consistency Size: info.Size(), }) diff --git a/api/server.go b/api/server.go index a628f40..044f1a4 100644 --- a/api/server.go +++ b/api/server.go @@ -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 { @@ -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)) @@ -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 @@ -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)) @@ -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() @@ -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)) @@ -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 { @@ -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)) diff --git a/api/session.go b/api/session.go new file mode 100644 index 0000000..cf060bd --- /dev/null +++ b/api/session.go @@ -0,0 +1,401 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// MaxTokensPerSession limits the number of concurrent tokens (browser tabs). +// This prevents unbounded memory growth if tabs are opened repeatedly. +// +// Tokens are cryptographically random strings used to authenticate API requests. +// Each browser tab receives its own token when it connects (via CreateSession or +// RestoreSession). The token must be included as a Bearer token in the Authorization +// header for protected endpoints like /api/exec. This prevents unauthorized processes +// from executing scripts, even though the server only listens on localhost. +const MaxTokensPerSession = 20 + +// Session represents a persistent execution environment for a runbook session. +// Environment changes made by scripts persist across block executions. +// Multiple browser tabs can share the same session, each with their own token. +type Session struct { + // ValidTokens maps each active token to its creation time. + // Multiple tokens allow multiple browser tabs to share the same session + // without invalidating each other. Uses a map for O(1) lookup during validation. + ValidTokens map[string]time.Time // token -> created time + Env map[string]string // Current environment state (NEVER exposed via API) + InitialEnv map[string]string // Snapshot at creation (for reset) + InitialWorkDir string // Initial working directory (for reset) + WorkingDir string // Current working directory + ExecutionCount int // Global execution counter + CreatedAt time.Time + LastActivity time.Time +} + +// SessionMetadata is the public-safe subset of Session returned by GET endpoints. +// Environment variables are intentionally excluded for security. +type SessionMetadata struct { + WorkingDir string `json:"workingDir"` + ExecutionCount int `json:"executionCount"` + CreatedAt time.Time `json:"createdAt"` + LastActivity time.Time `json:"lastActivity"` + ActiveTabs int `json:"activeTabs"` // Number of active tokens (browser tabs) +} + +// SessionExecContext contains an immutable snapshot of session data needed for script execution. +// This is returned by ValidateToken to avoid race conditions from exposing the mutable Session pointer. +// All fields are copied while holding the lock, so they're safe to use after the lock is released. +type SessionExecContext struct { + Env []string // Environment as KEY=VALUE pairs, ready for exec.Cmd.Env + WorkDir string // Current working directory +} + +// SessionTokenResponse is returned when creating or restoring a session. +type SessionTokenResponse struct { + Token string `json:"token"` +} + +// SessionManager provides thread-safe access to the single active session. +// +// Why a manager for a single session? +// - Thread safety: Multiple HTTP handlers access the session concurrently +// - Nil handling: Session doesn't exist until first API call; manager handles this cleanly +// - Atomic operations: Create/replace session atomically without race conditions +// - Encapsulation: All session logic (create, validate, restore) in one place +// - Testability: Each test gets a fresh manager instance +// +// The session persists for the lifetime of the server process. All browser +// tabs share the same session, which matches the mental model of "one runbook +// = one environment" (like having multiple terminal windows to the same shell). +// Each tab gets its own token, but they all operate on the same environment. +type SessionManager struct { + session *Session // The single session, nil until created + mu sync.RWMutex // Protects concurrent access to session +} + +// NewSessionManager creates a new session manager with no active session. +// Call CreateSession() to initialize the session when the first client connects. +func NewSessionManager() *SessionManager { + return &SessionManager{} +} + +// generateSecretToken generates a cryptographically secure random token. +func generateSecretToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate secret token: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +// captureEnvironment captures the current process environment as a map. +func captureEnvironment() map[string]string { + env := make(map[string]string) + for _, e := range os.Environ() { + if idx := strings.Index(e, "="); idx != -1 { + key := e[:idx] + value := e[idx+1:] + env[key] = value + } + } + return env +} + +// copyEnvMap creates a deep copy of an environment map. +func copyEnvMap(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +// CreateSession creates a new session with the current process environment. +// If a session already exists, it is replaced (all existing tokens invalidated). +// The initialWorkingDir should be the runbook's directory. +func (sm *SessionManager) CreateSession(initialWorkingDir string) (*SessionTokenResponse, error) { + token, err := generateSecretToken() + if err != nil { + return nil, err + } + + // Resolve to absolute path + absWorkingDir, err := filepath.Abs(initialWorkingDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve working directory: %w", err) + } + + env := captureEnvironment() + now := time.Now() + + session := &Session{ + ValidTokens: map[string]time.Time{token: now}, + Env: env, + InitialEnv: copyEnvMap(env), + InitialWorkDir: absWorkingDir, + WorkingDir: absWorkingDir, + ExecutionCount: 0, + CreatedAt: now, + LastActivity: now, + } + + sm.mu.Lock() + sm.session = session // Replace any existing session + sm.mu.Unlock() + + return &SessionTokenResponse{ + Token: token, + }, nil +} + +// GetSession retrieves the current session (internal use only). +func (sm *SessionManager) GetSession() (*Session, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.session == nil { + return nil, false + } + + return sm.session, true +} + +// HasSession returns true if a session exists. +func (sm *SessionManager) HasSession() bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.session != nil +} + +// ValidateToken verifies that the provided token is one of the session's valid tokens. +// Returns an immutable execution context if valid, nil otherwise. +// +// The returned SessionExecContext contains copies of the session data needed for script +// execution. This avoids race conditions that would occur if we returned the mutable +// Session pointer, which could be modified by other goroutines after the lock is released. +func (sm *SessionManager) ValidateToken(token string) (*SessionExecContext, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.session == nil { + // No session exists, but still do a comparison to prevent timing attacks + _ = token == "dummy-comparison-to-prevent-timing-attack" + return nil, false + } + + // Check if token is in the valid tokens map + _, valid := sm.session.ValidTokens[token] + if !valid { + return nil, false + } + + // Return a snapshot of the execution context, copied while holding the lock. + // This ensures thread-safe access to session data after the lock is released. + return &SessionExecContext{ + Env: sm.session.EnvSlice(), // Creates a new slice, safe to use after unlock + WorkDir: sm.session.WorkingDir, // String is immutable in Go, safe to copy + }, true +} + +// JoinSession creates a new token for an existing session (useful for new browser tabs). +// Unlike CreateSession, this preserves the session's environment state. +// Returns nil if no session exists. +func (sm *SessionManager) JoinSession() (*SessionTokenResponse, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.session == nil { + return nil, nil // No session exists + } + + // Generate new token + token, err := generateSecretToken() + if err != nil { + return nil, err + } + + // If we've hit the max tokens, remove the oldest one + if len(sm.session.ValidTokens) >= MaxTokensPerSession { + sm.pruneOldestToken() + } + + // Add the new token + sm.session.ValidTokens[token] = time.Now() + sm.session.LastActivity = time.Now() + + return &SessionTokenResponse{ + Token: token, + }, nil +} + +// pruneOldestToken removes the oldest token from the session. +// Caller must hold the write lock. +func (sm *SessionManager) pruneOldestToken() { + var oldestToken string + var oldestTime time.Time + + for token, created := range sm.session.ValidTokens { + if oldestToken == "" || created.Before(oldestTime) { + oldestToken = token + oldestTime = created + } + } + + if oldestToken != "" { + delete(sm.session.ValidTokens, oldestToken) + } +} + +// RevokeToken removes a specific token from the session (for tab close cleanup). +// Returns true if the token was found and removed. +func (sm *SessionManager) RevokeToken(token string) bool { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.session == nil { + return false + } + + if _, exists := sm.session.ValidTokens[token]; exists { + delete(sm.session.ValidTokens, token) + return true + } + + return false +} + +// UpdateSessionEnv updates the session's environment and working directory after script execution. +func (sm *SessionManager) UpdateSessionEnv(env map[string]string, workDir string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.session == nil { + return fmt.Errorf("no active session") + } + + sm.session.Env = env + sm.session.WorkingDir = workDir + sm.session.ExecutionCount++ + sm.session.LastActivity = time.Now() + + return nil +} + +// ResetSession restores the session to its initial environment state. +func (sm *SessionManager) ResetSession() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.session == nil { + return fmt.Errorf("no active session") + } + + sm.session.Env = copyEnvMap(sm.session.InitialEnv) + sm.session.WorkingDir = sm.session.InitialWorkDir + sm.session.LastActivity = time.Now() + + return nil +} + +// DeleteSession removes the current session (invalidates all tokens). +func (sm *SessionManager) DeleteSession() { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.session = nil +} + +// GetMetadata returns the public-safe metadata for the session. +func (sm *SessionManager) GetMetadata() (*SessionMetadata, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.session == nil { + return nil, false + } + + return &SessionMetadata{ + WorkingDir: sm.session.WorkingDir, + ExecutionCount: sm.session.ExecutionCount, + CreatedAt: sm.session.CreatedAt, + LastActivity: sm.session.LastActivity, + ActiveTabs: len(sm.session.ValidTokens), + }, true +} + +// TokenCount returns the number of valid tokens (active browser tabs). +func (sm *SessionManager) TokenCount() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.session == nil { + return 0 + } + + return len(sm.session.ValidTokens) +} + +// EnvSlice converts the session's environment map to a slice for use with exec.Cmd. +func (s *Session) EnvSlice() []string { + result := make([]string, 0, len(s.Env)) + for k, v := range s.Env { + result = append(result, k+"="+v) + } + return result +} + +// excludedEnvVars are environment variables that should not be captured/overwritten +// because they are shell internals or change with each execution. +var excludedEnvVars = map[string]bool{ + "_": true, // Last command + "SHLVL": true, // Shell level + "RUNBOOKS_OUTPUT": true, // Temp capture dir, deleted after each execution + "OLDPWD": true, // Previous directory (we track workdir separately) + "FUNCNAME": true, // Bash function name stack + "LINENO": true, // Current line number + "RANDOM": true, // Random number + "SECONDS": true, // Seconds since shell start + "EPOCHSECONDS": true, // Unix timestamp (changes every second) + "EPOCHREALTIME": true, // Unix timestamp with microseconds + "BASHPID": true, // PID of current bash process (differs in subshells) + "BASH_COMMAND": true, // Currently executing command + "BASH_SUBSHELL": true, // Subshell nesting level + "BASH_EXECUTION_STRING": true, // Command passed to -c option + "PPID": true, // Parent PID - wrong in new shell + "BASH_LINENO": true, // Call stack line numbers (array) + "BASH_SOURCE": true, // Call stack source files (array) + "BASH_ARGC": true, // Arg count stack + "BASH_ARGV": true, // Arg value stack + "BASH_REMATCH": true, // Regex match results + "PIPESTATUS": true, // Exit codes of last pipeline + "HISTCMD": true, // History number of current command + "SRANDOM": true, // 32-bit random (bash 5.1+) + + // Internal wrapper variables (must match wrapScriptForEnvCapture) + "__RUNBOOKS_ENV_CAPTURE_PATH": true, + "__RUNBOOKS_PWD_CAPTURE_PATH": true, + "__RUNBOOKS_USER_EXIT_HANDLER": true, // Stores user's EXIT trap handler + "__RUNBOOKS_COMBINED_EXIT": true, // Our combined exit handler function name +} + +// FilterCapturedEnv filters out shell-internal variables from captured environment. +func FilterCapturedEnv(env map[string]string) map[string]string { + filtered := make(map[string]string, len(env)) + for k, v := range env { + // Skip excluded vars + if excludedEnvVars[k] { + continue + } + // Skip BASH_* variables (there are many) + if strings.HasPrefix(k, "BASH_") { + continue + } + filtered[k] = v + } + return filtered +} diff --git a/api/session_handlers.go b/api/session_handlers.go new file mode 100644 index 0000000..6a2989d --- /dev/null +++ b/api/session_handlers.go @@ -0,0 +1,121 @@ +package api + +import ( + "net/http" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// HandleCreateSession creates a new session and returns the token. +// POST /api/session +// No authentication required. +func HandleCreateSession(sm *SessionManager, runbookPath string) gin.HandlerFunc { + return func(c *gin.Context) { + // Use runbook directory as initial working directory + runbookDir := filepath.Dir(runbookPath) + + response, err := sm.CreateSession(runbookDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) + return + } + + c.JSON(http.StatusOK, response) + } +} + +// HandleJoinSession allows a new browser tab to join an existing session. +// POST /api/session/join +// No authentication required. +func HandleJoinSession(sm *SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + response, err := sm.JoinSession() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore session"}) + return + } + + if response == nil { + // No session exists + c.JSON(http.StatusUnauthorized, gin.H{"error": "No session exists"}) + return + } + + c.JSON(http.StatusOK, response) + } +} + +// SessionAuthMiddleware validates the Bearer token and aborts if invalid. +// Use this middleware on routes that require session authentication. +func SessionAuthMiddleware(sm *SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + token := extractBearerToken(c) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header. Include 'Authorization: Bearer ' from session creation."}) + return + } + if _, valid := sm.ValidateToken(token); !valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session token. Try refreshing the page or restarting Runbooks."}) + return + } + c.Next() + } +} + +// HandleGetSession returns session metadata (NOT environment variables). +// GET /api/session +// Requires Bearer token authentication (enforced by SessionAuthMiddleware). +func HandleGetSession(sm *SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + metadata, ok := sm.GetMetadata() + if !ok { + // This shouldn't happen since middleware validated the token, but handle it anyway + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + c.JSON(http.StatusOK, metadata) + } +} + +// HandleResetSession resets a session to its initial environment state. +// POST /api/session/reset +// Requires Bearer token authentication (enforced by SessionAuthMiddleware). +func HandleResetSession(sm *SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + if err := sm.ResetSession(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset session"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Session reset to initial state"}) + } +} + +// HandleDeleteSession deletes a session. +// DELETE /api/session +// Requires Bearer token authentication (enforced by SessionAuthMiddleware). +func HandleDeleteSession(sm *SessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + sm.DeleteSession() + c.JSON(http.StatusOK, gin.H{"message": "Session deleted"}) + } +} + +// extractBearerToken extracts the Bearer token from the Authorization header. +func extractBearerToken(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if auth == "" { + return "" + } + + // Expect "Bearer " + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + + return parts[1] +} diff --git a/api/session_test.go b/api/session_test.go new file mode 100644 index 0000000..8cf4363 --- /dev/null +++ b/api/session_test.go @@ -0,0 +1,949 @@ +package api + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestNewSessionManager(t *testing.T) { + sm := NewSessionManager() + if sm == nil { + t.Fatal("NewSessionManager returned nil") + } + if sm.session != nil { + t.Fatal("session should be nil initially") + } +} + +func TestCreateSession(t *testing.T) { + sm := NewSessionManager() + + // Create a session with a temp directory as the working dir + tmpDir := t.TempDir() + response, err := sm.CreateSession(tmpDir) + + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + if response.Token == "" { + t.Error("Session token is empty") + } + + // Token should be 64 hex chars (32 bytes) + if len(response.Token) != 64 { + t.Errorf("Token length should be 64, got %d", len(response.Token)) + } + + // Verify session was stored + session, ok := sm.GetSession() + if !ok { + t.Fatal("Session not found after creation") + } + + // Verify working directory is absolute + if !filepath.IsAbs(session.WorkingDir) { + t.Errorf("Working directory should be absolute, got: %s", session.WorkingDir) + } + + // Verify initial working dir is set + if session.InitialWorkDir != session.WorkingDir { + t.Error("InitialWorkDir should match WorkingDir on creation") + } + + // Verify environment was captured + if len(session.Env) == 0 { + t.Error("Environment should not be empty") + } + + // Verify PATH is in environment (common env var) + if _, ok := session.Env["PATH"]; !ok { + t.Error("PATH should be in captured environment") + } + + // Verify initial env is a copy + if &session.Env == &session.InitialEnv { + t.Error("Env and InitialEnv should be separate maps") + } + + // Verify token is in valid tokens + if len(session.ValidTokens) != 1 { + t.Errorf("Should have exactly 1 valid token, got %d", len(session.ValidTokens)) + } +} + +func TestCreateSessionReplacesExisting(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + + // Create first session + response1, _ := sm.CreateSession(tmpDir) + + // Create second session - should replace the first + response2, _ := sm.CreateSession(tmpDir) + + // Tokens should be different + if response1.Token == response2.Token { + t.Error("New session should have a different token") + } + + // Should only have one session with one token + if !sm.HasSession() { + t.Error("Should have a session") + } + + if sm.TokenCount() != 1 { + t.Errorf("Should have 1 token after replacement, got %d", sm.TokenCount()) + } + + // New token should work + _, valid := sm.ValidateToken(response2.Token) + if !valid { + t.Error("New token should be valid") + } + + // Old token should NOT work (session was replaced) + _, valid = sm.ValidateToken(response1.Token) + if valid { + t.Error("Old token should be invalid after session replacement") + } +} + +func TestValidateToken(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + response, _ := sm.CreateSession(tmpDir) + + tests := []struct { + name string + token string + wantValid bool + }{ + { + name: "valid token", + token: response.Token, + wantValid: true, + }, + { + name: "invalid token", + token: "invalid-token", + wantValid: false, + }, + { + name: "empty token", + token: "", + wantValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execCtx, valid := sm.ValidateToken(tt.token) + if valid != tt.wantValid { + t.Errorf("ValidateToken() valid = %v, want %v", valid, tt.wantValid) + } + if tt.wantValid && execCtx == nil { + t.Error("ValidateToken() should return exec context when valid") + } + if !tt.wantValid && execCtx != nil { + t.Error("ValidateToken() should not return exec context when invalid") + } + }) + } +} + +func TestValidateTokenReturnsSnapshot(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + response, _ := sm.CreateSession(tmpDir) + + // Get execution context + execCtx, valid := sm.ValidateToken(response.Token) + if !valid { + t.Fatal("Token should be valid") + } + + // Verify it contains a snapshot of the data + if execCtx.WorkDir != tmpDir { + t.Errorf("WorkDir should be %s, got %s", tmpDir, execCtx.WorkDir) + } + + if len(execCtx.Env) == 0 { + t.Error("Env should not be empty") + } + + // Verify the env is a copy (modifying it shouldn't affect the session) + originalLen := len(execCtx.Env) + execCtx.Env = append(execCtx.Env, "NEW_VAR=test") + + // Get a new context - it should have the original length + execCtx2, _ := sm.ValidateToken(response.Token) + if len(execCtx2.Env) != originalLen { + t.Error("Modifying returned Env should not affect session") + } +} + +func TestValidateTokenNoSession(t *testing.T) { + sm := NewSessionManager() + + // No session exists + _, valid := sm.ValidateToken("any-token") + if valid { + t.Error("Should return invalid when no session exists") + } +} + +func TestJoinSessionAddsToken(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + response1, _ := sm.CreateSession(tmpDir) + + // Restore session should add a new token (not replace) + response2, err := sm.JoinSession() + if err != nil { + t.Fatalf("JoinSession failed: %v", err) + } + + if response2 == nil { + t.Fatal("JoinSession returned nil") + } + + if response2.Token == response1.Token { + t.Error("Restore should generate a different token") + } + + // Both tokens should now be valid + if sm.TokenCount() != 2 { + t.Errorf("Should have 2 tokens, got %d", sm.TokenCount()) + } + + // Original token should still work + _, valid := sm.ValidateToken(response1.Token) + if !valid { + t.Error("Original token should still be valid after restore") + } + + // New token should also work + _, valid = sm.ValidateToken(response2.Token) + if !valid { + t.Error("New token should be valid after restore") + } +} + +func TestRestoreNoSession(t *testing.T) { + sm := NewSessionManager() + + restored, err := sm.JoinSession() + if err != nil { + t.Fatalf("JoinSession should not return error: %v", err) + } + + if restored != nil { + t.Error("JoinSession should return nil when no session exists") + } +} + +func TestMultipleTabsShareSession(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + + // Tab 1 creates session + response1, _ := sm.CreateSession(tmpDir) + + // Tab 2 restores (gets its own token) + response2, _ := sm.JoinSession() + + // Tab 3 restores (gets its own token) + response3, _ := sm.JoinSession() + + // All three tokens should be valid + _, valid1 := sm.ValidateToken(response1.Token) + _, valid2 := sm.ValidateToken(response2.Token) + _, valid3 := sm.ValidateToken(response3.Token) + + if !valid1 || !valid2 || !valid3 { + t.Error("All tokens should be valid") + } + + // Should have 3 tokens + if sm.TokenCount() != 3 { + t.Errorf("Should have 3 tokens, got %d", sm.TokenCount()) + } + + // All tabs share the same session state + // Update env from tab 1 + _ = sm.UpdateSessionEnv(map[string]string{"SHARED": "value"}, tmpDir) + + // Tab 2 and 3 should see the same state + session, _ := sm.GetSession() + if session.Env["SHARED"] != "value" { + t.Error("All tabs should see the same session state") + } +} + +func TestMaxTokensLimit(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + // Create MaxTokensPerSession - 1 more tokens (one already exists from CreateSession) + for i := 0; i < MaxTokensPerSession-1; i++ { + _, err := sm.JoinSession() + if err != nil { + t.Fatalf("JoinSession failed: %v", err) + } + } + + if sm.TokenCount() != MaxTokensPerSession { + t.Errorf("Should have %d tokens, got %d", MaxTokensPerSession, sm.TokenCount()) + } + + // Adding one more should prune the oldest + _, err := sm.JoinSession() + if err != nil { + t.Fatalf("JoinSession failed: %v", err) + } + + // Should still be at max + if sm.TokenCount() != MaxTokensPerSession { + t.Errorf("Should still have %d tokens after pruning, got %d", MaxTokensPerSession, sm.TokenCount()) + } +} + +func TestRevokeToken(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + response1, _ := sm.CreateSession(tmpDir) + response2, _ := sm.JoinSession() + + // Revoke token 1 + revoked := sm.RevokeToken(response1.Token) + if !revoked { + t.Error("RevokeToken should return true for valid token") + } + + // Token 1 should no longer be valid + _, valid := sm.ValidateToken(response1.Token) + if valid { + t.Error("Revoked token should be invalid") + } + + // Token 2 should still be valid + _, valid = sm.ValidateToken(response2.Token) + if !valid { + t.Error("Other token should still be valid") + } + + // Should have 1 token left + if sm.TokenCount() != 1 { + t.Errorf("Should have 1 token after revoke, got %d", sm.TokenCount()) + } +} + +func TestRevokeTokenNotFound(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + revoked := sm.RevokeToken("nonexistent-token") + if revoked { + t.Error("RevokeToken should return false for nonexistent token") + } +} + +func TestUpdateSessionEnv(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + newEnv := map[string]string{ + "NEW_VAR": "new_value", + "PATH": "/new/path", + } + newWorkDir := "/new/work/dir" + + err := sm.UpdateSessionEnv(newEnv, newWorkDir) + if err != nil { + t.Fatalf("UpdateSessionEnv failed: %v", err) + } + + session, _ := sm.GetSession() + + if session.Env["NEW_VAR"] != "new_value" { + t.Error("Environment should be updated") + } + + if session.WorkingDir != newWorkDir { + t.Errorf("Working directory should be updated, got: %s", session.WorkingDir) + } + + if session.ExecutionCount != 1 { + t.Errorf("Execution count should be 1, got: %d", session.ExecutionCount) + } +} + +func TestResetSession(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + // Get initial env count + session, _ := sm.GetSession() + initialEnvCount := len(session.InitialEnv) + initialWorkDir := session.InitialWorkDir + + // Update with new environment and working directory + newEnv := map[string]string{ + "NEW_VAR": "new_value", + } + _ = sm.UpdateSessionEnv(newEnv, "/new/work/dir") + + // Verify update + session, _ = sm.GetSession() + if len(session.Env) != 1 { + t.Errorf("Env should have 1 var, got: %d", len(session.Env)) + } + if session.WorkingDir != "/new/work/dir" { + t.Error("WorkingDir should be updated") + } + + // Reset session + err := sm.ResetSession() + if err != nil { + t.Fatalf("ResetSession failed: %v", err) + } + + // Verify reset + session, _ = sm.GetSession() + if len(session.Env) != initialEnvCount { + t.Errorf("Env should have %d vars after reset, got: %d", initialEnvCount, len(session.Env)) + } + + if _, ok := session.Env["NEW_VAR"]; ok { + t.Error("NEW_VAR should be removed after reset") + } + + if session.WorkingDir != initialWorkDir { + t.Errorf("WorkingDir should be reset to %s, got: %s", initialWorkDir, session.WorkingDir) + } +} + +func TestDeleteSession(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + response, _ := sm.CreateSession(tmpDir) + + // Add another token + sm.JoinSession() + + sm.DeleteSession() + + _, ok := sm.GetSession() + if ok { + t.Error("Session should be deleted") + } + + // All tokens should be invalid + _, valid := sm.ValidateToken(response.Token) + if valid { + t.Error("Tokens should be invalid after session deletion") + } +} + +func TestGetMetadata(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + // Add another token (simulating 2 tabs) + sm.JoinSession() + + // Update to increment execution count + _ = sm.UpdateSessionEnv(map[string]string{"A": "B"}, tmpDir) + + metadata, ok := sm.GetMetadata() + if !ok { + t.Fatal("GetMetadata returned not ok") + } + + if metadata.ExecutionCount != 1 { + t.Errorf("Execution count should be 1, got: %d", metadata.ExecutionCount) + } + + if metadata.CreatedAt.IsZero() { + t.Error("CreatedAt should not be zero") + } + + if metadata.ActiveTabs != 2 { + t.Errorf("ActiveTabs should be 2, got: %d", metadata.ActiveTabs) + } +} + +func TestGetMetadataNoSession(t *testing.T) { + sm := NewSessionManager() + + _, ok := sm.GetMetadata() + if ok { + t.Error("GetMetadata should return false when no session exists") + } +} + +func TestEnvSlice(t *testing.T) { + session := &Session{ + Env: map[string]string{ + "VAR1": "value1", + "VAR2": "value2", + }, + } + + slice := session.EnvSlice() + + if len(slice) != 2 { + t.Errorf("EnvSlice should have 2 items, got: %d", len(slice)) + } + + // Check that both vars are present (order may vary) + found := map[string]bool{} + for _, s := range slice { + if s == "VAR1=value1" { + found["VAR1"] = true + } + if s == "VAR2=value2" { + found["VAR2"] = true + } + } + + if !found["VAR1"] || !found["VAR2"] { + t.Errorf("EnvSlice missing expected values: %v", slice) + } +} + +func TestFilterCapturedEnv(t *testing.T) { + input := map[string]string{ + "PATH": "/usr/bin", + "HOME": "/home/user", + "_": "/bin/bash", // Should be filtered + "SHLVL": "1", // Should be filtered + "BASH_VERSION": "5.0", // Should be filtered (BASH_* prefix) + "CUSTOM_VAR": "custom", + } + + filtered := FilterCapturedEnv(input) + + // Should keep + if _, ok := filtered["PATH"]; !ok { + t.Error("PATH should be kept") + } + if _, ok := filtered["HOME"]; !ok { + t.Error("HOME should be kept") + } + if _, ok := filtered["CUSTOM_VAR"]; !ok { + t.Error("CUSTOM_VAR should be kept") + } + + // Should filter + if _, ok := filtered["_"]; ok { + t.Error("_ should be filtered") + } + if _, ok := filtered["SHLVL"]; ok { + t.Error("SHLVL should be filtered") + } + if _, ok := filtered["BASH_VERSION"]; ok { + t.Error("BASH_VERSION should be filtered") + } +} + +func TestCaptureEnvironment(t *testing.T) { + // Set a test env var + os.Setenv("RUNBOOKS_TEST_VAR", "test_value") + defer os.Unsetenv("RUNBOOKS_TEST_VAR") + + env := captureEnvironment() + + if env["RUNBOOKS_TEST_VAR"] != "test_value" { + t.Error("captureEnvironment should capture current environment") + } +} + +func TestCopyEnvMap(t *testing.T) { + original := map[string]string{ + "A": "1", + "B": "2", + } + + copied := copyEnvMap(original) + + // Modify original + original["A"] = "modified" + original["C"] = "3" + + // Copy should be unchanged + if copied["A"] != "1" { + t.Error("Copy should be independent from original") + } + if _, ok := copied["C"]; ok { + t.Error("Copy should not have new keys from original") + } +} + +// Security Tests + +func TestSecurityInvalidTokenReturnsUnauthorized(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + // Try with wrong token + _, valid := sm.ValidateToken("wrong-token") + if valid { + t.Error("Invalid token should return unauthorized") + } +} + +func TestSecurityNoSessionReturnsUnauthorized(t *testing.T) { + sm := NewSessionManager() + + // Try to validate when no session exists + _, valid := sm.ValidateToken("any-token") + if valid { + t.Error("No session should return unauthorized") + } +} + +func TestSecurityTokenIsNotExposed(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + sm.CreateSession(tmpDir) + + // GetMetadata should NOT contain the tokens + metadata, _ := sm.GetMetadata() + + // Verify by checking the struct has expected fields but no token + if metadata.WorkingDir == "" { + t.Error("Metadata should have WorkingDir") + } + + // ActiveTabs tells you how many, but not what the tokens are + if metadata.ActiveTabs != 1 { + t.Errorf("ActiveTabs should be 1, got %d", metadata.ActiveTabs) + } + + // The SessionMetadata struct intentionally excludes sensitive fields + // This is enforced at compile time by the struct definition +} + +func TestSecurityTokensAreUnique(t *testing.T) { + sm := NewSessionManager() + tmpDir := t.TempDir() + + tokens := make(map[string]bool) + + // Create multiple tokens and verify they are unique + response, _ := sm.CreateSession(tmpDir) + tokens[response.Token] = true + + for i := 0; i < 50; i++ { + response, err := sm.JoinSession() + if err != nil { + t.Fatalf("JoinSession failed: %v", err) + } + + if tokens[response.Token] { + t.Error("Duplicate token generated") + } + tokens[response.Token] = true + } +} + +// Test wrapper script generation +func TestWrapScriptForEnvCapture(t *testing.T) { + script := `#!/bin/bash +echo "Hello" +export MY_VAR=value +cd /tmp` + + wrapped := wrapScriptForEnvCapture(script, "/tmp/env.txt", "/tmp/pwd.txt") + + // Verify wrapper contains necessary components + if !strings.Contains(wrapped, "__RUNBOOKS_ENV_CAPTURE_PATH") { + t.Error("Wrapper should contain env capture path variable") + } + + if !strings.Contains(wrapped, "__RUNBOOKS_PWD_CAPTURE_PATH") { + t.Error("Wrapper should contain pwd capture path variable") + } + + // The wrapper uses builtin trap to set our combined exit handler + // This ensures user EXIT traps don't override our env capture + if !strings.Contains(wrapped, "builtin trap __runbooks_combined_exit EXIT") { + t.Error("Wrapper should set EXIT trap using builtin") + } + + // Verify trap interception is set up + if !strings.Contains(wrapped, "__RUNBOOKS_USER_EXIT_HANDLER") { + t.Error("Wrapper should have user exit handler variable for trap interception") + } + + if !strings.Contains(wrapped, script) { + t.Error("Wrapper should contain original script") + } +} + +// Test that user EXIT traps are chained with our env capture +func TestWrapScriptForEnvCaptureWithUserTrap(t *testing.T) { + // Create temp files for env capture + envFile, err := os.CreateTemp("", "test-env-trap-*.txt") + if err != nil { + t.Fatalf("Failed to create temp env file: %v", err) + } + envCapturePath := envFile.Name() + envFile.Close() + defer os.Remove(envCapturePath) + + pwdFile, err := os.CreateTemp("", "test-pwd-trap-*.txt") + if err != nil { + t.Fatalf("Failed to create temp pwd file: %v", err) + } + pwdCapturePath := pwdFile.Name() + pwdFile.Close() + defer os.Remove(pwdCapturePath) + + // Get a unique path for the user's EXIT trap to create a marker file + // We use TempDir to ensure uniqueness without actually creating the file + userTrapRanPath := filepath.Join(t.TempDir(), "user-trap-ran.marker") + + // Script that sets an EXIT trap for cleanup AND exports an env var + script := fmt.Sprintf(`#!/bin/bash +export MY_TEST_VAR=test_value_12345 +trap "touch %q" EXIT +echo "Script running" +`, userTrapRanPath) + + wrapped := wrapScriptForEnvCapture(script, envCapturePath, pwdCapturePath) + + // Write wrapped script to temp file and execute it + tmpScript, err := os.CreateTemp("", "test-wrapped-*.sh") + if err != nil { + t.Fatalf("Failed to create temp script file: %v", err) + } + tmpScript.WriteString(wrapped) + tmpScript.Close() + defer os.Remove(tmpScript.Name()) + os.Chmod(tmpScript.Name(), 0700) + + // Run the script + cmd := exec.Command("bash", tmpScript.Name()) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Script execution failed: %v\nOutput: %s", err, output) + } + + // Verify user's EXIT trap ran (marker file should exist) + if _, err := os.Stat(userTrapRanPath); os.IsNotExist(err) { + t.Error("User's EXIT trap should have run and created the marker file") + } + // Note: t.TempDir() handles cleanup automatically + + // Verify our env capture also ran + env, _ := parseEnvCapture(envCapturePath, pwdCapturePath) + if env == nil { + t.Fatal("Environment capture should have run") + } + + if env["MY_TEST_VAR"] != "test_value_12345" { + t.Errorf("MY_TEST_VAR should be captured, got: %q", env["MY_TEST_VAR"]) + } +} + +// Test env capture parsing with NUL-terminated format (from env -0) +func TestParseEnvCapture(t *testing.T) { + // Create temp files with test content using NUL-terminated format + envFile, _ := os.CreateTemp("", "test-env-*.txt") + // NUL-terminated entries as produced by `env -0` + envFile.WriteString("VAR1=value1\x00VAR2=value2\x00PATH=/usr/bin:/usr/local/bin\x00") + envFile.Close() + defer os.Remove(envFile.Name()) + + pwdFile, _ := os.CreateTemp("", "test-pwd-*.txt") + pwdFile.WriteString("/home/user/project\n") + pwdFile.Close() + defer os.Remove(pwdFile.Name()) + + env, pwd := parseEnvCapture(envFile.Name(), pwdFile.Name()) + + if env["VAR1"] != "value1" { + t.Errorf("VAR1 should be value1, got: %s", env["VAR1"]) + } + + if env["VAR2"] != "value2" { + t.Errorf("VAR2 should be value2, got: %s", env["VAR2"]) + } + + if env["PATH"] != "/usr/bin:/usr/local/bin" { + t.Errorf("PATH should be /usr/bin:/usr/local/bin, got: %s", env["PATH"]) + } + + if pwd != "/home/user/project" { + t.Errorf("pwd should be /home/user/project, got: %s", pwd) + } +} + +// Test that env capture correctly handles multiline values (e.g., RSA keys, JSON) +func TestParseEnvCaptureMultilineValues(t *testing.T) { + envFile, _ := os.CreateTemp("", "test-env-multiline-*.txt") + // Simulate a multiline value like an RSA key or JSON + multilineValue := "line1\nline2\nline3" + jsonValue := `{"key": "value", "nested": {"foo": "bar"}}` + // NUL-terminated entries - the newlines are WITHIN the values + envFile.WriteString("SIMPLE=simple_value\x00MULTILINE=" + multilineValue + "\x00JSON=" + jsonValue + "\x00") + envFile.Close() + defer os.Remove(envFile.Name()) + + pwdFile, _ := os.CreateTemp("", "test-pwd-*.txt") + pwdFile.WriteString("/home/user\n") + pwdFile.Close() + defer os.Remove(pwdFile.Name()) + + env, pwd := parseEnvCapture(envFile.Name(), pwdFile.Name()) + + if env["SIMPLE"] != "simple_value" { + t.Errorf("SIMPLE should be simple_value, got: %s", env["SIMPLE"]) + } + + if env["MULTILINE"] != multilineValue { + t.Errorf("MULTILINE should preserve newlines, expected %q, got: %q", multilineValue, env["MULTILINE"]) + } + + if env["JSON"] != jsonValue { + t.Errorf("JSON should be preserved, expected %q, got: %q", jsonValue, env["JSON"]) + } + + if pwd != "/home/user" { + t.Errorf("pwd should be /home/user, got: %s", pwd) + } +} + +// Test fallback to newline-delimited format (for systems without env -0) +func TestParseEnvCaptureLegacyNewlineFormat(t *testing.T) { + envFile, _ := os.CreateTemp("", "test-env-legacy-*.txt") + // Legacy newline-delimited format (from plain `env` without -0) + envFile.WriteString("VAR1=value1\nVAR2=value2\nPATH=/usr/bin\n") + envFile.Close() + defer os.Remove(envFile.Name()) + + pwdFile, _ := os.CreateTemp("", "test-pwd-*.txt") + pwdFile.WriteString("/home/user\n") + pwdFile.Close() + defer os.Remove(pwdFile.Name()) + + env, pwd := parseEnvCapture(envFile.Name(), pwdFile.Name()) + + if env["VAR1"] != "value1" { + t.Errorf("VAR1 should be value1, got: %s", env["VAR1"]) + } + + if env["VAR2"] != "value2" { + t.Errorf("VAR2 should be value2, got: %s", env["VAR2"]) + } + + if env["PATH"] != "/usr/bin" { + t.Errorf("PATH should be /usr/bin, got: %s", env["PATH"]) + } + + if pwd != "/home/user" { + t.Errorf("pwd should be /home/user, got: %s", pwd) + } +} + +// Test that legacy newline format can still handle multiline values via continuation detection +func TestParseEnvCaptureLegacyMultilineValues(t *testing.T) { + envFile, _ := os.CreateTemp("", "test-env-legacy-multiline-*.txt") + // Simulate `env` output with multiline values (no NUL chars) + // The continuation lines don't have valid env var names before = + envFile.WriteString("SIMPLE=simple\nMULTILINE=line1\nline2\nline3\nJSON={\n \"key\": \"value\"\n}\nLAST=end\n") + envFile.Close() + defer os.Remove(envFile.Name()) + + pwdFile, _ := os.CreateTemp("", "test-pwd-*.txt") + pwdFile.WriteString("/home/user\n") + pwdFile.Close() + defer os.Remove(pwdFile.Name()) + + env, pwd := parseEnvCapture(envFile.Name(), pwdFile.Name()) + + if env["SIMPLE"] != "simple" { + t.Errorf("SIMPLE should be 'simple', got: %q", env["SIMPLE"]) + } + + expectedMultiline := "line1\nline2\nline3" + if env["MULTILINE"] != expectedMultiline { + t.Errorf("MULTILINE should be %q, got: %q", expectedMultiline, env["MULTILINE"]) + } + + expectedJSON := "{\n \"key\": \"value\"\n}" + if env["JSON"] != expectedJSON { + t.Errorf("JSON should be %q, got: %q", expectedJSON, env["JSON"]) + } + + if env["LAST"] != "end" { + t.Errorf("LAST should be 'end', got: %q", env["LAST"]) + } + + if pwd != "/home/user" { + t.Errorf("pwd should be /home/user, got: %s", pwd) + } +} + +func TestParseEnvCaptureNonExistentFiles(t *testing.T) { + env, pwd := parseEnvCapture("/non/existent/env.txt", "/non/existent/pwd.txt") + + if env != nil { + t.Error("env should be nil for non-existent file") + } + + if pwd != "" { + t.Error("pwd should be empty for non-existent file") + } +} + +// Test isValidEnvVarName helper function +func TestIsValidEnvVarName(t *testing.T) { + tests := []struct { + name string + expected bool + }{ + // Valid names + {"PATH", true}, + {"HOME", true}, + {"MY_VAR", true}, + {"_PRIVATE", true}, + {"var123", true}, + {"A", true}, + {"_", true}, + {"__", true}, + {"a1b2c3", true}, + + // Invalid names + {"", false}, // empty + {"123VAR", false}, // starts with digit + {"MY-VAR", false}, // contains hyphen + {"MY.VAR", false}, // contains dot + {"MY VAR", false}, // contains space + {" \"key\"", false}, // JSON-like (starts with space) + {"{", false}, // JSON brace + {"line2", true}, // looks valid but could be continuation - that's OK, context determines + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := isValidEnvVarName(tc.name) + if result != tc.expected { + t.Errorf("isValidEnvVarName(%q) = %v, expected %v", tc.name, result, tc.expected) + } + }) + } +} diff --git a/docs/src/content/docs/authoring/blocks/Check.md b/docs/src/content/docs/authoring/blocks/Check.md index 08467ec..bcfb1f3 100644 --- a/docs/src/content/docs/authoring/blocks/Check.md +++ b/docs/src/content/docs/authoring/blocks/Check.md @@ -266,7 +266,9 @@ In this example, the check has access to all variables from both `lambda-config` ### Execution Context -Scripts run in a **non-interactive shell**, which means shell aliases (like `ll`) and shell functions (like `nvm`, `rvm`) are **not available**. Environment variables are inherited from the process that launched Runbooks. +Scripts run in a **persistent environment** — environment variable changes (`export`, `unset`) and working directory changes (`cd`) carry forward to subsequent blocks. This lets you structure your runbook like a workflow where earlier steps set up the environment for later steps. + +Scripts also run in a **non-interactive shell**, which means shell aliases (like `ll`) and shell functions (like `nvm`, `rvm`) are **not available**. For full details, see [Shell Execution Context](/security/shell-execution-context/). diff --git a/docs/src/content/docs/authoring/blocks/Command.mdx b/docs/src/content/docs/authoring/blocks/Command.mdx index fb5a8c1..856537f 100644 --- a/docs/src/content/docs/authoring/blocks/Command.mdx +++ b/docs/src/content/docs/authoring/blocks/Command.mdx @@ -38,8 +38,6 @@ Command blocks and [Check](/authoring/blocks/check/) blocks share many features - `successMessage` (string) - Message shown when command succeeds (default: "Success") - `failMessage` (string) - Message shown when command fails (default: "Failed") - `runningMessage` (string) - Message shown while running (default: "Running...") -- `captureFiles` (boolean) - When `true`, any files created by the command are captured to the workspace's generated files -- `captureFilesOutputPath` (string) - Relative subdirectory within the output folder for captured files. Only valid when `captureFiles={true}` ### Inline content @@ -274,7 +272,9 @@ In this example, the command has access to all variables from both `lambda-confi ### Execution Context -Scripts run in a **non-interactive shell**, which means shell aliases (like `ll`) and shell functions (like `nvm`, `rvm`) are **not available**. Environment variables are inherited from the process that launched Runbooks. +Scripts run in a **persistent environment** — environment variable changes (`export`, `unset`) and working directory changes (`cd`) carry forward to subsequent blocks. This lets you structure your runbook like a workflow where earlier steps set up the environment for later steps. + +Scripts also run in a **non-interactive shell**, which means shell aliases (like `ll`) and shell functions (like `nvm`, `rvm`) are **not available**. For full details, see [Shell Execution Context](/security/shell-execution-context/). @@ -347,41 +347,67 @@ log_info "Deployment successful!" ## Capturing Output Files -When a command creates files that you want to persist to your generated files folder, use the `captureFiles` prop. This is useful for commands that generate configuration files, artifacts, or other output. +Commands can save files to the generated files directory using the `$RUNBOOKS_OUTPUT` environment variable. Any files written to this directory will automatically appear in the file panel after the command completes successfully. + +### Basic Example + +```bash +#!/bin/bash +# Export OpenTofu outputs to generated files +tofu output -json > "$RUNBOOKS_OUTPUT/tf-outputs.json" + +# Copy a config file +cp config.yaml "$RUNBOOKS_OUTPUT/" +``` -### Basic File Capture +Or as an inline command: ```mdx greeting.txt`} - captureFiles + command={`echo "Hello from the command!" > "$RUNBOOKS_OUTPUT/greeting.txt"`} successMessage="File created successfully!" /> ``` -Any files created by the command will appear in the workspace's generated files panel. +### Organizing Files with Subdirectories -### File Capture with Output Path +You can create subdirectories within `$RUNBOOKS_OUTPUT` to organize your captured files: -To organize captured files into a subdirectory, use `captureFilesOutputPath`: +```bash +#!/bin/bash +mkdir -p "$RUNBOOKS_OUTPUT/terraform" +mkdir -p "$RUNBOOKS_OUTPUT/config" -```mdx - config.json && mkdir -p subdir && echo 'key=value' > subdir/settings.ini`} - captureFiles - captureFilesOutputPath="config" - successMessage="Config files created!" -/> +tofu output -json > "$RUNBOOKS_OUTPUT/opentofu/outputs.json" +echo '{"env": "production"}' > "$RUNBOOKS_OUTPUT/config/settings.json" ``` -In this example, all created files will be placed under a `config/` subdirectory in the generated files. +This creates: + +``` +generated/ +├── opentofu/ +│ └── outputs.json +└── config/ + └── settings.json +``` + +### How It Works + +1. Before your script runs, Runbooks creates a temporary capture directory +2. The `$RUNBOOKS_OUTPUT` environment variable points to this directory +3. Your script writes files to `$RUNBOOKS_OUTPUT` +4. After successful execution, files are copied to the generated files directory +5. The temporary directory is cleaned up + + ## Common Use Cases diff --git a/docs/src/content/docs/intro/generated_files.mdx b/docs/src/content/docs/intro/generated_files.mdx index 5ef04dd..bf698ec 100644 --- a/docs/src/content/docs/intro/generated_files.mdx +++ b/docs/src/content/docs/intro/generated_files.mdx @@ -22,24 +22,19 @@ The [Template](/authoring/blocks/template/) and [TemplateInline](/authoring/bloc Template blocks automatically re-render when you change input values, so your generated files stay in sync with the form. -### 2. Command blocks with file capture +### 2. Command blocks with $RUNBOOKS_OUTPUT -[Command](/authoring/blocks/command/) blocks can optionally capture files they create using the `captureFiles` prop: +[Command](/authoring/blocks/command/) blocks can write files to the `$RUNBOOKS_OUTPUT` environment variable to capture them: ```mdx tf-outputs.json`} - captureFiles + id="export-terraform" + command={`tofu output -json > "$RUNBOOKS_OUTPUT/tf-outputs.json"`} title="Export Terraform outputs" /> ``` -This is useful when scripts or tools generate files as part of their execution. - - +Any files written to `$RUNBOOKS_OUTPUT` automatically appear in your generated files after the command completes successfully. ## Where files are stored @@ -80,16 +75,12 @@ Within a runbook, you can organize generated files into subdirectories: - **Template blocks**: The directory structure of your template folder is preserved in the output. - **TemplateInline blocks**: Use the `outputPath` prop to specify a path like `config/app.yaml`. -- **Command blocks**: Use `captureFilesOutputPath` to place captured files in a subdirectory. +- **Command/Check blocks**: Create subdirectories within `$RUNBOOKS_OUTPUT`. -```mdx - config.json`} - captureFiles - captureFilesOutputPath="config" - title="Create config files" -/> +```bash +# In your command script +mkdir -p "$RUNBOOKS_OUTPUT/config" +echo '{}' > "$RUNBOOKS_OUTPUT/config/app.json" ``` ## Viewing generated files diff --git a/docs/src/content/docs/security/execution-model.md b/docs/src/content/docs/security/execution-model.md index 84eea91..b815287 100644 --- a/docs/src/content/docs/security/execution-model.md +++ b/docs/src/content/docs/security/execution-model.md @@ -19,6 +19,28 @@ When Runbooks loads, it immediately shows a warning to users to confirm that the The Runbooks backend server (which runs locally on your computer) only accepts connections from `localhost` (127.0.0.1). This prevents remote attacks where a malicious website could send requests to your local Runbooks server. +### Session Token Authentication + +Even with localhost-only binding, an additional layer of protection prevents unauthorized script execution. When you open a runbook, the browser receives a cryptographically random session token that must be included with every execution request. + +Without token authentication, any process on your machine could send requests to `localhost:7825` and execute scripts. The token requirement ensures only browser tabs that loaded the Runbooks UI can trigger execution. + +If you see "Invalid or expired session token" errors, try refreshing the page to obtain a new token. + +**How it works:** + +1. When a browser tab connects, it receives a unique token +2. The token is stored in memory only (not in cookies or localStorage) +3. Every `/api/exec` request must include this token in the `Authorization` header +4. Requests without a valid token are rejected with `401 Unauthorized` + +**Multi-tab behavior:** + +- Multiple browser tabs share the same session (environment state) +- Each tab receives its own token when it connects +- Up to 20 concurrent tokens are supported; older tokens are automatically pruned +- Closing a tab doesn't invalidate other tabs' tokens + ### Executable Registry By default, Runbooks uses an **executable registry,** which is a _registry_ of all _executable_ artifacts, to make sure that the backend server will only allow execution of scripts and commands defined directly in the Runbook you opened (versus running arbitrary scripts). @@ -127,4 +149,4 @@ Regardless of mode, the actual execution process is: **Security note:** Scripts run with your user's full environment variables and permissions. Runbooks is designed for **trusted runbooks only** - it's meant to streamline tasks you would otherwise run manually in your terminal. -For details on interpreter detection and shell limitations (aliases, functions, RC files), see [Shell Execution Context](/security/shell-execution-context/). +For details on interpreter detection, shell limitations, and how environment changes persist across script executions, see [Shell Execution Context](/security/shell-execution-context/). diff --git a/docs/src/content/docs/security/shell-execution-context.md b/docs/src/content/docs/security/shell-execution-context.md index b190cc2..ae15962 100644 --- a/docs/src/content/docs/security/shell-execution-context.md +++ b/docs/src/content/docs/security/shell-execution-context.md @@ -1,21 +1,150 @@ --- title: Shell Execution Context -description: Understanding how Runbooks executes scripts in a non-interactive shell +description: Understanding how Runbooks executes scripts and maintains environment state --- -Scripts executed by Runbooks in [Check](/authoring/blocks/check) or [Command](/authoring/blocks/command) blocks run in a **non-interactive shell**. This has important implications for what works and what doesn't. +## Persistent Environment Model -## What's Available +**Think of Runbooks like a persistent terminal session.** When you run scripts in Check or Command blocks, environment changes carry forward to subsequent blocks — just like typing commands in a terminal. + +| What persists | Example | +|---------------|---------| +| Environment variables | `export AWS_PROFILE=prod` stays set for later blocks | +| Working directory | `cd /path/to/project` changes where later scripts run | +| Unset variables | `unset DEBUG` removes the variable for later blocks | + +This means you can structure your runbook like a workflow: + +1. **Block 1**: Set up environment (`export AWS_REGION=us-east-1`) +2. **Block 2**: Run a command that uses `$AWS_REGION` +3. **Block 3**: Clean up (`unset AWS_REGION`) + +### Bash Scripts Only + +:::caution[Environment persistence requires Bash] +Environment variable changes **only persist for Bash scripts** (`#!/bin/bash` or `#!/bin/sh`). Non-Bash scripts like Python, Ruby, or Node.js can **read** environment variables from the session, but changes they make (e.g., `os.environ["VAR"] = "value"` in Python) will **not** persist to subsequent blocks. +::: + +| Script Type | Can read env vars | Can set persistent env vars | +|-------------|-------------------|----------------------------| +| Bash (`#!/bin/bash`) | ✅ Yes | ✅ Yes | +| Sh (`#!/bin/sh`) | ✅ Yes | ✅ Yes | +| Python (`#!/usr/bin/env python3`) | ✅ Yes | ❌ No | +| Ruby (`#!/usr/bin/env ruby`) | ✅ Yes | ❌ No | +| Node.js (`#!/usr/bin/env node`) | ✅ Yes | ❌ No | +| Other interpreters | ✅ Yes | ❌ No | + +**Why?** Environment persistence works by wrapping your script in a Bash wrapper that captures environment changes after execution. This wrapper is Bash-specific and can't be applied to other interpreters. Additionally, environment changes in subprocesses (like a Python script) can't propagate back to the parent process — this is a fundamental limitation of how Unix processes work. + +### Multiline Environment Variables + +Environment variables can contain embedded newlines — RSA keys, JSON configs, multiline strings, etc. These values are correctly preserved across blocks: + +```bash +#!/bin/bash +export SSH_KEY="-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA... +-----END RSA PRIVATE KEY-----" + +export JSON_CONFIG='{ + "database": "postgres", + "settings": { "timeout": 30 } +}' +``` + +Runbooks uses NUL-terminated output (`env -0`) when capturing environment variables, which correctly handles values containing newlines. This works on Linux, macOS, and Windows with Git Bash. + +### User Trap Support + +Your scripts can optionally use `trap` commands for cleanup. + +```bash +#!/bin/bash +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Your script logic... +export RESULT="computed value" +``` + +Runbooks intercepts EXIT traps to ensure both your cleanup code **and** environment capture (capturing the environment variables that were set in this script and making those values available to other scripts) run correctly. When your script exits: + +1. Your trap handler runs first (cleanup happens) +2. Runbooks captures the final environment state +3. The original exit code is preserved + +This means you can write scripts with proper cleanup logic and still have environment changes persist to subsequent blocks. + +### Multiple Browser Tabs + +If you open the same runbook in multiple browser tabs, they all share the same environment. Changes made in one tab are visible in all others — like having multiple terminal windows connected to the same shell session. + +### Concurrent Script Execution + +:::caution[Environment changes may be lost when scripts run concurrently] +If you run multiple scripts at the same time (for example, clicking "Run" on two different blocks before the first completes), environment changes from one script may silently overwrite changes from the other. +::: + +**Why this happens:** When a script starts, it captures the current environment as a snapshot. When it finishes, it replaces the session environment with whatever the script ended with. If two scripts run concurrently: + +1. Script A and Script B both start with environment `{X=1}` +2. Script A sets `X=2` +3. Script B sets `Y=3` +4. Whichever finishes last overwrites the other's changes + +For example, if Script B finishes last, the session ends up with `{X=1, Y=3}` — losing Script A's change to `X`. + +**Recommendation:** If your scripts depend on environment changes from previous scripts, wait for each script to complete before running the next one. The environment model is designed for sequential, step-by-step execution, similar to typing commands in a terminal one at a time. + +### Implementation Notes + +The Runbooks server maintains a single session per runbook instance. Each script execution captures environment changes and working directory updates, then applies them to the session state. This happens automatically — you don't need to do anything special in your scripts. + +The session resets when you restart the Runbooks server. You can also manually reset the environment to its initial state using the session controls in the UI. + +--- + +## Built-in Environment Variables + +Runbooks exposes the following environment variables to all scripts: + +| Variable | Description | +|----------|-------------| +| `RUNBOOKS_OUTPUT` | Path to a directory where scripts can write files to be captured. Files written here appear in the generated files panel after successful execution. | + +### Capturing Output Files + +To save files to the generated files directory, write them to `$RUNBOOKS_OUTPUT`: + +```bash +#!/bin/bash +# Generate a config and capture it +tofu output -json > "$RUNBOOKS_OUTPUT/outputs.json" + +# Create subdirectories as needed +mkdir -p "$RUNBOOKS_OUTPUT/config" +echo '{"env": "production"}' > "$RUNBOOKS_OUTPUT/config/settings.json" +``` + +Files are only captured after successful execution (exit code 0 or 2). If your script fails, any files written to `$RUNBOOKS_OUTPUT` are discarded. + +See [Capturing Output Files](/authoring/blocks/command/#capturing-output-files) for more details. + +--- + +## Non-Interactive Shell + +Scripts run in a **non-interactive shell**, which affects what's available: | Feature | Available? | Notes | |---------|------------|-------| -| Environment variables | ✅ Yes | Inherited from the process that launched Runbooks | +| Environment variables | ✅ Yes | Inherited from Runbooks + changes from previous blocks | | Binaries in `$PATH` | ✅ Yes | `git`, `aws`, `terraform`, etc. | | Shell aliases | ❌ No | `ll`, `la`, custom aliases | | Shell functions | ❌ No | `nvm`, `rvm`, `assume`, etc. | | RC files | ❌ No | `.bashrc`, `.zshrc` are NOT sourced | -## Example: Aliases vs Binaries +### Example: Aliases vs Binaries ```bash # ❌ Will NOT work - ll is typically a bash alias for "ls -l" @@ -25,9 +154,9 @@ Scripts executed by Runbooks in [Check](/authoring/blocks/check) or [Command](/a ``` -## Why This Matters +### Why This Matters -Many developer tools are implemented as **shell functions** rather than standalone binaries. These functions are often defined in your shell's RC files (`.bashrc`, `.zshrc`) and only exist in interactive shell sessions. +Many developer tools are implemented as **shell functions** rather than standalone binaries. These functions are defined in your shell's RC files (`.bashrc`, `.zshrc`) and only exist in interactive shell sessions. Common tools that are shell functions (not binaries): - **nvm** — Node Version Manager @@ -36,11 +165,11 @@ Common tools that are shell functions (not binaries): - **conda activate** - **assume** — Shell function from [Granted](https://docs.commonfate.io/granted/introduction) -These tools need to be shell functions because they modify your current shell's environment (e.g., changing `$PATH` or setting environment variables), which can't be done from a subprocess. +These tools need to be shell functions because they modify your current shell's environment (e.g., changing `$PATH`), which can't be done from a subprocess. -## Workarounds +### Workarounds -For tools that are shell functions, instead of invoking them directly, you can check for the underlying installation instead: +For tools that are shell functions, check for the underlying installation instead: ```bash #!/bin/bash @@ -54,7 +183,7 @@ else fi ``` -If you absolutely need shell functions, you can source the RC file in your script that runs in the Check or Command block (use with caution): +If you absolutely need shell functions, source the RC file in your script (use with caution): ```bash #!/bin/bash @@ -65,6 +194,8 @@ source ~/.bashrc 2>/dev/null || source ~/.zshrc 2>/dev/null nvm --version ``` +--- + ## Interpreter Detection Runbooks determines which interpreter to use for your script: @@ -91,3 +222,25 @@ set -e # Your script here... ``` +--- + +## Demo Runbooks + +The Runbooks repository includes demo runbooks that showcase these execution features: + +### Persistent Environment Demo + +The [`demo-runbook-execution-model`](https://github.com/gruntwork-io/runbooks/tree/main/testdata/demo-runbook-execution-model) runbook demonstrates: + +- Setting and reading environment variables across blocks +- Working directory persistence +- Multiline environment variables (RSA keys, JSON) +- Non-bash scripts reading (but not setting) persistent env vars + +### File Capture Demo + +The [`demo-runbook-capture-files-from-scripts`](https://github.com/gruntwork-io/runbooks/tree/main/testdata/demo-runbook-capture-files-from-scripts) runbook demonstrates: + +- Using `$RUNBOOKS_OUTPUT` to capture generated files +- Combining environment persistence with file generation +- Creating OpenTofu configs from environment variables set in earlier blocks diff --git a/testdata/demo-runbook-capture-files-from-scripts/runbook.mdx b/testdata/demo-runbook-capture-files-from-scripts/runbook.mdx new file mode 100644 index 0000000..ef31381 --- /dev/null +++ b/testdata/demo-runbook-capture-files-from-scripts/runbook.mdx @@ -0,0 +1,77 @@ +# Capturing Files from Scripts + +This runbook demonstrates how scripts can generate files that are automatically captured +and saved to the `/generated` folder. Files written to the special `$RUNBOOKS_OUTPUT` +directory are collected after script execution and appear in the Generated Files panel. + +## How It Works + +1. Every script has access to the `$RUNBOOKS_OUTPUT` environment variable +2. Any files written to this directory are captured after the script completes +3. Captured files are copied to the `/generated` folder in your project +4. The files appear in the Generated Files panel for easy viewing and downloading + +This is useful for generating configuration files, OpenTofu modules, setup scripts, +or any other artifacts as part of your runbook workflow. + +--- + +## 1. Configure Project Variables + +First, let's set up some environment variables that describe our project. These will +be used in the next step to generate customized configuration files. + + + +--- + +## 2. Generate Configuration Files + +Now let's generate some configuration files using the variables we just set. The script +will write files to `$RUNBOOKS_OUTPUT`, which will be captured and copied to `/generated`. + + + +After running this command, check the **Generated Files** panel to see the captured files. +You can also find them in your project's `/generated` folder. + +--- + +## What Gets Generated + +The script creates several files demonstrating different use cases: + +| File | Description | +|------|-------------| +| `config.json` | JSON configuration with project settings | +| `tofu/variables.tf` | OpenTofu variable definitions | +| `tofu/main.tf` | Main OpenTofu configuration | +| `tofu/outputs.tf` | OpenTofu output definitions | +| `scripts/deploy.sh` | Deployment script using project variables | + +--- + +## Summary + +The `$RUNBOOKS_OUTPUT` feature enables runbooks to: + +- **Generate customized configs** using environment variables from earlier steps +- **Create complete project scaffolding** with multiple files and directories +- **Produce deployment artifacts** that are ready to use + +This creates a powerful workflow where earlier steps gather information (from user input, +checks, or API calls) and later steps use that information to generate tailored output. diff --git a/testdata/demo-runbook-capture-files-from-scripts/scripts/generate-files.sh b/testdata/demo-runbook-capture-files-from-scripts/scripts/generate-files.sh new file mode 100644 index 0000000..af00d9d --- /dev/null +++ b/testdata/demo-runbook-capture-files-from-scripts/scripts/generate-files.sh @@ -0,0 +1,298 @@ +#!/bin/bash +# Generate configuration files using project variables +# Files are written to $RUNBOOKS_OUTPUT and captured to /generated + +set -euo pipefail + +echo "Generating configuration files..." +echo "" + +# Verify RUNBOOKS_OUTPUT is available +if [ -z "${RUNBOOKS_OUTPUT:-}" ]; then + echo "❌ Error: RUNBOOKS_OUTPUT is not set" + echo "This variable is automatically provided by the Runbooks server." + exit 1 +fi + +# Verify project variables are set +if [ -z "${PROJECT_NAME:-}" ]; then + echo "❌ Error: PROJECT_NAME is not set" + echo "Please run 'Set project variables' first." + exit 1 +fi + +echo "📁 Output directory: $RUNBOOKS_OUTPUT" +echo "📦 Project: $PROJECT_NAME ($ENVIRONMENT)" +echo "" + +# Create directories +mkdir -p "$RUNBOOKS_OUTPUT/tofu" +mkdir -p "$RUNBOOKS_OUTPUT/scripts" + +# ----------------------------------------------------------------------------- +# Generate config.json +# ----------------------------------------------------------------------------- +echo "Creating config.json..." +cat > "$RUNBOOKS_OUTPUT/config.json" << EOF +{ + "project": { + "name": "${PROJECT_NAME}", + "environment": "${ENVIRONMENT}", + "team_email": "${TEAM_EMAIL}", + "cost_center": "${COST_CENTER}" + }, + "infrastructure": { + "aws_region": "${AWS_REGION}", + "aws_account_id": "${AWS_ACCOUNT_ID}" + }, + "application": { + "port": ${APP_PORT}, + "replicas": ${REPLICA_COUNT} + }, + "generated": { + "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "generator": "runbooks" + } +} +EOF + +# ----------------------------------------------------------------------------- +# Generate OpenTofu variables.tf +# ----------------------------------------------------------------------------- +echo "Creating tofu/variables.tf..." +cat > "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +# ============================================================================= +# Variables for the infrastructure +# Generated by Runbooks +# ============================================================================= + +variable "project_name" { + description = "Name of the project" + type = string +EOF +echo " default = \"${PROJECT_NAME}\"" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} + +variable "environment" { + description = "Deployment environment (dev, staging, prod)" + type = string +EOF +echo " default = \"${ENVIRONMENT}\"" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} + +variable "aws_region" { + description = "AWS region for deployment" + type = string +EOF +echo " default = \"${AWS_REGION}\"" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} + +variable "team_email" { + description = "Team contact email for notifications" + type = string +EOF +echo " default = \"${TEAM_EMAIL}\"" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} + +variable "app_port" { + description = "Port the application listens on" + type = number +EOF +echo " default = ${APP_PORT}" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} + +variable "replica_count" { + description = "Number of application replicas" + type = number +EOF +echo " default = ${REPLICA_COUNT}" >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" +cat >> "$RUNBOOKS_OUTPUT/tofu/variables.tf" << 'EOF' +} +EOF + +# ----------------------------------------------------------------------------- +# Generate OpenTofu main.tf +# ----------------------------------------------------------------------------- +echo "Creating tofu/main.tf..." +cat > "$RUNBOOKS_OUTPUT/tofu/main.tf" << 'EOF' +# ============================================================================= +# Main OpenTofu Configuration +# Generated by Runbooks +# ============================================================================= + +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" { + # Configure your backend here + # bucket = "your-terraform-state-bucket" + # key = "state/${var.project_name}/${var.environment}/terraform.tfstate" + # region = var.aws_region + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "OpenTofu" + GeneratedBy = "Runbooks" + TeamEmail = var.team_email + } + } +} + +# ----------------------------------------------------------------------------- +# Example Resources +# ----------------------------------------------------------------------------- + +resource "aws_s3_bucket" "app_data" { + bucket = "${var.project_name}-${var.environment}-data" +} + +resource "aws_s3_bucket_versioning" "app_data" { + bucket = aws_s3_bucket.app_data.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_security_group" "app" { + name = "${var.project_name}-${var.environment}-app-sg" + description = "Security group for ${var.project_name} application" + + ingress { + from_port = var.app_port + to_port = var.app_port + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + description = "Allow internal traffic to app port" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = { + Name = "${var.project_name}-${var.environment}-app-sg" + } +} +EOF + +# ----------------------------------------------------------------------------- +# Generate OpenTofu outputs.tf +# ----------------------------------------------------------------------------- +echo "Creating tofu/outputs.tf..." +cat > "$RUNBOOKS_OUTPUT/tofu/outputs.tf" << 'EOF' +# ============================================================================= +# Outputs +# Generated by Runbooks +# ============================================================================= + +output "s3_bucket_name" { + description = "Name of the S3 bucket for application data" + value = aws_s3_bucket.app_data.id +} + +output "s3_bucket_arn" { + description = "ARN of the S3 bucket" + value = aws_s3_bucket.app_data.arn +} + +output "security_group_id" { + description = "ID of the application security group" + value = aws_security_group.app.id +} + +output "project_info" { + description = "Project configuration summary" + value = { + name = var.project_name + environment = var.environment + region = var.aws_region + team = var.team_email + } +} +EOF + +# ----------------------------------------------------------------------------- +# Generate deployment script +# ----------------------------------------------------------------------------- +echo "Creating scripts/deploy.sh..." +cat > "$RUNBOOKS_OUTPUT/scripts/deploy.sh" << EOF +#!/bin/bash +# ============================================================================= +# Deployment Script for ${PROJECT_NAME} +# Generated by Runbooks +# ============================================================================= + +set -euo pipefail + +PROJECT="${PROJECT_NAME}" +ENV="${ENVIRONMENT}" +REGION="${AWS_REGION}" + +echo "🚀 Deploying \$PROJECT to \$ENV in \$REGION" +echo "" + +# Navigate to OpenTofu directory +cd "\$(dirname "\$0")/../tofu" + +# Initialize OpenTofu +echo "📦 Initializing OpenTofu..." +tofu init + +# Plan changes +echo "" +echo "📋 Planning changes..." +tofu plan -out=tfplan + +# Prompt for confirmation +echo "" +read -p "Apply changes? (y/N) " -n 1 -r +echo +if [[ \$REPLY =~ ^[Yy]$ ]]; then + echo "" + echo "⚡ Applying changes..." + tofu apply tfplan + echo "" + echo "✅ Deployment complete!" +else + echo "Deployment cancelled." +fi +EOF +chmod +x "$RUNBOOKS_OUTPUT/scripts/deploy.sh" + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +echo "" +echo "✅ Generated files:" +find "$RUNBOOKS_OUTPUT" -type f | sort | while read file; do + relpath="${file#$RUNBOOKS_OUTPUT/}" + size=$(wc -c < "$file" | tr -d ' ') + echo " 📄 $relpath ($size bytes)" +done + +echo "" +echo "These files will appear in the Generated Files panel" +echo "and are saved to your project's /generated folder." diff --git a/testdata/demo-runbook-capture-files-from-scripts/scripts/set-project-vars.sh b/testdata/demo-runbook-capture-files-from-scripts/scripts/set-project-vars.sh new file mode 100644 index 0000000..b2b3fda --- /dev/null +++ b/testdata/demo-runbook-capture-files-from-scripts/scripts/set-project-vars.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Set project configuration variables +# These will be used by subsequent scripts to generate customized files + +echo "Configuring project variables..." +echo "" + +# Project identification +export PROJECT_NAME="acme-web-app" +export ENVIRONMENT="staging" + +# Infrastructure settings +export AWS_REGION="us-west-2" +export AWS_ACCOUNT_ID="123456789012" + +# Team information +export TEAM_EMAIL="platform-team@acme.com" +export COST_CENTER="engineering-platform" + +# Application settings +export APP_PORT="8080" +export REPLICA_COUNT="3" + +echo "Project variables configured:" +echo "" +echo " 📦 Project: $PROJECT_NAME" +echo " 🌍 Environment: $ENVIRONMENT" +echo " 🗺️ Region: $AWS_REGION" +echo " 📧 Team: $TEAM_EMAIL" +echo " 💰 Cost Center: $COST_CENTER" +echo " 🔌 App Port: $APP_PORT" +echo " 📋 Replicas: $REPLICA_COUNT" +echo "" +echo "These variables will be used to generate configuration files in the next step." diff --git a/testdata/demo-runbook-execution-model/checks/verify-env-vars.sh b/testdata/demo-runbook-execution-model/checks/verify-env-vars.sh new file mode 100755 index 0000000..15ea9b7 --- /dev/null +++ b/testdata/demo-runbook-execution-model/checks/verify-env-vars.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Verify that environment variables from Block 1 persisted + +echo "Checking for environment variables from Block 1..." +echo "" + +errors=0 + +if [ -z "$DEMO_VAR" ]; then + echo "❌ DEMO_VAR is not set!" + errors=$((errors + 1)) +else + echo "✅ DEMO_VAR=$DEMO_VAR" +fi + +if [ -z "$DEMO_COUNT" ]; then + echo "❌ DEMO_COUNT is not set!" + errors=$((errors + 1)) +else + echo "✅ DEMO_COUNT=$DEMO_COUNT" +fi + +if [ -z "$DEMO_PROJECT" ]; then + echo "❌ DEMO_PROJECT is not set!" + errors=$((errors + 1)) +else + echo "✅ DEMO_PROJECT=$DEMO_PROJECT" +fi + +echo "" + +if [ $errors -gt 0 ]; then + echo "Some environment variables are missing. Did you run Block 1 first?" + exit 1 +fi + +echo "All environment variables from Block 1 are present!" +echo "The persistent environment is working correctly." + diff --git a/testdata/demo-runbook-execution-model/checks/verify-modified-env.sh b/testdata/demo-runbook-execution-model/checks/verify-modified-env.sh new file mode 100755 index 0000000..7f6f1f1 --- /dev/null +++ b/testdata/demo-runbook-execution-model/checks/verify-modified-env.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Verify that modifications from Block 3 persisted + +echo "Checking for modified environment variables..." +echo "" + +errors=0 + +# Check DEMO_COUNT was incremented +if [ "$DEMO_COUNT" != "2" ]; then + echo "❌ DEMO_COUNT should be 2, but is: $DEMO_COUNT" + errors=$((errors + 1)) +else + echo "✅ DEMO_COUNT=2 (correctly incremented)" +fi + +# Check DEMO_MODIFIED exists +if [ -z "$DEMO_MODIFIED" ]; then + echo "❌ DEMO_MODIFIED is not set!" + errors=$((errors + 1)) +else + echo "✅ DEMO_MODIFIED=$DEMO_MODIFIED" +fi + +# Check DEMO_VAR was updated +if [ "$DEMO_VAR" != "updated-in-block-3" ]; then + echo "❌ DEMO_VAR should be 'updated-in-block-3', but is: $DEMO_VAR" + errors=$((errors + 1)) +else + echo "✅ DEMO_VAR=updated-in-block-3 (correctly updated)" +fi + +echo "" + +if [ $errors -gt 0 ]; then + echo "Some modifications did not persist. Did you run Block 3?" + exit 1 +fi + +echo "All modifications from Block 3 are present!" +echo "Environment changes correctly propagate between blocks." + diff --git a/testdata/demo-runbook-execution-model/checks/verify-multiline-env.sh b/testdata/demo-runbook-execution-model/checks/verify-multiline-env.sh new file mode 100755 index 0000000..e7ab169 --- /dev/null +++ b/testdata/demo-runbook-execution-model/checks/verify-multiline-env.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Verify that multiline environment variables persisted correctly +# This is the key test - if newlines are corrupted, this check will fail + +errors=0 + +echo "Checking MULTILINE_KEY..." +if [[ -z "$MULTILINE_KEY" ]]; then + echo "❌ MULTILINE_KEY is not set" + errors=$((errors + 1)) +else + # Count the number of lines + line_count=$(echo "$MULTILINE_KEY" | wc -l | tr -d ' ') + if [[ "$line_count" -eq 5 ]]; then + echo "✅ MULTILINE_KEY has correct line count: $line_count" + else + echo "❌ MULTILINE_KEY has wrong line count: $line_count (expected 5)" + echo " Value: $MULTILINE_KEY" + errors=$((errors + 1)) + fi + + # Check it contains the markers + if [[ "$MULTILINE_KEY" == *"BEGIN EXAMPLE KEY"* ]] && [[ "$MULTILINE_KEY" == *"END EXAMPLE KEY"* ]]; then + echo "✅ MULTILINE_KEY contains correct markers" + else + echo "❌ MULTILINE_KEY is missing expected markers" + errors=$((errors + 1)) + fi +fi + +echo "" +echo "Checking JSON_CONFIG..." +if [[ -z "$JSON_CONFIG" ]]; then + echo "❌ JSON_CONFIG is not set" + errors=$((errors + 1)) +else + # Check it contains expected JSON structure + if [[ "$JSON_CONFIG" == *'"database"'* ]] && [[ "$JSON_CONFIG" == *'"settings"'* ]]; then + echo "✅ JSON_CONFIG contains expected structure" + else + echo "❌ JSON_CONFIG is missing expected JSON keys" + echo " Value: $JSON_CONFIG" + errors=$((errors + 1)) + fi + + # Count lines - should be multiline + line_count=$(echo "$JSON_CONFIG" | wc -l | tr -d ' ') + if [[ "$line_count" -ge 5 ]]; then + echo "✅ JSON_CONFIG preserved as multiline: $line_count lines" + else + echo "❌ JSON_CONFIG lost newlines: only $line_count lines (expected 6+)" + errors=$((errors + 1)) + fi +fi + +echo "" +echo "Checking MULTILINE_SIMPLE..." +if [[ -z "$MULTILINE_SIMPLE" ]]; then + echo "❌ MULTILINE_SIMPLE is not set" + errors=$((errors + 1)) +else + line_count=$(echo "$MULTILINE_SIMPLE" | wc -l | tr -d ' ') + if [[ "$line_count" -eq 3 ]]; then + echo "✅ MULTILINE_SIMPLE has correct line count: $line_count" + else + echo "❌ MULTILINE_SIMPLE has wrong line count: $line_count (expected 3)" + errors=$((errors + 1)) + fi +fi + +echo "" +if [[ $errors -eq 0 ]]; then + echo "✅ All multiline environment variables preserved correctly!" + exit 0 +else + echo "❌ $errors error(s) found - multiline values may have been corrupted" + exit 1 +fi diff --git a/testdata/demo-runbook-execution-model/checks/verify-python-env.sh b/testdata/demo-runbook-execution-model/checks/verify-python-env.sh new file mode 100644 index 0000000..9963991 --- /dev/null +++ b/testdata/demo-runbook-execution-model/checks/verify-python-env.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Check if environment variables set by Python persisted + +echo "=== Checking for Python-set Environment Variables ===" +echo "" + +# Check each variable +check_var() { + local var_name=$1 + local var_value="${!var_name}" + + if [ -n "$var_value" ]; then + echo "✓ $var_name = $var_value" + return 0 + else + echo "✗ $var_name is NOT SET" + return 1 + fi +} + +all_passed=true + +check_var "PYTHON_SET_VAR" || all_passed=false +check_var "PYTHON_COUNT" || all_passed=false +check_var "PYTHON_PROJECT" || all_passed=false + +echo "" + +if [ "$all_passed" = true ]; then + echo "All Python-set environment variables persisted!" + exit 0 +else + echo "Some or all Python-set environment variables did NOT persist." + echo "This is expected - Python's os.environ changes only affect its own process." + echo "The bash wrapper captures env from bash, not from Python's subprocess." + exit 1 +fi + diff --git a/testdata/demo-runbook-execution-model/checks/verify-workdir.sh b/testdata/demo-runbook-execution-model/checks/verify-workdir.sh new file mode 100755 index 0000000..0de3830 --- /dev/null +++ b/testdata/demo-runbook-execution-model/checks/verify-workdir.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Verify that working directory change persisted + +echo "Checking working directory..." +echo "" + +current_dir=$(pwd) +echo "Current directory: $current_dir" + +errors=0 + +# Check if we're in /tmp +if [ "$current_dir" != "/tmp" ] && [ "$current_dir" != "/private/tmp" ]; then + echo "❌ Expected to be in /tmp, but we're in: $current_dir" + errors=$((errors + 1)) +else + echo "✅ Correctly in /tmp (or /private/tmp on macOS)" +fi + +# Check the marker variable +if [ "$DEMO_WORKDIR_CHANGED" != "true" ]; then + echo "❌ DEMO_WORKDIR_CHANGED marker not found" + errors=$((errors + 1)) +else + echo "✅ DEMO_WORKDIR_CHANGED=true (marker present)" +fi + +echo "" + +if [ $errors -gt 0 ]; then + echo "Working directory change did not persist. Did you run Block 5?" + exit 1 +fi + +echo "Working directory change persisted correctly!" +echo "The persistent environment tracks directory changes." + diff --git a/testdata/demo-runbook-execution-model/runbook.mdx b/testdata/demo-runbook-execution-model/runbook.mdx new file mode 100644 index 0000000..bd2fb5b --- /dev/null +++ b/testdata/demo-runbook-execution-model/runbook.mdx @@ -0,0 +1,199 @@ +# Persistent Environment Model Demo + +This runbook demonstrates the persistent execution environment model, where environment +variable changes made in one block persist to subsequent blocks within the same session. + +## Overview + +In this demo, we'll: +1. Set environment variables in Block 1 +2. Read those variables in Block 2 to prove persistence +3. Modify the variables in Block 3 +4. Verify the modifications in Block 4 +5. Change the working directory and verify persistence + +--- + +## 1. Set Initial Environment Variables + +Let's start by setting some environment variables. These will persist to all subsequent blocks. + + + +--- + +## 1.1 Python Can READ Bash-Set Environment Variables + +Non-bash scripts like Python can **read** environment variables set by earlier bash scripts. This Python script verifies it can access DEMO_VAR, DEMO_COUNT, and DEMO_PROJECT set in Block 1. + + + +--- + +## 1.2 Test Python Setting Environment Variables + +Now let's test if a Python script can SET environment variables that persist to subsequent blocks. + + + +--- + +## 1.3 Demonstrate That Environment Variables Set By Python Do NOT Persist + +Let's check if the environment variables set by Python persisted to this block. + + + +--- + +## 2. Verify Environment Variables Persist + +Now let's verify that the variables we set in Block 1 are available in this block. + + + +--- + +## 3. Modify Environment Variables + +Let's modify the environment variables to demonstrate that changes propagate forward. + + + +--- + +## 4. Verify Modifications + +Let's verify that the modifications from Block 3 are visible. + + + +--- + +## 5. Multiline Environment Variables + +Environment variables can contain embedded newlines (RSA keys, JSON configs, multiline strings). +This section demonstrates that such values are correctly preserved across sessions. + + + +--- + +## 5.1 Verify Multiline Values Persist + +Let's verify that the multiline values were preserved correctly - not truncated at the first newline. + + + +--- + +## 6. Change Working Directory + +The persistent environment also tracks the working directory. Let's change it. + + + +--- + +## 7. Verify Working Directory + +Let's verify that the working directory change persisted. + + + +--- + +## Summary + +If all checks passed, you've successfully demonstrated the persistent environment model: + +- **Environment variables** set in one block are available in subsequent blocks +- **Modifications** to environment variables propagate forward +- **Multiline values** (RSA keys, JSON, etc.) preserve embedded newlines correctly +- **Working directory** changes persist across blocks + +This is similar to how Jupyter notebooks maintain state across cells, enabling complex +multi-step workflows where each step builds on the previous one. + +### Re-execution Note + +If you re-run Block 1 now, it will execute with the **current** environment state, +not a historical snapshot. The environment will be reset to the initial values specified +in that block. diff --git a/testdata/demo-runbook-execution-model/scripts/change-workdir.sh b/testdata/demo-runbook-execution-model/scripts/change-workdir.sh new file mode 100755 index 0000000..8ef21a0 --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/change-workdir.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Change working directory to demonstrate persistence + +echo "Current working directory:" +pwd +echo "" + +echo "Changing to /tmp..." +cd /tmp + +echo "New working directory:" +pwd +echo "" + +# Export a marker to prove we changed directories +export DEMO_WORKDIR_CHANGED="true" +export DEMO_EXPECTED_DIR="/tmp" + +echo "Working directory changed!" +echo "This will persist to subsequent blocks." + diff --git a/testdata/demo-runbook-execution-model/scripts/modify-env.sh b/testdata/demo-runbook-execution-model/scripts/modify-env.sh new file mode 100755 index 0000000..ee15e24 --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/modify-env.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Modify environment variables to demonstrate propagation + +echo "Modifying environment variables..." +echo "" + +# Show current values +echo "Current values:" +echo " DEMO_COUNT=$DEMO_COUNT" +echo "" + +# Increment DEMO_COUNT +export DEMO_COUNT=$((DEMO_COUNT + 1)) + +# Add a new variable +export DEMO_MODIFIED="modified-in-block-3" + +# Update DEMO_VAR +export DEMO_VAR="updated-in-block-3" + +echo "New values:" +echo " DEMO_VAR=$DEMO_VAR (updated)" +echo " DEMO_COUNT=$DEMO_COUNT (incremented)" +echo " DEMO_MODIFIED=$DEMO_MODIFIED (new)" +echo "" +echo "These modifications will persist to subsequent blocks!" + diff --git a/testdata/demo-runbook-execution-model/scripts/python-set-env.py b/testdata/demo-runbook-execution-model/scripts/python-set-env.py new file mode 100644 index 0000000..d84a48e --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/python-set-env.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Test setting environment variables from Python""" + +import os +import sys + +print("=== Python Setting Environment Variables ===") +print(f"Python version: {sys.version}") +print() + +# Try to set environment variables +os.environ["PYTHON_SET_VAR"] = "hello-from-python" +os.environ["PYTHON_COUNT"] = "42" +os.environ["PYTHON_PROJECT"] = "runbooks-python-test" + +print("Environment variables set in Python:") +print(f" PYTHON_SET_VAR = {os.environ.get('PYTHON_SET_VAR')}") +print(f" PYTHON_COUNT = {os.environ.get('PYTHON_COUNT')}") +print(f" PYTHON_PROJECT = {os.environ.get('PYTHON_PROJECT')}") +print() + +# Also try using export-like syntax by writing to a file +# This is a workaround that some systems use +print("Note: Python's os.environ only affects the current process.") +print("For env vars to persist, the execution model needs to capture them.") +print() +print("Python script completed!") + diff --git a/testdata/demo-runbook-execution-model/scripts/python-test.py b/testdata/demo-runbook-execution-model/scripts/python-test.py new file mode 100644 index 0000000..79c1883 --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/python-test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Test that Python can READ environment variables set by earlier bash scripts.""" + +import os +import sys + +print("=== Python Reading Bash-Set Environment Variables ===") +print(f"Python version: {sys.version.split()[0]}") +print() + +# Read environment variables set by the bash script in Block 1 +demo_var = os.environ.get("DEMO_VAR") +demo_count = os.environ.get("DEMO_COUNT") +demo_project = os.environ.get("DEMO_PROJECT") + +print("Checking for environment variables set by bash in Block 1:") +print() + +all_found = True + +if demo_var: + print(f" ✓ DEMO_VAR = {demo_var}") +else: + print(" ✗ DEMO_VAR is NOT SET") + all_found = False + +if demo_count: + print(f" ✓ DEMO_COUNT = {demo_count}") +else: + print(" ✗ DEMO_COUNT is NOT SET") + all_found = False + +if demo_project: + print(f" ✓ DEMO_PROJECT = {demo_project}") +else: + print(" ✗ DEMO_PROJECT is NOT SET") + all_found = False + +print() + +if all_found: + print("✅ Python successfully read all bash-set environment variables!") + sys.exit(0) +else: + print("❌ Some environment variables were not found.") + print(" Make sure to run Block 1 (Set Initial Environment Variables) first!") + sys.exit(1) + diff --git a/testdata/demo-runbook-execution-model/scripts/set-initial-env.sh b/testdata/demo-runbook-execution-model/scripts/set-initial-env.sh new file mode 100755 index 0000000..535a3c4 --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/set-initial-env.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Set initial environment variables for the demo + +echo "Setting initial environment variables..." + +export DEMO_VAR="hello-from-block-1000" +export DEMO_COUNT=1 +export DEMO_PROJECT="runbooks-persistent-env-demo" + +echo "" +echo "Environment variables set:" +echo " DEMO_VAR=$DEMO_VAR" +echo " DEMO_COUNT=$DEMO_COUNT" +echo " DEMO_PROJECT=$DEMO_PROJECT" +echo "" +echo "These variables will persist to subsequent blocks!" + diff --git a/testdata/demo-runbook-execution-model/scripts/set-multiline-env.sh b/testdata/demo-runbook-execution-model/scripts/set-multiline-env.sh new file mode 100755 index 0000000..e67438d --- /dev/null +++ b/testdata/demo-runbook-execution-model/scripts/set-multiline-env.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Demonstrate setting environment variables with multiline values +# This tests that embedded newlines are correctly preserved across sessions + +# Simulate an RSA-style key with multiple lines +export MULTILINE_KEY="-----BEGIN EXAMPLE KEY----- +line1-of-key-content +line2-of-key-content +line3-of-key-content +-----END EXAMPLE KEY-----" + +# JSON with newlines +export JSON_CONFIG='{ + "database": "postgres", + "host": "localhost", + "settings": { + "timeout": 30 + } +}' + +# Simple multiline string +export MULTILINE_SIMPLE="first line +second line +third line" + +echo "Set multiline environment variables:" +echo "" +echo "MULTILINE_KEY (5 lines):" +echo "$MULTILINE_KEY" +echo "" +echo "JSON_CONFIG:" +echo "$JSON_CONFIG" +echo "" +echo "MULTILINE_SIMPLE:" +echo "$MULTILINE_SIMPLE" diff --git a/testdata/runbook-with-capture-files/runbook.mdx b/testdata/runbook-with-capture-files/runbook.mdx index 3dd7814..f6d25db 100644 --- a/testdata/runbook-with-capture-files/runbook.mdx +++ b/testdata/runbook-with-capture-files/runbook.mdx @@ -1,6 +1,6 @@ # Test: Command File Capture -This runbook tests the `captureFiles` feature for Command components. +This runbook tests the `$RUNBOOKS_OUTPUT` file capture feature. ## Test 1: Basic File Capture @@ -9,31 +9,28 @@ This command creates a file that should appear in the workspace. greeting.txt`} - captureFiles + description="This command writes a file to RUNBOOKS_OUTPUT" + command={`echo "Hello from the command!" > "$RUNBOOKS_OUTPUT/greeting.txt"`} successMessage="File created successfully!" failMessage="Failed to create file" /> -## Test 2: File Capture with Output Path +## Test 2: File Capture with Subdirectory -This command creates files in a subdirectory of the workspace. +This command creates files in a subdirectory. config.json && echo 'key=value' > subdir/settings.ini`} - captureFiles - captureFilesOutputPath="config" + command={`mkdir -p "$RUNBOOKS_OUTPUT/config/subdir" && echo '{"name": "test"}' > "$RUNBOOKS_OUTPUT/config/config.json" && echo 'key=value' > "$RUNBOOKS_OUTPUT/config/subdir/settings.ini"`} successMessage="Config files created!" failMessage="Failed to create config files" /> ## Test 3: Regular Command (No Capture) -This command runs but does NOT capture files (default behavior). +This command runs but does NOT write to RUNBOOKS_OUTPUT. - {/* Indicate when command will capture files to workspace */} - {captureFiles && ( -
- - - Any files created by this command will be added to your generated files - {captureFilesOutputPath && ( - - in the - {captureFilesOutputPath}/ - subfolder - - )} - -
- )} - {/* Show status messages for waiting/rendering/error states */} {requiredVariables.length > 0 && !hasAllRequiredVariables && !isRendering && (
diff --git a/web/src/components/mdx/_shared/hooks/useScriptExecution.ts b/web/src/components/mdx/_shared/hooks/useScriptExecution.ts index 4555bf6..06dd2c9 100644 --- a/web/src/components/mdx/_shared/hooks/useScriptExecution.ts +++ b/web/src/components/mdx/_shared/hooks/useScriptExecution.ts @@ -22,10 +22,6 @@ interface UseScriptExecutionProps { inputsId?: string | string[] children?: ReactNode componentType: ComponentType - /** When true, files written by the script are captured to the workspace */ - captureFiles?: boolean - /** Relative subdirectory within the output folder for captured files */ - captureFilesOutputPath?: string } interface UseScriptExecutionReturn { @@ -68,8 +64,6 @@ export function useScriptExecution({ inputsId, children, componentType, - captureFiles, - captureFilesOutputPath, }: UseScriptExecutionProps): UseScriptExecutionReturn { // Get executable registry to look up executable ID const { getExecutableByComponentId, useExecutableRegistry: execRegistryEnabled } = useExecutableRegistry() @@ -208,9 +202,9 @@ export function useScriptExecution({ const sourceCode = renderedScript !== null ? renderedScript : rawScriptContent // Use the API exec hook for real script execution - // Pass onFilesCaptured callback to update file tree when command captures files + // Pass onFilesCaptured callback to update file tree when scripts write to $RUNBOOKS_OUTPUT const { state: execState, execute: executeScript, executeByComponentId, cancel: cancelExec } = useApiExec({ - onFilesCaptured: captureFiles ? handleFilesCaptured : undefined, + onFilesCaptured: handleFilesCaptured, }) // Map exec state to our status type, handling warn status for Check components @@ -361,12 +355,6 @@ export function useScriptExecution({ stringVariables[key] = String(value) } - // Build capture files options (only for commands, not checks) - const captureOptions = captureFiles ? { - captureFiles, - captureFilesOutputPath, - } : undefined - if (execRegistryEnabled) { // Registry mode: Look up executable in registry and use executable ID const executable = getExecutableByComponentId(componentId) @@ -375,19 +363,19 @@ export function useScriptExecution({ // Show error to user instead of silently failing setRegistryError(createAppError( `Executable not found for component "${componentId}"`, - 'This usually means there was a parsing error when the runbook was loaded. ' + - 'Check the runbooks server logs for details, or try restarting runbooks. ' + - 'Common causes include changing a script before re-loading runbooks, or syntax errors in the command or script path.' + 'This means that Runbooks attempted to run a script or command that was not defined when Runbooks was first loaded. ' + + 'Common causes include changing a script before re-loading runbooks, or syntax errors in the command or script path. ' + + 'Try re-opening your runbook, or check the runbooks server logs for details.' )) return } - executeScript(executable.id, stringVariables, captureOptions) + executeScript(executable.id, stringVariables) } else { // Live reload mode: Send component ID directly - executeByComponentId(componentId, stringVariables, captureOptions) + executeByComponentId(componentId, stringVariables) } - }, [execRegistryEnabled, executeScript, executeByComponentId, componentId, getExecutableByComponentId, importedVarValues, captureFiles, captureFilesOutputPath]) + }, [execRegistryEnabled, executeScript, executeByComponentId, componentId, getExecutableByComponentId, importedVarValues]) // Cleanup on unmount: cancel all pending operations useEffect(() => { diff --git a/web/src/contexts/SessionContext.tsx b/web/src/contexts/SessionContext.tsx new file mode 100644 index 0000000..d85e3b4 --- /dev/null +++ b/web/src/contexts/SessionContext.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState, useRef, type ReactNode } from 'react' +import { SessionContext } from './SessionContext.types' + +interface SessionTokenResponse { + token: string +} + +interface SessionProviderProps { + children: ReactNode +} + +/** + * Provider component that manages session lifecycle for the persistent environment model. + * + * Session flow: + * 1. On mount: Try to join existing session (POST /api/session/join) + * 2. If session exists: Get a new token for this tab + * 3. If no session exists: Create new session (POST /api/session) + * 4. Store token in memory only (cleared on tab close for security) + * + * This "join first" approach ensures multiple browser tabs share the same session. + * Each tab gets its own token, but they all operate on the same environment state. + */ +export function SessionProvider({ children }: SessionProviderProps) { + const [isReady, setIsReady] = useState(false) + const [error, setError] = useState(null) + + // Token is stored in a ref to avoid re-renders when it changes + // and to keep it out of React DevTools (security) + const tokenRef = useRef(null) + + // Track if initialization has started to prevent double-init in StrictMode + const initStartedRef = useRef(false) + + // Create a new session + const createSession = useCallback(async (): Promise => { + try { + const response = await fetch('/api/session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to create session: ${response.status}`) + } + + const data: SessionTokenResponse = await response.json() + return data + } catch (err) { + console.error('[SessionContext] Failed to create session:', err) + return null + } + }, []) + + // Join an existing session (for new tabs connecting to an existing session) + const joinSession = useCallback(async (): Promise => { + try { + const response = await fetch('/api/session/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + // 401 means no session exists on the server + if (response.status === 401) { + return null + } + throw new Error(`Failed to join session: ${response.status}`) + } + + const data: SessionTokenResponse = await response.json() + return data + } catch (err) { + console.error('[SessionContext] Failed to join session:', err) + return null + } + }, []) + + // Initialize session on mount + useEffect(() => { + // Prevent double initialization in StrictMode + if (initStartedRef.current) { + return + } + initStartedRef.current = true + + const initSession = async () => { + try { + // Always try to join first - this allows multiple tabs to share the same session + console.log('[SessionContext] Attempting to join existing session') + const joined = await joinSession() + + if (joined) { + console.log('[SessionContext] Joined existing session') + tokenRef.current = joined.token + setIsReady(true) + return + } + + // No session exists, create a new one + console.log('[SessionContext] No session exists, creating new session') + const newSession = await createSession() + + if (newSession) { + console.log('[SessionContext] Session created') + tokenRef.current = newSession.token + setIsReady(true) + } else { + throw new Error('Failed to create session') + } + } catch (err) { + console.error('[SessionContext] Session initialization failed:', err) + setError(err instanceof Error ? err : new Error(String(err))) + // Still mark as ready so the app doesn't hang, but session features won't work + setIsReady(true) + } + } + + initSession() + }, [createSession, joinSession]) + + // Get the Authorization header for authenticated requests + const getAuthHeader = useCallback((): { Authorization: string } | Record => { + if (tokenRef.current) { + return { Authorization: `Bearer ${tokenRef.current}` } + } + return {} + }, []) + + // Reset the session to its initial environment state + const resetSession = useCallback(async (): Promise => { + if (!tokenRef.current) { + console.warn('[SessionContext] Cannot reset session: no active session') + return + } + + try { + const response = await fetch('/api/session/reset', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenRef.current}`, + }, + }) + + if (!response.ok) { + throw new Error(`Failed to reset session: ${response.status}`) + } + + console.log('[SessionContext] Session reset successfully') + } catch (err) { + console.error('[SessionContext] Failed to reset session:', err) + throw err + } + }, []) + + return ( + + {children} + + ) +} diff --git a/web/src/contexts/SessionContext.types.ts b/web/src/contexts/SessionContext.types.ts new file mode 100644 index 0000000..aab8b97 --- /dev/null +++ b/web/src/contexts/SessionContext.types.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +export interface SessionContextType { + /** True once the session is established and ready for use */ + isReady: boolean + /** Returns the Authorization header object for authenticated requests */ + getAuthHeader: () => { Authorization: string } | Record + /** Resets the session to its initial environment state */ + resetSession: () => Promise + /** Any error that occurred during session initialization */ + error: Error | null +} + +export const SessionContext = createContext(undefined) diff --git a/web/src/contexts/useSession.ts b/web/src/contexts/useSession.ts new file mode 100644 index 0000000..bb6c818 --- /dev/null +++ b/web/src/contexts/useSession.ts @@ -0,0 +1,28 @@ +import { useContext } from 'react' +import { SessionContext, type SessionContextType } from './SessionContext.types' + +/** + * Hook to access the session context for persistent environment management. + * + * @example + * const { isReady, getAuthHeader } = useSession() + * + * // Use in API calls + * const response = await fetch('/api/exec', { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/json', + * ...getAuthHeader(), + * }, + * body: JSON.stringify({ ... }), + * }) + */ +export function useSession(): SessionContextType { + const context = useContext(SessionContext) + + if (context === undefined) { + throw new Error('useSession must be used within a SessionProvider') + } + + return context +} diff --git a/web/src/hooks/useApiExec.ts b/web/src/hooks/useApiExec.ts index 3cd9c29..a743737 100644 --- a/web/src/hooks/useApiExec.ts +++ b/web/src/hooks/useApiExec.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react' import { z } from 'zod' import { createAppError, type AppError } from '@/types/error' import { FileTreeNodeArraySchema } from '@/components/artifacts/code/FileTree.types' +import { useSession } from '@/contexts/useSession' // Zod schemas for SSE events const ExecLogEventSchema = z.object({ @@ -57,11 +58,6 @@ export interface ExecState { error: AppError | null } -export interface CaptureFilesOptions { - captureFiles?: boolean - captureFilesOutputPath?: string -} - export interface UseApiExecOptions { /** Callback invoked when files are captured from a command execution */ onFilesCaptured?: (event: FilesCapturedEvent) => void @@ -69,8 +65,8 @@ export interface UseApiExecOptions { export interface UseApiExecReturn { state: ExecState - execute: (executableId: string, variables?: Record, captureOptions?: CaptureFilesOptions) => void - executeByComponentId: (componentId: string, variables?: Record, captureOptions?: CaptureFilesOptions) => void + execute: (executableId: string, variables?: Record) => void + executeByComponentId: (componentId: string, variables?: Record) => void cancel: () => void reset: () => void } @@ -78,9 +74,15 @@ export interface UseApiExecReturn { /** * Hook to execute scripts via the /api/exec endpoint with SSE streaming * Uses executable IDs from the executable registry instead of raw script content + * + * Integrates with SessionContext for persistent environment support: + * - Sends Authorization header for session validation + * - Environment changes made by scripts persist to subsequent executions */ export function useApiExec(options?: UseApiExecOptions): UseApiExecReturn { const { onFilesCaptured } = options || {} + const { getAuthHeader } = useSession() + const [state, setState] = useState({ logs: [], status: 'pending', @@ -267,8 +269,6 @@ export function useApiExec(options?: UseApiExecOptions): UseApiExecReturn { executable_id?: string; component_id?: string; template_var_values: Record; - capture_files?: boolean; - capture_files_output_path?: string; } ) => { // Cancel any existing execution @@ -287,11 +287,12 @@ export function useApiExec(options?: UseApiExecOptions): UseApiExecReturn { const abortController = new AbortController() abortControllerRef.current = abortController - // Send POST request to /api/exec + // Send POST request to /api/exec with session auth header const response = await fetch('/api/exec', { method: 'POST', headers: { 'Content-Type': 'application/json', + ...getAuthHeader(), }, body: JSON.stringify(payload), signal: abortController.signal, @@ -335,16 +336,14 @@ export function useApiExec(options?: UseApiExecOptions): UseApiExecReturn { } finally { abortControllerRef.current = null } - }, [cancel, processSSEStream]) + }, [cancel, processSSEStream, getAuthHeader]) // Execute script by executable ID (used in registry mode) const execute = useCallback( - (executableId: string, templateVarValues: Record = {}, captureOptions?: CaptureFilesOptions) => { + (executableId: string, templateVarValues: Record = {}) => { executeScript({ executable_id: executableId, template_var_values: templateVarValues, - capture_files: captureOptions?.captureFiles, - capture_files_output_path: captureOptions?.captureFilesOutputPath, }) }, [executeScript] @@ -357,12 +356,10 @@ export function useApiExec(options?: UseApiExecOptions): UseApiExecReturn { // This allows script changes to take effect immediately without restarting the server, // but bypasses registry validation (only use with --live-file-reload flag). const executeByComponentId = useCallback( - (componentId: string, templateVarValues: Record = {}, captureOptions?: CaptureFilesOptions) => { + (componentId: string, templateVarValues: Record = {}) => { executeScript({ component_id: componentId, template_var_values: templateVarValues, - capture_files: captureOptions?.captureFiles, - capture_files_output_path: captureOptions?.captureFilesOutputPath, }) }, [executeScript] diff --git a/web/src/main.tsx b/web/src/main.tsx index 2cc49b2..121c4ae 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -7,19 +7,22 @@ import { ExecutableRegistryProvider } from './contexts/ExecutableRegistryContext import { ErrorReportingProvider } from './contexts/ErrorReportingContext' import { TelemetryProvider } from './contexts/TelemetryContext' import { LogsProvider } from './contexts/LogsContext' +import { SessionProvider } from './contexts/SessionContext' createRoot(document.getElementById('root')!).render( - - - - - - - - - + + + + + + + + + + + , )