From ae52d2156391db899eac6e075a10ddc253e44ceb Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 13:12:36 +0100 Subject: [PATCH 1/4] Execute debugger REPL commands inside job container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a job uses a container: definition, the debugger REPL was always executing run commands on the host via IProcessInvoker directly. This meant that commands like 'cat /etc/os-release' would show the host OS instead of the container's OS. Fix this by reusing IStepHost — the same abstraction that normal run: steps use — to decide whether to execute on the host or inside the container via docker exec. The decision is based on the current step type: - Action steps (user-defined run:/uses:) execute inside the container, matching the behavior of normal workflow run: steps. - Infrastructure steps (Set up job, Initialize containers, Stop containers, Complete job) always execute on the host. This also correctly handles container hooks (where ContainerId may be empty but execution should still go through the container hook manager) and PrependPath propagation for both host and container execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 7 +- src/Runner.Worker/Dap/DapReplExecutor.cs | 147 +++++++++++++++++------ src/Test/L0/Worker/DapReplExecutorL0.cs | 74 +++++++++++- 3 files changed, 185 insertions(+), 43 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 3b3ec7cbfaf..4fca8b650e2 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -1195,7 +1195,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 diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 751f92c514c..ff0eeed5f77 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 ContainerStepHost; + + // 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,44 @@ 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 ContainerStepHost containerHost) + { + containerHost.PrependPath = prependPath; + } + else + { + string existingPath; + environment.TryGetValue(Constants.PathVariable, out existingPath); + existingPath = existingPath ?? System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? string.Empty; + environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, existingPath); + } + } + + // 8. Resolve working directory — translate for container var workingDirectory = command.WorkingDirectory; if (string.IsNullOrEmpty(workingDirectory)) { @@ -141,48 +170,49 @@ 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()) + // 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 +228,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/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs index 687d2093a02..f77eced248d 100644 --- a/src/Test/L0/Worker/DapReplExecutorL0.cs +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -7,7 +7,9 @@ using GitHub.DistributedTask.Pipelines.ContextData; 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 +42,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 +54,7 @@ private Mock CreateMockContext( PrependPath = new List(), JobDefaults = jobDefaults ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + Container = container, }; mock.Setup(x => x.Global).Returns(global); @@ -65,7 +69,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 +237,71 @@ 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); + } + } } } From 847564d9a027fe483c005d84d1ae113bbf876b99 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 16:26:18 +0100 Subject: [PATCH 2/4] Show execution context banner when debugger pauses at a step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the debugger pauses at a step, emit a console output banner so the user knows where REPL commands will execute: ⬡ alpine:3.20 (abc123def456) — commands run inside the container ⊙ host — commands run on the host This compensates for DAP not supporting custom prompts. The banner appears automatically each time the debugger stops, using the container image name and a short container ID for container steps, or 'host' for infrastructure steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 4fca8b650e2..93dbbc1b213 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -860,6 +860,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(step); + // Wait for debugger command await WaitForCommandAsync(cancellationToken); } @@ -1412,6 +1415,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(IStep step) + { + if (!_isClientConnected) + { + return; + } + + bool isActionStep = step 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)) From d58cfbd3769fcfb5011cc33413f1b34f86b15c81 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 14 May 2026 19:15:42 +0100 Subject: [PATCH 3/4] Address PR feedback: use IContainerStepHost interface, add hooks warning and test - Check against IContainerStepHost interface instead of the concrete ContainerStepHost type, so alternative implementations registered in the service locator are handled correctly. - Add a trace warning when container hooks are enabled, since the hook manager does not raise OutputDataReceived/ErrorDataReceived events and REPL output will not be streamed in that mode. - Add a test for the container hooks path where ContainerId is empty but hooks are enabled, verifying CreateStepHost still returns an IContainerStepHost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapReplExecutor.cs | 13 ++++++++-- src/Test/L0/Worker/DapReplExecutorL0.cs | 31 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index ff0eeed5f77..09a45f12662 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -75,7 +75,7 @@ private async Task ExecuteScriptAsync( // 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 ContainerStepHost; + var isContainerStepHost = stepHost is IContainerStepHost; // 2. Resolve shell — same logic as ScriptHandler string shellCommand; @@ -145,7 +145,7 @@ private async Task ExecuteScriptAsync( // 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment if (context.Global.PrependPath.Count > 0) { - if (stepHost is ContainerStepHost containerHost) + if (stepHost is IContainerStepHost containerHost) { containerHost.PrependPath = prependPath; } @@ -177,6 +177,15 @@ private async Task ExecuteScriptAsync( // Stream execution info to debugger SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n"); + // 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)) + { + _trace.Warning("Container hooks are enabled -- REPL output will not be streamed to the debug console"); + } + // 9. Execute via IStepHost — handles docker exec for containers, // direct process execution for host, and container hooks stepHost.OutputDataReceived += (sender, args) => diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs index f77eced248d..e70c615fc94 100644 --- a/src/Test/L0/Worker/DapReplExecutorL0.cs +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -5,6 +5,7 @@ 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; @@ -303,5 +304,35 @@ public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost() 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); + } + } + } } } From ce5bdf55bacd237d9e2d86b72263c19cf228749e Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Mon, 18 May 2026 10:23:02 +0100 Subject: [PATCH 4/4] Address PR feedback: simplify banner, port PATH chain, surface hooks warning - SendExecutionContextBanner now reads _currentStep directly instead of taking it as a parameter. - PrependPath logic for host execution now follows the same priority chain as Handler.AddPrependPathToEnvironment (job variable, then task-environment, then system environment) since the REPL run command accepts env: just like a workflow run step. - The container-hooks warning is now also emitted to the debug console via SendOutput("stderr", ...) so the user actually sees it instead of only finding it in the diag log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapDebugger.cs | 6 +++--- src/Runner.Worker/Dap/DapReplExecutor.cs | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 93dbbc1b213..9848b872b73 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -861,7 +861,7 @@ internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) SendStoppedEvent(reason, description); // Emit a banner so the user knows where REPL commands will execute - SendExecutionContextBanner(step); + SendExecutionContextBanner(); // Wait for debugger command await WaitForCommandAsync(cancellationToken); @@ -1419,14 +1419,14 @@ 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(IStep step) + private void SendExecutionContextBanner() { if (!_isClientConnected) { return; } - bool isActionStep = step is IActionRunner; + bool isActionStep = _currentStep is IActionRunner; var container = _jobContext?.Global?.Container; string target; diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 09a45f12662..434907c2cc7 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -151,10 +151,13 @@ private async Task ExecuteScriptAsync( } else { - string existingPath; - environment.TryGetValue(Constants.PathVariable, out existingPath); - existingPath = existingPath ?? System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? string.Empty; - environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, existingPath); + 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); } } @@ -183,7 +186,9 @@ private async Task ExecuteScriptAsync( // streamed to the debug console in that mode. if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables)) { - _trace.Warning("Container hooks are enabled -- REPL output will not be streamed to the debug console"); + 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,