Skip to content
Merged
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
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions scripts/Build-MSIX.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down
92 changes: 87 additions & 5 deletions scripts/LocalPackage.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
Expand All @@ -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 `
Expand Down
59 changes: 54 additions & 5 deletions scripts/Test-Deploy-LocalPackage.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @{
Expand Down Expand Up @@ -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*'
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
62 changes: 51 additions & 11 deletions src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<bool> json,
Option<bool> 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<bool> json, Option<bool> 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<bool> option,
bool defaultValue)
{
try
{
return parseResult.GetValue(option);
}
catch (InvalidOperationException)
{
return defaultValue;
}
}
}
}
Loading
Loading