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
21 changes: 15 additions & 6 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ Local AI payload. Its protected intent is stored under
`%APPDATA%\OpenClawTray\store-migration\intent.dpapi`. Repeating preparation with
unchanged state reuses the unexpired intent. Intent expires after 30 days; only
new consent may renew it. Corrupt records require explicit recovery rather than
silent replacement. Preparation is serialized and writes via a flushed sibling
temporary file followed by atomic rename.
silent replacement. The inventory includes saved Gateway identity directories and
the credential-free `gateways\native-setup-draft.json` recovery descriptor; other
unexpected files in that directory fail closed. Atomic-write temporary siblings
for that descriptor are non-state and ignored only when their names contain the
expected 32-digit lowercase hexadecimal identifier. Preparation is serialized and writes
via a flushed sibling temporary file followed by atomic rename.

`completed.dpapi` is a separate receipt, written only after exclusive source
ownership, a fresh strict inventory matching the protected intent, and canonical
Expand Down Expand Up @@ -224,10 +228,15 @@ continue to normal startup, because refusing to launch cannot repair either one.
Still confirm before the tag that the released Inno installer registers
`DisplayVersion` `2026.9.5` and `DisplayName` `OpenClaw Companion version 2026.9.5`.
Both are refused, and each produces a different message, so check for the right one.
A prerelease suffix is a version refusal: that user is told to update the previous app
to a supported version first. A mismatched name is an unsupported installation: that
user is told migration is unavailable. Neither is offered migration, so the registered
values still have to be exact.
An alpha or other unsupported prerelease suffix is a version refusal: that user is told
to update the previous app to a supported version first. The repository's numeric
stable-correction form, such as `2026.9.8-1`, is accepted and ordered after its
unsuffixed base; its executable file version remains the base numeric version
(`2026.9.8.0`) while its product version must identify the exact correction.
Build properties remain numeric, so pin a correction as `X.Y.Z.N` when a correction
must be the minimum source (for example, `2026.9.8.1` for `2026.9.8-1`). A mismatched
name is an unsupported installation: that user is told migration is unavailable.
Refused sources are not offered migration, so the registered values still have to be exact.

### Developer migration test package

Expand Down
12 changes: 8 additions & 4 deletions src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ public InnoInstallationDetection Detect()
if (architecture is null)
return Unsupported("The installed executable has an unsupported PE architecture.",
sourcePayloadPresent: sourcePayloadPresent);
if (!MigrationVersionPolicy.TryParseReleaseVersion(binary.Version, out var binaryVersion) ||
binaryVersion != version)
if (!MigrationVersionPolicy.IsExecutableVersionCompatible(
registration.DisplayVersion!, binary.Version, binary.ProductVersion))
return Unsupported("The executable file version does not match the registered release version.",
sourcePayloadPresent: sourcePayloadPresent);

Expand Down Expand Up @@ -229,7 +229,10 @@ internal sealed record InnoInstallationRegistration(
string? InstallLocation, string? DisplayVersion, string? Publisher,
string? DisplayName, string? UninstallString);

internal sealed record InnoInstallationExecutable(Machine Machine, string? Version);
internal sealed record InnoInstallationExecutable(
Machine Machine,
string? Version,
string? ProductVersion);

internal interface IInnoInstallationReadSource
{
Expand Down Expand Up @@ -314,7 +317,8 @@ public InnoInstallationExecutable ReadExecutable(string path)
if (headers.CoffHeader.Machine is Machine.Amd64 or Machine.Arm64 &&
headers.PEHeader.Magic != PEMagic.PE32Plus)
throw new InvalidDataException("The installed executable PE headers are inconsistent.");
return new(headers.CoffHeader.Machine, FileVersionInfo.GetVersionInfo(path).FileVersion);
var version = FileVersionInfo.GetVersionInfo(path);
return new(headers.CoffHeader.Machine, version.FileVersion, version.ProductVersion);
}

