diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 7a1f1cbb0a1..b07d60d39fc 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -179,6 +179,7 @@ public static class Features public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers"; public static readonly string BatchActionResolution = "actions_batch_action_resolution"; public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload"; + public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message"; } // Node version migration related constants diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 9848b872b73..d5dba2fe25d 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -63,6 +63,7 @@ public sealed class DapDebugger : RunnerService, IDapDebugger private volatile DapSessionState _state = DapSessionState.NotStarted; private CancellationTokenRegistration? _cancellationRegistration; private bool _isFirstStep = true; + private bool _welcomeMessageSent; // Dev Tunnel relay host for remote debugging private TunnelRelayTunnelHost _tunnelRelayHost; @@ -490,6 +491,11 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can }); Trace.Info("Sent initialized event"); } + + if (request.Command == "configurationDone") + { + SendWelcomeMessage(); + } } catch (Exception ex) { @@ -508,6 +514,7 @@ internal async Task HandleMessageAsync(string messageJson, CancellationToken can internal void HandleClientConnected() { _isClientConnected = true; + _welcomeMessageSent = false; Trace.Info("Client connected to debug session"); // If we're paused, re-send the stopped event so the new client @@ -818,6 +825,34 @@ private void SendOutput(string category, string text) }); } + internal void SendWelcomeMessage() + { + if (_welcomeMessageSent) + { + return; + } + _welcomeMessageSent = true; + + var debuggerConfig = _jobContext?.Global?.Debugger; + if (debuggerConfig?.OverrideWelcomeMessage == true) + { + if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage)) + { + SendOutput("console", debuggerConfig.WelcomeMessage); + Trace.Info("Sent custom welcome message"); + } + else + { + Trace.Info("Welcome message suppressed by override"); + } + } + else + { + SendOutput("console", DapReplParser.GetGeneralHelp()); + Trace.Info("Sent default welcome message"); + } + } + internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { bool pauseOnNextStep; diff --git a/src/Runner.Worker/Dap/DebuggerConfig.cs b/src/Runner.Worker/Dap/DebuggerConfig.cs index df139a15c18..13106cb563d 100644 --- a/src/Runner.Worker/Dap/DebuggerConfig.cs +++ b/src/Runner.Worker/Dap/DebuggerConfig.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { @@ -8,10 +8,12 @@ namespace GitHub.Runner.Worker.Dap /// public sealed class DebuggerConfig { - public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) + public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null) { Enabled = enabled; Tunnel = tunnel; + OverrideWelcomeMessage = overrideWelcomeMessage; + WelcomeMessage = welcomeMessage; } /// Whether the debugger is enabled for this job. @@ -23,6 +25,19 @@ public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel) /// public DebuggerTunnelInfo Tunnel { get; } + /// + /// When true, the runner overrides the default welcome message with + /// . A null or empty + /// suppresses the message entirely. When false, the default help text is shown. + /// + public bool OverrideWelcomeMessage { get; } + + /// + /// Optional welcome message content for the debugger console. Only used when + /// is true. + /// + public string WelcomeMessage { get; } + /// Whether the tunnel configuration is complete and valid. public bool HasValidTunnel => Tunnel != null && !string.IsNullOrEmpty(Tunnel.TunnelId) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index f072335b440..d071790f37d 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -970,7 +970,8 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation Global.WriteDebug = Global.Variables.Step_Debug ?? false; // Debugger enabled flag (from acquire response). - Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel); + var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false; + Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage); // Hook up JobServerQueueThrottling event, we will log warning on server tarpit. _jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived; diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 96cf07a71c2..782878f79e4 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -267,6 +267,21 @@ public DebuggerTunnelInfo DebuggerTunnel set; } + /// + /// Optional welcome message shown in the debugger console when a client connects. + /// Only used when the actions_runner_override_debugger_welcome_message + /// feature flag is set to true in the job variables. With the flag set, + /// a non-empty value is shown as-is and a null or empty value suppresses the + /// default welcome message. When the flag is not set, the runner shows its + /// built-in help text and this field is ignored. + /// + [DataMember(EmitDefaultValue = false)] + public string DebuggerWelcomeMessage + { + get; + set; + } + /// /// Gets the workflow-level action dependencies (lockfile entries) /// diff --git a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs index 1a451d28f12..667c6810e84 100644 --- a/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs +++ b/src/Test/L0/Sdk/RSWebApi/AgentJobRequestMessageL0.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Runtime.Serialization.Json; using System.Text; @@ -17,13 +17,13 @@ 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"); @@ -37,13 +37,13 @@ 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"); @@ -57,13 +57,13 @@ 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"); @@ -161,6 +161,26 @@ public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty() Assert.Empty(recoveredMessage.ActionsDependencies); } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void VerifyDebuggerWelcomeMessageRoundTrips() + { + // Arrange + var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage)); + string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}"); + + // Act + using var stream = new MemoryStream(); + stream.Write(Encoding.UTF8.GetBytes(json)); + stream.Position = 0; + var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage; + + // Assert + Assert.NotNull(recoveredMessage); + Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage); + } + private static string DoubleQuotify(string text) { return text.Replace('\'', '"'); diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index f1a29306ff1..92efbaa00c9 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Net; using System.Net.Sockets; @@ -236,7 +236,7 @@ private static async Task ReadWebSocketDataUntilAsync(WebSocket client, } } - private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null) + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null) { var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo { @@ -245,7 +245,7 @@ private static Mock CreateJobContextWithTunnel(CancellationTo HostToken = "test-token", Port = port }; - var debuggerConfig = new DebuggerConfig(true, tunnel); + var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage); var jobContext = new Mock(); jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig }); @@ -742,6 +742,8 @@ public async Task OnJobCompletedSendsTerminatedAndExitedEvents() // Read the configurationDone response await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; // Complete the job — OnJobCompletedAsync pauses when stepping, @@ -849,6 +851,8 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Command = "configurationDone" }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + // Read the welcome message output event await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); await waitTask; @@ -867,5 +871,224 @@ public async Task WaitForCommandAsyncUnblocksOnCancellationDuringWait() Assert.Equal(completedTask, finished); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + 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 = "configurationDone" + }); + + // First message: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second message: welcome output event with default help text + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("\"category\":\"console\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + Assert.Contains("help", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: "Welcome to debugging!"); + 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 = "configurationDone" + }); + + // First: configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Second: custom welcome message + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Welcome to debugging!", welcomeMsg); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: ""); + 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 = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, + overrideWelcomeMessage: true, + welcomeMessage: null); + 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 = "configurationDone" + }); + + // Read configurationDone response + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Send threads request — if welcome message was suppressed, this + // should be the next response (no output event in between) + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WelcomeMessageSentOnlyOnce() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + + using var client = await ConnectClientAsync(port); + var stream = client.GetStream(); + + // First configurationDone + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone" + }); + + var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse); + + // Welcome message should appear + var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"output\"", welcomeMsg); + Assert.Contains("Actions Debug Console", welcomeMsg); + + // Second configurationDone — should NOT produce another welcome message + await SendRequestAsync(stream, new Request + { + Seq = 2, + Type = "request", + Command = "configurationDone" + }); + + var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"configurationDone\"", secondResponse); + + // Next message should be threads response, not another welcome output + await SendRequestAsync(stream, new Request + { + Seq = 3, + Type = "request", + Command = "threads" + }); + + var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"command\":\"threads\"", threadsResponse); + + await _debugger.StopAsync(); + } + } } }