Compare commits

..

7 Commits

Author SHA1 Message Date
Tingluo Huang
b7fd7da153 Fix null ref exception when websocket client retries. (#4589) 2026-07-30 19:54:20 -04:00
Tingluo Huang
ed0bf12a66 Implement VSock secret notifier. (#4565) 2026-07-30 11:57:39 -04:00
dependabot[bot]
ec6b92b5dc Bump eslint-plugin-github from 6.1.0 to 6.1.2 in /src/Misc/expressionFunc/hashFiles (#4590)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-30 10:22:14 +01:00
dependabot[bot]
34ef7f24f8 Bump lint-staged from 16.4.0 to 17.2.0 in /src/Misc/expressionFunc/hashFiles (#4580)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-29 15:16:01 +00:00
dependabot[bot]
b8dd9a3e3c Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4584)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-29 12:14:51 +00:00
dependabot[bot]
cc01a0d090 Bump undici from 6.24.1 to 6.27.0 in /src/Misc/expressionFunc/hashFiles (#4522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-07-29 13:06:16 +01:00
dependabot[bot]
03a0707cbe Bump @typescript-eslint/parser from 8.63.0 to 8.65.0 in /src/Misc/expressionFunc/hashFiles (#4582)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 11:23:54 +01:00
11 changed files with 528 additions and 1282 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -38,13 +38,13 @@
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/parser": "^8.65.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^6.1.0",
"eslint-plugin-github": "^6.1.2",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"lint-staged": "^17.2.0",
"prettier": "^3.0.3",
"typescript": "^6.0.3"
}

View File

@@ -61,7 +61,7 @@ namespace GitHub.Runner.Common
if (!string.IsNullOrEmpty(liveConsoleFeedUrl))
{
_liveConsoleFeedUrl = liveConsoleFeedUrl;
InitializeWebsocketClient(liveConsoleFeedUrl, token, TimeSpan.Zero, retryConnection: true);
InitializeWebsocketClient(liveConsoleFeedUrl, TimeSpan.Zero, retryConnection: true);
}
}
@@ -164,9 +164,9 @@ namespace GitHub.Runner.Common
return ValueTask.CompletedTask;
}
private void InitializeWebsocketClient(string liveConsoleFeedUrl, string accessToken, TimeSpan delay, bool retryConnection = false)
private void InitializeWebsocketClient(string liveConsoleFeedUrl, TimeSpan delay, bool retryConnection = false)
{
if (string.IsNullOrEmpty(accessToken))
if (string.IsNullOrEmpty(_token))
{
Trace.Info($"No access token from server");
return;
@@ -179,12 +179,7 @@ namespace GitHub.Runner.Common
}
Trace.Info($"Creating websocket client ..." + liveConsoleFeedUrl);
this._websocketClient = new ClientWebSocket();
this._websocketClient.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
var userAgentValues = new List<ProductInfoHeaderValue>();
userAgentValues.AddRange(UserAgentUtility.GetDefaultRestUserAgent());
userAgentValues.AddRange(HostContext.UserAgents);
this._websocketClient.Options.SetRequestHeader("User-Agent", string.Join(" ", userAgentValues.Select(x => x.ToString())));
this._websocketClient = CreateWebSocketClient();
// during initialization, retry upto 3 times to setup connection
this._websocketConnectTask = ConnectWebSocketClient(liveConsoleFeedUrl, delay, retryConnection);
@@ -201,8 +196,15 @@ namespace GitHub.Runner.Common
{
Trace.Info($"Attempting to start websocket client with delay {delay}.");
await Task.Delay(delay);
using var connectTimeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await this._websocketClient.ConnectAsync(new Uri(feedStreamUrl), connectTimeoutTokenSource.Token);
using (var connectTimeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{
if (this._websocketClient == null)
{
this._websocketClient = CreateWebSocketClient();
}
await this._websocketClient.ConnectAsync(new Uri(feedStreamUrl), connectTimeoutTokenSource.Token);
}
Trace.Info($"Successfully started websocket client.");
connected = true;
}
@@ -211,6 +213,7 @@ namespace GitHub.Runner.Common
Trace.Info("Exception caught during websocket client connect, retry connection.");
Trace.Error(ex);
retries++;
this._websocketClient?.Dispose();
this._websocketClient = null;
_lastConnectionFailure = DateTime.Now;
}
@@ -259,7 +262,7 @@ namespace GitHub.Runner.Common
Trace.Info($"Websocket is not open, let's attempt to connect back again with random backoff {delay} ms.");
Trace.Verbose(ex.ToString());
retries++;
InitializeWebsocketClient(_liveConsoleFeedUrl, _token, delay);
InitializeWebsocketClient(_liveConsoleFeedUrl, delay);
}
}
}
@@ -274,13 +277,24 @@ namespace GitHub.Runner.Common
if (_lastConnectionFailure.HasValue && DateTime.Now > _lastConnectionFailure.Value.AddMinutes(10))
{
// Some minutes passed since we retried last time, try connection again
InitializeWebsocketClient(_liveConsoleFeedUrl, _token, TimeSpan.Zero);
InitializeWebsocketClient(_liveConsoleFeedUrl, TimeSpan.Zero);
}
}
return delivered;
}
private ClientWebSocket CreateWebSocketClient()
{
var client = new ClientWebSocket();
client.Options.SetRequestHeader("Authorization", $"Bearer {_token}");
var userAgentValues = new List<ProductInfoHeaderValue>();
userAgentValues.AddRange(UserAgentUtility.GetDefaultRestUserAgent());
userAgentValues.AddRange(HostContext.UserAgents);
client.Options.SetRequestHeader("User-Agent", string.Join(" ", userAgentValues.Select(x => x.ToString())));
return client;
}
private void CloseWebSocket(WebSocketCloseStatus closeStatus, CancellationToken cancellationToken)
{
try

View File

@@ -0,0 +1,232 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using GitHub.DistributedTask.Logging;
using GitHub.Runner.Sdk;
using Newtonsoft.Json;
namespace GitHub.Runner.Common
{
[ServiceLocator(Default = typeof(VSockSecretNotifier))]
public interface IVSockSecretNotifier : IRunnerService, IAsyncDisposable
{
bool TryStartNotifier();
void NotifyNewSecret(NewSecretEventArgs newSecret);
}
public sealed class VSockSecretNotifier : RunnerService, IVSockSecretNotifier
{
private Socket _vsock = null;
private CancellationTokenSource _cancellationTokenSource = null;
private Task _secretNotificationTask = null;
private Channel<byte[]> _channel = Channel.CreateUnbounded<byte[]>(new UnboundedChannelOptions() { SingleReader = true });
public bool TryStartNotifier()
{
if (_vsock != null)
{
Trace.Verbose("VSocket is already connected.");
return true;
}
// `GITHUB_ACTIONS_RUNNER_VSOCK_CID_PORT` is expected to be in the format "CID:PORT", e.g. "2:9999".
string vsockCidPort = Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_VSOCK_CID_PORT");
if (string.IsNullOrEmpty(vsockCidPort))
{
Trace.Verbose("VSocket CID/Port environment variable is not set.");
return false;
}
string[] parts = vsockCidPort.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
Trace.Verbose("VSocket CID/Port environment variable is not in the correct format.");
return false;
}
uint cid, port;
if (!uint.TryParse(parts[0], out cid) || !uint.TryParse(parts[1], out port))
{
Trace.Verbose("VSocket CID/Port environment variable contains invalid numbers.");
return false;
}
Trace.Info($"Attempting to start VSocket secret notifier with CID: {cid}, Port: {port}.");
try
{
SafeSocketHandle nativeSocket = NativeSocket((int)(AddressFamily)40, (int)SocketType.Stream, 0);
if (nativeSocket.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
nativeSocket.Dispose();
throw new SocketException(error);
}
_vsock = new Socket(nativeSocket);
_vsock.Connect(new HostVsockEndPoint(cid, port));
}
catch (Exception ex)
{
Trace.Error($"Failed to create and connect VSocket: {ex}");
_vsock?.Dispose();
_vsock = null;
return false;
}
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken);
_secretNotificationTask = ProcessSecretChannel();
Trace.Info($"VSocket secret notifier started successfully.");
return true;
}
public void NotifyNewSecret(NewSecretEventArgs newSecret)
{
if (_vsock == null)
{
Trace.Verbose("VSocket is not connected, skipping secret notification.");
return;
}
byte[] payloadBytes = Encoding.UTF8.GetBytes(StringUtil.ConvertToJson(new { RunnerSecrets = newSecret }, Formatting.None));
byte[] lengthPrefix = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(payloadBytes.Length));
byte[] fullPayload = new byte[lengthPrefix.Length + payloadBytes.Length];
Buffer.BlockCopy(lengthPrefix, 0, fullPayload, 0, lengthPrefix.Length);
Buffer.BlockCopy(payloadBytes, 0, fullPayload, lengthPrefix.Length, payloadBytes.Length);
// we don't need to check return since unbounded channel will always accept the item.
_channel.Writer.TryWrite(fullPayload);
}
public async ValueTask DisposeAsync()
{
if (_vsock != null && _secretNotificationTask != null)
{
_cancellationTokenSource?.Cancel();
try
{
await _secretNotificationTask;
}
catch (Exception ex)
{
Trace.Error($"Secret notification task finished with error: {ex}");
}
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
_vsock?.Dispose();
_vsock = null;
}
}
private async Task ProcessSecretChannel()
{
try
{
while (!_cancellationTokenSource.Token.IsCancellationRequested &&
await _channel.Reader.WaitToReadAsync(_cancellationTokenSource.Token))
{
while (_channel.Reader.TryRead(out var payload))
{
try
{
// Socket.SendAsync on a stream socket may send fewer bytes than requested,
// so keep sending until the entire payload has been written.
int totalSent = 0;
while (totalSent < payload.Length)
{
int bytesSent = await _vsock.SendAsync(payload.AsMemory(totalSent), SocketFlags.None, _cancellationTokenSource.Token);
if (bytesSent == 0)
{
throw new SocketException((int)SocketError.ConnectionReset);
}
totalSent += bytesSent;
}
}
catch (OperationCanceledException)
{
Trace.Info("Secret notification task was canceled.");
}
catch (Exception ex)
{
Trace.Error($"Failed to notify new secret over VSocket: {ex}");
}
}
}
}
catch (OperationCanceledException)
{
Trace.Info("Secret notification task was canceled.");
}
catch (Exception ex)
{
Trace.Error($"Failed to process secret channel: {ex}");
}
_channel.Writer.TryComplete();
}
[DllImport("libc", SetLastError = true, EntryPoint = "socket")]
private static extern SafeSocketHandle NativeSocket(int domain, int type, int protocol);
}
internal sealed class HostVsockEndPoint : EndPoint
{
private const int SocketAddressSize = 16;
private readonly uint _cid;
private readonly uint _port;
public HostVsockEndPoint(uint cid, uint port)
{
_cid = cid;
_port = port;
}
public override AddressFamily AddressFamily => (AddressFamily)40;
public override SocketAddress Serialize()
{
SocketAddress socketAddress = new SocketAddress(AddressFamily.Unspecified, SocketAddressSize);
// sockaddr_vm layout: family(0-1), reserved1(2-3), port(4-7), cid(8-11)
ushort family = (ushort)AddressFamily;
socketAddress[0] = (byte)(family & 0xFF);
socketAddress[1] = (byte)((family >> 8) & 0xFF);
socketAddress[2] = 0;
socketAddress[3] = 0;
socketAddress[4] = (byte)(_port & 0xFF);
socketAddress[5] = (byte)((_port >> 8) & 0xFF);
socketAddress[6] = (byte)((_port >> 16) & 0xFF);
socketAddress[7] = (byte)((_port >> 24) & 0xFF);
socketAddress[8] = (byte)(_cid & 0xFF);
socketAddress[9] = (byte)((_cid >> 8) & 0xFF);
socketAddress[10] = (byte)((_cid >> 16) & 0xFF);
socketAddress[11] = (byte)((_cid >> 24) & 0xFF);
return socketAddress;
}
public override EndPoint Create(SocketAddress socketAddress)
{
uint port = (uint)socketAddress[4]
| ((uint)socketAddress[5] << 8)
| ((uint)socketAddress[6] << 16)
| ((uint)socketAddress[7] << 24);
uint cid = (uint)socketAddress[8]
| ((uint)socketAddress[9] << 8)
| ((uint)socketAddress[10] << 16)
| ((uint)socketAddress[11] << 24);
return new HostVsockEndPoint(cid, port);
}
}
}

View File

@@ -1,15 +1,14 @@
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common.Util;
using Newtonsoft.Json;
using System;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Services.WebApi;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using System.Text;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker
{
@@ -46,6 +45,7 @@ namespace GitHub.Runner.Worker
var jobRunner = HostContext.CreateService<IJobRunner>();
var terminal = HostContext.GetService<ITerminal>();
await using (var secretNotifier = HostContext.GetService<IVSockSecretNotifier>())
using (var channel = HostContext.CreateService<IProcessChannel>())
using (var jobRequestCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken))
using (var channelTokenSource = new CancellationTokenSource())
@@ -86,6 +86,14 @@ namespace GitHub.Runner.Worker
HostContext.WritePerfCounter($"WorkerJobMessageReceived_{jobMessage.RequestId.ToString()}");
// Initialize the secret masker and set the thread culture.
if (Constants.Runner.Platform == Constants.OSPlatform.Linux &&
secretNotifier.TryStartNotifier())
{
HostContext.SecretMasker.NewSecretAdded += (sender, e) =>
{
secretNotifier.NotifyNewSecret(e);
};
}
InitializeSecretMasker(jobMessage);
SetCulture(jobMessage);

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.Serialization;
namespace GitHub.DistributedTask.Logging
{
@@ -11,5 +13,41 @@ namespace GitHub.DistributedTask.Logging
void AddValueEncoder(ValueEncoder encoder);
ISecretMasker Clone();
String MaskSecrets(String input);
public event EventHandler<NewSecretEventArgs> NewSecretAdded;
}
public abstract class NewSecretEventArgs : EventArgs
{
public abstract String Type { get; }
}
[DataContract]
public sealed class NewRegexSecretEventArgs : NewSecretEventArgs
{
[DataMember]
public override String Type => "regex";
public NewRegexSecretEventArgs(String pattern)
{
Pattern = pattern;
}
[DataMember]
public String Pattern { get; private set; }
}
[DataContract]
public sealed class NewVariableSecretEventArgs : NewSecretEventArgs
{
[DataMember]
public override String Type => "variable";
public NewVariableSecretEventArgs(List<string> values)
{
Values.AddRange(values);
}
[DataMember]
public List<string> Values { get; private set; } = new List<string>();
}
}

