diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index 3b3ec7cbfaf..9848b872b73 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(); + // Wait for debugger command await WaitForCommandAsync(cancellationToken); } @@ -1195,7 +1198,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 +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() + { + 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/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); + } + } + } } }