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>
Co-authored-by: Tingluo Huang <tingluohuang@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"; |
|
|
||
| _listener = new TcpListener(IPAddress.Loopback, port); | ||
| _listener.Start(); | ||
| Trace.Info($"DAP server listening on 127.0.0.1:{port}"); |
| if (dapDebugger != null) | ||
| { | ||
| try { await dapDebugger.StopAsync(); } catch { } | ||
| } | ||
| dapDebugger = null; |
There was a problem hiding this comment.
do we want to failed the job here? and rely on final for the `dapDebugger.StopAsync()?
| try { await dapDebugger.StopAsync(); } catch { } | ||
| dapDebugger = null; |
There was a problem hiding this comment.
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 { } |
There was a problem hiding this comment.
also, StopAsync() seems doesn't throw exception.
| } | ||
| catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) | ||
| { | ||
| Trace.Info("Job was cancelled before debugger client connected."); |
There was a problem hiding this comment.
do we want to jobContext.Error("Debugger is cancelled due to job cancellation") or something similar?
| } | ||
| catch | ||
| { | ||
| // Debugger not available — continue without debugging |
There was a problem hiding this comment.
dapDebugger = HostContext.GetService<IDapDebugger>(); would never throw, i think. So we don't need to have try-catch.
|
|
||
| if (dapDebugger != null) | ||
| { | ||
| dapDebugger.OnJobCompleted(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
should the isFirstStep just be tracked within dapDebugger?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
same about the null check.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
do you want to report telemetry on how many job is able to connect?
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: