diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs new file mode 100644 index 000000000..9d0acca68 --- /dev/null +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -0,0 +1,1299 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using Newtonsoft.Json; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Stores information about a completed step for stack trace display. + /// + internal sealed class CompletedStepInfo + { + public string DisplayName { get; set; } + public TaskResult? Result { get; set; } + public int FrameId { get; set; } + } + + /// + /// Single public facade for the Debug Adapter Protocol subsystem. + /// Owns the full transport, handshake, step-level pauses, variable + /// inspection, reconnection, and cancellation flow. + /// + public sealed class DapDebugger : RunnerService, IDapDebugger + { + private const int _defaultPort = 4711; + private const int _defaultTimeoutMinutes = 15; + private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; + private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; + private const string _contentLengthHeader = "Content-Length: "; + private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB + private const int _maxHeaderLineLength = 8192; // 8 KB + private const int _connectionRetryDelayMilliseconds = 500; + + // Thread ID for the single job execution thread + private const int _jobThreadId = 1; + + // Frame ID for the current step (always 1) + private const int _currentFrameId = 1; + + // Frame IDs for completed steps start at 1000 + private const int _completedFrameIdBase = 1000; + + private TcpListener _listener; + private TcpClient _client; + private NetworkStream _stream; + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + private int _nextSeq = 1; + private Task _connectionLoopTask; + private volatile DapSessionState _state = DapSessionState.NotStarted; + private CancellationTokenRegistration? _cancellationRegistration; + private bool _isFirstStep = true; + + // Synchronization for step execution + private TaskCompletionSource _commandTcs; + private readonly object _stateLock = new object(); + + // Session readiness — signaled when configurationDone is received + private TaskCompletionSource _readyTcs; + + // Whether to pause before the next step (set by 'next' command) + private bool _pauseOnNextStep = true; + + // Current execution context + private IStep _currentStep; + private IExecutionContext _jobContext; + private int _currentStepIndex; + + // Track completed steps for stack trace + private readonly List _completedSteps = new List(); + private int _nextCompletedFrameId = _completedFrameIdBase; + + // Client connection tracking for reconnection support + private volatile bool _isClientConnected; + + // Scope/variable inspection provider — reusable by future DAP features + private DapVariableProvider _variableProvider; + + // REPL command executor for run() commands + private DapReplExecutor _replExecutor; + + public bool IsActive => + _state == DapSessionState.Ready || + _state == DapSessionState.Paused || + _state == DapSessionState.Running; + + internal DapSessionState State => _state; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _variableProvider = new DapVariableProvider(hostContext.SecretMasker); + _replExecutor = new DapReplExecutor(hostContext, SendOutput); + Trace.Info("DapDebugger initialized"); + } + + public Task StartAsync(IExecutionContext jobContext) + { + ArgUtil.NotNull(jobContext, nameof(jobContext)); + var port = ResolvePort(); + + Trace.Info($"Starting DAP debugger on port {port}"); + + _jobContext = jobContext; + _readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}"); + + _state = DapSessionState.WaitingForConnection; + _connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken); + + _cancellationRegistration = jobContext.CancellationToken.Register(() => + { + Trace.Info("Job cancellation requested, unblocking pending waits."); + _readyTcs?.TrySetCanceled(); + _commandTcs?.TrySetResult(DapCommand.Disconnect); + }); + + Trace.Info($"DAP debugger started on port {port}"); + return Task.CompletedTask; + } + + public async Task WaitUntilReadyAsync() + { + if (_state == DapSessionState.NotStarted || _listener == null || _jobContext == null) + { + return; + } + + var timeoutMinutes = ResolveTimeout(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes)); + + try + { + Trace.Info($"Waiting for debugger client connection (timeout: {timeoutMinutes} minutes)..."); + using (timeoutCts.Token.Register(() => _readyTcs?.TrySetCanceled())) + { + await _readyTcs.Task; + } + + Trace.Info("DAP debugger ready."); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !_jobContext.CancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"No debugger client connected within {timeoutMinutes} minutes."); + } + } + + public async Task OnJobCompletedAsync() + { + if (_state != DapSessionState.NotStarted) + { + try + { + OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } + + await StopAsync(); + } + + public async Task StopAsync() + { + if (_cancellationRegistration.HasValue) + { + _cancellationRegistration.Value.Dispose(); + _cancellationRegistration = null; + } + + if (_state != DapSessionState.NotStarted) + { + try + { + Trace.Info("Stopping DAP debugger"); + + CleanupConnection(); + + try { _listener?.Stop(); } + catch { /* best effort */ } + + if (_connectionLoopTask != null) + { + try + { + await Task.WhenAny(_connectionLoopTask, Task.Delay(5000)); + } + catch { /* best effort */ } + } + } + catch (Exception ex) + { + Trace.Error("Error stopping DAP debugger"); + Trace.Error(ex); + } + } + + lock (_stateLock) + { + if (_state != DapSessionState.NotStarted && _state != DapSessionState.Terminated) + { + _state = DapSessionState.Terminated; + } + } + + _isClientConnected = false; + _listener = null; + _client = null; + _stream = null; + _readyTcs = null; + _connectionLoopTask = null; + } + + public async Task OnStepStartingAsync(IStep step) + { + if (!IsActive) + { + return; + } + + try + { + bool isFirst = _isFirstStep; + _isFirstStep = false; + await OnStepStartingAsync(step, isFirst); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + } + } + + public void OnStepCompleted(IStep step) + { + if (!IsActive) + { + return; + } + + try + { + var result = step.ExecutionContext?.Result; + Trace.Info("Step completed"); + + // Add to completed steps list for stack trace + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _completedSteps.Add(new CompletedStepInfo + { + DisplayName = step.DisplayName, + Result = result, + FrameId = _nextCompletedFrameId++ + }); + } + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + } + } + + internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) + { + Request request = null; + try + { + request = JsonConvert.DeserializeObject(messageJson); + if (request == null) + { + Trace.Warning("Failed to deserialize DAP request"); + return; + } + + if (!string.Equals(request.Type, "request", StringComparison.OrdinalIgnoreCase)) + { + Trace.Warning("Received DAP message that was not a request"); + return; + } + + Trace.Info("Handling DAP request"); + + Response response; + if (request.Command == "evaluate") + { + response = await HandleEvaluateAsync(request, cancellationToken); + } + else + { + 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) + }; + } + + response.RequestSeq = request.Seq; + response.Command = request.Command; + + SendResponse(response); + + if (request.Command == "initialize") + { + SendEvent(new Event + { + EventType = "initialized" + }); + Trace.Info("Sent initialized event"); + } + } + catch (Exception ex) + { + Trace.Error($"Error handling DAP request ({ex.GetType().Name})"); + if (request != null) + { + var maskedMessage = HostContext?.SecretMasker?.MaskSecrets(ex.Message) ?? ex.Message; + var errorResponse = CreateResponse(request, false, maskedMessage, body: null); + errorResponse.RequestSeq = request.Seq; + errorResponse.Command = request.Command; + SendResponse(errorResponse); + } + } + } + + internal void HandleClientConnected() + { + _isClientConnected = true; + Trace.Info("Client connected to debug session"); + + // If we're paused, re-send the stopped event so the new client + // knows the current state (important for reconnection) + string description = null; + lock (_stateLock) + { + if (_state == DapSessionState.Paused && _currentStep != null) + { + description = $"Stopped before step: {_currentStep.DisplayName}"; + } + } + + if (description != null) + { + Trace.Info("Re-sending stopped event to reconnected client"); + SendStoppedEvent("step", description); + } + } + + internal void HandleClientDisconnected() + { + _isClientConnected = false; + Trace.Info("Client disconnected from debug session"); + + // Intentionally do NOT release the command TCS here. + // The session stays paused, waiting for a client to reconnect. + // The debugger's connection loop will accept a new client and + // call HandleClientConnected, which re-sends the stopped event. + } + + private async Task ConnectionLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + Trace.Info("Waiting for debug client connection..."); + _client = await _listener.AcceptTcpClientAsync(); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + _stream = _client.GetStream(); + var remoteEndPoint = _client.Client.RemoteEndPoint; + Trace.Info($"Debug client connected from {remoteEndPoint}"); + + HandleClientConnected(); + + // Enter message processing loop until client disconnects or cancellation is requested + await ProcessMessagesAsync(cancellationToken); + + Trace.Info("Client disconnected, waiting for reconnection..."); + HandleClientDisconnected(); + CleanupConnection(); + } + catch (Exception ex) + { + CleanupConnection(); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + Trace.Error("Debugger connection error"); + Trace.Error(ex); + + try + { + await Task.Delay(_connectionRetryDelayMilliseconds, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + Trace.Info("Connection loop ended"); + } + + private void CleanupConnection() + { + _sendLock.Wait(); + try + { + try { _stream?.Close(); } catch { /* best effort */ } + try { _client?.Close(); } catch { /* best effort */ } + _stream = null; + _client = null; + } + finally + { + _sendLock.Release(); + } + } + + private async Task ProcessMessagesAsync(CancellationToken cancellationToken) + { + Trace.Info("Starting DAP message processing loop"); + + try + { + while (!cancellationToken.IsCancellationRequested && _client?.Connected == true) + { + var json = await ReadMessageAsync(cancellationToken); + if (json == null) + { + Trace.Info("Client disconnected (end of stream)"); + break; + } + + await HandleMessageAsync(json, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Trace.Info("Message processing cancelled"); + } + catch (IOException ex) + { + Trace.Info($"Connection closed ({ex.GetType().Name})"); + } + catch (Exception ex) + { + Trace.Error($"Error in message loop ({ex.GetType().Name})"); + } + + Trace.Info("DAP message processing loop ended"); + } + + private async Task ReadMessageAsync(CancellationToken cancellationToken) + { + int contentLength = -1; + + while (true) + { + var line = await ReadLineAsync(cancellationToken); + if (line == null) + { + return null; + } + + if (line.Length == 0) + { + break; + } + + if (line.StartsWith(_contentLengthHeader, StringComparison.OrdinalIgnoreCase)) + { + var lengthStr = line.Substring(_contentLengthHeader.Length).Trim(); + if (!int.TryParse(lengthStr, out contentLength)) + { + throw new InvalidDataException($"Invalid Content-Length: {lengthStr}"); + } + } + } + + if (contentLength < 0) + { + throw new InvalidDataException("Missing Content-Length header"); + } + + if (contentLength > _maxMessageSize) + { + throw new InvalidDataException($"Message size {contentLength} exceeds maximum allowed size of {_maxMessageSize}"); + } + + var buffer = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await _stream.ReadAsync(buffer, totalRead, contentLength - totalRead, cancellationToken); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading message body"); + } + totalRead += bytesRead; + } + + var json = Encoding.UTF8.GetString(buffer); + Trace.Verbose("Received DAP message body"); + return json; + } + + private async Task ReadLineAsync(CancellationToken cancellationToken) + { + var lineBuilder = new StringBuilder(); + var buffer = new byte[1]; + var previousWasCr = false; + + while (true) + { + var bytesRead = await _stream.ReadAsync(buffer, 0, 1, cancellationToken); + if (bytesRead == 0) + { + return lineBuilder.Length > 0 ? lineBuilder.ToString() : null; + } + + var c = (char)buffer[0]; + + if (c == '\n' && previousWasCr) + { + if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r') + { + lineBuilder.Length--; + } + return lineBuilder.ToString(); + } + + previousWasCr = c == '\r'; + lineBuilder.Append(c); + + if (lineBuilder.Length > _maxHeaderLineLength) + { + throw new InvalidDataException($"Header line exceeds maximum length of {_maxHeaderLineLength}"); + } + } + } + + /// + /// Serializes and writes a DAP message with Content-Length framing. + /// Must be called within the _sendLock. + /// + /// Secret masking is intentionally NOT applied here at the serialization + /// layer. Masking the raw JSON would corrupt protocol envelope fields + /// (type, event, command, seq) if a secret collides with those strings. + /// Instead, each DAP producer masks user-visible text at the point of + /// construction via the runner's SecretMasker. See DapVariableProvider, + /// DapReplExecutor, and DapDebugger for the call sites. + /// + private void SendMessageInternal(ProtocolMessage message) + { + var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }); + + var bodyBytes = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {bodyBytes.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + _stream.Write(headerBytes, 0, headerBytes.Length); + _stream.Write(bodyBytes, 0, bodyBytes.Length); + _stream.Flush(); + + Trace.Verbose("Sent DAP message"); + } + + private void SendMessage(ProtocolMessage message) + { + try + { + _sendLock.Wait(); + try + { + if (_stream == null) + { + Trace.Warning("Cannot send message: no client connected"); + return; + } + + message.Seq = _nextSeq++; + SendMessageInternal(message); + } + finally + { + _sendLock.Release(); + } + + Trace.Info("Sent message"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to send message ({ex.GetType().Name})"); + } + } + + private void SendEvent(Event evt) + { + SendMessage(evt); + } + + private void SendResponse(Response response) + { + SendMessage(response); + } + + private void SendOutput(string category, string text) + { + SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + } + + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) + { + bool pauseOnNextStep; + CancellationToken cancellationToken; + lock (_stateLock) + { + if (_state != DapSessionState.Ready && + _state != DapSessionState.Paused && + _state != DapSessionState.Running) + { + return; + } + + _currentStep = step; + _currentStepIndex = _completedSteps.Count; + pauseOnNextStep = _pauseOnNextStep; + cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; + } + + // Reset variable references so stale nested refs from the + // previous step are not served to the client. + _variableProvider?.Reset(); + + // Determine if we should pause + bool shouldPause = isFirstStep || pauseOnNextStep; + + if (!shouldPause) + { + Trace.Info("Step starting without debugger pause"); + return; + } + + var reason = isFirstStep ? "entry" : "step"; + var description = isFirstStep + ? $"Stopped at job entry: {step.DisplayName}" + : $"Stopped before step: {step.DisplayName}"; + + Trace.Info("Step starting with debugger pause"); + + // Send stopped event to debugger (only if client is connected) + SendStoppedEvent(reason, description); + + // Wait for debugger command + await WaitForCommandAsync(cancellationToken); + } + + internal void OnJobCompleted() + { + Trace.Info("Job completed, sending terminated event"); + + int exitCode; + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + Trace.Info("Session already terminated, skipping OnJobCompleted events"); + return; + } + _state = DapSessionState.Terminated; + exitCode = _jobContext?.Result == TaskResult.Succeeded ? 0 : 1; + } + + SendEvent(new Event + { + EventType = "terminated", + Body = new TerminatedEventBody() + }); + + SendEvent(new Event + { + EventType = "exited", + Body = new ExitedEventBody + { + ExitCode = exitCode + } + }); + } + + private Response HandleInitialize(Request request) + { + if (request.Arguments != null) + { + try + { + request.Arguments.ToObject(); + Trace.Info("Initialize arguments received"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse initialize arguments ({ex.GetType().Name})"); + } + } + + lock (_stateLock) + { + _state = DapSessionState.Initializing; + } + + // Build capabilities — MVP only supports configurationDone + var capabilities = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsEvaluateForHovers = true, + + // All other capabilities are false for MVP + SupportsFunctionBreakpoints = false, + SupportsConditionalBreakpoints = false, + SupportsStepBack = false, + SupportsSetVariable = false, + SupportsRestartFrame = false, + SupportsGotoTargetsRequest = false, + SupportsStepInTargetsRequest = false, + SupportsCompletionsRequest = true, + SupportsModulesRequest = false, + SupportsTerminateRequest = false, + SupportTerminateDebuggee = false, + SupportsDelayedStackTraceLoading = false, + SupportsLoadedSourcesRequest = false, + SupportsProgressReporting = false, + SupportsRunInTerminalRequest = false, + SupportsCancelRequest = false, + SupportsExceptionOptions = false, + SupportsValueFormattingOptions = false, + SupportsExceptionInfoRequest = false, + }; + + Trace.Info("Initialize request handled, capabilities sent"); + return CreateResponse(request, true, body: capabilities); + } + + private Response HandleAttach(Request request) + { + Trace.Info("Attach request handled"); + return CreateResponse(request, true, body: null); + } + + private Response HandleConfigurationDone(Request request) + { + lock (_stateLock) + { + _state = DapSessionState.Ready; + } + + _readyTcs.TrySetResult(true); + + Trace.Info("Configuration done, debug session is ready"); + return CreateResponse(request, true, body: null); + } + + private Response HandleDisconnect(Request request) + { + Trace.Info("Disconnect request received"); + + lock (_stateLock) + { + _state = DapSessionState.Terminated; + + // Release any blocked step execution + _commandTcs?.TrySetResult(DapCommand.Disconnect); + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleThreads(Request request) + { + IExecutionContext jobContext; + lock (_stateLock) + { + jobContext = _jobContext; + } + + var threadName = jobContext != null + ? MaskUserVisibleText($"Job: {jobContext.GetGitHubContext("job") ?? "workflow job"}") + : "Job Thread"; + + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread + { + Id = _jobThreadId, + Name = threadName + } + } + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleStackTrace(Request request) + { + IStep currentStep; + int currentStepIndex; + CompletedStepInfo[] completedSteps; + lock (_stateLock) + { + currentStep = _currentStep; + currentStepIndex = _currentStepIndex; + completedSteps = _completedSteps.ToArray(); + } + + var frames = new List(); + + // Add current step as the top frame + if (currentStep != null) + { + var resultIndicator = currentStep.ExecutionContext?.Result != null + ? $" [{currentStep.ExecutionContext.Result}]" + : " [running]"; + + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), + Line = currentStepIndex + 1, + Column = 1, + PresentationHint = "normal" + }); + } + else + { + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = "(no step executing)", + Line = 0, + Column = 1, + PresentationHint = "subtle" + }); + } + + // Add completed steps as additional frames (most recent first) + for (int i = completedSteps.Length - 1; i >= 0; i--) + { + var completedStep = completedSteps[i]; + var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + frames.Add(new StackFrame + { + Id = completedStep.FrameId, + Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), + Line = 1, + Column = 1, + PresentationHint = "subtle" + }); + } + + var body = new StackTraceResponseBody + { + StackFrames = frames, + TotalFrames = frames.Count + }; + + return CreateResponse(request, true, body: body); + } + + private Response HandleScopes(Request request) + { + var args = request.Arguments?.ToObject(); + var frameId = args?.FrameId ?? _currentFrameId; + + var context = GetExecutionContextForFrame(frameId); + if (context == null) + { + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = new List() + }); + } + + var scopes = _variableProvider.GetScopes(context); + return CreateResponse(request, true, body: new ScopesResponseBody + { + Scopes = scopes + }); + } + + private Response HandleVariables(Request request) + { + var args = request.Arguments?.ToObject(); + var variablesRef = args?.VariablesReference ?? 0; + + var context = GetCurrentExecutionContext(); + if (context == null) + { + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = new List() + }); + } + + var variables = _variableProvider.GetVariables(context, variablesRef); + return CreateResponse(request, true, body: new VariablesResponseBody + { + Variables = variables + }); + } + + private async Task HandleEvaluateAsync(Request request, CancellationToken cancellationToken) + { + var args = request.Arguments?.ToObject(); + var expression = args?.Expression ?? string.Empty; + var frameId = args?.FrameId ?? _currentFrameId; + var evalContext = args?.Context ?? "hover"; + + Trace.Info("Evaluate request received"); + + // REPL context -> route through the DSL dispatcher + if (string.Equals(evalContext, "repl", StringComparison.OrdinalIgnoreCase)) + { + var result = await HandleReplInputAsync(expression, frameId, cancellationToken); + return CreateResponse(request, true, body: result); + } + + // Watch/hover/variables/clipboard -> expression evaluation only + var context = GetExecutionContextForFrame(frameId); + var evalResult = _variableProvider.EvaluateExpression(expression, context); + return CreateResponse(request, true, body: evalResult); + } + + /// + /// Routes REPL input through the DSL parser. If the input matches a + /// known command it is dispatched; otherwise it falls through to + /// expression evaluation. + /// + private async Task HandleReplInputAsync( + string input, + int frameId, + CancellationToken cancellationToken) + { + // Try to parse as a DSL command + var command = DapReplParser.TryParse(input, out var parseError); + + if (parseError != null) + { + return new EvaluateResponseBody + { + Result = parseError, + Type = "error", + VariablesReference = 0 + }; + } + + if (command != null) + { + return await DispatchReplCommandAsync(command, frameId, cancellationToken); + } + + // Not a DSL command -> evaluate as a GitHub Actions expression + // (this lets the REPL console also work for ad-hoc expression queries) + var context = GetExecutionContextForFrame(frameId); + return _variableProvider.EvaluateExpression(input, context); + } + + private async Task DispatchReplCommandAsync( + DapReplCommand command, + int frameId, + CancellationToken cancellationToken) + { + switch (command) + { + case HelpCommand help: + var helpText = string.IsNullOrEmpty(help.Topic) + ? DapReplParser.GetGeneralHelp() + : help.Topic.Equals("run", StringComparison.OrdinalIgnoreCase) + ? DapReplParser.GetRunHelp() + : $"Unknown help topic: {help.Topic}. Try: help or help(\"run\")"; + return new EvaluateResponseBody + { + Result = helpText, + Type = "string", + VariablesReference = 0 + }; + + case RunCommand run: + var context = GetExecutionContextForFrame(frameId); + return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken); + + default: + return new EvaluateResponseBody + { + Result = $"Unknown command type: {command.GetType().Name}", + Type = "error", + VariablesReference = 0 + }; + } + } + + private Response HandleCompletions(Request request) + { + var args = request.Arguments?.ToObject(); + var text = args?.Text ?? string.Empty; + + var items = new List(); + + // Offer DSL commands when the user is starting to type + if (string.IsNullOrEmpty(text) || "help".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help", + Text = "help", + Detail = "Show available debug console commands", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "help(\"run\")".StartsWith(text, StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "help(\"run\")", + Text = "help(\"run\")", + Detail = "Show help for the run command", + Type = "function" + }); + } + if (string.IsNullOrEmpty(text) || "run(".StartsWith(text, StringComparison.OrdinalIgnoreCase) + || text.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new CompletionItem + { + Label = "run(\"...\")", + Text = "run(\"", + Detail = "Execute a script (like a workflow run step)", + Type = "function" + }); + } + + return CreateResponse(request, true, body: new CompletionsResponseBody + { + Targets = items + }); + } + + private Response HandleContinue(Request request) + { + Trace.Info("Continue command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = false; + _commandTcs?.TrySetResult(DapCommand.Continue); + } + } + + return CreateResponse(request, true, body: new ContinueResponseBody + { + AllThreadsContinued = true + }); + } + + private Response HandleNext(Request request) + { + Trace.Info("Next (step over) command received"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + _pauseOnNextStep = true; + _commandTcs?.TrySetResult(DapCommand.Next); + } + } + + return CreateResponse(request, true, body: null); + } + + private Response HandleSetBreakpoints(Request request) + { + // MVP: acknowledge but don't process breakpoints + // All steps pause automatically via _pauseOnNextStep + return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); + } + + private Response HandleSetExceptionBreakpoints(Request request) + { + // MVP: acknowledge but don't process exception breakpoints + return CreateResponse(request, true, body: null); + } + + /// + /// Blocks the step execution thread until a debugger command is received + /// or the job is cancelled. Job cancellation is handled by the registration + /// in StartAsync which sets _commandTcs to Disconnect. + /// + private async Task WaitForCommandAsync(CancellationToken cancellationToken) + { + lock (_stateLock) + { + if (_state == DapSessionState.Terminated) + { + return; + } + _state = DapSessionState.Paused; + _commandTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + Trace.Info("Waiting for debugger command..."); + + var command = await _commandTcs.Task; + + Trace.Info("Received debugger command"); + + lock (_stateLock) + { + if (_state == DapSessionState.Paused) + { + _state = DapSessionState.Running; + } + } + + // Send continued event for normal flow commands + if (!cancellationToken.IsCancellationRequested && + (command == DapCommand.Continue || command == DapCommand.Next)) + { + SendEvent(new Event + { + EventType = "continued", + Body = new ContinuedEventBody + { + ThreadId = _jobThreadId, + AllThreadsContinued = true + } + }); + } + } + + /// + /// Resolves the execution context for a given stack frame ID. + /// Frame 1 = current step; frames 1000+ = completed steps (no + /// context available - those steps have already finished). + /// Falls back to the job-level context when no step is active. + /// + private IExecutionContext GetExecutionContextForFrame(int frameId) + { + if (frameId == _currentFrameId) + { + return GetCurrentExecutionContext(); + } + + // Completed-step frames don't carry a live execution context. + return null; + } + + private IExecutionContext GetCurrentExecutionContext() + { + lock (_stateLock) + { + return _currentStep?.ExecutionContext ?? _jobContext; + } + } + + /// + /// Sends a stopped event to the connected client. + /// Silently no-ops if no client is connected. + /// + private void SendStoppedEvent(string reason, string description) + { + if (!_isClientConnected) + { + Trace.Info("No client connected, deferring stopped event"); + return; + } + + SendEvent(new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = reason, + Description = MaskUserVisibleText(description), + ThreadId = _jobThreadId, + AllThreadsStopped = true + } + }); + } + + private string MaskUserVisibleText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return HostContext?.SecretMasker?.MaskSecrets(value) ?? value; + } + + /// + /// Creates a DAP response with common fields pre-populated. + /// + private Response CreateResponse(Request request, bool success, string message = null, object body = null) + { + return new Response + { + Type = "response", + RequestSeq = request.Seq, + Command = request.Command, + Success = success, + Message = success ? null : message, + Body = body + }; + } + + internal int ResolvePort() + { + var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable); + if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535) + { + Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}"); + return customPort; + } + + return _defaultPort; + } + + internal int ResolveTimeout() + { + var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable); + if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out var customTimeout) && customTimeout > 0) + { + Trace.Info($"Using custom DAP timeout {customTimeout} minutes from {_timeoutEnvironmentVariable}"); + return customTimeout; + } + + return _defaultTimeoutMinutes; + } + } +} diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs new file mode 100644 index 000000000..53cd7a436 --- /dev/null +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -0,0 +1,1231 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapCommand + { + Continue, + Next, + StepIn, + StepOut, + Disconnect + } + + /// + /// Base class of requests, responses, and events per DAP specification. + /// + public class ProtocolMessage + { + /// + /// Sequence number of the message (also known as message ID). + /// The seq for the first message sent by a client or debug adapter is 1, + /// and for each subsequent message is 1 greater than the previous message. + /// + [JsonProperty("seq")] + public int Seq { get; set; } + + /// + /// Message type: 'request', 'response', 'event' + /// + [JsonProperty("type")] + public string Type { get; set; } + } + + /// + /// A client or debug adapter initiated request. + /// + public class Request : ProtocolMessage + { + /// + /// The command to execute. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Object containing arguments for the command. + /// Using JObject for flexibility with different argument types. + /// + [JsonProperty("arguments")] + public JObject Arguments { get; set; } + } + + /// + /// Response for a request. + /// + public class Response : ProtocolMessage + { + /// + /// Sequence number of the corresponding request. + /// + [JsonProperty("request_seq")] + public int RequestSeq { get; set; } + + /// + /// Outcome of the request. If true, the request was successful. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// The command requested. + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// Contains the raw error in short form if success is false. + /// + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + + /// + /// Contains request result if success is true and error details if success is false. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + /// + /// A debug adapter initiated event. + /// + public class Event : ProtocolMessage + { + public Event() + { + Type = "event"; + } + + /// + /// Type of event. + /// + [JsonProperty("event")] + public string EventType { get; set; } + + /// + /// Event-specific information. + /// + [JsonProperty("body", NullValueHandling = NullValueHandling.Ignore)] + public object Body { get; set; } + } + + #region Initialize Request/Response + + /// + /// Arguments for 'initialize' request. + /// + public class InitializeRequestArguments + { + /// + /// The ID of the client using this adapter. + /// + [JsonProperty("clientID")] + public string ClientId { get; set; } + + /// + /// The human-readable name of the client using this adapter. + /// + [JsonProperty("clientName")] + public string ClientName { get; set; } + + /// + /// The ID of the debug adapter. + /// + [JsonProperty("adapterID")] + public string AdapterId { get; set; } + + /// + /// The ISO-639 locale of the client using this adapter, e.g. en-US or de-CH. + /// + [JsonProperty("locale")] + public string Locale { get; set; } + + /// + /// If true all line numbers are 1-based (default). + /// + [JsonProperty("linesStartAt1")] + public bool LinesStartAt1 { get; set; } = true; + + /// + /// If true all column numbers are 1-based (default). + /// + [JsonProperty("columnsStartAt1")] + public bool ColumnsStartAt1 { get; set; } = true; + + /// + /// Determines in what format paths are specified. The default is 'path'. + /// + [JsonProperty("pathFormat")] + public string PathFormat { get; set; } = "path"; + + /// + /// Client supports the type attribute for variables. + /// + [JsonProperty("supportsVariableType")] + public bool SupportsVariableType { get; set; } + + /// + /// Client supports the paging of variables. + /// + [JsonProperty("supportsVariablePaging")] + public bool SupportsVariablePaging { get; set; } + + /// + /// Client supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// Client supports memory references. + /// + [JsonProperty("supportsMemoryReferences")] + public bool SupportsMemoryReferences { get; set; } + + /// + /// Client supports progress reporting. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + } + + /// + /// Debug adapter capabilities returned in InitializeResponse. + /// + public class Capabilities + { + /// + /// The debug adapter supports the configurationDone request. + /// + [JsonProperty("supportsConfigurationDoneRequest")] + public bool SupportsConfigurationDoneRequest { get; set; } + + /// + /// The debug adapter supports function breakpoints. + /// + [JsonProperty("supportsFunctionBreakpoints")] + public bool SupportsFunctionBreakpoints { get; set; } + + /// + /// The debug adapter supports conditional breakpoints. + /// + [JsonProperty("supportsConditionalBreakpoints")] + public bool SupportsConditionalBreakpoints { get; set; } + + /// + /// The debug adapter supports a (side effect free) evaluate request for data hovers. + /// + [JsonProperty("supportsEvaluateForHovers")] + public bool SupportsEvaluateForHovers { get; set; } + + /// + /// The debug adapter supports stepping back via the stepBack and reverseContinue requests. + /// + [JsonProperty("supportsStepBack")] + public bool SupportsStepBack { get; set; } + + /// + /// The debug adapter supports setting a variable to a value. + /// + [JsonProperty("supportsSetVariable")] + public bool SupportsSetVariable { get; set; } + + /// + /// The debug adapter supports restarting a frame. + /// + [JsonProperty("supportsRestartFrame")] + public bool SupportsRestartFrame { get; set; } + + /// + /// The debug adapter supports the gotoTargets request. + /// + [JsonProperty("supportsGotoTargetsRequest")] + public bool SupportsGotoTargetsRequest { get; set; } + + /// + /// The debug adapter supports the stepInTargets request. + /// + [JsonProperty("supportsStepInTargetsRequest")] + public bool SupportsStepInTargetsRequest { get; set; } + + /// + /// The debug adapter supports the completions request. + /// + [JsonProperty("supportsCompletionsRequest")] + public bool SupportsCompletionsRequest { get; set; } + + /// + /// The debug adapter supports the modules request. + /// + [JsonProperty("supportsModulesRequest")] + public bool SupportsModulesRequest { get; set; } + + /// + /// The debug adapter supports the terminate request. + /// + [JsonProperty("supportsTerminateRequest")] + public bool SupportsTerminateRequest { get; set; } + + /// + /// The debug adapter supports the terminateDebuggee attribute on the disconnect request. + /// + [JsonProperty("supportTerminateDebuggee")] + public bool SupportTerminateDebuggee { get; set; } + + /// + /// The debug adapter supports the delayed loading of parts of the stack. + /// + [JsonProperty("supportsDelayedStackTraceLoading")] + public bool SupportsDelayedStackTraceLoading { get; set; } + + /// + /// The debug adapter supports the loadedSources request. + /// + [JsonProperty("supportsLoadedSourcesRequest")] + public bool SupportsLoadedSourcesRequest { get; set; } + + /// + /// The debug adapter supports sending progress reporting events. + /// + [JsonProperty("supportsProgressReporting")] + public bool SupportsProgressReporting { get; set; } + + /// + /// The debug adapter supports the runInTerminal request. + /// + [JsonProperty("supportsRunInTerminalRequest")] + public bool SupportsRunInTerminalRequest { get; set; } + + /// + /// The debug adapter supports the cancel request. + /// + [JsonProperty("supportsCancelRequest")] + public bool SupportsCancelRequest { get; set; } + + /// + /// The debug adapter supports exception options. + /// + [JsonProperty("supportsExceptionOptions")] + public bool SupportsExceptionOptions { get; set; } + + /// + /// The debug adapter supports value formatting options. + /// + [JsonProperty("supportsValueFormattingOptions")] + public bool SupportsValueFormattingOptions { get; set; } + + /// + /// The debug adapter supports exception info request. + /// + [JsonProperty("supportsExceptionInfoRequest")] + public bool SupportsExceptionInfoRequest { get; set; } + } + + #endregion + + #region Attach Request + + /// + /// Arguments for 'attach' request. Additional attributes are implementation specific. + /// + public class AttachRequestArguments + { + /// + /// Arbitrary data from the previous, restarted session. + /// + [JsonProperty("__restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + #endregion + + #region Disconnect Request + + /// + /// Arguments for 'disconnect' request. + /// + public class DisconnectRequestArguments + { + /// + /// A value of true indicates that this disconnect request is part of a restart sequence. + /// + [JsonProperty("restart")] + public bool Restart { get; set; } + + /// + /// Indicates whether the debuggee should be terminated when the debugger is disconnected. + /// + [JsonProperty("terminateDebuggee")] + public bool TerminateDebuggee { get; set; } + + /// + /// Indicates whether the debuggee should stay suspended when the debugger is disconnected. + /// + [JsonProperty("suspendDebuggee")] + public bool SuspendDebuggee { get; set; } + } + + #endregion + + #region Threads Request/Response + + /// + /// A Thread in DAP represents a unit of execution. + /// For Actions runner, we have a single thread representing the job. + /// + public class Thread + { + /// + /// Unique identifier for the thread. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the thread. + /// + [JsonProperty("name")] + public string Name { get; set; } + } + + /// + /// Response body for 'threads' request. + /// + public class ThreadsResponseBody + { + /// + /// All threads. + /// + [JsonProperty("threads")] + public List Threads { get; set; } = new List(); + } + + #endregion + + #region StackTrace Request/Response + + /// + /// Arguments for 'stackTrace' request. + /// + public class StackTraceArguments + { + /// + /// Retrieve the stacktrace for this thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// The index of the first frame to return. + /// + [JsonProperty("startFrame")] + public int? StartFrame { get; set; } + + /// + /// The maximum number of frames to return. + /// + [JsonProperty("levels")] + public int? Levels { get; set; } + } + + /// + /// A Stackframe contains the source location. + /// For Actions runner, each step is a stack frame. + /// + public class StackFrame + { + /// + /// An identifier for the stack frame. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// The name of the stack frame, typically a method name. + /// For Actions, this is the step display name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The source of the frame. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The line within the source of the frame. + /// + [JsonProperty("line")] + public int Line { get; set; } + + /// + /// Start position of the range covered by the stack frame. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// The end line of the range covered by the stack frame. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by the stack frame. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + + /// + /// A hint for how to present this frame in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// A Source is a descriptor for source code. + /// + public class Source + { + /// + /// The short name of the source. + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } + + /// + /// The path of the source to be shown in the UI. + /// + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + /// + /// If the value > 0 the contents of the source must be retrieved through + /// the 'source' request (even if a path is specified). + /// + [JsonProperty("sourceReference", NullValueHandling = NullValueHandling.Ignore)] + public int? SourceReference { get; set; } + + /// + /// A hint for how to present the source in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + } + + /// + /// Response body for 'stackTrace' request. + /// + public class StackTraceResponseBody + { + /// + /// The frames of the stack frame. + /// + [JsonProperty("stackFrames")] + public List StackFrames { get; set; } = new List(); + + /// + /// The total number of frames available in the stack. + /// + [JsonProperty("totalFrames", NullValueHandling = NullValueHandling.Ignore)] + public int? TotalFrames { get; set; } + } + + #endregion + + #region Scopes Request/Response + + /// + /// Arguments for 'scopes' request. + /// + public class ScopesArguments + { + /// + /// Retrieve the scopes for the stack frame identified by frameId. + /// + [JsonProperty("frameId")] + public int FrameId { get; set; } + } + + /// + /// A Scope is a named container for variables. + /// For Actions runner, scopes are: github, env, inputs, steps, secrets, runner, job + /// + public class Scope + { + /// + /// Name of the scope such as 'Arguments', 'Locals', or 'Registers'. + /// For Actions: 'github', 'env', 'inputs', 'steps', 'secrets', 'runner', 'job' + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// A hint for how to present this scope in the UI. + /// + [JsonProperty("presentationHint", NullValueHandling = NullValueHandling.Ignore)] + public string PresentationHint { get; set; } + + /// + /// The variables of this scope can be retrieved by passing the value of + /// variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named variables in this scope. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed variables in this scope. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// If true, the number of variables in this scope is large or expensive to retrieve. + /// + [JsonProperty("expensive")] + public bool Expensive { get; set; } + + /// + /// The source for this scope. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The start line of the range covered by this scope. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// Start position of the range covered by this scope. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// The end line of the range covered by this scope. + /// + [JsonProperty("endLine", NullValueHandling = NullValueHandling.Ignore)] + public int? EndLine { get; set; } + + /// + /// End position of the range covered by this scope. + /// + [JsonProperty("endColumn", NullValueHandling = NullValueHandling.Ignore)] + public int? EndColumn { get; set; } + } + + /// + /// Response body for 'scopes' request. + /// + public class ScopesResponseBody + { + /// + /// The scopes of the stack frame. + /// + [JsonProperty("scopes")] + public List Scopes { get; set; } = new List(); + } + + #endregion + + #region Variables Request/Response + + /// + /// Arguments for 'variables' request. + /// + public class VariablesArguments + { + /// + /// The variable for which to retrieve its children. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// Filter to limit the child variables to either named or indexed. + /// + [JsonProperty("filter", NullValueHandling = NullValueHandling.Ignore)] + public string Filter { get; set; } + + /// + /// The index of the first variable to return. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// The number of variables to return. + /// + [JsonProperty("count", NullValueHandling = NullValueHandling.Ignore)] + public int? Count { get; set; } + } + + /// + /// A Variable is a name/value pair. + /// + public class Variable + { + /// + /// The variable's name. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The variable's value. + /// + [JsonProperty("value")] + public string Value { get; set; } + + /// + /// The type of the variable's value. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the variable is structured and its children + /// can be retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + + /// + /// A reference that allows the client to request the location where the + /// variable's value is declared. + /// + [JsonProperty("declarationLocationReference", NullValueHandling = NullValueHandling.Ignore)] + public int? DeclarationLocationReference { get; set; } + + /// + /// The evaluatable name of this variable which can be passed to the evaluate + /// request to fetch the variable's value. + /// + [JsonProperty("evaluateName", NullValueHandling = NullValueHandling.Ignore)] + public string EvaluateName { get; set; } + } + + /// + /// Response body for 'variables' request. + /// + public class VariablesResponseBody + { + /// + /// All (or a range) of variables for the given variable reference. + /// + [JsonProperty("variables")] + public List Variables { get; set; } = new List(); + } + + #endregion + + #region Continue Request/Response + + /// + /// Arguments for 'continue' request. + /// + public class ContinueArguments + { + /// + /// Specifies the active thread. If the debug adapter supports single thread + /// execution, setting this will resume only the specified thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If this flag is true, execution is resumed only for the thread with given + /// threadId. If false, all threads are resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + /// + /// Response body for 'continue' request. + /// + public class ContinueResponseBody + { + /// + /// If true, all threads are resumed. If false, only the thread with the given + /// threadId is resumed. + /// + [JsonProperty("allThreadsContinued")] + public bool AllThreadsContinued { get; set; } = true; + } + + #endregion + + #region Next Request + + /// + /// Arguments for 'next' request. + /// + public class NextArguments + { + /// + /// Specifies the thread for which to resume execution for one step. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// Stepping granularity. + /// + [JsonProperty("granularity", NullValueHandling = NullValueHandling.Ignore)] + public string Granularity { get; set; } + + /// + /// If this flag is true, all other suspended threads are not resumed. + /// + [JsonProperty("singleThread")] + public bool SingleThread { get; set; } + } + + #endregion + + #region Evaluate Request/Response + + /// + /// Arguments for 'evaluate' request. + /// + public class EvaluateArguments + { + /// + /// The expression to evaluate. + /// + [JsonProperty("expression")] + public string Expression { get; set; } + + /// + /// Evaluate the expression in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// The context in which the evaluate request is used. + /// Values: 'watch', 'repl', 'hover', 'clipboard', 'variables' + /// + [JsonProperty("context", NullValueHandling = NullValueHandling.Ignore)] + public string Context { get; set; } + } + + /// + /// Response body for 'evaluate' request. + /// + public class EvaluateResponseBody + { + /// + /// The result of the evaluate request. + /// + [JsonProperty("result")] + public string Result { get; set; } + + /// + /// The type of the evaluate result. + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// If variablesReference is > 0, the evaluate result is structured. + /// + [JsonProperty("variablesReference")] + public int VariablesReference { get; set; } + + /// + /// The number of named child variables. + /// + [JsonProperty("namedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? NamedVariables { get; set; } + + /// + /// The number of indexed child variables. + /// + [JsonProperty("indexedVariables", NullValueHandling = NullValueHandling.Ignore)] + public int? IndexedVariables { get; set; } + + /// + /// A memory reference to a location appropriate for this result. + /// + [JsonProperty("memoryReference", NullValueHandling = NullValueHandling.Ignore)] + public string MemoryReference { get; set; } + } + + #endregion + + #region Completions Request/Response + + /// + /// Arguments for 'completions' request. + /// + public class CompletionsArguments + { + /// + /// Returns completions in the scope of this stack frame. + /// + [JsonProperty("frameId", NullValueHandling = NullValueHandling.Ignore)] + public int? FrameId { get; set; } + + /// + /// One or more source lines. Typically this is the text users have typed + /// in the debug console (REPL). + /// + [JsonProperty("text")] + public string Text { get; set; } + + /// + /// The position within 'text' for which to determine the completion proposals. + /// It is measured in UTF-16 code units. + /// + [JsonProperty("column")] + public int Column { get; set; } + + /// + /// A line for which to determine the completion proposals. + /// If missing the first line of the text is assumed. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + } + + /// + /// A completion item in the debug console. + /// + public class CompletionItem + { + /// + /// The label of this completion item. + /// + [JsonProperty("label")] + public string Label { get; set; } + + /// + /// If text is returned and not an empty string, then it is inserted instead + /// of the label. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// A human-readable string with additional information about this item. + /// + [JsonProperty("detail", NullValueHandling = NullValueHandling.Ignore)] + public string Detail { get; set; } + + /// + /// The item's type. Typically the client uses this information to render the item + /// in the UI with an icon. + /// Values: 'method', 'function', 'constructor', 'field', 'variable', 'class', + /// 'interface', 'module', 'property', 'unit', 'value', 'enum', 'keyword', + /// 'snippet', 'text', 'color', 'file', 'reference', 'customcolor' + /// + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + + /// + /// Start position (0-based) within 'text' that should be replaced + /// by the completion text. + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public int? Start { get; set; } + + /// + /// Length of the text that should be replaced by the completion text. + /// + [JsonProperty("length", NullValueHandling = NullValueHandling.Ignore)] + public int? Length { get; set; } + } + + /// + /// Response body for 'completions' request. + /// + public class CompletionsResponseBody + { + /// + /// The possible completions. + /// + [JsonProperty("targets")] + public List Targets { get; set; } = new List(); + } + + #endregion + + #region Events + + /// + /// Body for 'stopped' event. + /// The event indicates that the execution of the debuggee has stopped. + /// + public class StoppedEventBody + { + /// + /// The reason for the event. For backward compatibility this string is shown + /// in the UI if the description attribute is missing. + /// Values: 'step', 'breakpoint', 'exception', 'pause', 'entry', 'goto', + /// 'function breakpoint', 'data breakpoint', 'instruction breakpoint' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The full reason for the event, e.g. 'Paused on exception'. + /// This string is shown in the UI as is and can be translated. + /// + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + public string Description { get; set; } + + /// + /// The thread which was stopped. + /// + [JsonProperty("threadId", NullValueHandling = NullValueHandling.Ignore)] + public int? ThreadId { get; set; } + + /// + /// A value of true hints to the client that this event should not change the focus. + /// + [JsonProperty("preserveFocusHint", NullValueHandling = NullValueHandling.Ignore)] + public bool? PreserveFocusHint { get; set; } + + /// + /// Additional information. E.g. if reason is 'exception', text contains the + /// exception name. + /// + [JsonProperty("text", NullValueHandling = NullValueHandling.Ignore)] + public string Text { get; set; } + + /// + /// If allThreadsStopped is true, a debug adapter can announce that all threads + /// have stopped. + /// + [JsonProperty("allThreadsStopped", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsStopped { get; set; } + + /// + /// Ids of the breakpoints that triggered the event. + /// + [JsonProperty("hitBreakpointIds", NullValueHandling = NullValueHandling.Ignore)] + public List HitBreakpointIds { get; set; } + } + + /// + /// Body for 'continued' event. + /// The event indicates that the execution of the debuggee has continued. + /// + public class ContinuedEventBody + { + /// + /// The thread which was continued. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + + /// + /// If true, all threads have been resumed. + /// + [JsonProperty("allThreadsContinued", NullValueHandling = NullValueHandling.Ignore)] + public bool? AllThreadsContinued { get; set; } + } + + /// + /// Body for 'terminated' event. + /// The event indicates that debugging of the debuggee has terminated. + /// + public class TerminatedEventBody + { + /// + /// A debug adapter may set restart to true to request that the client + /// restarts the session. + /// + [JsonProperty("restart", NullValueHandling = NullValueHandling.Ignore)] + public object Restart { get; set; } + } + + /// + /// Body for 'output' event. + /// The event indicates that the target has produced some output. + /// + public class OutputEventBody + { + /// + /// The output category. If not specified, 'console' is assumed. + /// Values: 'console', 'important', 'stdout', 'stderr', 'telemetry' + /// + [JsonProperty("category", NullValueHandling = NullValueHandling.Ignore)] + public string Category { get; set; } + + /// + /// The output to report. + /// + [JsonProperty("output")] + public string Output { get; set; } + + /// + /// Support for keeping an output log organized by grouping related messages. + /// Values: 'start', 'startCollapsed', 'end' + /// + [JsonProperty("group", NullValueHandling = NullValueHandling.Ignore)] + public string Group { get; set; } + + /// + /// If variablesReference is > 0, the output contains objects which can be + /// retrieved by passing variablesReference to the variables request. + /// + [JsonProperty("variablesReference", NullValueHandling = NullValueHandling.Ignore)] + public int? VariablesReference { get; set; } + + /// + /// The source location where the output was produced. + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The source location's line where the output was produced. + /// + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + /// + /// The position in line where the output was produced. + /// + [JsonProperty("column", NullValueHandling = NullValueHandling.Ignore)] + public int? Column { get; set; } + + /// + /// Additional data to report. + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; set; } + } + + /// + /// Body for 'thread' event. + /// The event indicates that a thread has started or exited. + /// + public class ThreadEventBody + { + /// + /// The reason for the event. + /// Values: 'started', 'exited' + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + /// + /// The identifier of the thread. + /// + [JsonProperty("threadId")] + public int ThreadId { get; set; } + } + + /// + /// Body for 'exited' event. + /// The event indicates that the debuggee has exited and returns its exit code. + /// + public class ExitedEventBody + { + /// + /// The exit code returned from the debuggee. + /// + [JsonProperty("exitCode")] + public int ExitCode { get; set; } + } + + #endregion + + #region Error Response + + /// + /// A structured error message. + /// + public class Message + { + /// + /// Unique identifier for the message. + /// + [JsonProperty("id")] + public int Id { get; set; } + + /// + /// A format string for the message. + /// + [JsonProperty("format")] + public string Format { get; set; } + + /// + /// An object used as a dictionary for looking up the variables in the format string. + /// + [JsonProperty("variables", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Variables { get; set; } + + /// + /// If true send to telemetry. + /// + [JsonProperty("sendTelemetry", NullValueHandling = NullValueHandling.Ignore)] + public bool? SendTelemetry { get; set; } + + /// + /// If true show user. + /// + [JsonProperty("showUser", NullValueHandling = NullValueHandling.Ignore)] + public bool? ShowUser { get; set; } + + /// + /// A url where additional information about this message can be found. + /// + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string Url { get; set; } + + /// + /// A label that is presented to the user as the UI for opening the url. + /// + [JsonProperty("urlLabel", NullValueHandling = NullValueHandling.Ignore)] + public string UrlLabel { get; set; } + } + + /// + /// Body for error responses. + /// + public class ErrorResponseBody + { + /// + /// A structured error message. + /// + [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] + public Message Error { get; set; } + } + + #endregion +} diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs new file mode 100644 index 000000000..751f92c51 --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Handlers; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Executes objects in the job's runtime context. + /// + /// Mirrors the behavior of a normal workflow run: step as closely + /// as possible by reusing the runner's existing shell-resolution logic, + /// script fixup helpers, and process execution infrastructure. + /// + /// Output is streamed to the debugger via DAP output events with + /// secrets masked before emission. + /// + internal sealed class DapReplExecutor + { + private readonly IHostContext _hostContext; + private readonly Action _sendOutput; + private readonly Tracing _trace; + + public DapReplExecutor(IHostContext hostContext, Action sendOutput) + { + _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext)); + _sendOutput = sendOutput ?? throw new ArgumentNullException(nameof(sendOutput)); + _trace = hostContext.GetTrace(nameof(DapReplExecutor)); + } + + /// + /// Executes a and returns the exit code as a + /// formatted . + /// + public async Task ExecuteRunCommandAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + if (context == null) + { + return ErrorResult("No execution context available. The debugger must be paused at a step to run commands."); + } + + try + { + return await ExecuteScriptAsync(command, context, cancellationToken); + } + catch (Exception ex) + { + _trace.Error($"REPL run command failed ({ex.GetType().Name})"); + var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message); + return ErrorResult($"Command failed: {maskedError}"); + } + } + + private async Task ExecuteScriptAsync( + RunCommand command, + IExecutionContext context, + CancellationToken cancellationToken) + { + // 1. Resolve shell — same logic as ScriptHandler + string shellCommand; + string argFormat; + + if (!string.IsNullOrEmpty(command.Shell)) + { + // Explicit shell from the DSL + var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell); + shellCommand = parsed.shellCommand; + argFormat = string.IsNullOrEmpty(parsed.shellArgs) + ? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand) + : parsed.shellArgs; + } + else + { + // Default shell — mirrors ScriptHandler platform defaults + shellCommand = ResolveDefaultShell(context); + argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand); + } + + _trace.Info("Resolved REPL shell"); + + // 2. Expand ${{ }} expressions in the script body, just like + // ActionRunner evaluates step inputs before ScriptHandler sees them + var contents = ExpandExpressions(command.Script, context); + contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents); + + // Write to a temp file (same pattern as ScriptHandler) + var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand); + var scriptFilePath = Path.Combine( + _hostContext.GetDirectory(WellKnownDirectory.Temp), + $"dap_repl_{Guid.NewGuid()}{extension}"); + + Encoding encoding = new UTF8Encoding(false); +#if OS_WINDOWS + contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n"); + encoding = Console.InputEncoding.CodePage != 65001 + ? Console.InputEncoding + : encoding; +#endif + File.WriteAllText(scriptFilePath, contents, encoding); + + try + { + // 3. Format arguments with script path + var resolvedPath = scriptFilePath.Replace("\"", "\\\""); + if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}")) + { + return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'"); + } + var arguments = string.Format(argFormat, resolvedPath); + + // 4. Resolve shell command path + string prependPath = string.Join( + Path.PathSeparator.ToString(), + Enumerable.Reverse(context.Global.PrependPath)); + var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath) + ?? shellCommand; + + // 5. Build environment — merge from execution context like a real step + var environment = BuildEnvironment(context, command.Env); + + // 6. Resolve working directory + var workingDirectory = command.WorkingDirectory; + if (string.IsNullOrEmpty(workingDirectory)) + { + var githubContext = context.ExpressionValues.TryGetValue("github", out var gh) + ? gh as DictionaryContextData + : null; + var workspace = githubContext?.TryGetValue("workspace", out var ws) == true + ? (ws as StringContextData)?.Value + : null; + workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work); + } + + _trace.Info("Executing REPL command"); + + // Stream execution info to debugger + SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); + + // 7. Execute via IProcessInvoker (same as DefaultStepHost) + int exitCode; + using (var processInvoker = _hostContext.CreateService()) + { + processInvoker.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stdout", masked + "\n"); + } + }; + + processInvoker.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + var masked = _hostContext.SecretMasker.MaskSecrets(args.Data); + SendOutput("stderr", masked + "\n"); + } + }; + + exitCode = await processInvoker.ExecuteAsync( + workingDirectory: workingDirectory, + fileName: commandPath, + arguments: arguments, + environment: environment, + requireExitCodeZero: false, + outputEncoding: null, + killProcessOnCancel: true, + cancellationToken: cancellationToken); + } + + _trace.Info($"REPL command exited with code {exitCode}"); + + // 8. Return only the exit code summary (output was already streamed) + return new EvaluateResponseBody + { + Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.", + Type = exitCode == 0 ? "string" : "error", + VariablesReference = 0 + }; + } + finally + { + // Clean up temp script file + try { File.Delete(scriptFilePath); } + catch { /* best effort */ } + } + } + + /// + /// Expands ${{ }} expressions in the input string using the + /// runner's template evaluator — the same evaluation path that processes + /// step inputs before runs them. + /// + /// Each ${{ expr }} occurrence is individually evaluated and + /// replaced with its masked string result, mirroring the semantics of + /// expression interpolation in a workflow run: step body. + /// + internal string ExpandExpressions(string input, IExecutionContext context) + { + if (string.IsNullOrEmpty(input) || !input.Contains("${{")) + { + return input ?? string.Empty; + } + + var result = new StringBuilder(); + int pos = 0; + + while (pos < input.Length) + { + var start = input.IndexOf("${{", pos, StringComparison.Ordinal); + if (start < 0) + { + result.Append(input, pos, input.Length - pos); + break; + } + + // Append the literal text before the expression + result.Append(input, pos, start - pos); + + var end = input.IndexOf("}}", start + 3, StringComparison.Ordinal); + if (end < 0) + { + // Unterminated expression — keep literal + result.Append(input, start, input.Length - start); + break; + } + + var expr = input.Substring(start + 3, end - start - 3).Trim(); + end += 2; // skip past "}}" + + // Evaluate the expression + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new GitHub.DistributedTask.ObjectTemplating.Tokens.BasicExpressionToken( + null, null, null, expr); + var evaluated = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + result.Append(_hostContext.SecretMasker.MaskSecrets(evaluated ?? string.Empty)); + } + catch (Exception ex) + { + _trace.Warning($"Expression expansion failed ({ex.GetType().Name})"); + // Keep the original expression literal on failure + result.Append(input, start, end - start); + } + + pos = end; + } + + return result.ToString(); + } + + /// + /// Resolves the default shell the same way + /// does: check job defaults, then fall back to platform default. + /// + internal string ResolveDefaultShell(IExecutionContext context) + { + // Check job defaults + if (context.Global?.JobDefaults != null && + context.Global.JobDefaults.TryGetValue("run", out var runDefaults) && + runDefaults.TryGetValue("shell", out var defaultShell) && + !string.IsNullOrEmpty(defaultShell)) + { + _trace.Info("Using job default shell"); + return defaultShell; + } + +#if OS_WINDOWS + string prependPath = string.Join( + Path.PathSeparator.ToString(), + context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty()); + var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath); + return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell"; +#else + return "sh"; +#endif + } + + /// + /// Merges the job context environment with any REPL-specific overrides. + /// + internal Dictionary BuildEnvironment( + IExecutionContext context, + Dictionary replEnv) + { + var env = new Dictionary(VarUtil.EnvironmentVariableKeyComparer); + + // Pull environment from the execution context (same as ActionRunner) + if (context.ExpressionValues.TryGetValue("env", out var envData)) + { + if (envData is DictionaryContextData dictEnv) + { + foreach (var pair in dictEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + else if (envData is CaseSensitiveDictionaryContextData csEnv) + { + foreach (var pair in csEnv) + { + if (pair.Value is StringContextData str) + { + env[pair.Key] = str.Value; + } + } + } + } + + // Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.) + foreach (var ctxPair in context.ExpressionValues) + { + if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null) + { + foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables()) + { + env[rtEnv.Key] = rtEnv.Value; + } + } + } + + // Apply REPL-specific overrides last (so they win), + // expanding any ${{ }} expressions in the values + if (replEnv != null) + { + foreach (var pair in replEnv) + { + env[pair.Key] = ExpandExpressions(pair.Value, context); + } + } + + return env; + } + + private void SendOutput(string category, string text) + { + _sendOutput(category, text); + } + + private static EvaluateResponseBody ErrorResult(string message) + { + return new EvaluateResponseBody + { + Result = message, + Type = "error", + VariablesReference = 0 + }; + } + } +} diff --git a/src/Runner.Worker/Dap/DapReplParser.cs b/src/Runner.Worker/Dap/DapReplParser.cs new file mode 100644 index 000000000..c23f4c181 --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplParser.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Base type for all REPL DSL commands. + /// + internal abstract class DapReplCommand + { + } + + /// + /// help or help("run") + /// + internal sealed class HelpCommand : DapReplCommand + { + public string Topic { get; set; } + } + + /// + /// run("echo hello") or + /// run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp") + /// + internal sealed class RunCommand : DapReplCommand + { + public string Script { get; set; } + public string Shell { get; set; } + public Dictionary Env { get; set; } + public string WorkingDirectory { get; set; } + } + + /// + /// Parses REPL input into typed objects. + /// + /// Grammar (intentionally minimal — extend as the DSL grows): + /// + /// help → HelpCommand { Topic = null } + /// help("run") → HelpCommand { Topic = "run" } + /// run("script body") → RunCommand { Script = "script body" } + /// run("script", shell: "bash") → RunCommand { Shell = "bash" } + /// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } } + /// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" } + /// + /// + /// Parsing is intentionally hand-rolled rather than regex-based so it can + /// handle nested braces, quoted strings with escapes, and grow to support + /// future commands without accumulating regex complexity. + /// + internal static class DapReplParser + { + /// + /// Attempts to parse REPL input into a command. Returns null if the + /// input does not match any known DSL command (i.e. it should be + /// treated as an expression instead). + /// + internal static DapReplCommand TryParse(string input, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var trimmed = input.Trim(); + + // help / help("topic") + if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase)) + { + return ParseHelp(trimmed, out error); + } + + // run("...") + if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + return ParseRun(trimmed, out error); + } + + // Not a DSL command + return null; + } + + internal static string GetGeneralHelp() + { + return """ + Actions Debug Console + + Commands: + help Show this help + help("run") Show help for the run command + run("script") Execute a script (like a workflow run step) + + Anything else is evaluated as a GitHub Actions expression. + Example: github.repository + Example: ${{ github.event_name }} + + """; + } + + internal static string GetRunHelp() + { + return """ + run command — execute a script in the job context + + Usage: + run("echo hello") + run("echo $FOO", shell: "bash") + run("echo $FOO", env: { FOO: "bar" }) + run("ls", working_directory: "/tmp") + run("echo $X", shell: "bash", env: { X: "1" }, working_directory: "/tmp") + + Options: + shell: Shell to use (default: job default, e.g. bash) + env: Extra environment variables as { KEY: "value" } + working_directory: Working directory for the command + + Behavior: + - Equivalent to a workflow `run:` step + - Expressions in the script body are expanded (${{ ... }}) + - Output is streamed in real time and secrets are masked + + """; + } + + #region Parsers + + private static HelpCommand ParseHelp(string input, out string error) + { + error = null; + if (input.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return new HelpCommand(); + } + + // help("topic") + var inner = ExtractParenthesizedArgs(input, "help", out error); + if (error != null) return null; + + var topic = ExtractQuotedString(inner.Trim(), out error); + if (error != null) return null; + + return new HelpCommand { Topic = topic }; + } + + private static RunCommand ParseRun(string input, out string error) + { + error = null; + + var inner = ExtractParenthesizedArgs(input, "run", out error); + if (error != null) return null; + + // Split into argument list respecting quotes and braces + var args = SplitArguments(inner, out error); + if (error != null) return null; + if (args.Count == 0) + { + error = "run() requires a script argument. Example: run(\"echo hello\")"; + return null; + } + + // First arg must be the script body (a quoted string) + var script = ExtractQuotedString(args[0].Trim(), out error); + if (error != null) + { + error = $"First argument to run() must be a quoted string. {error}"; + return null; + } + + var cmd = new RunCommand { Script = script }; + + // Parse remaining keyword arguments + for (int i = 1; i < args.Count; i++) + { + var kv = args[i].Trim(); + var colonIdx = kv.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}"; + return null; + } + + var key = kv.Substring(0, colonIdx).Trim(); + var value = kv.Substring(colonIdx + 1).Trim(); + + switch (key.ToLowerInvariant()) + { + case "shell": + cmd.Shell = ExtractQuotedString(value, out error); + if (error != null) { error = $"shell: {error}"; return null; } + break; + + case "working_directory": + cmd.WorkingDirectory = ExtractQuotedString(value, out error); + if (error != null) { error = $"working_directory: {error}"; return null; } + break; + + case "env": + cmd.Env = ParseEnvBlock(value, out error); + if (error != null) { error = $"env: {error}"; return null; } + break; + + default: + error = $"Unknown option: {key}. Valid options: shell, env, working_directory"; + return null; + } + } + + return cmd; + } + + #endregion + + #region Low-level parsing helpers + + /// + /// Given "cmd(...)" returns the inner content between the outer parens. + /// + private static string ExtractParenthesizedArgs(string input, string prefix, out string error) + { + error = null; + var start = prefix.Length; // skip "cmd" + if (start >= input.Length || input[start] != '(') + { + error = $"Expected '(' after {prefix}"; + return null; + } + + if (input[input.Length - 1] != ')') + { + error = $"Expected ')' at end of {prefix}(...)"; + return null; + } + + return input.Substring(start + 1, input.Length - start - 2); + } + + /// + /// Extracts a double-quoted string value, handling escaped quotes. + /// + internal static string ExtractQuotedString(string input, out string error) + { + error = null; + if (string.IsNullOrEmpty(input)) + { + error = "Expected a quoted string, got empty input"; + return null; + } + + if (input[0] != '"') + { + error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}"; + return null; + } + + var sb = new StringBuilder(); + for (int i = 1; i < input.Length; i++) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + sb.Append(input[i + 1]); + i++; + } + else if (input[i] == '"') + { + // Check nothing meaningful follows the closing quote + var rest = input.Substring(i + 1).Trim(); + if (rest.Length > 0) + { + error = $"Unexpected content after closing quote: {Truncate(rest, 40)}"; + return null; + } + return sb.ToString(); + } + else + { + sb.Append(input[i]); + } + } + + error = "Unterminated string (missing closing \")"; + return null; + } + + /// + /// Splits a comma-separated argument list, respecting quoted strings + /// and nested braces so that "a, b", env: { K: "V, W" } is + /// correctly split into two arguments. + /// + internal static List SplitArguments(string input, out string error) + { + error = null; + var result = new List(); + var current = new StringBuilder(); + int depth = 0; + bool inQuote = false; + + for (int i = 0; i < input.Length; i++) + { + var ch = input[i]; + + if (ch == '\\' && inQuote && i + 1 < input.Length) + { + current.Append(ch); + current.Append(input[++i]); + continue; + } + + if (ch == '"') + { + inQuote = !inQuote; + current.Append(ch); + continue; + } + + if (!inQuote) + { + if (ch == '{') + { + depth++; + current.Append(ch); + continue; + } + if (ch == '}') + { + depth--; + current.Append(ch); + continue; + } + if (ch == ',' && depth == 0) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + } + + current.Append(ch); + } + + if (inQuote) + { + error = "Unterminated string in arguments"; + return null; + } + if (depth != 0) + { + error = "Unmatched braces in arguments"; + return null; + } + + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } + + /// + /// Parses { KEY: "value", KEY2: "value2" } into a dictionary. + /// + internal static Dictionary ParseEnvBlock(string input, out string error) + { + error = null; + var trimmed = input.Trim(); + if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}")) + { + error = "Expected env block in the form { KEY: \"value\" }"; + return null; + } + + var inner = trimmed.Substring(1, trimmed.Length - 2).Trim(); + if (string.IsNullOrEmpty(inner)) + { + return new Dictionary(); + } + + var pairs = SplitArguments(inner, out error); + if (error != null) return null; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + { + var colonIdx = pair.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}"; + return null; + } + + var key = pair.Substring(0, colonIdx).Trim(); + var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error); + if (error != null) return null; + + result[key] = val; + } + + return result; + } + + private static string Truncate(string value, int maxLength) + { + if (value == null) return "(null)"; + return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "..."; + } + + #endregion + } +} diff --git a/src/Runner.Worker/Dap/DapVariableProvider.cs b/src/Runner.Worker/Dap/DapVariableProvider.cs new file mode 100644 index 000000000..85c11f553 --- /dev/null +++ b/src/Runner.Worker/Dap/DapVariableProvider.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using GitHub.DistributedTask.Logging; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Maps runner execution context data to DAP scopes and variables. + /// + /// This is the single point where runner context values are materialized + /// for the debugger. All values pass through the runner's existing + /// so the DAP + /// surface never exposes anything beyond what a normal CI log would show. + /// + /// The secrets scope is intentionally opaque: keys are visible but every + /// value is replaced with a constant redaction marker. + /// + /// Designed to be reusable by future DAP features (evaluate, hover, REPL) + /// so that masking policy is never duplicated. + /// + internal sealed class DapVariableProvider + { + // Well-known scope names that map to top-level expression contexts. + // Order matters: the index determines the stable variablesReference ID. + private static readonly string[] _scopeNames = + { + "github", "env", "runner", "job", "steps", + "secrets", "inputs", "vars", "matrix", "needs" + }; + + // Scope references occupy the range [1, ScopeReferenceMax]. + private const int _scopeReferenceBase = 1; + private const int _scopeReferenceMax = 100; + + // Dynamic (nested) variable references start above the scope range. + private const int _dynamicReferenceBase = 101; + + private const string _redactedValue = "***"; + + private readonly ISecretMasker _secretMasker; + + // Maps dynamic variable reference IDs to the backing data and its + // dot-separated path (e.g. "github.event.pull_request"). + private readonly Dictionary _variableReferences = new(); + private int _nextVariableReference = _dynamicReferenceBase; + + public DapVariableProvider(ISecretMasker secretMasker) + { + _secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker)); + } + + /// + /// Clears all dynamic variable references. + /// Call this whenever the paused execution context changes (e.g. new step) + /// so that stale nested references are not served to the client. + /// + public void Reset() + { + _variableReferences.Clear(); + _nextVariableReference = _dynamicReferenceBase; + } + + /// + /// Returns the list of DAP scopes for the given execution context. + /// Each scope corresponds to a well-known runner expression context + /// (github, env, secrets, …) and carries a stable variablesReference + /// that the client can use to drill into variables. + /// + public List GetScopes(IExecutionContext context) + { + var scopes = new List(); + + if (context?.ExpressionValues == null) + { + return scopes; + } + + for (int i = 0; i < _scopeNames.Length; i++) + { + var scopeName = _scopeNames[i]; + if (!context.ExpressionValues.TryGetValue(scopeName, out var value) || value == null) + { + continue; + } + + var scope = new Scope + { + Name = scopeName, + VariablesReference = _scopeReferenceBase + i, + Expensive = false, + PresentationHint = scopeName == "secrets" ? "registers" : null + }; + + if (value is DictionaryContextData dict) + { + scope.NamedVariables = dict.Count; + } + else if (value is CaseSensitiveDictionaryContextData csDict) + { + scope.NamedVariables = csDict.Count; + } + + scopes.Add(scope); + } + + return scopes; + } + + /// + /// Returns the child variables for a given variablesReference. + /// The reference may point at a top-level scope (1–100) or a + /// dynamically registered nested container (101+). + /// + public List GetVariables(IExecutionContext context, int variablesReference) + { + var variables = new List(); + + if (context?.ExpressionValues == null) + { + return variables; + } + + PipelineContextData data = null; + string basePath = null; + bool isSecretsScope = false; + + if (variablesReference >= _scopeReferenceBase && variablesReference <= _scopeReferenceMax) + { + var scopeIndex = variablesReference - _scopeReferenceBase; + if (scopeIndex < _scopeNames.Length) + { + var scopeName = _scopeNames[scopeIndex]; + isSecretsScope = scopeName == "secrets"; + if (context.ExpressionValues.TryGetValue(scopeName, out data)) + { + basePath = scopeName; + } + } + } + else if (_variableReferences.TryGetValue(variablesReference, out var refData)) + { + data = refData.Data; + basePath = refData.Path; + isSecretsScope = basePath?.StartsWith("secrets", StringComparison.OrdinalIgnoreCase) == true; + } + + if (data == null) + { + return variables; + } + + ConvertToVariables(data, basePath, isSecretsScope, variables); + return variables; + } + + /// + /// Evaluates a GitHub Actions expression (e.g. "github.repository", + /// "${{ github.event_name }}") in the context of the current step and + /// returns a masked result suitable for the DAP evaluate response. + /// + /// Uses the runner's standard + /// so the full expression language is available (functions, operators, + /// context access). + /// + public EvaluateResponseBody EvaluateExpression(string expression, IExecutionContext context) + { + if (context?.ExpressionValues == null) + { + return new EvaluateResponseBody + { + Result = "(no execution context available)", + Type = "string", + VariablesReference = 0 + }; + } + + // Strip ${{ }} wrapper if present + var expr = expression?.Trim() ?? string.Empty; + if (expr.StartsWith("${{") && expr.EndsWith("}}")) + { + expr = expr.Substring(3, expr.Length - 5).Trim(); + } + + if (string.IsNullOrEmpty(expr)) + { + return new EvaluateResponseBody + { + Result = string.Empty, + Type = "string", + VariablesReference = 0 + }; + } + + try + { + var templateEvaluator = context.ToPipelineTemplateEvaluator(); + var token = new BasicExpressionToken(null, null, null, expr); + + var result = templateEvaluator.EvaluateStepDisplayName( + token, + context.ExpressionValues, + context.ExpressionFunctions); + + result = _secretMasker.MaskSecrets(result ?? "null"); + + return new EvaluateResponseBody + { + Result = result, + Type = InferResultType(result), + VariablesReference = 0 + }; + } + catch (Exception ex) + { + var errorMessage = _secretMasker.MaskSecrets($"Evaluation error: {ex.Message}"); + return new EvaluateResponseBody + { + Result = errorMessage, + Type = "string", + VariablesReference = 0 + }; + } + } + + /// + /// Infers a simple DAP type hint from the string representation of a result. + /// + internal static string InferResultType(string value) + { + value = value?.ToLower(); + if (value == null || value == "null") + return "null"; + if (value == "true" || value == "false") + return "boolean"; + if (double.TryParse(value, NumberStyles.Any, + CultureInfo.InvariantCulture, out _)) + return "number"; + if (value.StartsWith("{") || value.StartsWith("[")) + return "object"; + return "string"; + } + + #region Private helpers + + private void ConvertToVariables( + PipelineContextData data, + string basePath, + bool isSecretsScope, + List variables) + { + switch (data) + { + case DictionaryContextData dict: + foreach (var pair in dict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case CaseSensitiveDictionaryContextData csDict: + foreach (var pair in csDict) + { + variables.Add(CreateVariable(pair.Key, pair.Value, basePath, isSecretsScope)); + } + break; + + case ArrayContextData array: + for (int i = 0; i < array.Count; i++) + { + var variable = CreateVariable($"[{i}]", array[i], basePath, isSecretsScope); + variables.Add(variable); + } + break; + } + } + + private Variable CreateVariable( + string name, + PipelineContextData value, + string basePath, + bool isSecretsScope) + { + var childPath = string.IsNullOrEmpty(basePath) ? name : $"{basePath}.{name}"; + var variable = new Variable + { + Name = name, + EvaluateName = $"${{{{ {childPath} }}}}" + }; + + // Secrets scope: redact ALL values regardless of underlying type. + // Keys are visible but values are always replaced with the + // redaction marker, and nested containers are not drillable. + if (isSecretsScope) + { + variable.Value = _redactedValue; + variable.Type = "string"; + variable.VariablesReference = 0; + return variable; + } + + if (value == null) + { + variable.Value = "null"; + variable.Type = "null"; + variable.VariablesReference = 0; + return variable; + } + + switch (value) + { + case StringContextData str: + variable.Value = _secretMasker.MaskSecrets(str.Value); + variable.Type = "string"; + variable.VariablesReference = 0; + break; + + case NumberContextData num: + variable.Value = _secretMasker.MaskSecrets(num.Value.ToString("G15", CultureInfo.InvariantCulture)); + variable.Type = "number"; + variable.VariablesReference = 0; + break; + + case BooleanContextData boolVal: + variable.Value = boolVal.Value ? "true" : "false"; + variable.Type = "boolean"; + variable.VariablesReference = 0; + break; + + case DictionaryContextData dict: + variable.Value = $"Object ({dict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(dict, childPath); + variable.NamedVariables = dict.Count; + break; + + case CaseSensitiveDictionaryContextData csDict: + variable.Value = $"Object ({csDict.Count} properties)"; + variable.Type = "object"; + variable.VariablesReference = RegisterVariableReference(csDict, childPath); + variable.NamedVariables = csDict.Count; + break; + + case ArrayContextData array: + variable.Value = $"Array ({array.Count} items)"; + variable.Type = "array"; + variable.VariablesReference = RegisterVariableReference(array, childPath); + variable.IndexedVariables = array.Count; + break; + + default: + var rawValue = value.ToJToken()?.ToString() ?? "unknown"; + variable.Value = _secretMasker.MaskSecrets(rawValue); + variable.Type = value.GetType().Name; + variable.VariablesReference = 0; + break; + } + + return variable; + } + + private int RegisterVariableReference(PipelineContextData data, string path) + { + var reference = _nextVariableReference++; + _variableReferences[reference] = (data, path); + return reference; + } + + #endregion + } +} diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs new file mode 100644 index 000000000..533626a42 --- /dev/null +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker.Dap +{ + public enum DapSessionState + { + NotStarted, + WaitingForConnection, + Initializing, + Ready, + Paused, + Running, + Terminated + } + + [ServiceLocator(Default = typeof(DapDebugger))] + public interface IDapDebugger : IRunnerService + { + Task StartAsync(IExecutionContext jobContext); + Task WaitUntilReadyAsync(); + Task OnStepStartingAsync(IStep step); + void OnStepCompleted(IStep step); + Task OnJobCompletedAsync(); + } +} diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index d835682a4..70f2a47af 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -969,6 +969,9 @@ namespace GitHub.Runner.Worker // Verbosity (from GitHub.Step_Debug). Global.WriteDebug = Global.Variables.Step_Debug ?? false; + // Debugger enabled flag (from acquire response). + Global.EnableDebugger = message.EnableDebugger; + // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; } diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 64b33c755..60b4ef1fe 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -27,6 +27,7 @@ namespace GitHub.Runner.Worker public StepsContext StepsContext { get; set; } public Variables Variables { get; set; } public bool WriteDebug { get; set; } + public bool EnableDebugger { get; set; } public string InfrastructureFailureCategory { get; set; } public JObject ContainerHookState { get; set; } public bool HasTemplateEvaluatorMismatch { get; set; } diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs index a825116a6..de556bce3 100644 --- a/src/Runner.Worker/InternalsVisibleTo.cs +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Test")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 72ee5a403..10623bbef 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -28,6 +29,7 @@ namespace GitHub.Runner.Worker public sealed class JobRunner : RunnerService, IJobRunner { + private const string DebuggerConnectionTelemetryPrefix = "DebuggerConnectionResult"; private IJobServerQueue _jobServerQueue; private RunnerSettings _runnerSettings; private ITempDirectoryManager _tempDirectoryManager; @@ -112,6 +114,7 @@ namespace GitHub.Runner.Worker IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; + IDapDebugger dapDebugger = null; try { // Create the job execution context. @@ -178,6 +181,26 @@ namespace GitHub.Runner.Worker _tempDirectoryManager = HostContext.GetService(); _tempDirectoryManager.InitializeTempDirectory(jobContext); + // Setup the debugger + if (jobContext.Global.EnableDebugger) + { + Trace.Info("Debugger enabled for this job run"); + + try + { + dapDebugger = HostContext.GetService(); + await dapDebugger.StartAsync(jobContext); + } + catch (Exception ex) + { + Trace.Error($"Failed to start DAP debugger: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + jobContext.Error("Failed to start debugger."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } + } + + // Get the job extension. Trace.Info("Getting job extension."); IJobExtension jobExtension = HostContext.CreateService(); @@ -219,6 +242,33 @@ namespace GitHub.Runner.Worker await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000)); } + // Wait for DAP debugger client connection and handshake after "Set up job" + // so the job page shows the setup step before we block on the debugger + if (dapDebugger != null) + { + try + { + await dapDebugger.WaitUntilReadyAsync(); + AddDebuggerConnectionTelemetry(jobContext, "Connected"); + } + catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected."); + AddDebuggerConnectionTelemetry(jobContext, "Canceled"); + jobContext.Error("Job was cancelled before debugger client connected."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Canceled); + } + catch (Exception ex) + { + Trace.Error($"DAP debugger failed to become ready: {ex.Message}"); + AddDebuggerConnectionTelemetry(jobContext, $"Failed: {ex.Message}"); + + // If debugging was requested but the debugger is not available, fail the job + jobContext.Error("The debugger failed to start or no debugger client connected in time."); + return await CompleteJobAsync(server, jobContext, message, TaskResult.Failed); + } + } + // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -259,6 +309,11 @@ namespace GitHub.Runner.Worker runnerShutdownRegistration = null; } + if (dapDebugger != null) + { + await dapDebugger.OnJobCompletedAsync(); + } + await ShutdownQueue(throwOnFailure: false); } } @@ -440,6 +495,15 @@ namespace GitHub.Runner.Worker throw new AggregateException(exceptions); } + private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result) + { + jobContext.Global.JobTelemetry.Add(new JobTelemetry + { + Type = JobTelemetryType.General, + Message = $"{DebuggerConnectionTelemetryPrefix}: {result}" + }); + } + private void MaskTelemetrySecrets(List jobTelemetry) { foreach (var telemetryItem in jobTelemetry) diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 83ce87f64..21bdfa6f7 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Expressions; namespace GitHub.Runner.Worker @@ -50,6 +51,7 @@ namespace GitHub.Runner.Worker jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; + var dapDebugger = HostContext.GetService(); while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) { if (jobContext.JobSteps.Count == 0 && !checkPostJobActions) @@ -226,9 +228,14 @@ namespace GitHub.Runner.Worker } else { + // Pause for DAP debugger before step execution + await dapDebugger?.OnStepStartingAsync(step); + // Run the step await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); + + dapDebugger?.OnStepCompleted(step); } } finally @@ -255,6 +262,7 @@ namespace GitHub.Runner.Worker Trace.Info($"Current state: job state = '{jobContext.Result}'"); } + } private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken) diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index e6ecbf450..328f62160 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -253,6 +253,13 @@ namespace GitHub.DistributedTask.Pipelines set; } + [DataMember(EmitDefaultValue = false)] + public bool EnableDebugger + { + get; + set; + } + /// /// Gets the collection of variables associated with the current context. /// diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs new file mode 100644 index 000000000..33b30d308 --- /dev/null +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Text; +using Xunit; +using GitHub.DistributedTask.Pipelines; + +namespace GitHub.Actions.RunService.WebApi.Tests; + +public sealed class AgentJobRequestMessageL0 +{ + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithTrue() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_DefaultToFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent"); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyEnableDebuggerDeserialization_WithFalse() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false"); + } + + private static string DoubleQuotify(string text) + { + return text.Replace('\'', '"'); + } +} diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 59b890285..ec8a270f5 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -2,6 +2,7 @@ using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Handlers; using System; diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs new file mode 100644 index 000000000..f2c8557d1 --- /dev/null +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -0,0 +1,616 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Newtonsoft.Json; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapDebuggerL0 + { + private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT"; + private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT"; + private DapDebugger _debugger; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _debugger = new DapDebugger(); + _debugger.Initialize(hc); + return hc; + } + + private static async Task WithEnvironmentVariableAsync(string name, string value, Func action) + { + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + await action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } + } + + private static void WithEnvironmentVariable(string name, string value, Action action) + { + var originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + try + { + action(); + } + finally + { + Environment.SetEnvironmentVariable(name, originalValue); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task ConnectClientAsync(int port) + { + var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + return client; + } + + private static async Task SendRequestAsync(NetworkStream stream, Request request) + { + var json = JsonConvert.SerializeObject(request); + var body = Encoding.UTF8.GetBytes(json); + var header = $"Content-Length: {body.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + await stream.WriteAsync(headerBytes, 0, headerBytes.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + } + + /// + /// Reads a single DAP-framed message from a stream with a timeout. + /// Parses the Content-Length header, reads exactly that many bytes, + /// and returns the JSON body. Fails with a clear error on timeout. + /// + private static async Task ReadDapMessageAsync(NetworkStream stream, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var token = cts.Token; + + var headerBuilder = new StringBuilder(); + var buffer = new byte[1]; + var contentLength = -1; + + while (true) + { + var readTask = stream.ReadAsync(buffer, 0, 1, token); + var bytesRead = await readTask; + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP headers"); + } + + headerBuilder.Append((char)buffer[0]); + var headers = headerBuilder.ToString(); + if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { + foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase)) + { + contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim()); + } + } + break; + } + } + + if (contentLength < 0) + { + throw new InvalidOperationException("No Content-Length header found in DAP message"); + } + + var body = new byte[contentLength]; + var totalRead = 0; + while (totalRead < contentLength) + { + var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead, token); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed while reading DAP body"); + } + totalRead += bytesRead; + } + + return Encoding.UTF8.GetString(body); + } + + private static Mock CreateJobContext(CancellationToken cancellationToken, string jobName = null) + { + var jobContext = new Mock(); + jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); + jobContext + .Setup(x => x.GetGitHubContext(It.IsAny())) + .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); + return jobContext; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeSucceeds() + { + using (CreateTestContext()) + { + Assert.NotNull(_debugger); + Assert.False(_debugger.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortUsesCustomPortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "9999", () => + { + Assert.Equal(9999, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortIgnoresInvalidPortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () => + { + Assert.Equal(4711, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolvePortIgnoresOutOfRangePortFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(PortEnvironmentVariable, "99999", () => + { + Assert.Equal(4711, _debugger.ResolvePort()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutUsesCustomTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "30", () => + { + Assert.Equal(30, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutIgnoresInvalidTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "not-a-number", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveTimeoutIgnoresZeroTimeoutFromEnvironment() + { + using (CreateTestContext()) + { + WithEnvironmentVariable(TimeoutEnvironmentVariable, "0", () => + { + Assert.Equal(15, _debugger.ResolveTimeout()); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopLifecycle() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + Assert.True(client.Connected); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopMultipleTimesDoesNotThrow() + { + using (CreateTestContext()) + { + foreach (var port in new[] { GetFreePort(), GetFreePort() }) + { + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); + }); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyCompletesAfterClientConnectionAndConfigurationDone() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + Assert.Equal(DapSessionState.Ready, _debugger.State); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartStoresJobContextForThreadsRequest() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "threads" + }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", response); + Assert.Contains("\"name\":\"Job: ci-job\"", response); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationUnblocksAndOnJobCompletedTerminates() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + cts.Cancel(); + + // In the real runner, JobRunner always calls OnJobCompletedAsync + // from a finally block. The cancellation callback only unblocks + // pending waits; OnJobCompletedAsync handles state + cleanup. + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopWithoutStartDoesNotThrow() + { + using (CreateTestContext()) + { + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedTerminatesSession() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + await SendRequestAsync(client.GetStream(), new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + await waitTask; + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyBeforeStartIsNoOp() + { + using (CreateTestContext()) + { + await _debugger.WaitUntilReadyAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitUntilReadyJobCancellationPropagatesAsOperationCancelledException() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + await Task.Delay(50); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => waitTask); + Assert.IsNotType(ex); + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task InitializeRequestOverSocketPreservesProtocolMetadataWhenSecretsCollide() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("response"); + hc.SecretMasker.AddValue("initialize"); + hc.SecretMasker.AddValue("event"); + hc.SecretMasker.AddValue("initialized"); + + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + + var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"response\"", response); + Assert.Contains("\"command\":\"initialize\"", response); + Assert.Contains("\"success\":true", response); + + var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"type\":\"event\"", initializedEvent); + Assert.Contains("\"event\":\"initialized\"", initializedEvent); + + await _debugger.StopAsync(); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationDuringStepPauseReleasesWait() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + // Complete handshake so session is ready + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + await waitTask; + + // Simulate a step starting (which pauses) + var step = new Mock(); + step.Setup(s => s.DisplayName).Returns("Test Step"); + step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null); + var stepTask = _debugger.OnStepStartingAsync(step.Object); + + // Give the step time to pause + await Task.Delay(50); + + // Cancel the job — should release the step pause + cts.Cancel(); + await stepTask; + + // In the real runner, OnJobCompletedAsync always follows. + await _debugger.OnJobCompletedAsync(); + Assert.Equal(DapSessionState.Terminated, _debugger.State); + }); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopAsyncSafeAtAnyLifecyclePoint() + { + using (CreateTestContext()) + { + // StopAsync before start + await _debugger.StopAsync(); + + // Start then immediate stop (no connection, no WaitUntilReady) + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + await _debugger.StopAsync(); + }); + + // StopAsync after already stopped + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContext(cts.Token); + await _debugger.StartAsync(jobContext.Object); + + var waitTask = _debugger.WaitUntilReadyAsync(); + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + // Read the configurationDone response + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + await waitTask; + + // Complete the job — events are sent via OnJobCompletedAsync + await _debugger.OnJobCompletedAsync(); + + var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + // Both events should arrive (order may vary) + var combined = msg1 + msg2; + Assert.Contains("\"event\":\"terminated\"", combined); + Assert.Contains("\"event\":\"exited\"", combined); + }); + } + } + } +} diff --git a/src/Test/L0/Worker/DapMessagesL0.cs b/src/Test/L0/Worker/DapMessagesL0.cs new file mode 100644 index 000000000..1b8285717 --- /dev/null +++ b/src/Test/L0/Worker/DapMessagesL0.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using GitHub.Runner.Worker.Dap; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapMessagesL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestSerializesCorrectly() + { + var request = new Request + { + Seq = 1, + Type = "request", + Command = "initialize", + Arguments = JObject.FromObject(new { clientID = "test-client" }) + }; + + var json = JsonConvert.SerializeObject(request); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Seq); + Assert.Equal("request", deserialized.Type); + Assert.Equal("initialize", deserialized.Command); + Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResponseSerializesCorrectly() + { + var response = new Response + { + Seq = 2, + Type = "response", + RequestSeq = 1, + Success = true, + Command = "initialize", + Body = new Capabilities { SupportsConfigurationDoneRequest = true } + }; + + var json = JsonConvert.SerializeObject(response); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(2, deserialized.Seq); + Assert.Equal("response", deserialized.Type); + Assert.Equal(1, deserialized.RequestSeq); + Assert.True(deserialized.Success); + Assert.Equal("initialize", deserialized.Command); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EventSerializesWithCorrectType() + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + Description = "Stopped at entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + Assert.Equal("event", evt.Type); + + var json = JsonConvert.SerializeObject(evt); + Assert.Contains("\"type\":\"event\"", json); + Assert.Contains("\"event\":\"stopped\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StoppedEventBodyOmitsNullFields() + { + var body = new StoppedEventBody + { + Reason = "step" + }; + + var json = JsonConvert.SerializeObject(body); + Assert.Contains("\"reason\":\"step\"", json); + Assert.DoesNotContain("\"threadId\"", json); + Assert.DoesNotContain("\"allThreadsStopped\"", json); + Assert.DoesNotContain("\"description\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CapabilitiesMvpDefaults() + { + var caps = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsFunctionBreakpoints = false, + SupportsStepBack = false + }; + + var json = JsonConvert.SerializeObject(caps); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.SupportsConfigurationDoneRequest); + Assert.False(deserialized.SupportsFunctionBreakpoints); + Assert.False(deserialized.SupportsStepBack); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ContinueResponseBodySerialization() + { + var body = new ContinueResponseBody { AllThreadsContinued = true }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.AllThreadsContinued); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ThreadsResponseBodySerialization() + { + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread { Id = 1, Name = "Job Thread" } + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Single(deserialized.Threads); + Assert.Equal(1, deserialized.Threads[0].Id); + Assert.Equal("Job Thread", deserialized.Threads[0].Name); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StackFrameSerialization() + { + var frame = new StackFrame + { + Id = 1, + Name = "Step: Checkout", + Line = 1, + Column = 1, + PresentationHint = "normal" + }; + + var json = JsonConvert.SerializeObject(frame); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Id); + Assert.Equal("Step: Checkout", deserialized.Name); + Assert.Equal("normal", deserialized.PresentationHint); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExitedEventBodySerialization() + { + var body = new ExitedEventBody { ExitCode = 130 }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(130, deserialized.ExitCode); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void DapCommandEnumValues() + { + Assert.Equal(0, (int)DapCommand.Continue); + Assert.Equal(1, (int)DapCommand.Next); + Assert.Equal(4, (int)DapCommand.Disconnect); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestDeserializesFromRawJson() + { + var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}"; + var request = JsonConvert.DeserializeObject(json); + + Assert.Equal(5, request.Seq); + Assert.Equal("request", request.Type); + Assert.Equal("continue", request.Command); + Assert.Equal(1, request.Arguments["threadId"].Value()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ErrorResponseBodySerialization() + { + var body = new ErrorResponseBody + { + Error = new Message + { + Id = 1, + Format = "Something went wrong", + ShowUser = true + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Error.Id); + Assert.Equal("Something went wrong", deserialized.Error.Format); + Assert.True(deserialized.Error.ShowUser); + } + } +} diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs new file mode 100644 index 000000000..687d2093a --- /dev/null +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplExecutorL0 + { + private TestHostContext _hc; + private DapReplExecutor _executor; + private List _sentEvents; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _sentEvents = new List(); + _executor = new DapReplExecutor(_hc, (category, text) => + { + _sentEvents.Add(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = category, + Output = text + } + }); + }); + return _hc; + } + + private Mock CreateMockContext( + DictionaryContextData exprValues = null, + IDictionary> jobDefaults = null) + { + var mock = new Mock(); + mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData()); + mock.Setup(x => x.ExpressionFunctions).Returns(new List()); + + var global = new GlobalContext + { + PrependPath = new List(), + JobDefaults = jobDefaults + ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + }; + mock.Setup(x => x.Global).Returns(global); + + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ExecuteRunCommand_NullContext_ReturnsError() + { + using (CreateTestContext()) + { + var command = new RunCommand { Script = "echo hello" }; + var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None); + + Assert.Equal("error", result.Type); + Assert.Contains("No execution context available", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NoExpressions_ReturnsInput() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo hello", context.Object); + + Assert.Equal("echo hello", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NullInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions(null, context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_EmptyInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("", context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_UnterminatedExpression_KeepsLiteral() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object); + + Assert.Equal("echo ${{ github.repo", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_NoJobDefaults_ReturnsPlatformDefault() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ResolveDefaultShell(context.Object); + +#if OS_WINDOWS + Assert.True(result == "pwsh" || result == "powershell"); +#else + Assert.Equal("sh", result); +#endif + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault() + { + using (CreateTestContext()) + { + var jobDefaults = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["run"] = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["shell"] = "bash" + } + }; + var context = CreateMockContext(jobDefaults: jobDefaults); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("bash", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_MergesEnvContextAndReplOverrides() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "BAZ", "qux" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("bar", result["FOO"]); + Assert.Equal("qux", result["BAZ"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_ReplOverridesWin() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("original"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "FOO", "override" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("override", result["FOO"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var result = _executor.BuildEnvironment(context.Object, null); + + Assert.Equal("bar", result["FOO"]); + Assert.False(result.ContainsKey("BAZ")); + } + } + } +} diff --git a/src/Test/L0/Worker/DapReplParserL0.cs b/src/Test/L0/Worker/DapReplParserL0.cs new file mode 100644 index 000000000..0a15a37f4 --- /dev/null +++ b/src/Test/L0/Worker/DapReplParserL0.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplParserL0 + { + #region help command + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpReturnsHelpCommand() + { + var cmd = DapReplParser.TryParse("help", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Null(help.Topic); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpCaseInsensitive() + { + var cmd = DapReplParser.TryParse("Help", out var error); + Assert.Null(error); + Assert.IsType(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_HelpWithTopic() + { + var cmd = DapReplParser.TryParse("help(\"run\")", out var error); + + Assert.Null(error); + var help = Assert.IsType(cmd); + Assert.Equal("run", help.Topic); + } + + #endregion + + #region run command — basic + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunSimpleScript() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Null(run.Shell); + Assert.Null(run.Env); + Assert.Null(run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithShell() + { + var cmd = DapReplParser.TryParse("run(\"echo hello\", shell: \"bash\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo hello", run.Script); + Assert.Equal("bash", run.Shell); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithWorkingDirectory() + { + var cmd = DapReplParser.TryParse("run(\"ls\", working_directory: \"/tmp\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("ls", run.Script); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEnv() + { + var cmd = DapReplParser.TryParse("run(\"echo $FOO\", env: { FOO: \"bar\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $FOO", run.Script); + Assert.NotNull(run.Env); + Assert.Equal("bar", run.Env["FOO"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithMultipleEnvVars() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { A: \"1\", B: \"2\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal(2, run.Env.Count); + Assert.Equal("1", run.Env["A"]); + Assert.Equal("2", run.Env["B"]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithAllOptions() + { + var input = "run(\"echo $X\", shell: \"zsh\", env: { X: \"1\" }, working_directory: \"/tmp\")"; + var cmd = DapReplParser.TryParse(input, out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo $X", run.Script); + Assert.Equal("zsh", run.Shell); + Assert.Equal("1", run.Env["X"]); + Assert.Equal("/tmp", run.WorkingDirectory); + } + + #endregion + + #region run command — edge cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithEscapedQuotes() + { + var cmd = DapReplParser.TryParse("run(\"echo \\\"hello\\\"\")", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("echo \"hello\"", run.Script); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunWithCommaInEnvValue() + { + var cmd = DapReplParser.TryParse("run(\"echo\", env: { CSV: \"a,b,c\" })", out var error); + + Assert.Null(error); + var run = Assert.IsType(cmd); + Assert.Equal("a,b,c", run.Env["CSV"]); + } + + #endregion + + #region error cases + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunEmptyArgsReturnsError() + { + var cmd = DapReplParser.TryParse("run()", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("requires a script argument", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnquotedArgReturnsError() + { + var cmd = DapReplParser.TryParse("run(echo hello)", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("quoted string", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunUnknownOptionReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\", timeout: \"10\")", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + Assert.Contains("Unknown option", error); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_RunMissingClosingParenReturnsError() + { + var cmd = DapReplParser.TryParse("run(\"echo\"", out var error); + + Assert.NotNull(error); + Assert.Null(cmd); + } + + #endregion + + #region non-DSL input falls through + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_ExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("github.repository", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_WrappedExpressionReturnsNull() + { + var cmd = DapReplParser.TryParse("${{ github.event_name }}", out var error); + + Assert.Null(error); + Assert.Null(cmd); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Parse_EmptyInputReturnsNull() + { + var cmd = DapReplParser.TryParse("", out var error); + Assert.Null(error); + Assert.Null(cmd); + + cmd = DapReplParser.TryParse(null, out error); + Assert.Null(error); + Assert.Null(cmd); + } + + #endregion + + #region help text + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetGeneralHelp_ContainsCommands() + { + var help = DapReplParser.GetGeneralHelp(); + + Assert.Contains("help", help); + Assert.Contains("run", help); + Assert.Contains("expression", help, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetRunHelp_ContainsOptions() + { + var help = DapReplParser.GetRunHelp(); + + Assert.Contains("shell", help); + Assert.Contains("env", help); + Assert.Contains("working_directory", help); + } + + #endregion + + #region internal parser helpers + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SplitArguments_HandlesNestedBraces() + { + var args = DapReplParser.SplitArguments("\"hello\", env: { A: \"1\", B: \"2\" }", out var error); + + Assert.Null(error); + Assert.Equal(2, args.Count); + Assert.Equal("\"hello\"", args[0].Trim()); + Assert.Contains("A:", args[1]); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ParseEnvBlock_HandlesEmptyBlock() + { + var result = DapReplParser.ParseEnvBlock("{ }", out var error); + + Assert.Null(error); + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + } +} diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs new file mode 100644 index 000000000..401fd098f --- /dev/null +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -0,0 +1,728 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapVariableProviderL0 + { + private TestHostContext _hc; + private DapVariableProvider _provider; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _provider = new DapVariableProvider(_hc.SecretMasker); + return _hc; + } + + private Moq.Mock CreateMockContext(DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + return mock; + } + + #region GetScopes tests + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var scopes = _provider.GetScopes(null); + Assert.Empty(scopes); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReturnsOnlyPopulatedScopes() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + // "runner" is not set — should not appear in scopes + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Equal(2, scopes.Count); + Assert.Equal("github", scopes[0].Name); + Assert.Equal("env", scopes[1].Name); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_ReportsNamedVariableCount() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "A", new StringContextData("1") }, + { "B", new StringContextData("2") }, + { "C", new StringContextData("3") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + Assert.Single(scopes); + Assert.Equal(3, scopes[0].NamedVariables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetScopes_SecretsGetSpecialPresentationHint() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_SECRET", new StringContextData("super-secret") } + }; + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") } + }; + + var ctx = CreateMockContext(exprValues); + var scopes = _provider.GetScopes(ctx.Object); + + var envScope = scopes.Find(s => s.Name == "env"); + var secretsScope = scopes.Find(s => s.Name == "secrets"); + + Assert.NotNull(envScope); + Assert.Null(envScope.PresentationHint); + + Assert.NotNull(secretsScope); + Assert.Equal("registers", secretsScope.PresentationHint); + } + } + + #endregion + + #region GetVariables — basic types + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsEmptyWhenContextIsNull() + { + using (CreateTestContext()) + { + var variables = _provider.GetVariables(null, 1); + Assert.Empty(variables); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsStringVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "CI", new StringContextData("true") }, + { "HOME", new StringContextData("/home/runner") } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at ScopeNames index 1 → variablesReference = 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + Assert.Equal(2, variables.Count); + + var ciVar = variables.Find(v => v.Name == "CI"); + Assert.NotNull(ciVar); + Assert.Equal("true", ciVar.Value); + Assert.Equal("string", ciVar.Type); + Assert.Equal(0, ciVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsBooleanVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") }, + }; + // Use a nested dict with boolean to test + var jobDict = new DictionaryContextData(); + // BooleanContextData is a valid PipelineContextData type + // but job context typically has strings. Use env scope instead. + exprValues["env"] = new DictionaryContextData + { + { "flag", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + // "env" is at index 1 → ref 2 + var variables = _provider.GetVariables(ctx.Object, 2); + + var flagVar = variables.Find(v => v.Name == "flag"); + Assert.NotNull(flagVar); + Assert.Equal("true", flagVar.Value); + Assert.Equal("boolean", flagVar.Type); + Assert.Equal(0, flagVar.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_ReturnsNumberVariables() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "count", new NumberContextData(42) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var countVar = variables.Find(v => v.Name == "count"); + Assert.NotNull(countVar); + Assert.Equal("42", countVar.Value); + Assert.Equal("number", countVar.Type); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_HandlesNullValues() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var dict = new DictionaryContextData(); + dict["present"] = new StringContextData("yes"); + dict["missing"] = null; + exprValues["env"] = dict; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var nullVar = variables.Find(v => v.Name == "missing"); + Assert.NotNull(nullVar); + Assert.Equal("null", nullVar.Value); + Assert.Equal("null", nullVar.Type); + Assert.Equal(0, nullVar.VariablesReference); + } + } + + #endregion + + #region GetVariables — nested expansion + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedDictionaryIsExpandable() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") }, + { "ref", new StringContextData("refs/heads/main") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + // "github" is at index 0 → ref 1 + var variables = _provider.GetVariables(ctx.Object, 1); + + var eventVar = variables.Find(v => v.Name == "event"); + Assert.NotNull(eventVar); + Assert.Equal("object", eventVar.Type); + Assert.True(eventVar.VariablesReference > 0, "Nested dict should have a non-zero variablesReference"); + Assert.Equal(2, eventVar.NamedVariables); + + // Now expand it + var children = _provider.GetVariables(ctx.Object, eventVar.VariablesReference); + Assert.Equal(2, children.Count); + + var nameVar = children.Find(v => v.Name == "name"); + Assert.NotNull(nameVar); + Assert.Equal("push", nameVar.Value); + Assert.Equal("${{ github.event.name }}", nameVar.EvaluateName); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NestedArrayIsExpandable() + { + using (CreateTestContext()) + { + var array = new ArrayContextData(); + array.Add(new StringContextData("item0")); + array.Add(new StringContextData("item1")); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "list", array } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var listVar = variables.Find(v => v.Name == "list"); + Assert.NotNull(listVar); + Assert.Equal("array", listVar.Type); + Assert.True(listVar.VariablesReference > 0); + Assert.Equal(2, listVar.IndexedVariables); + + // Expand the array + var items = _provider.GetVariables(ctx.Object, listVar.VariablesReference); + Assert.Equal(2, items.Count); + Assert.Equal("[0]", items[0].Name); + Assert.Equal("item0", items[0].Value); + Assert.Equal("[1]", items[1].Name); + Assert.Equal("item1", items[1].Value); + } + } + + #endregion + + #region Secret masking + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeValuesAreRedacted() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "MY_TOKEN", new StringContextData("ghp_abc123secret") }, + { "DB_PASSWORD", new StringContextData("p@ssword!") } + }; + + var ctx = CreateMockContext(exprValues); + // "secrets" is at index 5 → ref 6 + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Equal(2, variables.Count); + foreach (var v in variables) + { + Assert.Equal("***", v.Value); + Assert.Equal("string", v.Type); + } + + // Keys should still be visible + Assert.Contains(variables, v => v.Name == "MY_TOKEN"); + Assert.Contains(variables, v => v.Name == "DB_PASSWORD"); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_NonSecretScopeValuesMaskedBySecretMasker() + { + using (var hc = CreateTestContext()) + { + // Register a known secret value with the masker + hc.SecretMasker.AddValue("super-secret-token"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "SAFE", new StringContextData("hello world") }, + { "LEAKED", new StringContextData("prefix-super-secret-token-suffix") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 2); + + var safeVar = variables.Find(v => v.Name == "SAFE"); + Assert.NotNull(safeVar); + Assert.Equal("hello world", safeVar.Value); + + var leakedVar = variables.Find(v => v.Name == "LEAKED"); + Assert.NotNull(leakedVar); + Assert.DoesNotContain("super-secret-token", leakedVar.Value); + Assert.Contains("***", leakedVar.Value); + } + } + + #endregion + + #region Reset + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Reset_InvalidatesNestedReferences() + { + using (CreateTestContext()) + { + var innerDict = new DictionaryContextData + { + { "name", new StringContextData("push") } + }; + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event", innerDict } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + var eventVar = variables.Find(v => v.Name == "event"); + Assert.True(eventVar.VariablesReference > 0); + + var savedRef = eventVar.VariablesReference; + + // Reset should clear all dynamic references + _provider.Reset(); + + var children = _provider.GetVariables(ctx.Object, savedRef); + Assert.Empty(children); + } + } + + #endregion + + #region EvaluateName + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SetsEvaluateNameWithDotPath() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 1); + + var repoVar = variables.Find(v => v.Name == "repository"); + Assert.NotNull(repoVar); + Assert.Equal("${{ github.repository }}", repoVar.EvaluateName); + } + } + + #endregion + + #region EvaluateExpression + + /// + /// Creates a mock execution context with Global set up so that + /// ToPipelineTemplateEvaluator() works for real expression evaluation. + /// + private Moq.Mock CreateEvaluatableContext( + TestHostContext hc, + DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + mock.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mock.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + // ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls + // context.Write — provide a no-op so it doesn't NRE. + mock.Setup(x => x.Write(Moq.It.IsAny(), Moq.It.IsAny())); + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsValueForSimpleExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("github.repository", ctx.Object); + + Assert.Equal("owner/repo", result.Result); + Assert.Equal("string", result.Type); + Assert.Equal(0, result.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_StripsWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object); + + Assert.Equal("push", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_MasksSecretInResult() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("super-secret"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "TOKEN", new StringContextData("super-secret") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object); + + Assert.DoesNotContain("super-secret", result.Result); + Assert.Contains("***", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsErrorForInvalidExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData(); + + var ctx = CreateEvaluatableContext(hc, exprValues); + // An invalid expression syntax should not throw — it should + // return an error result. + var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object); + + Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsMessageWhenNoContext() + { + using (CreateTestContext()) + { + var result = _provider.EvaluateExpression("github.repository", null); + + Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsEmptyForEmptyExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("", ctx.Object); + + Assert.Equal(string.Empty, result.Result); + } + } + + #endregion + + #region InferResultType + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InferResultType_ClassifiesCorrectly() + { + using (CreateTestContext()) + { + Assert.Equal("null", DapVariableProvider.InferResultType(null)); + Assert.Equal("null", DapVariableProvider.InferResultType("null")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("true")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("false")); + Assert.Equal("number", DapVariableProvider.InferResultType("42")); + Assert.Equal("number", DapVariableProvider.InferResultType("3.14")); + Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}")); + Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]")); + Assert.Equal("string", DapVariableProvider.InferResultType("hello world")); + Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo")); + } + } + + #endregion + + #region Non-string secret type redaction + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNumberContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NUMERIC_SECRET", new NumberContextData(12345) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NUMERIC_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsBooleanContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "BOOL_SECRET", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("BOOL_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNestedDictionary() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NESTED_SECRET", new DictionaryContextData + { + { "inner_key", new StringContextData("inner_value") } + } + } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NESTED_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal("string", variables[0].Type); + // Nested container should NOT be drillable under secrets + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNullValue() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var secrets = new DictionaryContextData(); + secrets["NULL_SECRET"] = null; + exprValues["secrets"] = secrets; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NULL_SECRET", variables[0].Name); + Assert.Equal("***", variables[0].Value); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + #endregion + } +} diff --git a/src/Test/L0/Worker/JobExtensionL0.cs b/src/Test/L0/Worker/JobExtensionL0.cs index 60814998e..40c495a81 100644 --- a/src/Test/L0/Worker/JobExtensionL0.cs +++ b/src/Test/L0/Worker/JobExtensionL0.cs @@ -8,6 +8,7 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.Pipelines.ObjectTemplating; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; using Moq; using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -547,6 +548,10 @@ namespace GitHub.Runner.Common.Tests.Worker var _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + hc.SetSingleton(mockDapDebugger.Object); + await _stepsRunner.RunAsync(_jobEc); Assert.Equal("Create custom image", snapshotStep.DisplayName); diff --git a/src/Test/L0/Worker/StepsRunnerL0.cs b/src/Test/L0/Worker/StepsRunnerL0.cs index a22dc618f..2ab9f57fd 100644 --- a/src/Test/L0/Worker/StepsRunnerL0.cs +++ b/src/Test/L0/Worker/StepsRunnerL0.cs @@ -12,6 +12,7 @@ using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; namespace GitHub.Runner.Common.Tests.Worker { @@ -61,6 +62,10 @@ namespace GitHub.Runner.Common.Tests.Worker _stepsRunner = new StepsRunner(); _stepsRunner.Initialize(hc); + + var mockDapDebugger = new Mock(); + hc.SetSingleton(mockDapDebugger.Object); + return hc; }