From 25bd6bcb77b1727ac8e5a22a392a816b286fe12e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 12:09:06 -0700 Subject: [PATCH] feat: report the package and payload build identity from clawctl --version `clawctl --version` reported the launcher's assembly version and nothing else. Diagnosing an installation means knowing which packaging commit built it and which OpenClaw payload it shipped with, and neither was reachable from the command line. Support had to ask for msix-metadata.json from inside the package. Report all four values, and compile them in rather than resolve them. - Add a build target that writes ClawCtlBuildMetadata as compile-time string constants. They end up as literals in the binary, so the report cannot drift from the payload that shipped with it, needs no file or process access at startup, and survives trimming and NativeAOT, which are otherwise free to drop metadata nothing statically references. - Take the values from the build that produces the package: Build-MSIX.ps1 already resolves the package version, packaging commit, payload version, and resolved payload commit for msix-metadata.json, so it now passes the same four to the compiler. The local deployment script supplies the checkout's commit. - Fall back to release-policy.json, so an ordinary build reports the pin the checkout describes rather than a placeholder, and report `unknown` for a value no build supplied. - Render through ClawCtlConsole, so the report carries the same heading, grid, and colour policy as every other command. Each commit sits behind the version it produced as a muted parenthetical, which keeps the version the value a reader compares and the commit the value support pastes into a bug. The version tests asserted equality with the assembly version. They exist to catch the custom action being replaced by the library's, which reports whatever assembly started the process, so they now assert that the baked identity is present and the entry assembly's version is not. That is the same defect, and the JIT and NativeAOT suites both still catch it. `--json` is honoured too. The documented contract is that every non-interactive command accepts it, and silently ignoring it on the one command a support script reaches for first was the worst of the options. `--version` is satisfied by the version option before command dispatch, so the document is produced directly rather than projected from a command result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f472f437-3ee7-41f3-a501-81ae01590103 --- README.md | 40 ++++- scripts/Build-MSIX.ps1 | 4 + scripts/LocalPackage.psm1 | 92 +++++++++++- scripts/Test-Deploy-LocalPackage.Tests.ps1 | 59 +++++++- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 62 ++++++-- src/OpenClaw.Launcher/ClawCtlConsole.cs | 32 ++++ src/OpenClaw.Launcher/ClawCtlJson.cs | 26 ++++ .../OpenClaw.Launcher.csproj | 67 +++++++++ .../SmokeProgram.cs | 55 +++++-- .../ClawCtlCommandLineTests.cs | 141 ++++++++++++++++-- .../ProgramStartupTests.cs | 10 +- 11 files changed, 537 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 4e6597d1..64d65790 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,43 @@ the `NO_COLOR` environment variable, redirected output, and CI disable color; explicitly disabled. JSON output never contains terminal escape sequences. Help and version requests take precedence over the rest of the command line. -`clawctl --version bogus` prints the launcher version and exits `0` rather than +`clawctl --version bogus` reports the build identity and exits `0` rather than reporting `bogus`, because the version request is satisfied before the -remaining arguments are validated. The version printed is always the packaged -launcher's assembly version, including when the launcher is hosted by another -process. +remaining arguments are validated. + +`clawctl --version` reports the package version and packaging-repository +commit alongside the bundled OpenClaw payload version and its commit: + +```text +clawctl 0.0.0.1 + + Package: 0.0.0.1 (bfcb5ba73e7ea3e88ceed8c326e58e5baadc3191) + Payload: 2026.8.2 (0965053fe6b9341776df147a6934b7485c60b5ca) +``` + +Each commit is the one that produced the version it follows, and is muted so +the version stays the value a reader compares. + +`clawctl --version --json` reports the same identity as a versioned document, +so a support or deployment script can collect it without parsing prose: + +```json +{ + "ok": true, + "schemaVersion": 1, + "command": "version", + "package": { "version": "0.0.0.1", "commit": "bfcb5ba…" }, + "payload": { "version": "2026.8.2", "commit": "0965053…" } +} +``` + +Those four values are compiled into the binary as constants by the build that +produces the package, so the report cannot drift from the payload it shipped +with and costs no file or process access at startup. `Build-MSIX.ps1` supplies +the versions and commits it also records in `msix-metadata.json`; an ordinary +build falls back to the pin recorded in `release-policy.json`, and reports +`unknown` for a value no build supplied. The report never comes from the entry +assembly, so it stays correct when the launcher is hosted by another process. Response-file expansion is disabled. A leading `@` has no meaning to `clawctl` and is reported as an unrecognized argument rather than read from disk. diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 82c823a7..2b93289e 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -428,6 +428,10 @@ try { "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` "-p:PackageIdentityVersion=$PackageVersion" ` + "-p:ClawCtlPackageVersion=$PackageVersion" ` + "-p:ClawCtlPackageCommit=$($SourceCommit.ToLowerInvariant())" ` + "-p:ClawCtlPayloadVersion=$([string]$payloadInfo.packageVersion)" ` + "-p:ClawCtlPayloadCommit=$($payloadInfo.resolvedCommit.ToLowerInvariant())" ` "-p:AppxPackageDir=$appxOutput" ` -p:AppxBundle=Never ` -p:AppxPackageSigningEnabled=false ` diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 index b2eb4c47..625c9d9d 100644 --- a/scripts/LocalPackage.psm1 +++ b/scripts/LocalPackage.psm1 @@ -193,6 +193,21 @@ function Assert-LocalPackagePayload { return ([string]$metadata['nodeVersion']).TrimStart('v') } +function Get-LocalPackageCheckoutCommit { + param( + [scriptblock]$FindGit = { + @(Get-Command git -CommandType Application -ErrorAction SilentlyContinue) | + Select-Object -First 1 + } + ) + + $git = & $FindGit + if ($null -eq $git) { return '' } + $commit = (& $git.Source -C $PSScriptRoot rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $commit) { return '' } + return $commit.Trim().ToLowerInvariant() +} + function Assert-LocalPackagePayloadIsSafe { param([string]$Directory) @@ -575,9 +590,23 @@ function Get-LocalPackageOperations { return $null } Publish = { - param($project, $architecture, $output) + param($project, $architecture, $output, $metadata) + # Bake the checkout's commit into the local build so `clawctl + # --version` identifies what was deployed. A detached or missing + # git falls back to the project's own default. + $metadataArgs = @() + $commit = Get-LocalPackageCheckoutCommit + if ($commit) { + $metadataArgs += "-p:ClawCtlPackageCommit=$commit" + } + if ($null -ne $metadata) { + $metadataArgs += "-p:ClawCtlPackageVersion=$($metadata.PackageVersion)" + $metadataArgs += "-p:ClawCtlPayloadVersion=$($metadata.PayloadVersion)" + $metadataArgs += "-p:ClawCtlPayloadCommit=$($metadata.PayloadCommit)" + } & dotnet publish $project --configuration Release --runtime "win-$architecture" ` --self-contained "-p:Platform=$architecture" -p:PublishAot=true ` + @metadataArgs ` --output $output --nologo | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { @@ -753,6 +782,40 @@ function Invoke-LocalPackageDeployment { -Architecture $Architecture -PayloadDirectory $PayloadDirectory ` -PayloadRunId $PayloadRunId -RefreshPayload:$RefreshPayload -Operations $services } + $payloadMetadata = Read-LocalPackageRecord ( + Join-Path $payload.Directory 'payload-metadata.json' + ) + $previous = Read-LocalPackageRecord $statePath + $now = & $services.Now + $publishVersion = if ($null -ne $installed -and + $installed.IsDevelopmentMode -and + $null -ne $previous -and + $previous['version'] -eq $installed.Version) { + $installed.Version + } + else { + Get-LocalPackageNextVersion ` + -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` + -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` + -Now $now + } + $publishMetadata = @{ + PackageVersion = $publishVersion + PayloadVersion = if ([string]::IsNullOrWhiteSpace( + [string]$payloadMetadata['packageVersion'])) { + 'unknown' + } + else { + [string]$payloadMetadata['packageVersion'] + } + PayloadCommit = if ( + [string]$payloadMetadata['resolvedCommit'] -match '^[0-9a-fA-F]{40}$') { + ([string]$payloadMetadata['resolvedCommit']).ToLowerInvariant() + } + else { + 'unknown' + } + } $runtimeArchive = Invoke-LocalPackagePhase $progress 'Resolve Node.js runtime' { Resolve-LocalPackageRuntime -RuntimeDirectory (Join-Path $stateRoot 'runtime') ` -Architecture $Architecture -NodeVersion $payload.NodeVersion -Operations $services @@ -764,12 +827,12 @@ function Invoke-LocalPackageDeployment { $hostDirectory = Join-Path $stateRoot 'host' Invoke-LocalPackagePhase $progress 'Build launcher (NativeAOT)' { & $services.Publish (Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj') ` - $Architecture $hostDirectory + $Architecture $hostDirectory $publishMetadata } | Out-Null $sessionHostDirectory = Join-Path $stateRoot 'session-host' Invoke-LocalPackagePhase $progress 'Build session host (NativeAOT)' { & $services.Publish (Join-Path $root 'src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj') ` - $Architecture $sessionHostDirectory + $Architecture $sessionHostDirectory $null } | Out-Null $hostExecutable = Join-Path $hostDirectory 'openclaw.exe' if (-not (& $services.TestPath $hostExecutable)) { @@ -812,7 +875,6 @@ function Invoke-LocalPackageDeployment { $sessionHostHash (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash ) + $imageHashes + $mxcHashes) - $previous = Read-LocalPackageRecord $statePath $setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true) if (-not $Force -and $null -ne $previous -and $null -ne $installed -and $installed.IsDevelopmentMode -and @@ -839,7 +901,27 @@ function Invoke-LocalPackageDeployment { $version = Get-LocalPackageNextVersion ` -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` - -Now (& $services.Now) + -Now $now + if ($version -ne $publishVersion) { + $publishMetadata.PackageVersion = $version + Invoke-LocalPackagePhase $progress 'Rebuild launcher with package identity' { + & $services.Publish ( + Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj' + ) $Architecture $hostDirectory $publishMetadata + } | Out-Null + $hostInfo = Get-Item -LiteralPath $hostExecutable + $hostHash = (Get-FileHash -LiteralPath $hostExecutable -Algorithm SHA256).Hash + $fingerprint = Get-LocalPackageFingerprint (@( + $Architecture + $payload.Directory + [IO.Path]::GetFileName($runtimeArchive) + $hostInfo.Length.ToString() + $hostHash + $sessionHostInfo.Length.ToString() + $sessionHostHash + (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash + ) + $imageHashes + $mxcHashes) + } $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { New-LocalPackageLayout -LayoutDirectory $layoutDirectory -RepositoryRoot $root ` diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 index fd99515c..b59352a6 100644 --- a/scripts/Test-Deploy-LocalPackage.Tests.ps1 +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -104,13 +104,18 @@ function New-Fixture { SetupFailure = $false Removals = @() PreserveFlags = @() + PublishMetadata = $null } $state.WritePayload = { param($directory, $architecture, $text, $nodeVersion) New-Item -Path (Join-Path $directory 'app') -ItemType Directory -Force | Out-Null [IO.File]::WriteAllText((Join-Path $directory 'app\openclaw.mjs'), $text) [IO.File]::WriteAllText((Join-Path $directory 'payload-metadata.json'), (@{ - architecture = $architecture; layout = 'expanded-directory'; nodeVersion = $nodeVersion + architecture = $architecture + layout = 'expanded-directory' + nodeVersion = $nodeVersion + packageVersion = '2026.9.4' + resolvedCommit = '0965053fe6b9341776df147a6934b7485c60b5ca' } | ConvertTo-Json)) } $state.Operations = @{ @@ -147,8 +152,11 @@ function New-Fixture { return $null }.GetNewClosure() Publish = { - param($project, $architecture, $output) + param($project, $architecture, $output, $metadata) $state.Publishes++ + if ($project -like '*OpenClaw.Launcher.csproj') { + $state.PublishMetadata = $metadata + } if ($state.PublishFailure) { throw 'NativeAOT publish failed.' } New-Item -Path $output -ItemType Directory -Force | Out-Null $isSessionHost = $project -like '*OpenClaw.SessionHost*' @@ -159,8 +167,17 @@ function New-Fixture { } elseif (-not $state.SkipHost) { # Unchanged source must produce identical bytes, as a real - # incremental publish does; HostVersion models a source edit. - [IO.File]::WriteAllText((Join-Path $output 'openclaw.exe'), "host $($state.HostVersion)") + # incremental publish does; HostVersion models a source edit + # and metadata models generated compile-time constants. + $identity = if ($null -eq $metadata) { + '' + } + else { + $metadata | ConvertTo-Json -Compress + } + [IO.File]::WriteAllText( + (Join-Path $output 'openclaw.exe'), + "host $($state.HostVersion) $identity") } return $null }.GetNewClosure() @@ -212,6 +229,17 @@ try { $f.MxcStages -eq 1 -and $f.Publishes -eq 2 -and $f.Registrations -eq 1) 'First deployment did not acquire, build, and register exactly once.' Assert-True ($f.Setups -eq 1) 'Deployment did not leave the package runnable by preparing the runtime.' + Assert-True ( + $f.PublishMetadata.PackageVersion -eq $first.Version -and + $f.PublishMetadata.PayloadVersion -eq '2026.9.4' -and + $f.PublishMetadata.PayloadCommit -eq '0965053fe6b9341776df147a6934b7485c60b5ca' + ) 'Launcher publish did not receive the selected local package and payload identity.' + $localPackageModule = Get-Module LocalPackage + Assert-True ( + (& $localPackageModule { + Get-LocalPackageCheckoutCommit -FindGit { $null } + }) -eq '' + ) 'Missing Git should leave optional checkout metadata empty.' Assert-True (@($f.SetupPackageFamilyNames)[0] -eq 'OpenClaw.Gateway_fixture') 'Setup did not target the owning package family.' Assert-True (@($first).Count -eq 1 -and $first.PackageFullName) 'Deployment did not return a single registration record.' $layout = $first.LayoutDirectory @@ -254,7 +282,13 @@ try { $f.HostVersion = 2 $changed = Invoke-Fixture $f Assert-True ($changed.Changed) 'A launcher change was not detected.' - Assert-True ((Get-Content (Join-Path $layout 'openclaw.exe') -Raw) -eq 'host 2') 'The layout kept a stale launcher.' + $changedLauncher = Get-Content (Join-Path $layout 'openclaw.exe') -Raw + Assert-True ( + $changedLauncher.StartsWith('host 2 ', [StringComparison]::Ordinal) -and + $changedLauncher.Contains( + "`"PackageVersion`":`"$($changed.Version)`"", + [StringComparison]::Ordinal) + ) 'The layout kept a stale launcher or package identity.' # Payload refresh replaces content and retires the old generation only on success. $f.Offline = $false @@ -327,6 +361,21 @@ try { $supplied = Invoke-Fixture $j @{ PayloadDirectory = $external } Assert-True ($j.Downloads -eq 0 -and $j.Queries -eq 0) 'A supplied payload still contacted GitHub.' Assert-True ((Get-Content (Join-Path $supplied.LayoutDirectory 'app\openclaw.mjs') -Raw) -eq 'supplied') 'A supplied payload was not used.' + $legacy = New-Fixture + $legacyPayload = Join-Path $testRoot 'legacy supplied payload' + & $legacy.WritePayload $legacyPayload 'x64' 'legacy' '24.20.0' + $legacyMetadataPath = Join-Path $legacyPayload 'payload-metadata.json' + $legacyMetadata = Get-Content -LiteralPath $legacyMetadataPath -Raw | ConvertFrom-Json + $legacyMetadata.PSObject.Properties.Remove('packageVersion') + $legacyMetadata.PSObject.Properties.Remove('resolvedCommit') + [IO.File]::WriteAllText( + $legacyMetadataPath, + ($legacyMetadata | ConvertTo-Json)) + Invoke-Fixture $legacy @{ PayloadDirectory = $legacyPayload } | Out-Null + Assert-True ( + $legacy.PublishMetadata.PayloadVersion -eq 'unknown' -and + $legacy.PublishMetadata.PayloadCommit -eq 'unknown' + ) 'A legacy supplied payload did not use safe unknown identity fallbacks.' Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; RefreshPayload = $true } } 'cannot be combined' Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; PayloadRunId = [long]7 } } 'cannot be combined' Assert-Fails { Invoke-Fixture $j @{ PayloadRunId = [long]-1 } } 'positive workflow run' diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 073f65c4..8554a83a 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -203,7 +203,7 @@ public static RootCommand Create( // prints help and succeeds instead of reporting a missing command. var helpAction = new ClawCtlHelpAction(noColor); root.SetAction((parseResult, _) => Task.FromResult(helpAction.Invoke(parseResult))); - UseLauncherVersion(root); + UseLauncherVersion(root, json, noColor); UseClawCtlHelp(root, helpAction); return root; @@ -225,31 +225,71 @@ private static void UseClawCtlHelp(RootCommand root, ClawCtlHelpAction helpActio } // The built-in version action reports the entry assembly, which is the test - // or scenario host rather than the launcher. Report the launcher assembly so - // the value identifies the shipped package binary in every host. - private static void UseLauncherVersion(RootCommand root) + // or scenario host rather than the launcher. Report the build identity that + // was compiled into this binary so the value identifies the shipped package + // in every host. + private static void UseLauncherVersion( + RootCommand root, + Option json, + Option noColor) { foreach (Option option in root.Options) { if (option is VersionOption versionOption) { - versionOption.Action = new LauncherVersionAction(); + versionOption.Action = new LauncherVersionAction(json, noColor); } } } - private sealed class LauncherVersionAction : SynchronousCommandLineAction + private sealed class LauncherVersionAction(Option json, Option noColor) + : SynchronousCommandLineAction { public override bool ClearsParseErrors => true; public override int Invoke(ParseResult parseResult) { - string version = typeof(LauncherVersionAction).Assembly - .GetName() - .Version? - .ToString() ?? "unknown"; - parseResult.InvocationConfiguration.Output.WriteLine(version); + ArgumentNullException.ThrowIfNull(parseResult); + + TextWriter output = parseResult.InvocationConfiguration.Output; + bool jsonValue = GetBooleanValue(parseResult, json, defaultValue: false); + if (jsonValue) + { + ClawCtlJson.WriteVersion(output); + return 0; + } + + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareOutput( + GetBooleanValue(parseResult, noColor, defaultValue: true), + json: false, + ReferenceEquals(output, Console.Out), + WindowsHostConsole.Instance.IsInteractive, + Environment.GetEnvironmentVariable, + () => WindowsHostConsole.Instance + .TryEnableVirtualTerminalProcessing(output, _ => { }, out restore)); + + using (restore) + { + ClawCtlConsole.WriteVersion(output, useColor); + } + return 0; } + + private static bool GetBooleanValue( + ParseResult parseResult, + Option option, + bool defaultValue) + { + try + { + return parseResult.GetValue(option); + } + catch (InvalidOperationException) + { + return defaultValue; + } + } } } diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 77aa19d5..def377e6 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -77,6 +77,38 @@ internal static void WriteResult( } } + internal static void WriteVersion(TextWriter output, bool useColor = false) + { + ArgumentNullException.ThrowIfNull(output); + + var view = new ResultView( + SupportsUnicode(output), + ClawCtlBuildMetadata.PackageVersion, + ResolveWidth(output)); + view.Row( + "Package", + VersionValue( + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit)); + view.Row( + "Payload", + VersionValue( + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit)); + Render(output, view.Build(), useColor, view.Unicode); + } + + // The version is what a user compares; the commit is what support pastes + // into a bug. Keeping the commit on the same row as a muted parenthetical + // says that without spending a second label on it. + private static Paragraph VersionValue(string version, string commit) + { + var paragraph = new Paragraph(); + paragraph.Append(version); + paragraph.Append($" ({commit})", MutedStyle); + return paragraph; + } + internal static void WriteHelp( TextWriter output, ClawCtlHelpModel model, diff --git a/src/OpenClaw.Launcher/ClawCtlJson.cs b/src/OpenClaw.Launcher/ClawCtlJson.cs index 13391af1..7b18dacf 100644 --- a/src/OpenClaw.Launcher/ClawCtlJson.cs +++ b/src/OpenClaw.Launcher/ClawCtlJson.cs @@ -9,6 +9,8 @@ internal sealed record ClawCtlJsonDocument( bool Ok, int SchemaVersion, string Command, + ClawCtlJsonBuild? Package = null, + ClawCtlJsonBuild? Payload = null, ClawCtlJsonSession? Session = null, ClawCtlJsonRuntime? Runtime = null, ClawCtlJsonGateway? Gateway = null, @@ -17,6 +19,10 @@ internal sealed record ClawCtlJsonDocument( ClawCtlJsonWarning? Warning = null, ClawCtlJsonError? Error = null); +// A version and the commit that produced it. Reported for the package and for +// the OpenClaw payload it carries. +internal sealed record ClawCtlJsonBuild(string Version, string Commit); + internal sealed record ClawCtlJsonSession( string State, string? SandboxId = null, @@ -71,6 +77,26 @@ internal static void WriteResult(TextWriter output, IClawCtlResult result) ClawCtlJsonContext.Default.ClawCtlJsonDocument)); } + // The build identity is known at compile time, so the version document is + // produced directly rather than from a command result. --version is handled + // by the version option before command dispatch and has no result to + // project. + internal static void WriteVersion(TextWriter output) + { + ArgumentNullException.ThrowIfNull(output); + + Write(output, new ClawCtlJsonDocument( + true, + SchemaVersion, + "version", + Package: new ClawCtlJsonBuild( + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit), + Payload: new ClawCtlJsonBuild( + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit))); + } + internal static void WriteFailure(TextWriter output, string command, string message) { ArgumentNullException.ThrowIfNull(output); diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index 5073c2d1..e2de03f2 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -64,6 +64,73 @@ + + + <_ReleasePolicyPath>$(MSBuildThisFileDirectory)..\..\release-policy.json + <_ReleasePolicyText Condition="Exists('$(_ReleasePolicyPath)')">$([System.IO.File]::ReadAllText('$(_ReleasePolicyPath)')) + + $(PackageIdentityVersion) + <_GatewayTag Condition="'$(ClawCtlPackageVersion)' == '' and '$(_ReleasePolicyText)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"gatewayTag"\s*:\s*"([^"]*)".*$', '$1')) + <_MSIXRevision Condition="'$(ClawCtlPackageVersion)' == '' and '$(_ReleasePolicyText)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"msixRevision"\s*:\s*(\d+).*$','$1')) + <_GatewayYear Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$1')) + <_GatewayMonth Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$2')) + <_GatewayPatch Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$3')) + <_GatewayReleaseSequence Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)-(\d+)$', '$4')) + <_GatewayReleaseSequence Condition="'$(_GatewayReleaseSequence)' == '$(_GatewayTag)'">1 + <_PackageRevisionBase Condition="'$(_GatewayReleaseSequence)' != ''">$([MSBuild]::Multiply($(_GatewayReleaseSequence), 1000)) + <_PackageRevision Condition="'$(_PackageRevisionBase)' != '' and '$(_MSIXRevision)' != ''">$([MSBuild]::Add($(_PackageRevisionBase), $(_MSIXRevision))) + $(_GatewayYear).$(_GatewayMonth).$(_GatewayPatch).$(_PackageRevision) + $([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"payloadPackageVersion"\s*:\s*"([^"]*)".*$', '$1')) + $([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"approvedCommit"\s*:\s*"([^"]*)".*$', '$1')) + $(SourceRevisionId) + + unknown + unknown + unknown + unknown + + + + + <_BuildMetadataFile>$(IntermediateOutputPath)ClawCtlBuildMetadata.g.cs + + + <_BuildMetadataLine Include="// <auto-generated />" /> + <_BuildMetadataLine Include="namespace OpenClaw.Launcher%3B" /> + <_BuildMetadataLine Include="internal static class ClawCtlBuildMetadata" /> + <_BuildMetadataLine Include="{" /> + <_BuildMetadataLine Include=" public const string PackageVersion = "$(ClawCtlPackageVersion)"%3B" /> + <_BuildMetadataLine Include=" public const string PackageCommit = "$(ClawCtlPackageCommit)"%3B" /> + <_BuildMetadataLine Include=" public const string PayloadVersion = "$(ClawCtlPayloadVersion)"%3B" /> + <_BuildMetadataLine Include=" public const string PayloadCommit = "$(ClawCtlPayloadCommit)"%3B" /> + <_BuildMetadataLine Include="}" /> + + + + + + + + + + diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 23035d9f..bbc69104 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -45,6 +45,7 @@ private static async Task Main() ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), ("JSON failures survive NativeAOT", JsonFailureIsStructuredAsync), + ("version JSON survives NativeAOT", VersionJsonIsStructuredAsync), ("Spectre renders clawctl output under NativeAOT", SpectreOutputRenders), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) @@ -137,25 +138,60 @@ private static async Task SetupHelpPrintsCommandHelpAsync() // This driver's assembly version is 9.9.9.9. The library's built-in action // reports the entry assembly, so if the custom action were ever dropped - // this scenario would print 9.9.9.9 instead of the launcher's version. + // this scenario would print 9.9.9.9 instead of the baked build identity. private static async Task VersionReportsLauncherAssemblyAsync() { using Fixture fixture = Fixture.CreateWithoutApplication(); - string launcherVersion = LauncherVersion(); string driverVersion = DriverVersion(); int exitCode = await fixture.RunAsync(["--version"]).ConfigureAwait(false); AssertExitCode(0, exitCode, fixture); - string reported = fixture.Output.ToString().Trim(); - Assert( - string.Equals(reported, launcherVersion, StringComparison.Ordinal), - $"Expected the launcher version '{launcherVersion}' but got '{reported}'."); + string reported = fixture.Output.ToString(); + foreach (string expected in new[] + { + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit, + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit + }) + { + Assert( + reported.Contains(expected, StringComparison.Ordinal), + $"Expected the version report to contain '{expected}'."); + } + Assert( - !string.Equals(reported, driverVersion, StringComparison.Ordinal), + !string.Equals(reported.Trim(), driverVersion, StringComparison.Ordinal), $"Reported this driver's version '{driverVersion}' instead of the launcher's."); } + // The build identity is the one document produced outside the command + // result path, and it adds a type to the serializer context. Source + // generation has to cover it ahead of time or this returns an empty object. + private static async Task VersionJsonIsStructuredAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["--version", "--json"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + using JsonDocument document = JsonDocument.Parse(fixture.Output.ToString()); + JsonElement root = document.RootElement; + Assert(root.GetProperty("ok").GetBoolean(), "The version document reported failure."); + Assert( + root.GetProperty("command").GetString() == "version", + "The version document did not name the version command."); + Assert( + root.GetProperty("package").GetProperty("version").GetString() == + ClawCtlBuildMetadata.PackageVersion, + "The version document did not carry the baked package version."); + Assert( + root.GetProperty("payload").GetProperty("commit").GetString() == + ClawCtlBuildMetadata.PayloadCommit, + "The version document did not carry the baked payload commit."); + } + private static async Task VersionWinsOverTrailingAsync() { using Fixture fixture = Fixture.CreateWithoutApplication(); @@ -164,9 +200,8 @@ private static async Task VersionWinsOverTrailingAsync() AssertExitCode(0, exitCode, fixture); Assert( - string.Equals( - fixture.Output.ToString().Trim(), - LauncherVersion(), + fixture.Output.ToString().Contains( + ClawCtlBuildMetadata.PackageVersion, StringComparison.Ordinal), "Expected the launcher version with a trailing argument present."); } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index c2a446a5..dce64be8 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Text.Json; using System.Reflection; namespace OpenClaw.Launcher.Tests; @@ -284,22 +285,134 @@ public async Task PowerShellHelpDescribesTheIsolatedAgentShell() } // The built-in version action reports the entry assembly, which under a test - // host or scenario runner is not the launcher. Assert the launcher's own - // version so that substitution is caught. + // host or scenario runner is not the launcher. Assert the build identity + // compiled into the launcher so that substitution is caught. [Fact] - public async Task VersionReportsTheLauncherAssemblyVersion() + public async Task VersionReportsTheBakedBuildIdentity() { - string expected = typeof(Program).Assembly.GetName().Version?.ToString() - ?? "unknown"; - (int exitCode, string output, string error) = await RunAsync("--version").ConfigureAwait(true); + string reported = Normalize(output); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains(ClawCtlBuildMetadata.PackageVersion, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PackageCommit, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PayloadVersion, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PayloadCommit, reported, StringComparison.Ordinal); + + // The defect this guards: falling back to the library's action, which + // reports whichever assembly started the process. + string? entryVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(); + if (entryVersion is not null) + { + Assert.NotEqual(entryVersion, reported); + } + } + + // --version is satisfied by the version option before command dispatch, so + // it is the one place a JSON document is produced without a command result. + // The documented contract is that every non-interactive command supports + // --json, and silently ignoring it would break a caller that piped it. + [Fact] + public async Task VersionJsonReportsTheBuildIdentity() + { + (int exitCode, string output, string error) = + await RunAsync("--version", "--json").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.DoesNotContain("\u001b", output, StringComparison.Ordinal); + + using JsonDocument document = JsonDocument.Parse(output); + JsonElement root = document.RootElement; + Assert.True(root.GetProperty("ok").GetBoolean()); + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("version", root.GetProperty("command").GetString()); + + JsonElement package = root.GetProperty("package"); + Assert.Equal( + ClawCtlBuildMetadata.PackageVersion, + package.GetProperty("version").GetString()); + Assert.Equal( + ClawCtlBuildMetadata.PackageCommit, + package.GetProperty("commit").GetString()); + + JsonElement payload = root.GetProperty("payload"); + Assert.Equal( + ClawCtlBuildMetadata.PayloadVersion, + payload.GetProperty("version").GetString()); + Assert.Equal( + ClawCtlBuildMetadata.PayloadCommit, + payload.GetProperty("commit").GetString()); + } + + [Theory] + [InlineData("--json=invalid")] + [InlineData("--no-color=invalid")] + public async Task VersionPrecedenceSurvivesMalformedBooleanOptions(string option) + { + (int exitCode, string output, string error) = + await RunAsync("--version", option).ConfigureAwait(true); Assert.Equal(0, exitCode); Assert.Empty(error); - Assert.Equal(expected, output.Trim()); - Assert.NotEqual( - Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), - output.Trim()); + Assert.Contains(ClawCtlBuildMetadata.PackageVersion, output, StringComparison.Ordinal); + Assert.DoesNotContain("This shouldn't happen", output, StringComparison.Ordinal); + } + + [Fact] + public async Task VersionJsonIsAcceptedBeforeTheVersionOption() + { + (int exitCode, string output, _) = + await RunAsync("--json", "--version").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + using JsonDocument document = JsonDocument.Parse(output); + Assert.Equal("version", document.RootElement.GetProperty("command").GetString()); + } + + [Fact] + public async Task VersionPairsEachCommitWithItsVersion() + { + (_, string output, _) = await RunAsync("--version").ConfigureAwait(true); + string reported = Normalize(output); + + Assert.Contains("Package:", reported, StringComparison.Ordinal); + Assert.Contains("Payload:", reported, StringComparison.Ordinal); + + // Each commit belongs to the version it sits behind, so assert the + // pairing rather than the mere presence of four strings. + Assert.Contains( + $"Package: {ClawCtlBuildMetadata.PackageVersion} ({ClawCtlBuildMetadata.PackageCommit})", + reported, + StringComparison.Ordinal); + Assert.Contains( + $"Payload: {ClawCtlBuildMetadata.PayloadVersion} ({ClawCtlBuildMetadata.PayloadCommit})", + reported, + StringComparison.Ordinal); + } + + // The commit is de-emphasised relative to the version it qualifies, so the + // two must not render in the same style. + [Fact] + public void VersionStylesTheCommitApartFromTheVersion() + { + using var colored = new StringWriter(); + ClawCtlConsole.WriteVersion(colored, useColor: true); + string text = colored.ToString(); + + int versionIndex = text.IndexOf( + ClawCtlBuildMetadata.PackageVersion, + StringComparison.Ordinal); + int commitIndex = text.IndexOf( + ClawCtlBuildMetadata.PackageCommit, + StringComparison.Ordinal); + + Assert.True(versionIndex >= 0 && commitIndex > versionIndex); + Assert.Contains( + "\u001b[", + text[versionIndex..commitIndex], + StringComparison.Ordinal); } // The old parser rejected `--version` combined with anything else. The @@ -314,9 +427,11 @@ public async Task VersionWinsOverTrailingArguments() await RunAsync("--version", "bogus").ConfigureAwait(true); Assert.Equal(0, exitCode); - Assert.Equal( - typeof(Program).Assembly.GetName().Version?.ToString(), - output.Trim()); + Assert.Contains( + ClawCtlBuildMetadata.PackageVersion, + Normalize(output), + StringComparison.Ordinal); + Assert.DoesNotContain("bogus", output, StringComparison.Ordinal); } [Theory] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 6b7c0ae7..206aca1a 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -240,9 +240,13 @@ public async Task UnavailableDiagnosticsWarnsOnceAndStillRunsTheCommand() "Unable to create diagnostics", error.ToString(), StringComparison.Ordinal); - Assert.Equal( - typeof(Program).Assembly.GetName().Version?.ToString(), - output.ToString().Trim()); + + // The point of the test is that the command still produced its result + // after diagnostics failed, not what the version happens to be. + Assert.Contains( + ClawCtlBuildMetadata.PackageVersion, + output.ToString(), + StringComparison.Ordinal); } // Startup routes the agent alias without letting the clawctl parser see