Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/Runner.Worker/Dap/DapDebugger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
rentziass marked this conversation as resolved.
Outdated

// Wait for debugger command
await WaitForCommandAsync(cancellationToken);
}
Expand Down Expand Up @@ -1195,7 +1198,12 @@ private async Task<EvaluateResponseBody> 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
Expand Down Expand Up @@ -1407,6 +1415,40 @@ private void SendStoppedEvent(string reason, string description)
});
}

/// <summary>
/// Emits a console output banner telling the user whether REPL
/// commands will execute on the host or inside the job container.
/// </summary>
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)))
Comment thread
rentziass marked this conversation as resolved.
{
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))
Expand Down
147 changes: 107 additions & 40 deletions src/Runner.Worker/Dap/DapReplExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,6 +44,7 @@ public DapReplExecutor(IHostContext hostContext, Action<string, string> sendOutp
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
Expand All @@ -52,7 +54,7 @@ public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(

try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
}
catch (Exception ex)
{
Expand All @@ -65,9 +67,17 @@ public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
private async Task<EvaluateResponseBody> 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;
Comment thread
rentziass marked this conversation as resolved.
Outdated

// 2. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;

Expand All @@ -87,9 +97,9 @@ private async Task<EvaluateResponseBody> 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);
Expand All @@ -111,25 +121,44 @@ private async Task<EvaluateResponseBody> 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);
Comment thread
rentziass marked this conversation as resolved.
Outdated
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))
{
Expand All @@ -141,48 +170,49 @@ private async Task<EvaluateResponseBody> 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<IProcessInvoker>())
// 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");
}
Comment thread
rentziass marked this conversation as resolved.
};

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}.",
Expand All @@ -198,6 +228,43 @@ private async Task<EvaluateResponseBody> ExecuteScriptAsync(
}
}

/// <summary>
/// Creates the appropriate <see cref="IStepHost"/> for the current
/// execution context, mirroring how <see cref="ActionRunner"/> 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.
/// </summary>
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
{
if (!isActionStep)
{
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
return _hostContext.CreateService<IDefaultStepHost>();
}

var container = context?.Global?.Container;
if (container != null)
Comment thread
rentziass marked this conversation as resolved.
{
// 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<IContainerStepHost>();
containerStepHost.Container = container;
return containerStepHost;
}
}

_trace.Info("Creating DefaultStepHost for REPL execution");
return _hostContext.CreateService<IDefaultStepHost>();
}

/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes
Expand Down
Loading
Loading