Skip to content

Add DAP server#4298

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

Add DAP server#4298
rentziass wants to merge 33 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>
@rentziass rentziass marked this pull request as ready for review March 16, 2026 14:01
@rentziass rentziass requested a review from a team as a code owner March 16, 2026 14:01
Copilot AI review requested due to automatic review settings March 16, 2026 14:01
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}");
Comment on lines +196 to +200
if (dapDebugger != null)
{
try { await dapDebugger.StopAsync(); } catch { }
}
dapDebugger = null;
Copy link
Member

Choose a reason for hiding this comment

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

do we want to failed the job here? and rely on final for the `dapDebugger.StopAsync()?

Comment on lines +257 to +258
try { await dapDebugger.StopAsync(); } catch { }
dapDebugger = null;
Copy link
Member

Choose a reason for hiding this comment

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

same here, do we want to always just rely on the finally to stop the debugger?

Trace.Error($"Failed to start DAP debugger: {ex.Message}");
if (dapDebugger != null)
{
try { await dapDebugger.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.

also, StopAsync() seems doesn't throw exception.

}
catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info("Job was cancelled before debugger client connected.");
Copy link
Member

Choose a reason for hiding this comment

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

do we want to jobContext.Error("Debugger is cancelled due to job cancellation") or something similar?

}
catch
{
// Debugger not available — continue without debugging
Copy link
Member

Choose a reason for hiding this comment

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

dapDebugger = HostContext.GetService<IDapDebugger>(); would never throw, i think. So we don't need to have try-catch.


if (dapDebugger != null)
{
dapDebugger.OnJobCompleted();
Copy link
Member

Choose a reason for hiding this comment

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

does the OnJobCompleted need to be part of some finally, also do we want to merge it with StopAsync()?

// Pause for DAP debugger before step execution
if (dapDebugger != null)
{
await dapDebugger.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);
Copy link
Member

Choose a reason for hiding this comment

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

should the isFirstStep just be tracked within dapDebugger?

Copy link
Member

Choose a reason for hiding this comment

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

also, since the GetService<> should always return something, we can remove the null check.
we can also just do await dapDebugger?.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken);

await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);

if (dapDebugger != null)
Copy link
Member

Choose a reason for hiding this comment

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

same about the null check.

Copy link
Member

Choose a reason for hiding this comment

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

what's the reason for having the debugger and session?
is it possible to merge them?
would we ever support multiple sessions under the same debugger?

{
try
{
await dapDebugger.WaitUntilReadyAsync(jobRequestCancellationToken);
Copy link
Member

Choose a reason for hiding this comment

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

do you want to report telemetry on how many job is able to connect?

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.

4 participants