From cdac00c7d22b8e0931fd18732dbddcb1888aa864 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Wed, 7 Oct 2026 16:27:55 -0700 Subject: [PATCH 1/2] fix(migration): preserve native setup draft Allow the credential-free Native Gateway setup draft and its exact atomic-write temp siblings during Store migration inventory while continuing to reject unrelated files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RELEASING.md | 8 ++- .../Migration/MigrationInventory.cs | 29 +++++++++- .../NativeGatewaySetupService.cs | 4 +- src/OpenClaw.Shared/OpenClawAppIdentity.cs | 1 + .../MigrationInventoryTests.cs | 55 +++++++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5a7ac3abef..6dc5f53411 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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 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 diff --git a/src/OpenClaw.Connection/Migration/MigrationInventory.cs b/src/OpenClaw.Connection/Migration/MigrationInventory.cs index 7a38693619..7d8d4ba393 100644 --- a/src/OpenClaw.Connection/Migration/MigrationInventory.cs +++ b/src/OpenClaw.Connection/Migration/MigrationInventory.cs @@ -63,6 +63,10 @@ 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 nativeSetupDraft = Path.Combine( + "gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName); + if (Path.Exists(Path.Combine(roaming, nativeSetupDraft))) + capture.ReadJson("roaming", roaming, nativeSetupDraft); var directories = GetGatewayDirectories(roaming); // Removing a saved gateway deliberately leaves its identity directory behind. // Inventory that state without reinstating the record or selecting a gateway. @@ -156,16 +160,39 @@ private static string[] GetGatewayDirectories(string roaming) var names = new List(); foreach (var child in Directory.EnumerateFileSystemEntries(path)) { + var name = Path.GetFileName(child); + if (string.Equals( + name, OpenClawAppIdentity.NativeGatewaySetupDraftFileName, StringComparison.OrdinalIgnoreCase)) + { + CheckPath(child, directory: false); + 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(); } + private static bool IsNativeSetupDraftTemporaryFile(string name) + { + var prefix = $".{OpenClawAppIdentity.NativeGatewaySetupDraftFileName}."; + const string suffix = ".tmp"; + if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + !name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return false; + + var id = name.AsSpan(prefix.Length, name.Length - prefix.Length - suffix.Length); + return id.Length == 32 && id.IndexOfAnyExcept("0123456789abcdefABCDEF") < 0; + } + private static void CaptureIdentities(CaptureSession capture, string root, string directory, string relativeDirectory) { var relativeName = Path.Combine(relativeDirectory, IdentityFileName); diff --git a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs index ba12f3e48c..f23a0b4480 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs @@ -211,7 +211,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 PrepareAsync( NativeGatewaySetupDraft draft, diff --git a/src/OpenClaw.Shared/OpenClawAppIdentity.cs b/src/OpenClaw.Shared/OpenClawAppIdentity.cs index 99c7a21b21..0a58820c4a 100644 --- a/src/OpenClaw.Shared/OpenClawAppIdentity.cs +++ b/src/OpenClaw.Shared/OpenClawAppIdentity.cs @@ -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) { diff --git a/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs index 81ee1cd43d..be1df083d5 100644 --- a/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs +++ b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs @@ -310,6 +310,61 @@ public void Capture_RemovedGateway_PreservesItsOrphanIdentityWithoutReinstatingR Assert.NotEqual(inventory.Fingerprint, Capture().Fingerprint); } + [Fact] + public void Capture_NativeSetupDraft_IsInventoriedWithoutTreatingItAsAnIdentityDirectory() + { + var registry = WriteRegistry(); + new DeviceIdentity(registry.GetIdentityDirectory(GatewayId)).Initialize(); + var path = Write( + Path.Combine(Roaming, "gateways"), + OpenClawAppIdentity.NativeGatewaySetupDraftFileName, + """{"GatewayId":"draft-gateway","Port":18789,"PackageFamilyName":"OpenClawGateway","PreviousPort":null,"Contract":1}"""); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => + entry.CanonicalPath == path && + entry.RelativeName == Path.Combine("gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName) && + entry.Exists); + Assert.Contains(inventory.Entries, entry => + entry.RelativeName == Path.Combine("gateways", GatewayId, "device-key-ed25519.json") && + entry.Exists); + File.WriteAllText( + path, + """{"GatewayId":"draft-gateway","Port":18790,"PackageFamilyName":"OpenClawGateway","PreviousPort":null,"Contract":1}"""); + Assert.NotEqual(inventory.Fingerprint, Capture().Fingerprint); + } + + [Fact] + public void Capture_NativeSetupDraftAtomicTemporaryFile_IsIgnored() + { + Write( + Path.Combine(Roaming, "gateways"), + $".{OpenClawAppIdentity.NativeGatewaySetupDraftFileName}.{Guid.NewGuid():N}.tmp", + """{"GatewayId":"draft-gateway"}"""); + + var inventory = Capture(); + + Assert.DoesNotContain(inventory.Entries, entry => entry.RelativeName.EndsWith(".tmp")); + } + + [Fact] + public void Capture_DirectoryAtNativeSetupDraftPath_IsRejected() + { + Directory.CreateDirectory(Path.Combine( + Roaming, "gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName)); + + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_UnexpectedFileInGatewayIdentityDirectory_IsRejected() + { + Write(Path.Combine(Roaming, "gateways"), "unexpected.json", "{}"); + + Assert.Throws(() => Capture()); + } + [Fact] public void Capture_DuplicateGatewayIds_IsRejected() { From 92f47c6e6345abc9c4821c1f9324c73d6b0f4c62 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Wed, 7 Oct 2026 17:01:07 -0700 Subject: [PATCH 2/2] fix(migration): harden Store source validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RELEASING.md | 15 ++- .../Migration/InnoInstallationDetector.cs | 12 +- .../Migration/MigrationInventory.cs | 36 ++++-- .../Migration/MigrationVersionPolicy.cs | 105 +++++++++++++++++- .../InnoInstallationDetectorTests.cs | 59 +++++++++- .../MigrationInventoryTests.cs | 69 ++++++++++++ .../MigrationVersionPolicyTests.cs | 46 ++++++++ .../StoreMigrationStartupCoordinatorTests.cs | 19 +++- 8 files changed, 334 insertions(+), 27 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6dc5f53411..d423b8b3d9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -41,7 +41,7 @@ silent replacement. The inventory includes saved Gateway identity directories an 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 hexadecimal identifier. Preparation is serialized and writes +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 @@ -228,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 diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs index ef77e4176f..ba06aaf2bc 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -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); @@ -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 { @@ -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) diff --git a/src/OpenClaw.Connection/Migration/MigrationInventory.cs b/src/OpenClaw.Connection/Migration/MigrationInventory.cs index 7d8d4ba393..2dd06e0518 100644 --- a/src/OpenClaw.Connection/Migration/MigrationInventory.cs +++ b/src/OpenClaw.Connection/Migration/MigrationInventory.cs @@ -49,6 +49,12 @@ public sealed record Entry( /// recording their bytes does not establish ownership or authorize a policy. /// 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); @@ -65,9 +71,11 @@ public static MigrationInventory Capture(string roamingDirectory, string localDi var gatewayIds = ValidateRegistry(registry); var nativeSetupDraft = Path.Combine( "gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName); - if (Path.Exists(Path.Combine(roaming, nativeSetupDraft))) + var gatewaySnapshot = GetGatewayDirectorySnapshot(roaming); + afterGatewaySnapshot?.Invoke(); + if (gatewaySnapshot.NativeSetupDraftPresent) capture.ReadJson("roaming", roaming, nativeSetupDraft); - var directories = GetGatewayDirectories(roaming); + 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) @@ -103,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."); @@ -152,19 +162,21 @@ private static HashSet 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(); + var nativeSetupDraftPresent = false; foreach (var child in Directory.EnumerateFileSystemEntries(path)) { var name = Path.GetFileName(child); if (string.Equals( - name, OpenClawAppIdentity.NativeGatewaySetupDraftFileName, StringComparison.OrdinalIgnoreCase)) + name, OpenClawAppIdentity.NativeGatewaySetupDraftFileName, StringComparison.Ordinal)) { CheckPath(child, directory: false); + nativeSetupDraftPresent = true; continue; } if (IsNativeSetupDraftTemporaryFile(name)) @@ -178,21 +190,25 @@ private static string[] GetGatewayDirectories(string roaming) 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.OrdinalIgnoreCase) || - !name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + 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("0123456789abcdefABCDEF") < 0; + 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); diff --git a/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs b/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs index 4b615344ba..eaf770abf8 100644 --- a/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs +++ b/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs @@ -1,12 +1,113 @@ namespace OpenClaw.Connection.Migration; /// -/// 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. /// 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) diff --git a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs index dff56e4825..cf917dd214 100644 --- a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs @@ -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(); @@ -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(); @@ -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(); + } + /// /// 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. @@ -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] @@ -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 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; } diff --git a/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs index be1df083d5..e6d4e2eef6 100644 --- a/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs +++ b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs @@ -348,6 +348,33 @@ public void Capture_NativeSetupDraftAtomicTemporaryFile_IsIgnored() Assert.DoesNotContain(inventory.Entries, entry => entry.RelativeName.EndsWith(".tmp")); } + [Theory] + [InlineData(".native-setup-draft.json.0123456789abcdef0123456789abcde.tmp")] + [InlineData(".native-setup-draft.json.0123456789abcdef0123456789abcdef0.tmp")] + [InlineData(".native-setup-draft.json.0123456789abcdef0123456789abcdeg.tmp")] + [InlineData(".native-setup-draft.json.0123456789ABCDEF0123456789ABCDEF.tmp")] + [InlineData(".Native-setup-draft.json.0123456789abcdef0123456789abcdef.tmp")] + [InlineData(".native-setup-draft.json.0123456789abcdef0123456789abcdef.TMP")] + [InlineData("native-setup-draft.json.0123456789abcdef0123456789abcdef.tmp")] + [InlineData(".other.json.0123456789abcdef0123456789abcdef.tmp")] + [InlineData("Native-setup-draft.json")] + public void Capture_MalformedNativeSetupDraftTemporaryFile_IsRejected(string name) + { + Write(Path.Combine(Roaming, "gateways"), name, "{}"); + + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_DirectoryAtNativeSetupDraftTemporaryPath_IsRejected() + { + Directory.CreateDirectory(Path.Combine( + Roaming, "gateways", + $".{OpenClawAppIdentity.NativeGatewaySetupDraftFileName}.{Guid.NewGuid():N}.tmp")); + + Assert.Throws(() => Capture()); + } + [Fact] public void Capture_DirectoryAtNativeSetupDraftPath_IsRejected() { @@ -357,6 +384,48 @@ public void Capture_DirectoryAtNativeSetupDraftPath_IsRejected() Assert.Throws(() => Capture()); } + [ConnectionSymbolicLinkFact] + public void Capture_ReparsePointAtNativeSetupDraftPath_IsRejected() + { + var target = Write(Roaming, "draft-target.json", "{}"); + Directory.CreateDirectory(Path.Combine(Roaming, "gateways")); + File.CreateSymbolicLink( + Path.Combine(Roaming, "gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName), + target); + + Assert.Throws(() => Capture()); + } + + [ConnectionSymbolicLinkFact] + public void Capture_ReparsePointAtNativeSetupDraftTemporaryPath_IsRejected() + { + var target = Write(Roaming, "draft-temp-target.json", "{}"); + Directory.CreateDirectory(Path.Combine(Roaming, "gateways")); + File.CreateSymbolicLink( + Path.Combine( + Roaming, + "gateways", + $".{OpenClawAppIdentity.NativeGatewaySetupDraftFileName}.{Guid.NewGuid():N}.tmp"), + target); + + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_NativeSetupDraftCreatedDuringCapture_IsRejected() + { + var path = Path.Combine( + Roaming, "gateways", OpenClawAppIdentity.NativeGatewaySetupDraftFileName); + + Assert.Throws(() => MigrationInventory.Capture( + Roaming, + Local, + afterGatewaySnapshot: () => Write( + Path.GetDirectoryName(path)!, + Path.GetFileName(path), + """{"GatewayId":"draft-gateway"}"""))); + } + [Fact] public void Capture_UnexpectedFileInGatewayIdentityDirectory_IsRejected() { diff --git a/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs b/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs index 2ab7276337..56f42376a0 100644 --- a/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs +++ b/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs @@ -14,6 +14,47 @@ public void StableRelease_NormalizesMissingRevision(string text, string expected Assert.Equal(Version.Parse(expected), version); } + [Theory] + [InlineData("2026.9.8-1", "2026.9.8.1")] + [InlineData("2026.9.8-2", "2026.9.8.2")] + public void StableCorrection_UsesCorrectionAsRevision(string text, string expected) + { + Assert.True(MigrationVersionPolicy.TryParseReleaseVersion(text, out var version)); + Assert.Equal(Version.Parse(expected), version); + } + + [Fact] + public void StableCorrections_OrderAfterBaseAndEarlierCorrections() + { + Assert.True(MigrationVersionPolicy.TryParseReleaseVersion("2026.9.8", out var baseline)); + Assert.True(MigrationVersionPolicy.TryParseReleaseVersion("2026.9.8-1", out var first)); + Assert.True(MigrationVersionPolicy.TryParseReleaseVersion("2026.9.8-2", out var second)); + + Assert.True(first > baseline); + Assert.True(second > first); + } + + [Theory] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-1+abcdef", true)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-1", true)] + [InlineData("2026.9.8", "2026.9.8.0", null, true)] + [InlineData("2026.9.8", "2026.9.8.0", "2026.9.8+abcdef", true)] + [InlineData("2026.9.8", "2026.9.8.0", "2026.9.8.0+abcdef", true)] + [InlineData("2026.9.8", "2026.9.8.0", "2026.9.8-1+abcdef", false)] + [InlineData("2026.9.8", "2026.9.8.0", "2026.9.8-alpha.1+abcdef", false)] + [InlineData("2026.9.8-1", "2026.9.8.1", "2026.9.8-1", false)] + [InlineData("2026.9.8-1", "2026.9.7.0", "2026.9.8-1", false)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8", false)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-2", false)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-alpha.1", false)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-1+", false)] + [InlineData("2026.9.8-1", "2026.9.8.0", "2026.9.8-1+bad..metadata", false)] + [InlineData("2026.9.8-alpha.1", "2026.9.8.0", "2026.9.8-alpha.1", false)] + public void ExecutableVersionCompatibility_AccountsForCorrectionMetadata( + string release, string executable, string? product, bool expected) => + Assert.Equal(expected, + MigrationVersionPolicy.IsExecutableVersionCompatible(release, executable, product)); + [Theory] [InlineData(null)] [InlineData("")] @@ -24,6 +65,11 @@ public void StableRelease_NormalizesMissingRevision(string text, string expected [InlineData(" 2026.9.1")] [InlineData("2026.9.1 ")] [InlineData("2026.9.-1")] + [InlineData("2026.9.1-0")] + [InlineData("2026.9.1-01")] + [InlineData("2026.9.1-1.2")] + [InlineData("2026.9.1-2147483648")] + [InlineData("2026.9.1.0-1")] [InlineData("2026..1")] [InlineData("2026.9.2147483648")] [InlineData("2026.9.\u0661")] diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs index ec5d6e4b50..2d2db723b6 100644 --- a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs @@ -134,6 +134,20 @@ public void StableVersionAndArchitecture_AreGatedBeforeConsent( Assert.False(result.AllowsNormalStartup); } + [Theory] + [InlineData("2026.9.8.0", StoreMigrationStartupState.UpdateInno)] + [InlineData("2026.9.8.1", StoreMigrationStartupState.ConsentRequired)] + [InlineData("2026.9.8.2", StoreMigrationStartupState.ConsentRequired)] + public void StableCorrectionMinimum_UsesCorrectionOrdering( + string sourceVersion, StoreMigrationStartupState expected) + { + var result = Evaluate( + Detected(sourceVersion), new(MigrationStartupRecordStatus.None), + minimumSourceVersion: "2026.9.8-1"); + + Assert.Equal(expected, result.State); + } + [Fact] public void ExistingIntent_StillRequiresStoreConsent() { @@ -398,9 +412,10 @@ private static InnoInstallationDetection Detected(string version = "2026.9.1.0", SourcePayloadPresent: true); private static StoreMigrationStartupDecision Evaluate( - InnoInstallationDetection installation, MigrationStartupRecord record, string architecture = "x64") => + InnoInstallationDetection installation, MigrationStartupRecord record, string architecture = "x64", + string minimumSourceVersion = "2026.9.1") => new StoreMigrationStartupCoordinator(new Detector(() => installation), - new Records(() => record), NullLogger.Instance).Evaluate(true, "2026.9.1", architecture); + new Records(() => record), NullLogger.Instance).Evaluate(true, minimumSourceVersion, architecture); private sealed class Detector(Func read) : IInnoInstallationDetector {