Skip to content

Add DAP server#4298

Draft
rentziass wants to merge 28 commits intomainfrom
rentziass/debugger
Draft

Add DAP server#4298
rentziass wants to merge 28 commits intomainfrom
rentziass/debugger

Conversation

@rentziass
Copy link
Member

@rentziass rentziass commented Mar 13, 2026

This adds a DAP server to the runner to build debugging functionalities. The whole DAP integration is gated by the new EnableDebugger flag 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:

  • inspect the scope of the runner
  • test evaluating expressions
  • run shell commands as if they were run steps 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:

CleanShot 2026-03-13 at 15 43 05@2x

rentziass and others added 27 commits March 10, 2026 04:13
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;
Copy link
Member Author

Choose a reason for hiding this comment

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

This file contains types from the protocol.

set;
}

[DataMember(EmitDefaultValue = false)]
Copy link
Member Author

Choose a reason for hiding this comment

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

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)
Copy link
Member Author

@rentziass rentziass Mar 13, 2026

Choose a reason for hiding this comment

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

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;
Copy link
Member Author

Choose a reason for hiding this comment

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

Similar to DapMessages this file is the handler for all DAP protocol requests, more worth looking into than types themselves.

Comment on lines +127 to +147
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)
Copy link
Member Author

Choose a reason for hiding this comment

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

Think of this as our DAP "routes".

return CreateResponse(request, true, body: body);
}

private Response HandleStackTrace(Request request)
Copy link
Member Author

Choose a reason for hiding this comment

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

We're currently using the stack trace to show where we are in the job:

Image

return CreateResponse(request, true, body: body);
}

private Response HandleScopes(Request request)
Copy link
Member Author

Choose a reason for hiding this comment

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

These next two handlers power scope inspection, e.g.

Image

});
}

private async Task<Response> HandleEvaluateAsync(Request request, CancellationToken cancellationToken)
Copy link
Member Author

Choose a reason for hiding this comment

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

evaluate requests can both be requests to evaluate expressions:

Image

or REPL commands (coming from a console):

Image

});
}

private Response HandleContinue(Request request)
Copy link
Member Author

Choose a reason for hiding this comment

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

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
Copy link
Member Author

Choose a reason for hiding this comment

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

Enables run like commands through REPL:

Image

@rentziass rentziass force-pushed the rentziass/debugger branch from 0f5b436 to 9cd74b0 Compare March 13, 2026 14:19
///
/// 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
Copy link
Member Author

Choose a reason for hiding this comment

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

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,
Copy link
Member Author

Choose a reason for hiding this comment

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

Not ALL others are false 😅

return value ?? string.Empty;
}

return _variableProvider?.MaskSecrets(value)
Copy link
Member Author

Choose a reason for hiding this comment

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

Do we need both here since variable provider uses the HostContext.SecretMasker?

jobContext.Start();
jobContext.Debug($"Starting: {message.JobDisplayName}");

if (jobContext.Global.EnableDebugger)
Copy link
Member

Choose a reason for hiding this comment

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

should we start the DAP server right before // Get the job extension.?

try
{
var port = 4711;
var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT");
Copy link
Member

Choose a reason for hiding this comment

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

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))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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?

Comment on lines +144 to +145
dapServer.SetSession(debugSession);
debugSession.SetDapServer(dapServer);
Copy link
Member

Choose a reason for hiding this comment

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

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");
Copy link
Contributor

Choose a reason for hiding this comment

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

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)
Copy link
Member Author

Choose a reason for hiding this comment

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

This is where we will do autocancellation if users don't connect to the DAP server

Comment on lines +153 to +154
dapServer = null;
debugSession = null;
Copy link
Member

Choose a reason for hiding this comment

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

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)
Copy link
Member

Choose a reason for hiding this comment

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

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 { }
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Member

Choose a reason for hiding this comment

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

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}");
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Trace.Warning($"Error stopping DAP server: {ex.Message}");
Trace.Error($"Error stopping DAP server");
Trace.Error(ex);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants