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>
There was a problem hiding this comment.
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
EnableDebuggerfromAgentJobRequestMessage→ExecutionContext.Globaland wires debugger lifecycle intoJobRunnerand step boundaries intoStepsRunner. - 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.
| var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}"; | ||
| var variable = new Variable | ||
| { | ||
| Name = name, | ||
| EvaluateName = $"${{{{ {childPath} }}}}" |
| variable.Value = isSecretsScope ? RedactedValue : "null"; | ||
| variable.Type = "null"; |
src/Runner.Worker/Dap/DapServer.cs
Outdated
|
|
||
| _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>
ChristopherHX
left a comment
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ❤️
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}'"); | ||
| } | ||
|
|
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
- 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(); |
There was a problem hiding this comment.
RE https://github.com/actions/runner/pull/4298/changes#r2949684913
| Task OnJobCompletedAsync(); | |
| Task OnJobCompleteStartingAsync(CancellationToken cancellationToken); | |
| Task OnJobCompletedAsync(); |
| CancelSession(); | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
RE https://github.com/actions/runner/pull/4298/changes#r2949684913
| 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); |
There was a problem hiding this comment.
🤷 not sure what is RunContinuationsAsynchronously doing.

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: