Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ windows-latest, ubuntu-latest, macos-latest ]
os: [ windows-latest, ubuntu-latest, ubuntu-24.04, macos-latest ]
# ubuntu-24.04 is pinned on purpose: ubuntu-latest follows the newest LTS
# (26.04, util-linux 2.41), while 24.04 keeps an older script(1) and
# util-linux generation in the matrix - the Unix script(1) engine has
# version-sensitive invocation compatibility (see Start-BsnUnixPtyRecording
# and Get-BsnScriptInvocationArguments), and the Unix -Command system test
# pins that behavior on both generations.
runs-on: ${{ matrix.os }}
permissions:
id-token: write # Codecov OIDC token exchange (tokenless default-branch uploads)
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ before:
sync with ConvertTo-BsnAsciicast. `ConvertTo-BsnAsciicast` and
`Get-BsnCastTimeline` are the pure, Pester-tested conversion/parsing
logic - extend tests there rather than smoke-testing the native engine.
Unix script(1) invocation: the engine PROBES the installed script(1)
binary (Get-BsnScriptInvocationArguments) because util-linux 2.39+
(Ubuntu 24.04/26.04) rejects combining `--log-out` with a trailing
positional typescript file ('unexpected number of arguments') - which
made recordings come back empty - while 2.37 and older accept both
shapes; do not hardcode either invocation shape or parse util-linux
version strings. The `-Command` Unix system test plus the
Get-BsnScriptInvocationArguments unit tests keep this pinned, and the
CI matrix runs a pinned ubuntu-24.04 image (util-linux 2.39) next to
ubuntu-latest so two util-linux generations are covered.
- `src/Bsn.SystemManagement/scripts/Suspend-Action.ps1` must remain fully
self-contained (it is shipped alone to remote machines via
`Invoke-Command -FilePath`, which cannot carry the module's DLL): its
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ All notable changes to the modules in this repository are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and the modules adhere to [Semantic Versioning](https://semver.org/).

## [Bsn.Host 0.2.4] - 2026-11-08

### Fixed

- **Every recording on Ubuntu 24.04 and 26.04 (util-linux 2.39 / 2.41)
came back empty**: the script(1) engine combined `--log-out <file>`
with a trailing positional typescript file (`/dev/null`), which
util-linux 2.39 rejects with "unexpected number of arguments" - the
recording process exited immediately and produced a 0-event cast.
(Ubuntu 22.04 / util-linux 2.37 accepts both shapes; Ubuntu 20.04 /
2.34 has no `--log-timing` at all and uses the approximated-timing
branch, so it was never affected.) The engine now **probes the
installed script(1) binary** once with a no-op child and picks the
invocation shape it accepts - the historically tested positional form
where supported, the options-only form where not - so no util-linux
version string parsing is involved and future releases are handled by
the same probe. Found while verifying WSL2/Ubuntu 26.04 end to end:
`echo`-level recordings now capture markers, kernel release and zero
U+FFFD.

### Added

- A CI-runnable Linux/macOS system test for `-Command` recordings via
the script(1) engine (the engine had zero end-to-end CI coverage:
every other Unix system test skips under CI redirected stdio). It
asserts markers, kernel name and zero U+FFFD in the cast, and pins
the invocation-compatibility regression.
- Unit tests for the invocation probe with fake script(1) binaries
(accepting/rejecting), covering both invocation shapes.
- CI matrix: adds a pinned `ubuntu-24.04` image alongside
`ubuntu-latest` (26.04) so two util-linux generations exercise the
script(1) engine on every pull request.
- Show-InteractiveTranscript follows the same restore discipline for
playback writes (from 0.2.3) - unchanged here.

## [Bsn.Host 0.2.3] - 2026-11-07

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions src/Bsn.Host/Bsn.Host.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'Bsn.Host.psm1'
ModuleVersion = '0.2.3'
ModuleVersion = '0.2.4'
GUID = '9d84a7b8-1f2b-4d60-a4bd-3d7ef1d9f337'
Author = 'BSVN'
CompanyName = 'BSVN'
Expand All @@ -21,7 +21,7 @@
Tags = @('BSVN', 'Lmt', 'Host', 'Transcript', 'Recording', 'Terminal', 'Session', 'Asciicast', 'Asciinema', 'TUI', 'Audit', 'Screencast')
LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE'
ProjectUri = 'https://github.com/BSVN/Lmt.Commons'
ReleaseNotes = 'Bug fix release: the LIVE recording view (what you watch while recording) no longer turns box-drawing text into CP437 mojibake on OEM-codepage consoles (cmd, Windows PowerShell 5.1 hosts) - the engine switches the console to UTF-8 for the recording and restores it afterwards; the cast file was always correct. Also includes the 0.2.2 fixes: recordings of byte-writing TUI applications (OpenCode and other Go/Node TUIs) bootstrap the console codepage to UTF-8; interop assembly loading is loader-managed (Import-Module -Force reuses the loaded assembly; cross-version imports get clear guidance). Fixes issues #5 and #15.'
ReleaseNotes = 'Bug fix release: recordings on Linux distros shipping util-linux 2.39 or 2.41 (Ubuntu 24.04 and 26.04) came back empty - the script(1) invocation combined --log-out with a trailing positional file, rejected since util-linux 2.39; the engine now probes the installed script(1) binary and picks the invocation shape it accepts, and a CI-runnable Linux/macOS script(1) engine system test plus a pinned ubuntu-24.04 CI image pin the regression. Also includes the 0.2.3 fixes: the LIVE recording view and Show-InteractiveTranscript playback no longer corrupt box-drawing characters on OEM-codepage consoles - the console output codepage/encoding is switched to UTF-8 for the operation and restored afterwards. Fixes issue #5.'
}
}
}
70 changes: 70 additions & 0 deletions src/Bsn.Host/Private/Resolve-BsnScriptInvocation.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<#
.SYNOPSIS
Builds the script(1) arguments for a timing-based recording, probing the
installed binary for invocation compatibility.

.DESCRIPTION
util-linux changed script(1) invocation compatibility across releases:

- util-linux 2.39+ (Ubuntu 24.04, 26.04) REJECTS combining --log-out
with a trailing positional typescript file ('unexpected number of
arguments') - the recording process exits immediately and every cast
comes back empty.
- util-linux 2.37 (Ubuntu 22.04) and older releases with --log-timing
accept both shapes.

This helper probes the installed binary once with a no-op child using
the classic positional form: where it is accepted, the historically
tested invocation is kept; where it is rejected, the options-only form
is used. No version string parsing is involved, so any future
util-linux release is handled by the same probe.

Kept private (invoked by the Start-InteractiveTranscript engine).
#>
function Resolve-BsnScriptInvocation {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
[string]$ScriptPath,

[Parameter(Mandatory = $true)]
[string]$Command,

[Parameter(Mandatory = $true)]
[string]$TimingPath,

[Parameter(Mandatory = $true)]
[string]$OutputPath
)

$probeTimingPath = [System.IO.Path]::GetTempFileName()
$probeOutputPath = [System.IO.Path]::GetTempFileName()
try {
$probeInfo = [System.Diagnostics.ProcessStartInfo]::new()
$probeInfo.FileName = $ScriptPath
$probeInfo.UseShellExecute = $false
$probeInfo.RedirectStandardError = $true
$probeInfo.Arguments = ('-q -f -e -c "true" --log-timing "{0}" --log-out "{1}" /dev/null' -f $probeTimingPath, $probeOutputPath)
$probeProcess = [System.Diagnostics.Process]::Start($probeInfo)
if (-not $probeProcess.WaitForExit(10000)) {
$probeProcess.Kill()
Write-Verbose -Message 'script(1) invocation probe timed out; using the options-only form.'
return ('-q -f -e -c "{0}" --log-timing "{1}" --log-out "{2}"' -f $Command, $TimingPath, $OutputPath)
}

# Drain stderr so the probe's rejection message never surfaces to the user.
$null = $probeProcess.StandardError.ReadToEnd()

if ($probeProcess.ExitCode -eq 0) {
Write-Verbose -Message 'script(1) accepts the positional typescript file; using the classic invocation.'
return ('-q -f -e -c "{0}" --log-timing "{1}" --log-out "{2}" /dev/null' -f $Command, $TimingPath, $OutputPath)
}

Write-Verbose -Message ('script(1) rejected the positional typescript file (exit {0}); using the options-only form.' -f $probeProcess.ExitCode)
return ('-q -f -e -c "{0}" --log-timing "{1}" --log-out "{2}"' -f $Command, $TimingPath, $OutputPath)
}
finally {
Remove-Item -LiteralPath $probeTimingPath, $probeOutputPath -Force -ErrorAction 'SilentlyContinue'
}
}
5 changes: 4 additions & 1 deletion src/Bsn.Host/Private/Start-BsnUnixPtyRecording.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ function Start-BsnUnixPtyRecording {
$outputPath = [System.IO.Path]::GetTempFileName()
$temporaryPaths = @($timingPath, $outputPath)

$processInfo.Arguments = ('-q -f -e -c "{0}" --log-timing "{1}" --log-out "{2}" /dev/null' -f $recordedCommand, $timingPath, $outputPath)
# The invocation shape depends on the installed script(1): probe it
# (util-linux 2.39+/2.41 reject the positional typescript file that
# older releases accept - see Resolve-BsnScriptInvocation).
$processInfo.Arguments = Resolve-BsnScriptInvocation -ScriptPath $scriptTool.Source -Command $recordedCommand -TimingPath $timingPath -OutputPath $outputPath
}
else {
$rawPath = [System.IO.Path]::GetTempFileName()
Expand Down
33 changes: 33 additions & 0 deletions src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,39 @@ Write-Output 'METACHAR:a|b&c<d>e^f'
}
}

Context 'Unix script(1) engine: -Command recording (util-linux 2.41 regression)' {
It 'records a -Command session and captures the markers on Linux/macOS' -Tag 'SystemTest' {
# Regression for Ubuntu 26.04 / util-linux 2.41: util-linux rejects
# combining --log-out with a trailing positional typescript file
# ('unexpected number of arguments'), which made every -Command
# recording come back empty (0 events). The invocation must use the
# options-only form. Runs under CI redirected stdio on purpose:
# script(1) allocates its own PTY, and -Command children like
# 'echo' do not need stdin, so this is deterministic in CI.
if (-not $script:CanRunSystemTests) {
Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.'
}
if ($PSVersionTable.PSVersion.Major -lt 7) {
Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.'
}
if ($script:IsWindowsPlatform) {
Set-ItResult -Skipped -Because 'Windows uses the ConPTY engine; this regression targets the Unix script(1) engine.'
}

$marker = 'UNIXMARKER-{0}' -f (Get-Random)
$castPath = Join-Path -Path $TestDrive -ChildPath 'unix-command.cast'
$result = Start-InteractiveTranscript -Command ("echo $marker; uname -s") -Path $castPath

$result.ExitCode | Should -Be 0 -Because 'the recorded echo command must succeed'
$stream = Get-BsnCastRawStream -CastPath $castPath
$stream.Length | Should -BeGreaterThan 0 -Because 'the recording must not be empty (util-linux 2.41 invocation regression)'
$stream.Contains($marker) | Should -BeTrue -Because 'the recorded marker must be captured'
$stream.Contains('Linux') -or $stream.Contains('Darwin') | Should -BeTrue -Because 'the uname -s output must be captured'
$replacementCount = ([regex]::Matches($stream, [regex]::Escape([string][char]0xFFFD))).Count
$replacementCount | Should -Be 0 -Because 'the recording must contain zero U+FFFD replacement characters'
}
}

Context 'Stop-InteractiveTranscript finalizes a running session' {
It 'stops early via the stop channel and captures everything before the stop' {
if (-not $script:CanRunSystemTests) {
Expand Down
81 changes: 81 additions & 0 deletions src/Bsn.Host/tests/unit/Resolve-BsnScriptInvocation.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
BeforeAll {
$moduleRoot = Split-Path -Parent -Path (Split-Path -Parent -Path $PSScriptRoot)
$manifestPath = Join-Path -Path $moduleRoot -ChildPath 'Bsn.Host.psd1'
# The module's DLL may already be loaded in this process (e.g., by PSScriptAnalyzer
# analyzing the manifest), which makes Import-Module fail with FileLoadException.
# Fall back to importing the psm1 directly - the assembly is already in memory.
try {
Import-Module -Name $manifestPath -Force -ErrorAction 'Stop'
}
catch [System.IO.FileLoadException] {
Import-Module -Name (Join-Path $moduleRoot 'Bsn.Host.psm1') -Force -ErrorAction 'Stop'
}

# Native keyword enums cannot be referenced by a bare type literal from
# test scope; pull the enum value from the defining module's own scope
# (see AGENTS.md and the Start-InteractiveTranscript tests).
$script:BsnWindows = & (Get-Module -Name 'Bsn.Common') { [BsnOperatingSystem]::Windows }

# Fake script(1) binaries are shell shims: they need a Unix host (chmod/exec).
if ((Get-BsnOperatingSystem) -ne $script:BsnWindows) {
$script:AcceptingShimPath = Join-Path -Path $TestDrive -ChildPath 'accepting-script'
Set-Content -LiteralPath $script:AcceptingShimPath -Value "#!/bin/sh`nexit 0" -NoNewline
& chmod +x $script:AcceptingShimPath

$script:RejectingShimPath = Join-Path -Path $TestDrive -ChildPath 'rejecting-script'
Set-Content -LiteralPath $script:RejectingShimPath -Value "#!/bin/sh`nexit 1" -NoNewline
& chmod +x $script:RejectingShimPath
}
}

Describe 'Resolve-BsnScriptInvocation' {
# https://github.com/BSVN/Lmt.Commons/issues/5 (Linux verification):
# util-linux 2.39+ rejects combining --log-out with a trailing positional
# typescript file, so the engine probes the installed binary and picks the
# invocation shape it accepts. The probe runs a no-op child ('-c "true"')
# through the shim, so the shim's exit code decides the branch.
It 'keeps the classic positional form when script(1) accepts it (util-linux <= 2.37)' {
if ((Get-BsnOperatingSystem) -eq $script:BsnWindows) {
Set-ItResult -Skipped -Because 'the fake script(1) shims are Unix shell executables; the probe is exercised end to end by the Unix system tests on Linux CI.'
}

# Resolve-BsnScriptInvocation is private: invoke it through the
# defining module's scope (the same way the engine calls it).
$arguments = & (Get-Module -Name 'Bsn.Host') {
param($p, $c, $t, $o)
Resolve-BsnScriptInvocation -ScriptPath $p -Command $c -TimingPath $t -OutputPath $o
} $script:AcceptingShimPath 'echo MARKER' '/tmp/t.json' '/tmp/o.log'

$arguments | Should -BeLike '*-c "echo MARKER" --log-timing "/tmp/t.json" --log-out "/tmp/o.log" /dev/null*' -Because 'a compatible binary must keep the historically tested positional form'
}

It 'falls back to the options-only form when script(1) rejects the positional file (util-linux >= 2.39)' {
if ((Get-BsnOperatingSystem) -eq $script:BsnWindows) {
Set-ItResult -Skipped -Because 'the fake script(1) shims are Unix shell executables; the probe is exercised end to end by the Unix system tests on Linux CI.'
}

$arguments = & (Get-Module -Name 'Bsn.Host') {
param($p, $c, $t, $o)
Resolve-BsnScriptInvocation -ScriptPath $p -Command $c -TimingPath $t -OutputPath $o
} $script:RejectingShimPath 'echo MARKER' '/tmp/t.json' '/tmp/o.log'

$arguments | Should -BeLike '*-c "echo MARKER" --log-timing "/tmp/t.json" --log-out "/tmp/o.log"*' -Because 'a rejecting binary must get the options-only invocation'
$arguments | Should -Not -BeLike '*/dev/null*' -Because 'the positional typescript file is what util-linux >= 2.39 rejects'
}

It 'includes the recorded command and both log paths in every invocation shape' {
if ((Get-BsnOperatingSystem) -eq $script:BsnWindows) {
Set-ItResult -Skipped -Because 'the fake script(1) shims are Unix shell executables; the probe is exercised end to end by the Unix system tests on Linux CI.'
}

foreach ($shim in @($script:AcceptingShimPath, $script:RejectingShimPath)) {
$arguments = & (Get-Module -Name 'Bsn.Host') {
param($p, $c, $t, $o)
Resolve-BsnScriptInvocation -ScriptPath $p -Command $c -TimingPath $t -OutputPath $o
} $shim 'uname -s' '/tmp/t.json' '/tmp/o.log'
$arguments.Contains('uname -s') | Should -BeTrue -Because 'the recorded command must be part of the -c argument'
$arguments.Contains('/tmp/t.json') | Should -BeTrue -Because 'the timing log path must be passed to --log-timing'
$arguments.Contains('/tmp/o.log') | Should -BeTrue -Because 'the output log path must be passed to --log-out'
}
}
}
Loading