Conversation
Introduce a reusable component that maps runner ExpressionValues and PipelineContextData into DAP scopes and variables. This is the single point where execution-context values are materialized for the debugger. Key design decisions: - Fixed scope reference IDs (1–100) for the 10 well-known scopes (github, env, runner, job, steps, secrets, inputs, vars, matrix, needs) - Dynamic reference IDs (101+) for lazy nested object/array expansion - All string values pass through HostContext.SecretMasker.MaskSecrets() - The secrets scope is intentionally opaque: keys shown, values replaced with a constant redaction marker - MaskSecrets() is public so future DAP features (evaluate, REPL) can reuse it without duplicating masking policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the stub HandleScopes/HandleVariables implementations that returned empty lists with real delegation to DapVariableProvider. Changes: - DapDebugSession now creates a DapVariableProvider on Initialize() - HandleScopes() resolves the execution context for the requested frame and delegates to the provider - HandleVariables() delegates to the provider for both top-level scope references and nested dynamic references - GetExecutionContextForFrame() maps frame IDs to contexts: frame 1 = current step, frames 1000+ = completed (no live context) - Provider is reset on each new step to invalidate stale nested refs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Provider tests (DapVariableProviderL0): - Scope discovery: empty context, populated scopes, variable count, stable reference IDs, secrets presentation hint - Variable types: string, boolean, number, null handling - Nested expansion: dictionaries and arrays with child drilling - Secret masking: redacted values in secrets scope, SecretMasker integration for non-secret scopes, MaskSecrets delegation - Reset: stale nested references invalidated after Reset() - EvaluateName: dot-path expression syntax Session integration tests (DapDebugSessionL0): - Scopes request returns scopes from step execution context - Variables request returns variables from step execution context - Scopes request returns empty when no step is active - Secrets values are redacted through the full request path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add EvaluateExpression() that evaluates GitHub Actions expressions
using the runner's existing PipelineTemplateEvaluator infrastructure.
How it works:
- Strips ${{ }} wrapper if present
- Creates a BasicExpressionToken and evaluates via
EvaluateStepDisplayName (supports the full expression language:
functions, operators, context access)
- Masks the result through MaskSecrets() — same masking path used
by scope inspection
- Returns a structured EvaluateResponseBody with type inference
- Catches evaluation errors and returns masked error messages
Also adds InferResultType() helper for DAP type hints.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add HandleEvaluate() that delegates expression evaluation to the DapVariableProvider, keeping all masking centralized. Changes: - Register 'evaluate' in the command dispatch switch - HandleEvaluate resolves frame context and delegates to DapVariableProvider.EvaluateExpression() - Set SupportsEvaluateForHovers = true in capabilities so DAP clients enable hover tooltips and the Watch pane No separate feature flag — the debugger is already gated by EnableDebugger on the job context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Provider tests (DapVariableProviderL0):
- Simple expression evaluation (github.repository)
- ${{ }} wrapper stripping
- Secret masking in evaluation results
- Graceful error for invalid expressions
- No-context returns descriptive message
- Empty expression returns empty string
- InferResultType classifies null/bool/number/object/string
Session integration tests (DapDebugSessionL0):
- evaluate request returns result when paused with context
- evaluate request returns graceful error when no step active
- evaluate request handles ${{ }} wrapper syntax
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce a typed command model and hand-rolled parser for the debug
console DSL. The parser turns REPL input into HelpCommand or
RunCommand objects, keeping parsing separate from execution.
Ruby-like DSL syntax:
help → general help
help("run") → command-specific help
run("echo hello") → run with default shell
run("echo $X", shell: "bash", env: { X: "1" })
→ run with explicit shell and env
Parser features:
- Handles escaped quotes, nested braces, and mixed arguments
- Keyword arguments: shell, env, working_directory
- Env blocks parsed as { KEY: "value", KEY2: "value2" }
- Returns null for non-DSL input (falls through to expression eval)
- Descriptive error messages for malformed input
- Help text scaffolding for discoverability
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implement the run command executor that makes REPL `run(...)` behave like a real workflow `run:` step by reusing the runner's existing infrastructure. Key design choices: - Shell resolution mirrors ScriptHandler: job defaults → explicit shell from DSL → platform default (bash→sh on Unix, pwsh→powershell on Windows) - Script fixup via ScriptHandlerHelpers.FixUpScriptContents() adds the same error-handling preamble as a real step - Environment is built from ExecutionContext.ExpressionValues[`env`] plus runtime context variables (GITHUB_*, RUNNER_*, etc.), with DSL-provided env overrides applied last - Working directory defaults to $GITHUB_WORKSPACE - Output is streamed in real time via DAP output events with secrets masked before emission through HostContext.SecretMasker - Only the exit code is returned in the evaluate response (avoiding the prototype's double-output bug) - Temp script files are cleaned up after execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Route `evaluate` requests by context: - `repl` context → DSL parser → command dispatch (help/run) - All other contexts (watch, hover, etc.) → expression evaluation If REPL input doesn't match any DSL command, it falls through to expression evaluation so the Debug Console also works for ad-hoc `github.repository`-style queries. Changes: - HandleEvaluateAsync replaces the sync HandleEvaluate - HandleReplInputAsync parses input through DapReplParser.TryParse - DispatchReplCommandAsync dispatches HelpCommand and RunCommand - DapReplExecutor is created alongside the DAP server reference - Remove vestigial `await Task.CompletedTask` from HandleMessageAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Parser tests (DapReplParserL0, 22 tests): - help: bare, case-insensitive, with topic - run: simple script, with shell, env, working_directory, all options - Edge cases: escaped quotes, commas in env values - Errors: empty args, unquoted arg, unknown option, missing paren - Non-DSL input falls through: expressions, wrapped expressions, empty - Help text contains expected commands and options - Internal helpers: SplitArguments with nested braces, empty env block Session integration tests (DapDebugSessionL0, 4 tests): - REPL help returns help text - REPL non-DSL input falls through to expression evaluation - REPL parse error returns error result (not a DAP error response) - watch context still evaluates expressions (not routed through REPL) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The run() command was passing ${{ }} expressions literally to the
shell instead of evaluating them first. This caused scripts like
`run("echo ${{ github.job }}")` to fail with 'bad substitution'.
Fix: add ExpandExpressions() that finds each ${{ expr }} occurrence,
evaluates it individually via PipelineTemplateEvaluator, masks the
result through SecretMasker, and substitutes it into the script body
before writing the temp file — matching how ActionRunner evaluates
step inputs before ScriptHandler sees them.
Also expands expressions in DSL-provided env values so that
`env: { TOKEN: "${{ secrets.MY_TOKEN }}" }` works correctly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Completions (SupportsCompletionsRequest = true):
- Respond to DAP 'completions' requests with our DSL commands
(help, help("run"), run(...)) so they appear in the debug
console autocomplete across all DAP clients
- Add CompletionsArguments, CompletionItem, and
CompletionsResponseBody to DapMessages
Friendly error messages for unsupported stepping commands:
- stepIn: explain that Actions debug at the step level
- stepOut: suggest using 'continue'
- stepBack/reverseContinue: note 'not yet supported'
- pause: explain automatic pausing at step boundaries
The DAP spec does not provide a capability to hide stepIn/stepOut
buttons (they are considered fundamental operations). The best
server-side UX is clear error messages when clients send them.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Guard WaitForCommandAsync against resurrecting terminated sessions (H1) - Mask exception messages in top-level DAP error responses (M1) - Move isFirstStep=false outside try block to prevent continue breakage (M5) - Guard OnJobCompleted with lock-internal state check to prevent duplicate events (M6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add centralized secret masking in DapServer.SendMessageInternal so all outbound DAP payloads (responses, events) are masked before serialization, creating a single egress funnel that catches secrets regardless of caller. - Redact the entire secrets scope in DapVariableProvider regardless of PipelineContextData type (NumberContextData, BooleanContextData, containers) not just StringContextData, closing the defense-in-depth gap. - Null values under secrets scope are now also redacted. - Existing per-call-site masking retained as defense-in-depth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -0,0 +1,1231 @@ | |||
| using System.Collections.Generic; | |||
| set; | ||
| } | ||
|
|
||
| [DataMember(EmitDefaultValue = false)] |
There was a problem hiding this comment.
Start here: this is a new field on the job message, and if true we're going to start the new DAP server.
| jobContext.Start(); | ||
| jobContext.Debug($"Starting: {message.JobDisplayName}"); | ||
|
|
||
| if (jobContext.Global.EnableDebugger) |
There was a problem hiding this comment.
If EnableDebugger is true we start the DAP server (default on :4711). These changes and those in StepsRunner are the most important ones in the PR as they plug DAP into existing flow, all the rest is feature flagged/new code.
| @@ -0,0 +1,906 @@ | |||
| using System; | |||
There was a problem hiding this comment.
Similar to DapMessages this file is the handler for all DAP protocol requests, more worth looking into than types themselves.
| response = request.Command switch | ||
| { | ||
| "initialize" => HandleInitialize(request), | ||
| "attach" => HandleAttach(request), | ||
| "configurationDone" => HandleConfigurationDone(request), | ||
| "disconnect" => HandleDisconnect(request), | ||
| "threads" => HandleThreads(request), | ||
| "stackTrace" => HandleStackTrace(request), | ||
| "scopes" => HandleScopes(request), | ||
| "variables" => HandleVariables(request), | ||
| "continue" => HandleContinue(request), | ||
| "next" => HandleNext(request), | ||
| "setBreakpoints" => HandleSetBreakpoints(request), | ||
| "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), | ||
| "completions" => HandleCompletions(request), | ||
| "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level — use 'next' to advance to the next step.", body: null), | ||
| "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level — use 'continue' to resume.", body: null), | ||
| "stepBack" => CreateResponse(request, false, "Step Back is not yet supported.", body: null), | ||
| "reverseContinue" => CreateResponse(request, false, "Reverse Continue is not yet supported.", body: null), | ||
| "pause" => CreateResponse(request, false, "Pause is not supported. The debugger pauses automatically at step boundaries.", body: null), | ||
| _ => CreateResponse(request, false, $"Unsupported command: {request.Command}", body: null) |
There was a problem hiding this comment.
Think of this as our DAP "routes".
| return CreateResponse(request, true, body: body); | ||
| } | ||
|
|
||
| private Response HandleStackTrace(Request request) |
| return CreateResponse(request, true, body: body); | ||
| } | ||
|
|
||
| private Response HandleScopes(Request request) |
| }); | ||
| } | ||
|
|
||
| private async Task<Response> HandleEvaluateAsync(Request request, CancellationToken cancellationToken) |
| }); | ||
| } | ||
|
|
||
| private Response HandleContinue(Request request) |
There was a problem hiding this comment.
continue == execute till the end ignoring breakpoints
| /// Output is streamed to the debugger via DAP <c>output</c> events with | ||
| /// secrets masked before emission. | ||
| /// </summary> | ||
| internal sealed class DapReplExecutor |
0f5b436 to
9cd74b0
Compare
| /// | ||
| /// This is the single point where runner context values are materialized | ||
| /// for the debugger. All string values pass through the runner's existing | ||
| /// <see cref="GitHub.DistributedTask.Logging.ISecretMasker"/> so the DAP |
There was a problem hiding this comment.
Re-reading this I'm pretty sure we need to mask non string values too 😄
| // All other capabilities are false for MVP | ||
| SupportsFunctionBreakpoints = false, | ||
| SupportsConditionalBreakpoints = false, | ||
| SupportsEvaluateForHovers = true, |
There was a problem hiding this comment.
Not ALL others are false 😅
| return value ?? string.Empty; | ||
| } | ||
|
|
||
| return _variableProvider?.MaskSecrets(value) |
There was a problem hiding this comment.
Do we need both here since variable provider uses the HostContext.SecretMasker?
| jobContext.Start(); | ||
| jobContext.Debug($"Starting: {message.JobDisplayName}"); | ||
|
|
||
| if (jobContext.Global.EnableDebugger) |
There was a problem hiding this comment.
should we start the DAP server right before // Get the job extension.?
| try | ||
| { | ||
| var port = 4711; | ||
| var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT"); |
There was a problem hiding this comment.
ACTIONS_RUNNER_DAP_PORT maybe?
| { | ||
| var port = 4711; | ||
| var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT"); | ||
| if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort)) |
There was a problem hiding this comment.
| if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort)) | |
| if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024) |
do we have any limitation on which port you can use?
| dapServer.SetSession(debugSession); | ||
| debugSession.SetDapServer(dapServer); |
There was a problem hiding this comment.
it's little bit odd that server set session, and session set server. 😄
should the jobrunner care about the session+server, or it should only care about a debugger?
| internal static string GetGeneralHelp() | ||
| { | ||
| var sb = new StringBuilder(); | ||
| sb.AppendLine("Actions Debug Console"); |
There was a problem hiding this comment.
could potentially just make a big string here instead of string building since we're just returning a string at the end
| debugSession.CancelSession(); | ||
| }); | ||
| } | ||
| catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) |
There was a problem hiding this comment.
This is where we will do autocancellation if users don't connect to the DAP server
| dapServer = null; | ||
| debugSession = null; |
There was a problem hiding this comment.
should we not set these to null, so we always run the cleanup in the final, in case the server/session holds up some resources?
| debugSession.CancelSession(); | ||
| }); | ||
| } | ||
| catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) |
There was a problem hiding this comment.
when we don't get anything connect or timeout, do we still want to run the job?
| catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) | ||
| { | ||
| Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); | ||
| try { await dapServer.StopAsync(); } catch { } |
There was a problem hiding this comment.
| try { await dapServer.StopAsync(); } catch { } | |
| try | |
| { | |
| await dapServer.StopAsync(); | |
| } | |
| catch (Exception ex) | |
| { | |
| Trace.Error("Fail to stop debugger server") | |
| Trace.Error(ex); | |
| } |
same for the other online try-catch.
There was a problem hiding this comment.
also, do we need to have 2 try-catch if the only thing different is a trace?
| } | ||
| catch (Exception ex) | ||
| { | ||
| Trace.Warning($"Error stopping DAP server: {ex.Message}"); |
There was a problem hiding this comment.
| Trace.Warning($"Error stopping DAP server: {ex.Message}"); | |
| Trace.Error($"Error stopping DAP server"); | |
| Trace.Error(ex); |





This adds a DAP server to the runner to build debugging functionalities. The whole DAP integration is gated by the new
EnableDebuggerflag on the job message (feature flagged at the API level).When a job starts, after the job setup we will start the DAP server and allow users to step over to every step in the job, and:
runsteps in their jobs (full job context, supporting expression expansion, etc.)Here's an example of what this looks like connecting to the runner from nvim-dap: