Skip to content
Merged
47 changes: 47 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,53 @@ $ mise exec -- which dotnet
Those inputs make the gates runnable; they do not by themselves complete the
runtime evidence.

#### Provisioning the runner

The machine needs Windows x64, `git`, PowerShell 7 (`pwsh`), and a WSL
distribution holding a checkout with its own Linux `dotnet`. The psmux job
drives both sides from one PowerShell process, so a runner without WSL fails
the gate rather than skipping it.

`pwsh` is the one a hosted runner would have supplied. Windows PowerShell 5.1
is not it, and the job's steps ask for `pwsh` by name:

```console
$ winget install --id Microsoft.PowerShell --silent
```

A Windows `dotnet` is not a prerequisite. `global.json` pins an exact SDK, so
the job installs that version into the runner's tool cache regardless of what
the machine already has. Restart the listener after installing anything it
needs to find on `PATH`; it reads the environment once, at start.

Register it with the `psmux` label; `self-hosted`, `Windows` and `X64` are
added for you, and `runs-on` matches on all four:

```console
$ ./config.cmd \
--unattended \
--replace \
--url https://github.com/libtmux/libtmux-dotnet \
--token "$(gh api -X POST \
repos/libtmux/libtmux-dotnet/actions/runners/registration-token \
--jq .token)" \
--name psmux-wsl-win \
--labels psmux
```

A registration token expires in an hour, so generate it when you use it. Run
the listener with `./run.cmd`, or install it as a service with `./svc.cmd
install` if it should survive a reboot. The runner must be online when the tag
is pushed: `psmux` has no `ubuntu-latest` fallback, so a queued job waits
rather than failing fast.

Confirm what GitHub sees before relying on it:

```console
$ gh api repos/libtmux/libtmux-dotnet/actions/runners \
--jq '.runners[] | "\(.name) \(.status) [\([.labels[].name] | join(","))]"'
```

### Recorded evidence is a release artifact

A capability row is `pending` until a matrix run records evidence for it, and
Expand Down
21 changes: 20 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,35 @@ jobs:
name: psmux native Windows and WSL
needs: [validate, psmux-metadata]
runs-on: [self-hosted, Windows, X64, psmux]
timeout-minutes: 45
# The gate restores into a throwaway NuGet cache on purpose, so every run
# pays a full cold restore. That is the point of the isolation, and on a
# self-hosted Windows machine it is slower than the hosted equivalent.
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

# global.json pins an exact SDK, so this runner installs it rather than
# using whatever Windows already has. A self-hosted runner is an ordinary
# user account that cannot write to C:\Program Files\dotnet, so the
# install goes to the runner's own tool cache, which also survives runs.
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet
with:
global-json-file: global.json

# The smoke runs the net8.0 assemblies as well, and global.json only
# brings the pinned 10.x SDK and its runtime. A hosted image happens to
# carry .NET 8 already; an isolated install directory does not, so the
# job installs it rather than depending on what the machine has.
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
env:
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}\dotnet
with:
dotnet-version: '8.0'

- name: Download the audited psmux release
shell: pwsh
env:
Expand Down
64 changes: 46 additions & 18 deletions eng/psmux/Invoke-PsmuxSmoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ elif [ "$requirement" != path ]; then
fi
printf '%s\n' "$candidate"
'@

# git checks this file out with CRLF wherever core.autocrlf is on, and the
# scripts above are handed to /bin/sh inside WSL. A trailing carriage return
# turns the first line into "expecting in", so these are LF by construction
# rather than by however the checkout happened to land.
$wslTimeoutScript = $wslTimeoutScript -replace "`r`n", "`n"
$wslDotnetValidationScript = $wslDotnetValidationScript -replace "`r`n", "`n"
$wslPathResolutionScript = $wslPathResolutionScript -replace "`r`n", "`n"
if ($ExpectedSha256 -ine $supportedSha256) {
throw 'ExpectedSha256 must match the exact audited psmux client build.'
}
Expand Down Expand Up @@ -198,13 +206,21 @@ function Get-BoundedNativeErrorDetail([string] $ErrorText) {
function Get-NativeExitMessage(
[string] $Leg,
[int] $ExitCode,
[string] $ErrorText) {
[string] $ErrorText,
[string] $OutputText = '') {
$message = "$Leg exited $ExitCode."
$detail = Get-BoundedNativeErrorDetail $ErrorText
if (-not $detail) {
return $message
if ($detail) {
return "$message stderr: $detail"
}
# A test runner reports the assertion that failed on stdout and leaves
# stderr empty, so an exit code on its own tells whoever reads the gate
# nothing about which check broke.
$detail = Get-BoundedNativeErrorDetail $OutputText
if ($detail) {
return "$message stdout: $detail"
}
return "$message stderr: $detail"
return $message
}

function Assert-QueryProgram(
Expand Down Expand Up @@ -336,7 +352,8 @@ function Invoke-CapturedNative(
throw (Get-NativeExitMessage `
"$Leg process-tree termination" `
$treeKill.ExitCode `
$treeKill.Error)
$treeKill.Error `
$treeKill.Output)
}
}
else {
Expand Down Expand Up @@ -436,7 +453,8 @@ function Convert-ToWslPath(
throw (Get-NativeExitMessage `
"WSL $Kind path $operation" `
$translation.ExitCode `
$translation.Error)
$translation.Error `
$translation.Output)
}
if ($translation.Output.Count -ne 1 -or
[string]::IsNullOrWhiteSpace($translation.Output[0])) {
Expand Down Expand Up @@ -595,8 +613,8 @@ try {

$binaryText = [Text.Encoding]::ASCII.GetString(
[IO.File]::ReadAllBytes($psmuxFile.FullName))
if (-not $binaryText.Contains('aa26cd3') -or
-not $binaryText.Contains('2026-08-17')) {
if (-not $binaryText.Contains('66cf613') -or
-not $binaryText.Contains('2026-08-18')) {
throw 'psmux does not contain the audited build markers.'
}

Expand Down Expand Up @@ -636,7 +654,8 @@ try {
throw (Get-NativeExitMessage `
'WSL .NET executable validation' `
$wslDotnetResult.ExitCode `
$wslDotnetResult.Error)
$wslDotnetResult.Error `
$wslDotnetResult.Output)
}
if ($wslDotnetResult.Output.Count -ne 1 -or
[string]::IsNullOrWhiteSpace($wslDotnetResult.Output[0])) {
Expand All @@ -662,7 +681,8 @@ try {
throw (Get-NativeExitMessage `
'psmux version query' `
$bannerResult.ExitCode `
$bannerResult.Error)
$bannerResult.Error `
$bannerResult.Output)
}
if ([string]::Join("`n", $banner) -cne
[string]::Join("`n", $expectedBanner)) {
Expand All @@ -687,7 +707,8 @@ try {
throw (Get-NativeExitMessage `
'psmux isolated namespace inspection' `
$existingResult.ExitCode `
$existingResult.Error)
$existingResult.Error `
$existingResult.Output)
}
if ($existing.Count -ne 0) {
throw "The isolated namespace is not empty: $([string]::Join(', ', $existing))"
Expand Down Expand Up @@ -743,7 +764,8 @@ try {
throw (Get-NativeExitMessage `
'psmux new-session after the creation attempt' `
$creationExitCode `
$creationResult.Error)
$creationResult.Error `
$creationResult.Output)
}
if (-not $createdSessionId) {
throw 'psmux created no session with an exact verifiable identity.'
Expand All @@ -764,7 +786,8 @@ try {
throw (Get-NativeExitMessage `
'psmux fixture input' `
$sendResult.ExitCode `
$sendResult.Error)
$sendResult.Error `
$sendResult.Output)
}

$ready = $false
Expand All @@ -784,7 +807,8 @@ try {
throw (Get-NativeExitMessage `
'psmux pane capture' `
$captureResult.ExitCode `
$captureResult.Error)
$captureResult.Error `
$captureResult.Output)
}
$ready = $capture.Where({ $_ -clike "*$expectedText*" }).Count -gt 0
if (-not $ready) {
Expand Down Expand Up @@ -820,7 +844,8 @@ try {
throw (Get-NativeExitMessage `
'Native Windows .NET smoke' `
$exitCode `
$nativeTestResult.Error)
$nativeTestResult.Error `
$nativeTestResult.Output)
}
Assert-OnePassingTest $nativeResultPath 'Native Windows .NET'
$nativeExampleResult = Invoke-CapturedNative `
Expand Down Expand Up @@ -895,7 +920,8 @@ try {
throw (Get-NativeExitMessage `
'WSL .NET smoke' `
$exitCode `
$wslTestResult.Error)
$wslTestResult.Error `
$wslTestResult.Output)
}
Assert-OnePassingTest $wslResultPath 'WSL .NET'
$wslExampleResult = Invoke-CapturedNative `
Expand Down Expand Up @@ -984,7 +1010,8 @@ finally {
throw (Get-NativeExitMessage `
'psmux cleanup identity query' `
$currentIdentityResult.ExitCode `
$currentIdentityResult.Error)
$currentIdentityResult.Error `
$currentIdentityResult.Output)
}
}
elseif ($currentIdentity.Count -ne 1) {
Expand All @@ -1006,7 +1033,8 @@ finally {
throw (Get-NativeExitMessage `
'psmux exact session cleanup' `
$killResult.ExitCode `
$killResult.Error)
$killResult.Error `
$killResult.Output)
}

for ($attempt = 0; $attempt -lt 50 -and
Expand Down
4 changes: 2 additions & 2 deletions eng/psmux/tests/test_smoke_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def test_first_psmux_launch_uses_verified_binary_and_isolated_data() -> None:
assert script.index("Get-FileHash", 0, first_launch) >= 0
assert script.index("$ExpectedSha256 -ine $supportedSha256", 0, first_launch) >= 0
assert "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d" in script
assert script.index("$binaryText.Contains('aa26cd3')", 0, first_launch) >= 0
assert script.index("$binaryText.Contains('2026-08-17')", 0, first_launch) >= 0
assert script.index("$binaryText.Contains('66cf613')", 0, first_launch) >= 0
assert script.index("$binaryText.Contains('2026-08-18')", 0, first_launch) >= 0
assert script.index("$env:PSMUX_DATA_DIR = $DataDirectory", 0, first_launch) >= 0
assert script.index("$env:PSMUX_NO_WARM = '1'", 0, first_launch) >= 0
assert '"set -g warm off`n"' in script
Expand Down
4 changes: 2 additions & 2 deletions src/LibTmux/Internal/PsmuxBinaryTrust.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ private static async Task VerifyCoreAsync(
BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var commit = new MarkerMatcher("aa26cd3"u8);
var date = new MarkerMatcher("2026-08-17"u8);
var commit = new MarkerMatcher("66cf613"u8);
var date = new MarkerMatcher("2026-08-18"u8);
long total = 0;
while (true)
{
Expand Down
6 changes: 3 additions & 3 deletions tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ public async Task Binary_trust_streams_hash_and_markers_across_buffer_boundaries
$"libtmux-streamed-psmux-{Guid.NewGuid():N}.exe");
byte[] contents = new byte[82032];
Array.Fill(contents, (byte)'x');
"aa26cd3"u8.CopyTo(contents.AsSpan(81917));
"2026-08-17"u8.CopyTo(contents.AsSpan(82000));
"66cf613"u8.CopyTo(contents.AsSpan(81917));
"2026-08-18"u8.CopyTo(contents.AsSpan(82000));
await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken);
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(contents));
Expand All @@ -177,7 +177,7 @@ public async Task Binary_trust_does_not_capture_the_callers_synchronization_cont
string binary = Path.Combine(
Path.GetTempPath(),
$"libtmux-context-psmux-{Guid.NewGuid():N}.exe");
byte[] contents = "aa26cd3 2026-08-17"u8.ToArray();
byte[] contents = "66cf613 2026-08-18"u8.ToArray();
await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken);
string hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(contents));
Expand Down
4 changes: 3 additions & 1 deletion tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public async Task Connect_and_typed_queries_use_audited_psmux()
socketName),
cancellationToken);

Assert.Equal(TmuxVersion.Parse("3.3.7"), server.Version);
Assert.Equal(
TmuxVersion.Parse(LibTmux.Internal.PsmuxCompatibility.SupportedVersion),
server.Version);
PsmuxServer refreshed = await server.RefreshAsync(cancellationToken);
Assert.Equal(server.Version, refreshed.Version);

Expand Down
Loading