diff --git a/images/Dockerfile b/images/Dockerfile
index e5d2f6f4f7c..df5e145d08c 100644
--- a/images/Dockerfile
+++ b/images/Dockerfile
@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
-ARG DOCKER_VERSION=29.4.0
-ARG BUILDX_VERSION=0.33.0
+ARG DOCKER_VERSION=29.5.0
+ARG BUILDX_VERSION=0.34.0
RUN apt update -y && apt install curl unzip -y
diff --git a/patches/last_processed_commit.txt b/patches/last_processed_commit.txt
index 98c5f1805db..586ed0f63bf 100644
--- a/patches/last_processed_commit.txt
+++ b/patches/last_processed_commit.txt
@@ -1 +1 @@
-d36839b001e3294e2b6e2663268c63b50d295df5
+ae2896c551a708bdc78bee403eb696e3a26ac95d
diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch
index cc6764718d9..9cfa1b271b5 100644
--- a/patches/runner-main-sdk8-ppc64le.patch
+++ b/patches/runner-main-sdk8-ppc64le.patch
@@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755
if ! [ -x "$(command -v ldconfig)" ]; then
diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs
-index 7a1f1cbb..dafc3ccf 100644
+index b07d60d3..55ed142b 100644
--- a/src/Runner.Common/Constants.cs
+++ b/src/Runner.Common/Constants.cs
@@ -59,7 +59,9 @@ namespace GitHub.Runner.Common
@@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644
NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051
diff --git a/src/dev.sh b/src/dev.sh
-index fc732597..0765551f 100755
+index fafdbffb..baed6dd2 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then
@@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644
-# From upstream commit: d36839b001e3294e2b6e2663268c63b50d295df5
+# From upstream commit: ae2896c551a708bdc78bee403eb696e3a26ac95d
diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch
index cc6764718d9..9cfa1b271b5 100644
--- a/patches/runner-main-sdk8-s390x.patch
+++ b/patches/runner-main-sdk8-s390x.patch
@@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755
if ! [ -x "$(command -v ldconfig)" ]; then
diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs
-index 7a1f1cbb..dafc3ccf 100644
+index b07d60d3..55ed142b 100644
--- a/src/Runner.Common/Constants.cs
+++ b/src/Runner.Common/Constants.cs
@@ -59,7 +59,9 @@ namespace GitHub.Runner.Common
@@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644
NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051
diff --git a/src/dev.sh b/src/dev.sh
-index fc732597..0765551f 100755
+index fafdbffb..baed6dd2 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then
@@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644
-# From upstream commit: d36839b001e3294e2b6e2663268c63b50d295df5
+# From upstream commit: ae2896c551a708bdc78bee403eb696e3a26ac95d
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 3b3ec7cbfaf..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;
@@ -860,6 +895,9 @@ internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
+ // Emit a banner so the user knows where REPL commands will execute
+ SendExecutionContextBanner();
+
// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
@@ -1195,7 +1233,12 @@ private async Task DispatchReplCommandAsync(
case RunCommand run:
var context = GetExecutionContextForFrame(frameId);
- return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
+ bool isActionStep;
+ lock (_stateLock)
+ {
+ isActionStep = _currentStep is IActionRunner;
+ }
+ return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
default:
return new EvaluateResponseBody
@@ -1407,6 +1450,40 @@ private void SendStoppedEvent(string reason, string description)
});
}
+ ///
+ /// Emits a console output banner telling the user whether REPL
+ /// commands will execute on the host or inside the job container.
+ ///
+ private void SendExecutionContextBanner()
+ {
+ if (!_isClientConnected)
+ {
+ return;
+ }
+
+ bool isActionStep = _currentStep is IActionRunner;
+ var container = _jobContext?.Global?.Container;
+
+ string target;
+ if (isActionStep && container != null &&
+ (!string.IsNullOrEmpty(container.ContainerId) ||
+ FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables)))
+ {
+ var image = container.ContainerImage ?? "container";
+ var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12
+ ? container.ContainerId.Substring(0, 12)
+ : container.ContainerId ?? "";
+ var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : "";
+ target = $"job container: {image}{idSuffix}";
+ }
+ else
+ {
+ target = "runner host";
+ }
+
+ SendOutput("console", $"\nCommands will run on {target}\n");
+ }
+
private string MaskUserVisibleText(string value)
{
if (string.IsNullOrEmpty(value))
diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs
index 751f92c514c..434907c2cc7 100644
--- a/src/Runner.Worker/Dap/DapReplExecutor.cs
+++ b/src/Runner.Worker/Dap/DapReplExecutor.cs
@@ -9,6 +9,7 @@
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
@@ -43,6 +44,7 @@ public DapReplExecutor(IHostContext hostContext, Action sendOutp
public async Task ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
+ bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
@@ -52,7 +54,7 @@ public async Task ExecuteRunCommandAsync(
try
{
- return await ExecuteScriptAsync(command, context, cancellationToken);
+ return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
}
catch (Exception ex)
{
@@ -65,9 +67,17 @@ public async Task ExecuteRunCommandAsync(
private async Task ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
+ bool isActionStep,
CancellationToken cancellationToken)
{
- // 1. Resolve shell — same logic as ScriptHandler
+ // 1. Resolve step host — container or host, same as ActionRunner.
+ // Only action steps (user-defined run:/uses:) execute inside the
+ // container. Infrastructure steps (Set up job, Initialize
+ // containers, Complete job, etc.) always run on the host.
+ var stepHost = CreateStepHost(context, isActionStep);
+ var isContainerStepHost = stepHost is IContainerStepHost;
+
+ // 2. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
@@ -87,9 +97,9 @@ private async Task ExecuteScriptAsync(
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
- _trace.Info("Resolved REPL shell");
+ _trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
- // 2. Expand ${{ }} expressions in the script body, just like
+ // 3. 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);
@@ -111,25 +121,47 @@ private async Task ExecuteScriptAsync(
try
{
- // 3. Format arguments with script path
- var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
+ // 4. Resolve script path — translate for container if needed
+ var resolvedPath = stepHost.ResolvePathForStepHost(context, 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
+ // 5. Resolve shell command path — for containers, use the shell
+ // name directly (it will be resolved inside the container);
+ // for host execution, resolve the full path on the host.
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
- var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
- ?? shellCommand;
+ var fileName = isContainerStepHost
+ ? shellCommand
+ : WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
- // 5. Build environment — merge from execution context like a real step
+ // 6. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
- // 6. Resolve working directory
+ // 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
+ if (context.Global.PrependPath.Count > 0)
+ {
+ if (stepHost is IContainerStepHost containerHost)
+ {
+ containerHost.PrependPath = prependPath;
+ }
+ else
+ {
+ string taskEnvPATH;
+ environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
+ string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable.
+ taskEnvPATH ?? // Then a task-environment variable.
+ System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
+ string.Empty;
+ environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath);
+ }
+ }
+
+ // 8. Resolve working directory — translate for container
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -141,48 +173,60 @@ private async Task ExecuteScriptAsync(
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
+ workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
_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())
+ // NOTE: When container hooks are enabled, ContainerStepHost routes
+ // execution through IContainerHookManager which does not raise
+ // OutputDataReceived/ErrorDataReceived events. Output will not be
+ // streamed to the debug console in that mode.
+ if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
+ {
+ const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
+ _trace.Warning(hookWarning);
+ SendOutput("stderr", hookWarning + "\n");
+ }
+
+ // 9. Execute via IStepHost — handles docker exec for containers,
+ // direct process execution for host, and container hooks
+ stepHost.OutputDataReceived += (sender, args) =>
{
- processInvoker.OutputDataReceived += (sender, args) =>
+ if (!string.IsNullOrEmpty(args.Data))
{
- if (!string.IsNullOrEmpty(args.Data))
- {
- var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
- SendOutput("stdout", masked + "\n");
- }
- };
+ var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
+ SendOutput("stdout", masked + "\n");
+ }
+ };
- processInvoker.ErrorDataReceived += (sender, args) =>
+ stepHost.ErrorDataReceived += (sender, args) =>
+ {
+ if (!string.IsNullOrEmpty(args.Data))
{
- 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);
- }
+ var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
+ SendOutput("stderr", masked + "\n");
+ }
+ };
+
+ int exitCode = await stepHost.ExecuteAsync(
+ context: context,
+ workingDirectory: workingDirectory,
+ fileName: fileName,
+ arguments: arguments,
+ environment: environment,
+ requireExitCodeZero: false,
+ outputEncoding: null,
+ killProcessOnCancel: true,
+ inheritConsoleHandler: false,
+ standardInInput: null,
+ cancellationToken: cancellationToken);
_trace.Info($"REPL command exited with code {exitCode}");
- // 8. Return only the exit code summary (output was already streamed)
+ // 10. 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}.",
@@ -198,6 +242,43 @@ private async Task ExecuteScriptAsync(
}
}
+ ///
+ /// Creates the appropriate for the current
+ /// execution context, mirroring how decides
+ /// between host and container execution.
+ ///
+ /// Only action steps (user-defined run:/uses: steps) run inside the
+ /// job container. Infrastructure steps like "Set up job", "Initialize
+ /// containers", "Stop containers", and "Complete job" always execute
+ /// on the host regardless of whether a container is configured.
+ ///
+ internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
+ {
+ if (!isActionStep)
+ {
+ _trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
+ return _hostContext.CreateService();
+ }
+
+ var container = context?.Global?.Container;
+ if (container != null)
+ {
+ // Container hooks don't always set ContainerId, but the container
+ // step host handles that internally
+ var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
+ if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
+ {
+ _trace.Info("Creating ContainerStepHost for REPL execution");
+ var containerStepHost = _hostContext.CreateService();
+ containerStepHost.Container = container;
+ return containerStepHost;
+ }
+ }
+
+ _trace.Info("Creating DefaultStepHost for REPL execution");
+ return _hostContext.CreateService();
+ }
+
///
/// Expands ${{ }} expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes
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();
+ }
+ }
}
}
diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs
index 687d2093a02..e70c615fc94 100644
--- a/src/Test/L0/Worker/DapReplExecutorL0.cs
+++ b/src/Test/L0/Worker/DapReplExecutorL0.cs
@@ -5,9 +5,12 @@
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
+using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
+using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -40,7 +43,8 @@ private TestHostContext CreateTestContext([CallerMemberName] string testName = "
private Mock CreateMockContext(
DictionaryContextData exprValues = null,
- IDictionary> jobDefaults = null)
+ IDictionary> jobDefaults = null,
+ ContainerInfo container = null)
{
var mock = new Mock();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
@@ -51,6 +55,7 @@ private Mock CreateMockContext(
PrependPath = new List(),
JobDefaults = jobDefaults
?? new Dictionary>(StringComparer.OrdinalIgnoreCase),
+ Container = container,
};
mock.Setup(x => x.Global).Returns(global);
@@ -65,7 +70,7 @@ public async Task ExecuteRunCommand_NullContext_ReturnsError()
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
- var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
+ var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
@@ -233,5 +238,101 @@ public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
Assert.False(result.ContainsKey("BAZ"));
}
}
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void CreateStepHost_NoContainer_ReturnsDefaultStepHost()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.EnqueueInstance(new DefaultStepHost());
+ var context = CreateMockContext();
+ var result = _executor.CreateStepHost(context.Object, isActionStep: true);
+
+ Assert.IsType(result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.EnqueueInstance(new ContainerStepHost());
+ var container = new ContainerInfo { ContainerId = "abc123" };
+ var context = CreateMockContext(container: container);
+ var result = _executor.CreateStepHost(context.Object, isActionStep: true);
+
+ Assert.IsType(result);
+ var containerHost = (ContainerStepHost)result;
+ Assert.Same(container, containerHost.Container);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void CreateStepHost_WithContainer_InfrastructureStep_ReturnsDefaultStepHost()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.EnqueueInstance(new DefaultStepHost());
+ var container = new ContainerInfo { ContainerId = "abc123" };
+ var context = CreateMockContext(container: container);
+ var result = _executor.CreateStepHost(context.Object, isActionStep: false);
+
+ Assert.IsType(result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.EnqueueInstance(new DefaultStepHost());
+ // Container exists but hasn't been started yet (no ContainerId)
+ var container = new ContainerInfo();
+ var context = CreateMockContext(container: container);
+ var result = _executor.CreateStepHost(context.Object, isActionStep: true);
+
+ Assert.IsType(result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.EnqueueInstance(new ContainerStepHost());
+ // Container hooks need both the feature flag and the env var
+ Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", "/some/hook/path");
+ try
+ {
+ var container = new ContainerInfo();
+ var context = CreateMockContext(container: container);
+ context.Object.Global.Variables = new Variables(
+ hc,
+ new Dictionary
+ {
+ { Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") }
+ });
+ var result = _executor.CreateStepHost(context.Object, isActionStep: true);
+ Assert.IsAssignableFrom(result);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null);
+ }
+ }
+ }
}
}
diff --git a/src/dev.sh b/src/dev.sh
index fc732597243..fafdbffb360 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
-DOTNETSDK_VERSION="8.0.420"
+DOTNETSDK_VERSION="8.0.421"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)
diff --git a/src/global.json b/src/global.json
index 209f8518853..12e63e7d30c 100644
--- a/src/global.json
+++ b/src/global.json
@@ -1,5 +1,5 @@
{
"sdk": {
- "version": "8.0.420"
+ "version": "8.0.421"
}
}