View File

@@ -10,6 +10,8 @@ namespace GitHub.DistributedTask.Logging
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class SecretMasker : ISecretMasker, IDisposable
{
public event EventHandler<NewSecretEventArgs> NewSecretAdded;
public SecretMasker()
{
m_originalValueSecrets = new HashSet<ValueSecret>();
@@ -66,6 +68,8 @@ namespace GitHub.DistributedTask.Logging
m_lock.ExitWriteLock();
}
}
NewSecretAdded?.Invoke(this, new NewRegexSecretEventArgs(pattern));
}
/// <summary>
@@ -133,6 +137,9 @@ namespace GitHub.DistributedTask.Logging
m_lock.ExitWriteLock();
}
}
// valueSecrets contains all the values run through the encoders.
NewSecretAdded?.Invoke(this, new NewVariableSecretEventArgs(valueSecrets.Select(x => x.m_value).ToList()));
}
/// <summary>

View File

@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.7" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.7" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.10" />
</ItemGroup>
<ItemGroup>

View File

@@ -1656,137 +1656,5 @@ namespace GitHub.Runner.Common.Tests.Worker
await _debugger.StopAsync();
}
}
#region Secret masking regression tests
//
// The DAP transport is a secret-carrying channel that bypasses the job
// log's masking, so every user-visible string a DAP producer builds must
// go through the runner's SecretMasker at the point of construction (the
// raw protocol JSON deliberately isn't masked — see SendMessageInternal).
// These tests pin the DapDebugger-owned sinks; DapReplExecutorL0 and
// DapVariableProviderL0 cover the REPL and expression sinks.
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ErrorResponseMasksSecretsInExceptionMessage()
{
using (var hc = CreateTestContext())
{
const string secret = "super-secret-token";
hc.SecretMasker.AddValue(secret);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
// A non-integer frameId makes argument deserialization throw, and
// Newtonsoft embeds the offending value in the exception message —
// exercising the HandleMessageAsync catch-all error response path.
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "evaluate",
Arguments = JObject.FromObject(new
{
expression = "github.repository",
context = "repl",
frameId = secret
})
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"success\":false", response);
Assert.Contains("***", response);
Assert.DoesNotContain(secret, response, StringComparison.Ordinal);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ThreadsResponseMasksSecretsInJobName()
{
using (var hc = CreateTestContext())
{
const string secret = "super-secret-token";
hc.SecretMasker.AddValue(secret);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: $"build-{secret}");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "threads"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", response);
Assert.Contains("Job: build-***", response);
Assert.DoesNotContain(secret, response, StringComparison.Ordinal);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StoppedEventMasksSecretsInStepDescription()
{
using (var hc = CreateTestContext())
{
const string secret = "super-secret-token";
hc.SecretMasker.AddValue(secret);
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
// configurationDone response + welcome message
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var step = CreateStep($"Run deploy {secret}");
var stepTask = _debugger.OnStepStartingAsync(step.Object);
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
Assert.Contains("Run deploy ***", stoppedEvent);
Assert.DoesNotContain(secret, stoppedEvent, StringComparison.Ordinal);
cts.Cancel();
await stepTask;
await _debugger.StopAsync();
}
}
#endregion
}
}