private static RegistryKey? OpenRegistration(RegistryHive hive, RegistryView view)
Expand Down
55 changes: 49 additions & 6 deletions src/OpenClaw.Connection/Migration/MigrationInventory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public sealed record Entry(
/// recording their bytes does not establish ownership or authorize a policy.
/// </summary>
public static MigrationInventory Capture(string roamingDirectory, string localDirectory)
=> Capture(roamingDirectory, localDirectory, afterGatewaySnapshot: null);

internal static MigrationInventory Capture(
string roamingDirectory,
string localDirectory,
Action? afterGatewaySnapshot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(roamingDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(localDirectory);
Expand All @@ -63,7 +69,13 @@ public static MigrationInventory Capture(string roamingDirectory, string localDi
var autoStart = settings is not null && ValidateSettings(settings.Value);
var registry = capture.ReadJson("roaming", roaming, "gateways.json");
var gatewayIds = ValidateRegistry(registry);
var directories = GetGatewayDirectories(roaming);
var nativeSetupDraft = Path.Combine(
"gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName);
var gatewaySnapshot = GetGatewayDirectorySnapshot(roaming);
afterGatewaySnapshot?.Invoke();
if (gatewaySnapshot.NativeSetupDraftPresent)
capture.ReadJson("roaming", roaming, nativeSetupDraft);
var directories = gatewaySnapshot.Directories;
// Removing a saved gateway deliberately leaves its identity directory behind.
// Inventory that state without reinstating the record or selecting a gateway.
var identityDirectories = gatewayIds.Union(directories, StringComparer.OrdinalIgnoreCase)
Expand Down Expand Up @@ -99,7 +111,9 @@ public static MigrationInventory Capture(string roamingDirectory, string localDi
capture.ReadJson("local", local, "windows-node-context.json");

capture.Recheck();
if (!directories.SequenceEqual(GetGatewayDirectories(roaming), StringComparer.Ordinal) ||
var currentGatewaySnapshot = GetGatewayDirectorySnapshot(roaming);
if (gatewaySnapshot.NativeSetupDraftPresent != currentGatewaySnapshot.NativeSetupDraftPresent ||
!directories.SequenceEqual(currentGatewaySnapshot.Directories, StringComparer.Ordinal) ||
!string.Equals(approvalsPath, Path.GetFullPath(ExecApprovalsStore.ResolveFilePath(roaming)), StringComparison.Ordinal))
throw new IOException("Migration sources changed during capture.");

Expand Down Expand Up @@ -148,24 +162,53 @@ private static HashSet<string> ValidateRegistry(JsonElement? root)
return ids;
}

private static string[] GetGatewayDirectories(string roaming)
private static GatewayDirectorySnapshot GetGatewayDirectorySnapshot(string roaming)
{
var path = Path.Combine(roaming, "gateways");
if (!CheckPath(path, directory: true))
return [];
return new([], NativeSetupDraftPresent: false);
var names = new List<string>();
var nativeSetupDraftPresent = false;
foreach (var child in Directory.EnumerateFileSystemEntries(path))
{
var name = Path.GetFileName(child);
if (string.Equals(
name, OpenClawAppIdentity.NativeGatewaySetupDraftFileName, StringComparison.Ordinal))
{
CheckPath(child, directory: false);
nativeSetupDraftPresent = true;
continue;
}
if (IsNativeSetupDraftTemporaryFile(name))
{
CheckPath(child, directory: false);
continue;
}
if (names.Count >= MaximumGateways)
throw new InvalidDataException("Too many gateway identity directories.");
CheckPath(child, directory: true);
var name = Path.GetFileName(child);
ValidateSegment(name);
names.Add(name);
}
return names.Order(StringComparer.Ordinal).ToArray();
return new(names.Order(StringComparer.Ordinal).ToArray(), nativeSetupDraftPresent);
}

private static bool IsNativeSetupDraftTemporaryFile(string name)
{
var prefix = $".{OpenClawAppIdentity.NativeGatewaySetupDraftFileName}.";
const string suffix = ".tmp";
if (!name.StartsWith(prefix, StringComparison.Ordinal) ||
!name.EndsWith(suffix, StringComparison.Ordinal))
return false;

var id = name.AsSpan(prefix.Length, name.Length - prefix.Length - suffix.Length);
return id.Length == 32 && id.IndexOfAnyExcept("0123456789abcdef") < 0;
}

private sealed record GatewayDirectorySnapshot(
string[] Directories,
bool NativeSetupDraftPresent);

private static void CaptureIdentities(CaptureSession capture, string root, string directory, string relativeDirectory)
{
var relativeName = Path.Combine(relativeDirectory, IdentityFileName);
Expand Down
105 changes: 103 additions & 2 deletions src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,113 @@
namespace OpenClaw.Connection.Migration;

/// <summary>
/// Only stable, three- or four-part numeric release versions are comparable.
/// Prerelease and informational versions cannot authorize an older uninstaller.
/// Stable three- or four-part numeric releases and the repository's numeric
/// stable-correction form are comparable. Other prerelease and informational
/// versions cannot authorize an older uninstaller.
/// </summary>
public static class MigrationVersionPolicy
{
public static bool TryParseReleaseVersion(string? value, out Version version)
{
version = new Version(0, 0, 0, 0);
if (value is null)
return false;

if (TryParseNumericVersion(value, out version))
return true;

return TryParseStableCorrection(value, out version);
}

public static bool IsExecutableVersionCompatible(
string releaseVersion, string? executableFileVersion, string? executableProductVersion)
{
if (!TryParseReleaseVersion(releaseVersion, out var release) ||
!TryParseNumericVersion(executableFileVersion, out var executable))
return false;

if (!TryParseStableCorrection(releaseVersion, out _))
{
if (release != executable)
return false;
if (string.IsNullOrEmpty(executableProductVersion))
return true;
return TryGetProductRelease(executableProductVersion, out var baseProductRelease) &&
!baseProductRelease.Contains('-') &&
TryParseNumericVersion(baseProductRelease, out var baseProductVersion) &&
baseProductVersion == release;
}

var baseVersion = new Version(release.Major, release.Minor, release.Build, 0);
return executable == baseVersion &&
TryGetProductRelease(executableProductVersion, out var productRelease) &&
string.Equals(productRelease, releaseVersion, StringComparison.Ordinal);
}

private static bool TryParseStableCorrection(string value, out Version version)
{
version = new Version(0, 0, 0, 0);
var separator = value.LastIndexOf('-');
if (separator <= 0 || value.IndexOf('-') != separator)
return false;

var correctionText = value.AsSpan(separator + 1);
if (correctionText.Length == 0 || correctionText[0] == '0' ||
correctionText.IndexOfAnyExcept("0123456789") >= 0 ||
!int.TryParse(correctionText, out var correction))
return false;

var baseText = value[..separator];
if (baseText.Count(character => character == '.') != 2 ||
!TryParseNumericVersion(baseText, out var baseVersion))
return false;

version = new Version(baseVersion.Major, baseVersion.Minor, baseVersion.Build, correction);
return true;
}

private static bool TryGetProductRelease(string? value, out string release)
{
release = "";
if (string.IsNullOrEmpty(value))
return false;

var separator = value.IndexOf('+');
if (separator < 0)
{
release = value;
return true;
}
if (separator == 0 || separator != value.LastIndexOf('+'))
return false;

var metadata = value.AsSpan(separator + 1);
if (metadata.Length == 0)
return false;
var identifierLength = 0;
foreach (var character in metadata)
{
if (character == '.')
{
if (identifierLength == 0)
return false;
identifierLength = 0;
continue;
}
if (character is not (>= '0' and <= '9') and
not (>= 'A' and <= 'Z') and
not (>= 'a' and <= 'z') and not '-')
return false;
identifierLength++;
}
if (identifierLength == 0)
return false;

release = value[..separator];
return true;
}

private static bool TryParseNumericVersion(string? value, out Version version)
{
version = new Version(0, 0, 0, 0);
if (value is null)
Expand Down
4 changes: 3 additions & 1 deletion src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ private static bool CanBind(IPAddress address, int port)
}

internal static string GetDraftPath(GatewayRegistry registry) =>
Path.Combine(Path.GetDirectoryName(registry.GetIdentityDirectory("native-setup"))!, "native-setup-draft.json");
Path.Combine(
Path.GetDirectoryName(registry.GetIdentityDirectory("native-setup"))!,
OpenClawAppIdentity.NativeGatewaySetupDraftFileName);

public async Task<NativeGatewaySetupSession> PrepareAsync(
NativeGatewaySetupDraft draft,
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Shared/OpenClawAppIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public static class OpenClawAppIdentity
public const string AppDataRootEnvironmentVariable = "OPENCLAW_TRAY_APPDATA_DIR";
public const string ReleaseDataDirectoryName = "OpenClawTray";
public const string DevDataDirectoryName = "OpenClawTray-Dev";
public const string NativeGatewaySetupDraftFileName = "native-setup-draft.json";

public static string NormalizeIdentity(string? identity)
{
Expand Down
59 changes: 55 additions & 4 deletions tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ public void AnUnreadableRegistration_DoesNotMaskALiveSibling()
[InlineData(Machine.Arm64, "arm64")]
public void DefaultProductionInstallation_IsDetected(Machine machine, string architecture) {
using var fixture = new Fixture();
fixture.Source.Binary = new(machine, "2026.9.17.0");
fixture.Source.Binary = new(machine, "2026.9.17.0", "2026.9.17+fixture");

var result = fixture.Detect();

Expand All @@ -191,7 +191,7 @@ public void ActualProductionInnoMetadata_IsDetected()
"OpenClaw Companion version 2026.9.5.0", $"\"{fixture.Payload("unins000.exe")}\"");
using (var production = registry.Root.CreateSubKey(InnoInstallationDetector.UninstallKey))
WriteRegistration(production, registration);
fixture.Source.Binary = new(Machine.Amd64, "2026.9.5.0");
fixture.Source.Binary = new(Machine.Amd64, "2026.9.5.0", "2026.9.5.0+fixture");
var detector = new InnoInstallationDetector(fixture.Temp.Path, fixture.Logger, registry.Source(fixture.Source));

var result = detector.Detect();
Expand Down Expand Up @@ -390,6 +390,54 @@ public void AnUnreadableRegisteredVersion_IsReportedAsAVersionRefusal(string? ve
Assert.Null(result.RegisteredVersion);
}

[Fact]
public void StableCorrectionRegistration_MatchesBaseExecutableFileVersion()
{
using var fixture = new Fixture();
fixture.Registration = fixture.Registration with
{
DisplayVersion = "2026.9.17-1",
DisplayName = "OpenClaw Companion version 2026.9.17-1",
};
fixture.Source.Binary = fixture.Source.Binary with
{
ProductVersion = "2026.9.17-1+0123456789abcdef",
};

var result = fixture.Detect();

Assert.Equal(InnoInstallationStatus.Detected, result.Status);
Assert.Equal(new Version(2026, 9, 17, 1), result.Installation!.Version);
}

[Theory]
[InlineData("2026.9.17")]
[InlineData("2026.9.17-2")]
[InlineData("2026.9.17-alpha.1")]
public void StableCorrectionRegistration_RejectsDifferentProductRelease(string productVersion)
{
using var fixture = new Fixture();
fixture.Registration = fixture.Registration with
{
DisplayVersion = "2026.9.17-1",
DisplayName = "OpenClaw Companion version 2026.9.17-1",
};
fixture.Source.Binary = fixture.Source.Binary with { ProductVersion = productVersion };

fixture.AssertUnsupported();
}

[Theory]
[InlineData("2026.9.17-1+abcdef")]
[InlineData("2026.9.17-alpha.1+abcdef")]
public void BaseRegistration_RejectsSuffixedProductRelease(string productVersion)
{
using var fixture = new Fixture();
fixture.Source.Binary = fixture.Source.Binary with { ProductVersion = productVersion };

fixture.AssertUnsupported();
}

/// <summary>
/// The counterpart: a refusal that has nothing to do with the version must not borrow the
/// update guidance, or every unsupported installation would be told to update.
Expand Down Expand Up @@ -597,7 +645,9 @@ public void NativeAdapter_ReadsActualPeMachineAndFileVersion(Machine machine)

var evidence = new InnoInstallationReadSource().ReadExecutable(path);
Assert.Equal(machine, evidence.Machine);
Assert.Equal(FileVersionInfo.GetVersionInfo(path).FileVersion, evidence.Version);
var expected = FileVersionInfo.GetVersionInfo(path);
Assert.Equal(expected.FileVersion, evidence.Version);
Assert.Equal(expected.ProductVersion, evidence.ProductVersion);
}

[Fact]
Expand Down Expand Up @@ -888,7 +938,8 @@ private sealed class FakeSource : IInnoInstallationReadSource
public Dictionary<(RegistryHive, RegistryView), InnoInstallationRegistration> Registrations { get; } = new();
public List<(RegistryHive, RegistryView)> RegistryReads { get; } = [];
public List<string> FileReads { get; } = [];
public InnoInstallationExecutable Binary { get; set; } = new(Machine.Amd64, "2026.9.17.0");
public InnoInstallationExecutable Binary { get; set; } =
new(Machine.Amd64, "2026.9.17.0", "2026.9.17+fixture");
public Exception? RegistryError { get; set; }
public RegistryHive? ErrorHive { get; set; }
public Exception? FileError { get; set; }
Expand Down
Loading
Loading