From 2e02381901bcc4208a9134aaddc3d8e836dba0af Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 14 Jan 2026 21:14:10 +0000 Subject: [PATCH] phase 4 complete --- .opencode/plans/dap-debugging.md | 97 ++++-- src/Runner.Worker/Dap/DapDebugSession.cs | 397 ++++++++++++++++++++++- 2 files changed, 459 insertions(+), 35 deletions(-) diff --git a/.opencode/plans/dap-debugging.md b/.opencode/plans/dap-debugging.md index cac7d49cf..ac3bda654 100644 --- a/.opencode/plans/dap-debugging.md +++ b/.opencode/plans/dap-debugging.md @@ -9,7 +9,7 @@ - [x] **Phase 1:** DAP Protocol Infrastructure (DapMessages.cs, DapServer.cs, basic DapDebugSession.cs) - [x] **Phase 2:** Debug Session Logic (DapVariableProvider.cs, variable inspection, step history tracking) - [x] **Phase 3:** StepsRunner Integration (pause hooks before/after step execution) -- [ ] **Phase 4:** Expression Evaluation & Shell (REPL) +- [x] **Phase 4:** Expression Evaluation & Shell (REPL) - [ ] **Phase 5:** Startup Integration (JobRunner.cs modifications) ## Overview @@ -283,7 +283,7 @@ public async Task RunAsync(IExecutionContext jobContext) Reuse existing `PipelineTemplateEvaluator`: ```csharp -private string EvaluateExpression(string expression) +private EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context) { // Strip ${{ }} wrapper if present var expr = expression.Trim(); @@ -292,69 +292,112 @@ private string EvaluateExpression(string expression) expr = expr.Substring(3, expr.Length - 5).Trim(); } - var templateEvaluator = _currentStep.ExecutionContext.ToPipelineTemplateEvaluator(); - var token = new BasicExpressionToken(null, null, null, expr); + var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr); + var templateEvaluator = context.ToPipelineTemplateEvaluator(); var result = templateEvaluator.EvaluateStepDisplayName( - token, - _currentStep.ExecutionContext.ExpressionValues, - _currentStep.ExecutionContext.ExpressionFunctions + expressionToken, + context.ExpressionValues, + context.ExpressionFunctions ); - return result; + // Mask secrets and determine type + result = HostContext.SecretMasker.MaskSecrets(result ?? "null"); + + return new EvaluateResponseBody + { + Result = result, + Type = DetermineResultType(result), + VariablesReference = 0 + }; } ``` +**Supported expression formats:** +- Plain expression: `github.ref`, `steps.build.outputs.result` +- Wrapped expression: `${{ github.event.pull_request.title }}` + #### 4.2 Shell Execution (REPL) -When `evaluate` is called with `context: "repl"`, spawn shell in step's environment: +Shell execution is triggered when: +1. The evaluate request has `context: "repl"`, OR +2. The expression starts with `!` (e.g., `!ls -la`), OR +3. The expression starts with `$` followed by a shell command (e.g., `$env`) + +**Usage examples in debug console:** +``` +!ls -la # List files in workspace +!env | grep GITHUB # Show GitHub environment variables +!cat $GITHUB_EVENT_PATH # View the event payload +!echo ${{ github.ref }} # Mix shell and expression (evaluated first) +``` + +**Implementation:** ```csharp -private async Task ExecuteShellCommand(string command) +private async Task ExecuteShellCommandAsync(string command, IExecutionContext context) { var processInvoker = HostContext.CreateService(); var output = new StringBuilder(); - processInvoker.OutputDataReceived += (_, line) => + processInvoker.OutputDataReceived += (sender, args) => { - output.AppendLine(line); - // Stream to client in real-time - _server.SendEvent(new OutputEvent + output.AppendLine(args.Data); + // Stream to client in real-time via DAP output event + _server?.SendEvent(new Event { - category = "stdout", - output = line + "\n" + EventType = "output", + Body = new OutputEventBody { Category = "stdout", Output = args.Data + "\n" } }); }; - processInvoker.ErrorDataReceived += (_, line) => + processInvoker.ErrorDataReceived += (sender, args) => { - _server.SendEvent(new OutputEvent + _server?.SendEvent(new Event { - category = "stderr", - output = line + "\n" + EventType = "output", + Body = new OutputEventBody { Category = "stderr", Output = args.Data + "\n" } }); }; - var env = BuildStepEnvironment(_currentStep); - var workDir = _currentStep.ExecutionContext.GetGitHubContext("workspace"); + // Build environment from job context (includes GITHUB_*, env context, prepend path) + var env = BuildShellEnvironment(context); + var workDir = GetWorkingDirectory(context); // Uses github.workspace + var (shell, shellArgs) = GetDefaultShell(); // Platform-specific detection int exitCode = await processInvoker.ExecuteAsync( workingDirectory: workDir, - fileName: GetDefaultShell(), // /bin/bash on unix, pwsh/powershell on windows - arguments: BuildShellArgs(command), + fileName: shell, + arguments: string.Format(shellArgs, command), environment: env, requireExitCodeZero: false, cancellationToken: CancellationToken.None ); - return new EvaluateResponse + return new EvaluateResponseBody { - result = output.ToString(), - variablesReference = 0 + Result = HostContext.SecretMasker.MaskSecrets(output.ToString()), + Type = exitCode == 0 ? "string" : "error", + VariablesReference = 0 }; } ``` +**Shell detection by platform:** + +| Platform | Priority | Shell | Arguments | +|----------|----------|-------|-----------| +| Windows | 1 | `pwsh` | `-NoProfile -NonInteractive -Command "{0}"` | +| Windows | 2 | `powershell` | `-NoProfile -NonInteractive -Command "{0}"` | +| Windows | 3 | `cmd.exe` | `/C "{0}"` | +| Unix | 1 | `bash` | `-c "{0}"` | +| Unix | 2 | `sh` | `-c "{0}"` | + +**Environment built for shell commands:** +- Current system environment variables +- GitHub Actions context variables (from `IEnvironmentContextData.GetRuntimeEnvironmentVariables()`) +- Prepend path from job context added to `PATH` + ### Phase 5: Startup Integration #### 5.1 Modify `JobRunner.cs` diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 25460a5d5..b3c8c6542 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -1,9 +1,18 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ObjectTemplating; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap @@ -484,23 +493,395 @@ namespace GitHub.Runner.Worker.Dap return CreateSuccessResponse(null); } - private Task HandleEvaluateAsync(Request request) + private async Task HandleEvaluateAsync(Request request) { var args = request.Arguments?.ToObject(); var expression = args?.Expression ?? ""; - var context = args?.Context ?? "hover"; + var evalContext = args?.Context ?? "hover"; - Trace.Info($"Evaluate: '{expression}' (context: {context})"); + Trace.Info($"Evaluate: '{expression}' (context: {evalContext})"); - // Stub implementation - Phase 4 will implement expression evaluation - var body = new EvaluateResponseBody + // Get the current execution context + var executionContext = _currentStep?.ExecutionContext ?? _jobContext; + if (executionContext == null) { - Result = $"(evaluation of '{expression}' will be implemented in Phase 4)", - Type = "string", + return CreateErrorResponse("No execution context available for evaluation"); + } + + try + { + // Check if this is a REPL/shell command (context: "repl") or starts with shell prefix + if (evalContext == "repl" || expression.StartsWith("!") || expression.StartsWith("$")) + { + // Shell execution mode + var command = expression.TrimStart('!', '$').Trim(); + if (string.IsNullOrEmpty(command)) + { + return CreateSuccessResponse(new EvaluateResponseBody + { + Result = "(empty command)", + Type = "string", + VariablesReference = 0 + }); + } + + var result = await ExecuteShellCommandAsync(command, executionContext); + return CreateSuccessResponse(result); + } + else + { + // Expression evaluation mode + var result = EvaluateExpression(expression, executionContext); + return CreateSuccessResponse(result); + } + } + catch (Exception ex) + { + Trace.Error($"Evaluation failed: {ex}"); + return CreateErrorResponse($"Evaluation failed: {ex.Message}"); + } + } + + /// + /// Evaluates a GitHub Actions expression (e.g., "github.event.pull_request.title" or "${{ github.ref }}") + /// + private EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context) + { + // Strip ${{ }} wrapper if present + var expr = expression.Trim(); + if (expr.StartsWith("${{") && expr.EndsWith("}}")) + { + expr = expr.Substring(3, expr.Length - 5).Trim(); + } + + Trace.Info($"Evaluating expression: {expr}"); + + // Create an expression token from the string + var expressionToken = new BasicExpressionToken(fileId: null, line: null, column: null, expression: expr); + + // Get the template evaluator + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + + // Evaluate using the display name evaluator which can handle arbitrary expressions + string result; + try + { + result = templateEvaluator.EvaluateStepDisplayName( + expressionToken, + context.ExpressionValues, + context.ExpressionFunctions); + } + catch (Exception ex) + { + // If the template evaluator fails, try direct expression evaluation + Trace.Info($"Template evaluation failed, trying direct: {ex.Message}"); + result = EvaluateDirectExpression(expr, context); + } + + // Mask secrets in the result + result = HostContext.SecretMasker.MaskSecrets(result ?? "null"); + + // Determine the type based on the result + var type = DetermineResultType(result); + + return new EvaluateResponseBody + { + Result = result, + Type = type, VariablesReference = 0 }; + } - return Task.FromResult(CreateSuccessResponse(body)); + /// + /// Directly evaluates an expression by looking up values in the context data. + /// Used as a fallback when the template evaluator doesn't work. + /// + private string EvaluateDirectExpression(string expression, IExecutionContext context) + { + // Try to look up the value directly in expression values + var parts = expression.Split('.'); + + if (parts.Length == 0 || !context.ExpressionValues.TryGetValue(parts[0], out var value)) + { + return $"(unknown: {expression})"; + } + + // Navigate through nested properties + object current = value; + for (int i = 1; i < parts.Length && current != null; i++) + { + current = GetNestedValue(current, parts[i]); + } + + if (current == null) + { + return "null"; + } + + // Convert to string representation + if (current is PipelineContextData pcd) + { + return pcd.ToJToken()?.ToString() ?? "null"; + } + + return current.ToString(); + } + + /// + /// Gets a nested value from a context data object. + /// + private object GetNestedValue(object data, string key) + { + switch (data) + { + case DictionaryContextData dict: + return dict.TryGetValue(key, out var dictValue) ? dictValue : null; + + case CaseSensitiveDictionaryContextData csDict: + return csDict.TryGetValue(key, out var csDictValue) ? csDictValue : null; + + case ArrayContextData array when int.TryParse(key.Trim('[', ']'), out var index): + return index >= 0 && index < array.Count ? array[index] : null; + + default: + return null; + } + } + + /// + /// Determines the type string for a result value. + /// + private string DetermineResultType(string value) + { + if (value == null || value == "null") + return "null"; + if (value == "true" || value == "false") + return "boolean"; + if (double.TryParse(value, out _)) + return "number"; + if (value.StartsWith("{") || value.StartsWith("[")) + return "object"; + return "string"; + } + + /// + /// Executes a shell command in the job's environment and streams output to the debugger. + /// + private async Task ExecuteShellCommandAsync(string command, IExecutionContext context) + { + Trace.Info($"Executing shell command: {command}"); + + var output = new StringBuilder(); + var errorOutput = new StringBuilder(); + var processInvoker = HostContext.CreateService(); + + processInvoker.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + output.AppendLine(args.Data); + // Stream output to the debugger in real-time + _server?.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "stdout", + Output = args.Data + "\n" + } + }); + } + }; + + processInvoker.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + errorOutput.AppendLine(args.Data); + // Stream error output to the debugger + _server?.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "stderr", + Output = args.Data + "\n" + } + }); + } + }; + + // Build the environment for the shell command + var env = BuildShellEnvironment(context); + + // Get the working directory + var workingDirectory = GetWorkingDirectory(context); + + // Get the default shell and arguments + var (shell, shellArgs) = GetDefaultShell(); + + Trace.Info($"Shell: {shell}, WorkDir: {workingDirectory}"); + + int exitCode; + try + { + exitCode = await processInvoker.ExecuteAsync( + workingDirectory: workingDirectory, + fileName: shell, + arguments: string.Format(shellArgs, command), + environment: env, + requireExitCodeZero: false, + cancellationToken: CancellationToken.None); + } + catch (Exception ex) + { + Trace.Error($"Shell execution failed: {ex}"); + return new EvaluateResponseBody + { + Result = $"Error: {ex.Message}", + Type = "error", + VariablesReference = 0 + }; + } + + // Combine stdout and stderr + var result = output.ToString(); + if (errorOutput.Length > 0) + { + if (result.Length > 0) + { + result += "\n--- stderr ---\n"; + } + result += errorOutput.ToString(); + } + + // Add exit code if non-zero + if (exitCode != 0) + { + result += $"\n(exit code: {exitCode})"; + } + + // Mask secrets in the output + result = HostContext.SecretMasker.MaskSecrets(result); + + return new EvaluateResponseBody + { + Result = result.TrimEnd('\r', '\n'), + Type = exitCode == 0 ? "string" : "error", + VariablesReference = 0 + }; + } + + /// + /// Builds the environment dictionary for shell command execution. + /// + private IDictionary BuildShellEnvironment(IExecutionContext context) + { + var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Copy current environment + foreach (var entry in System.Environment.GetEnvironmentVariables()) + { + if (entry is System.Collections.DictionaryEntry de) + { + env[de.Key.ToString()] = de.Value?.ToString() ?? ""; + } + } + + // Add context data as environment variables + foreach (var contextEntry in context.ExpressionValues) + { + if (contextEntry.Value is IEnvironmentContextData runtimeContext) + { + foreach (var envVar in runtimeContext.GetRuntimeEnvironmentVariables()) + { + env[envVar.Key] = envVar.Value; + } + } + } + + // Add prepend path if available + if (context.Global.PrependPath.Count > 0) + { + var prependPath = string.Join(Path.PathSeparator.ToString(), context.Global.PrependPath.Reverse()); + if (env.TryGetValue("PATH", out var existingPath)) + { + env["PATH"] = $"{prependPath}{Path.PathSeparator}{existingPath}"; + } + else + { + env["PATH"] = prependPath; + } + } + + return env; + } + + /// + /// Gets the working directory for shell command execution. + /// + private string GetWorkingDirectory(IExecutionContext context) + { + // Try to get workspace from github context + var githubContext = context.ExpressionValues["github"] as GitHubContext; + if (githubContext != null) + { + var workspace = githubContext["workspace"] as StringContextData; + if (workspace != null && Directory.Exists(workspace.Value)) + { + return workspace.Value; + } + } + + // Fallback to runner work directory + var workDir = HostContext.GetDirectory(WellKnownDirectory.Work); + if (Directory.Exists(workDir)) + { + return workDir; + } + + // Final fallback to current directory + return System.Environment.CurrentDirectory; + } + + /// + /// Gets the default shell command and argument format for the current platform. + /// + private (string shell, string args) GetDefaultShell() + { +#if OS_WINDOWS + // Try pwsh first, then fall back to powershell + var pwshPath = WhichUtil.Which("pwsh", false, Trace, null); + if (!string.IsNullOrEmpty(pwshPath)) + { + return (pwshPath, "-NoProfile -NonInteractive -Command \"{0}\""); + } + + var psPath = WhichUtil.Which("powershell", false, Trace, null); + if (!string.IsNullOrEmpty(psPath)) + { + return (psPath, "-NoProfile -NonInteractive -Command \"{0}\""); + } + + // Fallback to cmd + return ("cmd.exe", "/C \"{0}\""); +#else + // Try bash first, then sh + var bashPath = WhichUtil.Which("bash", false, Trace, null); + if (!string.IsNullOrEmpty(bashPath)) + { + return (bashPath, "-c \"{0}\""); + } + + var shPath = WhichUtil.Which("sh", false, Trace, null); + if (!string.IsNullOrEmpty(shPath)) + { + return (shPath, "-c \"{0}\""); + } + + // Fallback + return ("/bin/sh", "-c \"{0}\""); +#endif } private Response HandleSetBreakpoints(Request request)