View File

@@ -1,15 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
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.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
@@ -21,73 +18,10 @@ namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapReplExecutorL0
{
private const string Secret = "super-secret-token";
private TestHostContext _hc;
private DapReplExecutor _executor;
private List<Event> _sentEvents;
/// <summary>
/// A step host that never launches a process. It replays canned stdout
/// and stderr lines through the same events a real process raises, so
/// the REPL output pipeline — including secret masking — can be
/// exercised without executing anything.
/// </summary>
private sealed class FakeStepHost : RunnerService, IDefaultStepHost
{
private readonly IEnumerable<string> _stdout;
private readonly IEnumerable<string> _stderr;
private readonly Exception _executeException;
public FakeStepHost(
IEnumerable<string> stdout = null,
IEnumerable<string> stderr = null,
Exception executeException = null)
{
_stdout = stdout ?? Array.Empty<string>();
_stderr = stderr ?? Array.Empty<string>();
_executeException = executeException;
}
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
public string ResolvePathForStepHost(IExecutionContext executionContext, string path) => path;
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
=> Task.FromResult(preferredVersion);
public Task<int> ExecuteAsync(
IExecutionContext context,
string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> environment,
bool requireExitCodeZero,
Encoding outputEncoding,
bool killProcessOnCancel,
bool inheritConsoleHandler,
string standardInInput,
CancellationToken cancellationToken)
{
if (_executeException != null)
{
throw _executeException;
}
foreach (var line in _stdout)
{
OutputDataReceived?.Invoke(this, new ProcessDataReceivedEventArgs(line));
}
foreach (var line in _stderr)
{
ErrorDataReceived?.Invoke(this, new ProcessDataReceivedEventArgs(line));
}
return Task.FromResult(0);
}
}
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
_hc = new TestHostContext(this, testName);
@@ -107,25 +41,6 @@ namespace GitHub.Runner.Common.Tests.Worker
return _hc;
}
/// <summary>
/// Concatenates the text of every output event emitted so far, optionally
/// filtered to a single DAP output category.
/// </summary>
private string CapturedOutput(string category = null)
{
var builder = new StringBuilder();
foreach (var evt in _sentEvents)
{
var body = (OutputEventBody)evt.Body;
if (category == null || string.Equals(body.Category, category, StringComparison.Ordinal))
{
builder.Append(body.Output);
}
}
return builder.ToString();
}
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
@@ -141,35 +56,12 @@ namespace GitHub.Runner.Common.Tests.Worker
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
Container = container,
FileTable = new List<string>(),
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
};
mock.Setup(x => x.Global).Returns(global);
// ToPipelineTemplateEvaluator builds a trace writer that calls
// context.Write — provide a no-op so expression expansion doesn't NRE.
mock.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
return mock;
}
/// <summary>
/// Runs a REPL command end-to-end against a <see cref="FakeStepHost"/>.
/// The runner's temp directory is created up front because the executor
/// writes the generated script there before invoking the step host.
/// </summary>
private Task<EvaluateResponseBody> ExecuteWithFakeStepHostAsync(
TestHostContext hc,
FakeStepHost stepHost,
RunCommand command,
DictionaryContextData exprValues = null)
{
Directory.CreateDirectory(hc.GetDirectory(WellKnownDirectory.Temp));
hc.EnqueueInstance<IDefaultStepHost>(stepHost);
var context = CreateMockContext(exprValues);
return _executor.ExecuteRunCommandAsync(command, context.Object, isActionStep: false, CancellationToken.None);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -442,146 +334,5 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_EchoesScriptVerbatimAndTruncatesLongScripts()
{
using (var hc = CreateTestContext())
{
// The console echo reflects the user's own input back to the
// session that typed it, so it is intentionally not masked. It
// is truncated at 80 characters to keep the console readable.
var script = new string('a', 90);
await ExecuteWithFakeStepHostAsync(hc, new FakeStepHost(), new RunCommand { Script = script });
var console = CapturedOutput("console");
Assert.Contains($"{new string('a', 80)}...\n", console, StringComparison.Ordinal);
Assert.DoesNotContain(new string('a', 81), console, StringComparison.Ordinal);
}
}
#region Secret masking regression tests
//
// Output the REPL relays from elsewhere — process output, exception
// messages, evaluated expressions — must run through the runner's
// SecretMasker before it reaches the DAP transport, because the DAP
// console bypasses the normal job-log masking. (The console echo of the
// user's own typed input is deliberately excluded; see the test above.)
// The tests below pin each sink independently so a future refactor
// cannot silently drop masking from one of them.
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_MasksSecretsInStdout()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var stepHost = new FakeStepHost(stdout: new[] { $"the token is {Secret}", "second line" });
var result = await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
Assert.Equal("string", result.Type);
var stdout = CapturedOutput("stdout");
Assert.DoesNotContain(Secret, stdout, StringComparison.Ordinal);
Assert.Contains("the token is ***", stdout, StringComparison.Ordinal);
Assert.Contains("second line", stdout, StringComparison.Ordinal);
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_MasksSecretsInStderr()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var stepHost = new FakeStepHost(stderr: new[] { $"auth failed for {Secret}" });
await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
var stderr = CapturedOutput("stderr");
Assert.DoesNotContain(Secret, stderr, StringComparison.Ordinal);
Assert.Contains("auth failed for ***", stderr, StringComparison.Ordinal);
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task ExecuteRunCommand_MasksSecretsInFailureResult()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var stepHost = new FakeStepHost(
executeException: new InvalidOperationException($"spawn failed using {Secret}"));
var result = await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
Assert.Equal("error", result.Type);
Assert.DoesNotContain(Secret, result.Result, StringComparison.Ordinal);
Assert.Contains("spawn failed using ***", result.Result, StringComparison.Ordinal);
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ExpandExpressions_MasksSecretsInEvaluatedResult()
{
using (var hc = CreateTestContext())
{
hc.SecretMasker.AddValue(Secret);
var exprValues = new DictionaryContextData
{
["env"] = new DictionaryContextData
{
["TOKEN"] = new StringContextData(Secret)
}
};
var context = CreateMockContext(exprValues);
var result = _executor.ExpandExpressions("echo ${{ env.TOKEN }} done", context.Object);
Assert.DoesNotContain(Secret, result, StringComparison.Ordinal);
Assert.Equal("echo *** done", result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BuildEnvironment_MasksNothingButExpandsSecretValuesForExecution()
{
using (var hc = CreateTestContext())
{
// The environment handed to the process is *not* a user-visible
// sink — it must keep the real value so the command still works.
// Only what we echo back over DAP gets masked.
hc.SecretMasker.AddValue(Secret);
var exprValues = new DictionaryContextData
{
["env"] = new DictionaryContextData
{
["TOKEN"] = new StringContextData(Secret)
}
};
var context = CreateMockContext(exprValues);
var result = _executor.BuildEnvironment(context.Object, replEnv: null);
Assert.Equal(Secret, result["TOKEN"]);
}
}
#endregion
}
}

View File

@@ -16,11 +16,13 @@ namespace GitHub.Runner.Common.Tests.Worker
{
private Mock<IProcessChannel> _processChannel;
private Mock<IJobRunner> _jobRunner;
private Mock<IVSockSecretNotifier> _vsockSecretNotifier;
public WorkerL0()
{
_processChannel = new Mock<IProcessChannel>();
_jobRunner = new Mock<IJobRunner>();
_vsockSecretNotifier = new Mock<IVSockSecretNotifier>();
}
private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName)
@@ -88,6 +90,7 @@ namespace GitHub.Runner.Common.Tests.Worker
var worker = new GitHub.Runner.Worker.Worker();
hc.EnqueueInstance<IProcessChannel>(_processChannel.Object);
hc.EnqueueInstance<IJobRunner>(_jobRunner.Object);
hc.SetSingleton<IVSockSecretNotifier>(_vsockSecretNotifier.Object);
worker.Initialize(hc);
var jobMessage = CreateJobRequestMessage("job1");
var arWorkerMessages = new WorkerMessage[]
@@ -139,6 +142,7 @@ namespace GitHub.Runner.Common.Tests.Worker
var worker = new GitHub.Runner.Worker.Worker();
hc.EnqueueInstance<IProcessChannel>(_processChannel.Object);
hc.EnqueueInstance<IJobRunner>(_jobRunner.Object);
hc.SetSingleton<IVSockSecretNotifier>(_vsockSecretNotifier.Object);
worker.Initialize(hc);
var jobMessage = CreateJobRequestMessage("job1");
var cancelMessage = CreateJobCancelMessage(jobMessage.JobId);