Skip to content

Add DAP server#4298

Open
rentziass wants to merge 40 commits intomainfrom
rentziass/debugger
Open

Add DAP server#4298
rentziass wants to merge 40 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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a Debug Adapter Protocol (DAP) subsystem to the runner (gated by EnableDebugger on the job message) to enable step-level debugging, scope/variable inspection, expression evaluation, and a REPL for running commands in-job context.

Changes:

  • Introduces DAP runtime components (TCP server, debug session, variable provider, REPL parser/executor, DAP message models) under src/Runner.Worker/Dap/.
  • Plumbs EnableDebugger from AgentJobRequestMessageExecutionContext.Global and wires debugger lifecycle into JobRunner and step boundaries into StepsRunner.
  • Adds extensive L0 coverage for DAP protocol framing, session flow, masking behavior, REPL parsing/execution helpers, and message serialization.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs Adds EnableDebugger to job message contract.
src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs Validates EnableDebugger JSON deserialization behavior.
src/Runner.Worker/GlobalContext.cs Stores EnableDebugger on global execution context.
src/Runner.Worker/ExecutionContext.cs Copies EnableDebugger from job message into GlobalContext.
src/Runner.Worker/JobRunner.cs Starts/waits/stops DAP debugger based on EnableDebugger; fails job if requested but unavailable.
src/Runner.Worker/StepsRunner.cs Hooks step-boundary callbacks to debugger (pause/complete/job complete).
src/Runner.Worker/Dap/IDapServer.cs Introduces DAP server service interface.
src/Runner.Worker/Dap/IDapDebugger.cs Introduces debugger facade interface used by runner orchestration.
src/Runner.Worker/Dap/IDapDebugSession.cs Introduces debug session interface + session state enum.
src/Runner.Worker/Dap/DapServer.cs Implements TCP DAP server with framing + reconnection loop.
src/Runner.Worker/Dap/DapDebugger.cs Implements lifecycle facade for server/session with env-var overrides.
src/Runner.Worker/Dap/DapDebugSession.cs Implements DAP command handling + step-level pause/continue/next, evaluate, scopes/variables, reconnection.
src/Runner.Worker/Dap/DapVariableProvider.cs Maps runner expression contexts into DAP scopes/variables; performs masking/redaction.
src/Runner.Worker/Dap/DapReplParser.cs Parses help(...) / run(...) REPL DSL commands.
src/Runner.Worker/Dap/DapReplExecutor.cs Executes run(...) scripts using runner process infrastructure and streams masked output.
src/Runner.Worker/Dap/DapMessages.cs Adds DAP protocol message models and related DTOs.
src/Test/L0/Worker/DapVariableProviderL0.cs Tests scopes/variables, masking/redaction, nested expansion, evaluate, and helpers.
src/Test/L0/Worker/DapServerL0.cs Tests server lifecycle, framing, cancellation, and protocol-metadata preservation.
src/Test/L0/Worker/DapReplParserL0.cs Tests REPL DSL parsing and error cases.
src/Test/L0/Worker/DapReplExecutorL0.cs Tests expression expansion, shell resolution, and environment merging helpers.
src/Test/L0/Worker/DapMessagesL0.cs Tests serialization/deserialization of core DAP models.
src/Test/L0/Worker/DapDebuggerL0.cs Tests debugger facade lifecycle + env-var overrides + cancellation behavior.
src/Test/L0/Worker/DapDebugSessionL0.cs Tests end-to-end session flow, stepping behavior, masking, scopes/variables, evaluate, and REPL routing.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +300 to +304
var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}";
var variable = new Variable
{
Name = name,
EvaluateName = $"${{{{ {childPath} }}}}"
Comment on lines +309 to +310
variable.Value = isSecretsScope ? RedactedValue : "null";
variable.Type = "null";

_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Trace.Info($"DAP server listening on 127.0.0.1:{port}");
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
Copy link
Contributor

@ChristopherHX ChristopherHX left a comment

Choose a reason for hiding this comment

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

I like it 👍 , works pretty well in vscode.

  • Only the Callstack did not auto focus the active callframe for me so e.g. watch displayed the error ((no execution context available)) until selecting the step where the debugger stopped by hand
  • I used this vscode extension stub to attach: vscode-ext-to-attach.zip (generated by AI)
  • Expression debug logs end up in the uploaded job log (not debugger log) if you evaluate expressions (ACTIONS_STEP_DEBUG=true)

// Setup the debugger
if (jobContext.Global.EnableDebugger)
{
Trace.Info("Debugger enabled for this job run");
Copy link
Contributor

Choose a reason for hiding this comment

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

Write this to job log as well? So you can actually see why the job does not continue with the next step?

Only the error Failed to start debugger can be seen there.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thank you that's a great point, I'm going to add a new "artificial" step after job setup to better show users that the debugger is enabled and we're waiting on them to connect ❤️

Copy link
Member Author

Choose a reason for hiding this comment

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

Got a first draft working locally but it's a sizeable change, going to move it to its own PR. Adding a pre/post step might also be a good way to let us inspect state after the last actual job step completed too.

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed much more visible than just a log message in a Job setup and reads really like a much bigger change.

Adding a pre/post step might also be a good way to let us inspect state after the last actual job step completed too.

Sounds like an interesting solution.


Trace.Info($"Current state: job state = '{jobContext.Result}'");
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I usually find it useful to be able to inspect variables and run scripts after the last step of a job

Image
Suggested change
// Pause for DAP debugger before sending job completion
await dapDebugger?.OnJobCompleteStartingAsync(jobContext.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.

I totally agree with you but we need to figure out the right way for doing this, and it's probably going to come in the next PRs since this one is huge as is already 😄

We need to find a good way to both:

  • give users a good way to setup breakpoints looking at source code
  • allow users to setup breakpoints at times that can't be referred to from normal source code (pre/post actions steps and job completion come to mind here).

That is to say, we're quite a bit away still from fully releasing this but this is most definitely on our radar!

Copy link
Contributor

Choose a reason for hiding this comment

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

  • allow users to setup breakpoints at times that can't be referred to from normal source code [...]

job completion

Idk how this works for a DAP Adapter, but some vscode extension debugger have a checkbox for unhandled exception if this would allow a free text JobCompletion could be such a predefined enable/disable breakpoint.
E.g. in my experiment, only step over is a possible way to pause before job completion.

pre/post actions steps

Conditional breakpoints come into my mind: E.g. you make a breakpoint that would by default pause before pre, main and post. Optionally the condition could have a variable that can filter the stage e.g. condition stage == 'main' (using existing actions expressions with custom global variable from debugger)

void CancelSession();
Task OnStepStartingAsync(IStep step, IExecutionContext jobContext, CancellationToken cancellationToken);
void OnStepCompleted(IStep step);
Task OnJobCompletedAsync();
Copy link
Contributor

Choose a reason for hiding this comment

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

RE https://github.com/actions/runner/pull/4298/changes#r2949684913

Suggested change
Task OnJobCompletedAsync();
Task OnJobCompleteStartingAsync(CancellationToken cancellationToken);
Task OnJobCompletedAsync();

CancelSession();
});
}

Copy link
Contributor

@ChristopherHX ChristopherHX Mar 17, 2026

Choose a reason for hiding this comment

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

RE https://github.com/actions/runner/pull/4298/changes#r2949684913

Suggested change
public async Task OnJobCompleteStartingAsync(CancellationToken cancellationToken)
{
if (_started)
{
try
{
bool pauseOnNextStep;
lock (_stateLock)
{
if (_state == DapSessionState.Terminated)
{
Trace.Info("Session already terminated, skipping OnJobCompleted events");
return;
}
pauseOnNextStep = _pauseOnNextStep;
}
if (pauseOnNextStep)
{
var reason = "step";
var description = "Stopped at job completion";
Trace.Info("Job completed with debugger pause");
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
}
catch (Exception ex)
{
Trace.Warning($"DAP OnJobCompleteStartingAsync error: {ex.Message}");
}
}
}

Trace.Info($"Starting DAP debugger on port {port}");

_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectionTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Copy link
Member

Choose a reason for hiding this comment

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

🤷 not sure what is RunContinuationsAsynchronously doing.

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.

5 participants