diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs
new file mode 100644
index 000000000..e7d9866d0
--- /dev/null
+++ b/src/Runner.Worker/Dap/DapReplExecutor.cs
@@ -0,0 +1,308 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using GitHub.DistributedTask.Pipelines.ContextData;
+using GitHub.Runner.Common;
+using GitHub.Runner.Common.Util;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker.Handlers;
+
+namespace GitHub.Runner.Worker.Dap
+{
+ ///
+ /// Executes objects in the job's runtime context.
+ ///
+ /// Mirrors the behavior of a normal workflow run: step as closely
+ /// as possible by reusing the runner's existing shell-resolution logic,
+ /// script fixup helpers, and process execution infrastructure.
+ ///
+ /// Output is streamed to the debugger via DAP output events with
+ /// secrets masked before emission.
+ ///
+ internal sealed class DapReplExecutor
+ {
+ private readonly IHostContext _hostContext;
+ private readonly IDapServer _server;
+ private readonly Tracing _trace;
+
+ public DapReplExecutor(IHostContext hostContext, IDapServer server)
+ {
+ _hostContext = hostContext ?? throw new ArgumentNullException(nameof(hostContext));
+ _server = server;
+ _trace = hostContext.GetTrace(nameof(DapReplExecutor));
+ }
+
+ ///
+ /// Executes a and returns the exit code as a
+ /// formatted .
+ ///
+ public async Task ExecuteRunCommandAsync(
+ RunCommand command,
+ IExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ if (context == null)
+ {
+ return ErrorResult("No execution context available. The debugger must be paused at a step to run commands.");
+ }
+
+ try
+ {
+ return await ExecuteScriptAsync(command, context, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _trace.Error($"REPL run command failed: {ex}");
+ var maskedError = _hostContext.SecretMasker.MaskSecrets(ex.Message);
+ return ErrorResult($"Command failed: {maskedError}");
+ }
+ }
+
+ private async Task ExecuteScriptAsync(
+ RunCommand command,
+ IExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ // 1. Resolve shell — same logic as ScriptHandler
+ string shellCommand;
+ string argFormat;
+
+ if (!string.IsNullOrEmpty(command.Shell))
+ {
+ // Explicit shell from the DSL
+ var parsed = ScriptHandlerHelpers.ParseShellOptionString(command.Shell);
+ shellCommand = parsed.shellCommand;
+ argFormat = string.IsNullOrEmpty(parsed.shellArgs)
+ ? ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand)
+ : parsed.shellArgs;
+ }
+ else
+ {
+ // Default shell — mirrors ScriptHandler platform defaults
+ shellCommand = ResolveDefaultShell(context);
+ argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
+ }
+
+ _trace.Info($"REPL shell: {shellCommand}, argFormat: {argFormat}");
+
+ // 2. Prepare the script content
+ var contents = command.Script;
+ contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
+
+ // Write to a temp file (same pattern as ScriptHandler)
+ var extension = ScriptHandlerHelpers.GetScriptFileExtension(shellCommand);
+ var scriptFilePath = Path.Combine(
+ _hostContext.GetDirectory(WellKnownDirectory.Temp),
+ $"dap_repl_{Guid.NewGuid()}{extension}");
+
+ var encoding = new UTF8Encoding(false);
+#if OS_WINDOWS
+ contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
+ encoding = Console.InputEncoding.CodePage != 65001
+ ? Console.InputEncoding
+ : encoding;
+#endif
+ File.WriteAllText(scriptFilePath, contents, encoding);
+
+ try
+ {
+ // 3. Format arguments with script path
+ var resolvedPath = 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
+ string prependPath = string.Join(
+ Path.PathSeparator.ToString(),
+ Enumerable.Reverse(context.Global.PrependPath));
+ var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
+ ?? shellCommand;
+
+ // 5. Build environment — merge from execution context like a real step
+ var environment = BuildEnvironment(context, command.Env);
+
+ // 6. Resolve working directory
+ var workingDirectory = command.WorkingDirectory;
+ if (string.IsNullOrEmpty(workingDirectory))
+ {
+ var githubContext = context.ExpressionValues.TryGetValue("github", out var gh)
+ ? gh as DictionaryContextData
+ : null;
+ var workspace = githubContext?.TryGetValue("workspace", out var ws) == true
+ ? (ws as StringContextData)?.Value
+ : null;
+ workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
+ }
+
+ _trace.Info($"REPL executing: {commandPath} {arguments} (cwd: {workingDirectory})");
+
+ // 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())
+ {
+ processInvoker.OutputDataReceived += (sender, args) =>
+ {
+ if (!string.IsNullOrEmpty(args.Data))
+ {
+ var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
+ SendOutput("stdout", masked + "\n");
+ }
+ };
+
+ processInvoker.ErrorDataReceived += (sender, args) =>
+ {
+ 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);
+ }
+
+ _trace.Info($"REPL command exited with code {exitCode}");
+
+ // 8. 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}.",
+ Type = exitCode == 0 ? "string" : "error",
+ VariablesReference = 0
+ };
+ }
+ finally
+ {
+ // Clean up temp script file
+ try { File.Delete(scriptFilePath); }
+ catch { /* best effort */ }
+ }
+ }
+
+ ///
+ /// Resolves the default shell the same way
+ /// does: check job defaults, then fall back to platform default.
+ ///
+ private string ResolveDefaultShell(IExecutionContext context)
+ {
+ // Check job defaults
+ if (context.Global?.JobDefaults != null &&
+ context.Global.JobDefaults.TryGetValue("run", out var runDefaults) &&
+ runDefaults.TryGetValue("shell", out var defaultShell) &&
+ !string.IsNullOrEmpty(defaultShell))
+ {
+ _trace.Info($"Using job default shell: {defaultShell}");
+ return defaultShell;
+ }
+
+#if OS_WINDOWS
+ string prependPath = string.Join(
+ Path.PathSeparator.ToString(),
+ context.Global?.PrependPath != null ? Enumerable.Reverse(context.Global.PrependPath) : Array.Empty());
+ var pwshPath = WhichUtil.Which("pwsh", false, _trace, prependPath);
+ return !string.IsNullOrEmpty(pwshPath) ? "pwsh" : "powershell";
+#else
+ return "sh";
+#endif
+ }
+
+ ///
+ /// Merges the job context environment with any REPL-specific overrides.
+ ///
+ private Dictionary BuildEnvironment(
+ IExecutionContext context,
+ Dictionary replEnv)
+ {
+ var env = new Dictionary(VarUtil.EnvironmentVariableKeyComparer);
+
+ // Pull environment from the execution context (same as ActionRunner)
+ if (context.ExpressionValues.TryGetValue("env", out var envData))
+ {
+ if (envData is DictionaryContextData dictEnv)
+ {
+ foreach (var pair in dictEnv)
+ {
+ if (pair.Value is StringContextData str)
+ {
+ env[pair.Key] = str.Value;
+ }
+ }
+ }
+ else if (envData is CaseSensitiveDictionaryContextData csEnv)
+ {
+ foreach (var pair in csEnv)
+ {
+ if (pair.Value is StringContextData str)
+ {
+ env[pair.Key] = str.Value;
+ }
+ }
+ }
+ }
+
+ // Expose runtime context variables to the environment (GITHUB_*, RUNNER_*, etc.)
+ foreach (var ctxPair in context.ExpressionValues)
+ {
+ if (ctxPair.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
+ {
+ foreach (var rtEnv in runtimeContext.GetRuntimeEnvironmentVariables())
+ {
+ env[rtEnv.Key] = rtEnv.Value;
+ }
+ }
+ }
+
+ // Apply REPL-specific overrides last (so they win)
+ if (replEnv != null)
+ {
+ foreach (var pair in replEnv)
+ {
+ env[pair.Key] = pair.Value;
+ }
+ }
+
+ return env;
+ }
+
+ private void SendOutput(string category, string text)
+ {
+ _server?.SendEvent(new Event
+ {
+ EventType = "output",
+ Body = new OutputEventBody
+ {
+ Category = category,
+ Output = text
+ }
+ });
+ }
+
+ private static EvaluateResponseBody ErrorResult(string message)
+ {
+ return new EvaluateResponseBody
+ {
+ Result = message,
+ Type = "error",
+ VariablesReference = 0
+ };
+ }
+ }
+}