diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs
index 6977c3be3..fa432a8f6 100644
--- a/src/Runner.Worker/Dap/DapReplExecutor.cs
+++ b/src/Runner.Worker/Dap/DapReplExecutor.cs
@@ -207,7 +207,7 @@ namespace GitHub.Runner.Worker.Dap
/// replaced with its masked string result, mirroring the semantics of
/// expression interpolation in a workflow run: step body.
///
- private string ExpandExpressions(string input, IExecutionContext context)
+ internal string ExpandExpressions(string input, IExecutionContext context)
{
if (string.IsNullOrEmpty(input) || !input.Contains("${{"))
{
@@ -269,7 +269,7 @@ namespace GitHub.Runner.Worker.Dap
/// Resolves the default shell the same way
/// does: check job defaults, then fall back to platform default.
///
- private string ResolveDefaultShell(IExecutionContext context)
+ internal string ResolveDefaultShell(IExecutionContext context)
{
// Check job defaults
if (context.Global?.JobDefaults != null &&
@@ -295,7 +295,7 @@ namespace GitHub.Runner.Worker.Dap
///
/// Merges the job context environment with any REPL-specific overrides.
///
- private Dictionary BuildEnvironment(
+ internal Dictionary BuildEnvironment(
IExecutionContext context,
Dictionary replEnv)
{
diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs
index 1f0b2f8aa..2bb27be24 100644
--- a/src/Test/L0/Worker/DapDebugSessionL0.cs
+++ b/src/Test/L0/Worker/DapDebugSessionL0.cs
@@ -786,9 +786,17 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Single(_sentResponses);
Assert.True(_sentResponses[0].Success);
- // The response body is serialized — we can't easily inspect it from
- // the mock, but the important thing is it succeeded without exposing
- // raw secrets (which is tested in DapVariableProviderL0).
+
+ // Verify the response body actually contains redacted values
+ var body = _sentResponses[0].Body;
+ Assert.NotNull(body);
+ var varsBody = Assert.IsType(body);
+ Assert.NotEmpty(varsBody.Variables);
+ foreach (var variable in varsBody.Variables)
+ {
+ Assert.Equal(DapVariableProvider.RedactedValue, variable.Value);
+ Assert.DoesNotContain("ghp_verysecret", variable.Value);
+ }
// Resume to unblock
var continueJson = JsonConvert.SerializeObject(new Request
diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs
new file mode 100644
index 000000000..63e6779fb
--- /dev/null
+++ b/src/Test/L0/Worker/DapReplExecutorL0.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using GitHub.DistributedTask.Expressions2;
+using GitHub.DistributedTask.Pipelines.ContextData;
+using GitHub.Runner.Common.Tests;
+using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Dap;
+using Moq;
+using Xunit;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class DapReplExecutorL0
+ {
+ private TestHostContext _hc;
+ private DapReplExecutor _executor;
+ private Mock _mockServer;
+ private List _sentEvents;
+
+ private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
+ {
+ _hc = new TestHostContext(this, testName);
+ _sentEvents = new List();
+ _mockServer = new Mock();
+ _mockServer.Setup(x => x.SendEvent(It.IsAny()))
+ .Callback(e => _sentEvents.Add(e));
+ _executor = new DapReplExecutor(_hc, _mockServer.Object);
+ return _hc;
+ }
+
+ private Mock CreateMockContext(
+ DictionaryContextData exprValues = null,
+ IDictionary> jobDefaults = null)
+ {
+ var mock = new Mock();
+ mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
+ mock.Setup(x => x.ExpressionFunctions).Returns(new List());
+
+ var global = new GlobalContext
+ {
+ PrependPath = new List(),
+ JobDefaults = jobDefaults
+ ?? new Dictionary>(StringComparer.OrdinalIgnoreCase),
+ };
+ mock.Setup(x => x.Global).Returns(global);
+
+ return mock;
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task ExecuteRunCommand_NullContext_ReturnsError()
+ {
+ using (CreateTestContext())
+ {
+ var command = new RunCommand { Script = "echo hello" };
+ var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
+
+ Assert.Equal("error", result.Type);
+ Assert.Contains("No execution context available", result.Result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ExpandExpressions_NoExpressions_ReturnsInput()
+ {
+ using (CreateTestContext())
+ {
+ var context = CreateMockContext();
+ var result = _executor.ExpandExpressions("echo hello", context.Object);
+
+ Assert.Equal("echo hello", result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ExpandExpressions_NullInput_ReturnsEmpty()
+ {
+ using (CreateTestContext())
+ {
+ var context = CreateMockContext();
+ var result = _executor.ExpandExpressions(null, context.Object);
+
+ Assert.Equal(string.Empty, result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ExpandExpressions_EmptyInput_ReturnsEmpty()
+ {
+ using (CreateTestContext())
+ {
+ var context = CreateMockContext();
+ var result = _executor.ExpandExpressions("", context.Object);
+
+ Assert.Equal(string.Empty, result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ExpandExpressions_UnterminatedExpression_KeepsLiteral()
+ {
+ using (CreateTestContext())
+ {
+ var context = CreateMockContext();
+ var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object);
+
+ Assert.Equal("echo ${{ github.repo", result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ResolveDefaultShell_NoJobDefaults_ReturnsSh()
+ {
+ using (CreateTestContext())
+ {
+ var context = CreateMockContext();
+ var result = _executor.ResolveDefaultShell(context.Object);
+
+ Assert.Equal("sh", result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault()
+ {
+ using (CreateTestContext())
+ {
+ var jobDefaults = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["run"] = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["shell"] = "bash"
+ }
+ };
+ var context = CreateMockContext(jobDefaults: jobDefaults);
+ var result = _executor.ResolveDefaultShell(context.Object);
+
+ Assert.Equal("bash", result);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void BuildEnvironment_MergesEnvContextAndReplOverrides()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ var envData = new DictionaryContextData
+ {
+ ["FOO"] = new StringContextData("bar"),
+ };
+ exprValues["env"] = envData;
+
+ var context = CreateMockContext(exprValues);
+ var replEnv = new Dictionary { { "BAZ", "qux" } };
+ var result = _executor.BuildEnvironment(context.Object, replEnv);
+
+ Assert.Equal("bar", result["FOO"]);
+ Assert.Equal("qux", result["BAZ"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void BuildEnvironment_ReplOverridesWin()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ var envData = new DictionaryContextData
+ {
+ ["FOO"] = new StringContextData("original"),
+ };
+ exprValues["env"] = envData;
+
+ var context = CreateMockContext(exprValues);
+ var replEnv = new Dictionary { { "FOO", "override" } };
+ var result = _executor.BuildEnvironment(context.Object, replEnv);
+
+ Assert.Equal("override", result["FOO"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ var envData = new DictionaryContextData
+ {
+ ["FOO"] = new StringContextData("bar"),
+ };
+ exprValues["env"] = envData;
+
+ var context = CreateMockContext(exprValues);
+ var result = _executor.BuildEnvironment(context.Object, null);
+
+ Assert.Equal("bar", result["FOO"]);
+ Assert.False(result.ContainsKey("BAZ"));
+ }
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs
index ffda39465..2bc4f5fff 100644
--- a/src/Test/L0/Worker/DapServerL0.cs
+++ b/src/Test/L0/Worker/DapServerL0.cs
@@ -1,5 +1,10 @@
-using System;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
using System.Runtime.CompilerServices;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Worker.Dap;
@@ -166,5 +171,172 @@ namespace GitHub.Runner.Common.Tests.Worker
await _server.StopAsync();
}
}
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task MessageFraming_ValidMessage_ProcessedSuccessfully()
+ {
+ using (var hc = CreateTestContext())
+ {
+ var receivedMessages = new List();
+ var mockSession = new Mock();
+ mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny()))
+ .Callback((json, ct) => receivedMessages.Add(json))
+ .Returns(Task.CompletedTask);
+ _server.SetSession(mockSession.Object);
+
+ var cts = new CancellationTokenSource();
+ await _server.StartAsync(0, cts.Token);
+
+ var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
+ var listener = (TcpListener)listenerField.GetValue(_server);
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ var stream = client.GetStream();
+
+ // Wait for server to accept connection
+ await Task.Delay(100);
+
+ // Send a valid DAP request with Content-Length framing
+ var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}";
+ var body = Encoding.UTF8.GetBytes(requestJson);
+ var header = $"Content-Length: {body.Length}\r\n\r\n";
+ var headerBytes = Encoding.ASCII.GetBytes(header);
+
+ await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
+ await stream.WriteAsync(body, 0, body.Length);
+ await stream.FlushAsync();
+
+ // Wait for processing
+ await Task.Delay(500);
+
+ Assert.Single(receivedMessages);
+ Assert.Contains("initialize", receivedMessages[0]);
+
+ cts.Cancel();
+ await _server.StopAsync();
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task CentralizedMasking_SecretsInResponseAreMasked()
+ {
+ using (var hc = CreateTestContext())
+ {
+ // Register a secret
+ hc.SecretMasker.AddValue("super-secret-token");
+
+ var cts = new CancellationTokenSource();
+ await _server.StartAsync(0, cts.Token);
+
+ var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
+ var listener = (TcpListener)listenerField.GetValue(_server);
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ var stream = client.GetStream();
+
+ await Task.Delay(100);
+
+ // Send a response that contains the secret (through the server API)
+ var response = new Response
+ {
+ Type = "response",
+ RequestSeq = 1,
+ Command = "evaluate",
+ Success = true,
+ Body = new EvaluateResponseBody
+ {
+ Result = "The value is super-secret-token here",
+ Type = "string",
+ VariablesReference = 0
+ }
+ };
+
+ _server.SendResponse(response);
+
+ // Read what the client received
+ await Task.Delay(200);
+ var buffer = new byte[4096];
+ var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
+ var received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
+
+ // The response should NOT contain the raw secret
+ Assert.DoesNotContain("super-secret-token", received);
+ // It should contain the masked version
+ Assert.Contains("***", received);
+
+ cts.Cancel();
+ await _server.StopAsync();
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task CentralizedMasking_SecretsInEventsAreMasked()
+ {
+ using (var hc = CreateTestContext())
+ {
+ hc.SecretMasker.AddValue("event-secret-value");
+
+ var cts = new CancellationTokenSource();
+ await _server.StartAsync(0, cts.Token);
+
+ var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance);
+ var listener = (TcpListener)listenerField.GetValue(_server);
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ var stream = client.GetStream();
+
+ await Task.Delay(100);
+
+ _server.SendEvent(new Event
+ {
+ EventType = "output",
+ Body = new OutputEventBody
+ {
+ Category = "stdout",
+ Output = "Output contains event-secret-value here"
+ }
+ });
+
+ await Task.Delay(200);
+ var buffer = new byte[4096];
+ var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
+ var received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
+
+ Assert.DoesNotContain("event-secret-value", received);
+ Assert.Contains("***", received);
+
+ cts.Cancel();
+ await _server.StopAsync();
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task StopAsync_AwaitsConnectionLoopShutdown()
+ {
+ using (CreateTestContext())
+ {
+ var cts = new CancellationTokenSource();
+ await _server.StartAsync(0, cts.Token);
+
+ // Stop should complete within a reasonable time
+ var stopTask = _server.StopAsync();
+ var completed = await Task.WhenAny(stopTask, Task.Delay(10000));
+ Assert.Equal(stopTask, completed);
+ }
+ }
}
}
diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs
index 3dfb83f57..2f84b9f34 100644
--- a/src/Test/L0/Worker/DapVariableProviderL0.cs
+++ b/src/Test/L0/Worker/DapVariableProviderL0.cs
@@ -665,5 +665,108 @@ namespace GitHub.Runner.Common.Tests.Worker
}
#endregion
+
+ #region Non-string secret type redaction
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void GetVariables_SecretsScopeRedactsNumberContextData()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ exprValues["secrets"] = new DictionaryContextData
+ {
+ { "NUMERIC_SECRET", new NumberContextData(12345) }
+ };
+
+ var ctx = CreateMockContext(exprValues);
+ var variables = _provider.GetVariables(ctx.Object, 6);
+
+ Assert.Single(variables);
+ Assert.Equal("NUMERIC_SECRET", variables[0].Name);
+ Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
+ Assert.Equal("string", variables[0].Type);
+ Assert.Equal(0, variables[0].VariablesReference);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void GetVariables_SecretsScopeRedactsBooleanContextData()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ exprValues["secrets"] = new DictionaryContextData
+ {
+ { "BOOL_SECRET", new BooleanContextData(true) }
+ };
+
+ var ctx = CreateMockContext(exprValues);
+ var variables = _provider.GetVariables(ctx.Object, 6);
+
+ Assert.Single(variables);
+ Assert.Equal("BOOL_SECRET", variables[0].Name);
+ Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
+ Assert.Equal("string", variables[0].Type);
+ Assert.Equal(0, variables[0].VariablesReference);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void GetVariables_SecretsScopeRedactsNestedDictionary()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ exprValues["secrets"] = new DictionaryContextData
+ {
+ { "NESTED_SECRET", new DictionaryContextData
+ {
+ { "inner_key", new StringContextData("inner_value") }
+ }
+ }
+ };
+
+ var ctx = CreateMockContext(exprValues);
+ var variables = _provider.GetVariables(ctx.Object, 6);
+
+ Assert.Single(variables);
+ Assert.Equal("NESTED_SECRET", variables[0].Name);
+ Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
+ Assert.Equal("string", variables[0].Type);
+ // Nested container should NOT be drillable under secrets
+ Assert.Equal(0, variables[0].VariablesReference);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void GetVariables_SecretsScopeRedactsNullValue()
+ {
+ using (CreateTestContext())
+ {
+ var exprValues = new DictionaryContextData();
+ var secrets = new DictionaryContextData();
+ secrets["NULL_SECRET"] = null;
+ exprValues["secrets"] = secrets;
+
+ var ctx = CreateMockContext(exprValues);
+ var variables = _provider.GetVariables(ctx.Object, 6);
+
+ Assert.Single(variables);
+ Assert.Equal("NULL_SECRET", variables[0].Name);
+ Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value);
+ Assert.Equal(0, variables[0].VariablesReference);
+ }
+ }
+
+ #endregion
}
}