diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 210a045f1..5acb83a9f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,6 +87,9 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Post-save settings change effect ordering, detached snapshot comparison, and concurrent save serialization | `SettingsChangeCoordinator` | authoritative | | Exactly-once ordered app shutdown sequencing | `AppShutdownCoordinator` | authoritative | | App composition-root startup sequencing | `AppBootstrapper` (planned) | planned | +| Inno migration records, preparation, and pre-start completion guard | `MigrationRecordCodec`, `MigrationPreparation`, `MigrationInventory`, `InnoMigrationStartupGuard` | authoritative | +| Cross-session migration exclusion and source activity inspection | `MigrationOperationLock`, `InnoSourceActivityVerifier` (App only retains the runtime handle through process exit) | authoritative | +| Store migration startup admission and finalization | `InnoInstallationDetector`, `InnoSourceRemovalVerifier`, `MigrationStartupRecordReader`, `MigrationInventoryCapture`, `StoreMigrationStartupCoordinator`, `StoreMigrationFinalizationCoordinator`, `StoreMigrationStartupGuard` | authoritative | | Windows node connection generation, cancellation, start ordering, recovery, events, and telemetry | `NodeConnectionCoordinator` | authoritative | | Bootstrap/shared/device credential handoff, durable clear gate, and operator token recovery timing | `BootstrapTokenLifecycle` | authoritative | | Device role-upgrade approval, confirmation, and bounded node reconnect queue | `DevicePairApprovalCoordinator` | authoritative | @@ -259,6 +262,9 @@ leading and trailing pipe. Columns, in order: | chat-composer-host-lifetime | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatHostExtensions.cs | ad hoc per-render HostCallbacks assignment and no explicit composer session lifetime | IChatComposerFactory + ChatComposerSession, owned/disposed exactly once by MountedReactorChat | ChatPage and ChatWindow each receive a separate session over the same provider; the factory is a stateless singleton with no constructor-started work | disposing a MountedReactorChat disposes its ChatComposerSession (controller then view model) exactly once, and repeated Dispose calls are a no-op | ChatComposerSessionTests.Dispose_DisposesViewModelAndControllerExactlyOnce | behavioral | - | | reactor-chat-root-composer-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs | composer draft/attachment/slash/voice/send mutable state and direct composer send/model/thinking/catalog/queue-cancel provider calls | ChatComposerViewModel + ChatComposerController | provider subscription, initial load, immutable snapshot, selected/materialized/compose-only thread selection, timeline/generation/metadata projection, permission-card forwarding, checkpoint routing, #1089 scroll/follow tokens, root composition, and construction of one immutable ChatComposerInputs projection per render | the root holds no composer UseState/refs and calls no composer provider API directly; it builds ChatComposerInputs, forwards them to ReactorChatComposer for post-commit application, and binds the SelectThread handoff | ChatRootComposerClosureTests.Root_DoesNotReintroduceComposerMutableState | source-shape | when OpenClawReactorChatRoot is replaced by a different root/composer boundary | | sensitive-capture-guard | authoritative | src/OpenClaw.Tray.WinUI/Services/NodeService.cs | ordering of consent, visible per-use indication, and sensor access for instantaneous screen, camera, and location captures | SensitiveCaptureExecutor + SensitiveCapturePlans | NodeService supplies the consent prompt, localized notification, and platform sensor delegates | every sensitive instant capture completes consent and visible indication before its sensor delegate can run; denied consent never reaches the sensor | SensitiveCaptureExecutorTests.ExecuteAsync_RequiresConsentAndIndicatorBeforeSensorAccess | behavioral | - | +| inno-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | migration record parsing and completed-handoff admission | MigrationRecordCodec + InnoMigrationStartupGuard | App invokes the guard before ordinary startup; explicit destructive CLI uninstall remains separate | completed migration prevents normal unpackaged startup before settings or activation; development and packaged apps are unaffected | InnoMigrationContractTests.CompletedMigrationGuard_PrecedesSettingsAndActivation | source-shape | when packaged/unpackaged migration startup is fully hosted outside App | +| store-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | Inno discovery, pending-record inspection, and Store migration admission policy | InnoInstallationDetector + MigrationStartupRecordReader + StoreMigrationStartupCoordinator + StoreMigrationStartupGuard | App calls the compile-time-gated adapter before instance forwarding and normal services | disabled builds perform no migration inspection; preview admission never mutates source state or enables normal startup for pending migration | StoreMigrationStartupCoordinatorTests.Disabled_DoesNotInspectInstallationOrRecords | behavioral | - | +| store-migration-startup-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | direct registry discovery and migration startup policy | StoreMigrationStartupGuard + StoreMigrationStartupCoordinator | one adapter invocation after explicit CLI uninstall and before protocol processing and instance forwarding | App does not regain installation discovery, record parsing, or admission decisions | InnoMigrationContractTests.StorePreviewGuard_PrecedesInstanceForwardingAndNormalServices | source-shape | when packaged migration bootstrap is hosted outside App | | chat-markdown-presentation | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | paragraph and heading typography, literal code-frame presentation and Copy | ChatMarkdownPresentation | timeline keeps sanitization, parser flags, inert links/images and the existing list-wrapping callback | presentation uses public MarkdownOptions callbacks; code content is never truncated or parsed as markup | ReactorChatLayoutProofTests.CodeBlock_PreservesLiteralContentAndCopiesExactText | behavioral | - | | chat-model-picker-presentation | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs, then custom Button rows in ChatModelPicker | catalog search, provider groups, native row interaction states and model metadata presentation | NativeChatModelPicker through a generated Reactor wrapper | ChatModelPicker owns flyout lifetime; composer controller owns model mutation and authorization | native single-selection navigation never writes a model until explicit activation; filtering and resizing preserve raw provider-qualified identities | ReactorChatLayoutProofTests.ModelMenu_UsesNativeSelectionStatesAndPreservesNavigationDuringResize | behavioral | - | | chat-model-picker-button-rows-closed | closed | src/OpenClaw.Tray.WinUI/Chat/ChatModelPicker.cs | hand-built Button rows, custom selected fill/checkmark and search-box styling | NativeChatModelPicker | declarative trigger and flyout lifetime only | native AutoSuggestBox/ListView own search and row interaction visuals; no duplicate hand-styled selection path | ReactorChatLayoutProofTests.ModelMenu_UsesNativeSelectionStatesAndPreservesNavigationDuringResize | behavioral | - | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0faaa70d2..35e444c06 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -17,6 +17,185 @@ that MSIX job to succeed. Every tag release also attaches the unsigned Store MSIX bundle, standalone packages, and metadata for manual Partner Center submission. Dev-signed packages stay in Actions. +## Inno-to-Store migration foundation + +Issue #1374's foundation does not advertise or initiate migration. No Store +action, MSIX importer, or completion writer is enabled by this change. + +`MigrationPreparation.Prepare` is a non-UI API for a future explicitly consented +Inno action. It inventories existing state without moving the WSL gateway or +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. + +`completed.dpapi` is a separate receipt, written only after exclusive source +ownership, a fresh strict inventory matching the protected intent, and canonical +operator credential resolution for the active saved gateway. No active gateway +or unresolved credential fails closed without a receipt. The atomic writer never +replaces an existing receipt. Completed-receipt reads are independent of the +current wall clock: neither delayed removal nor a backward clock correction may +re-enable destructive cleanup. New records still reject future creation times, +and intent expiry and renewal checks remain unchanged. Cryptographic, identity, +path, and structural validation still apply to every read. Finalization after verified +Inno removal owns receipt and intent cleanup. A Store preview finalizer rechecks +the exact source registration and then verifies the canonical source executable, +uninstaller, process image, and mutex have disappeared. It observes the mutex +without acquiring it. Process inspection uses limited-query image access across +all sessions; unrelated paths, proven other-user candidates without an image, +and exited processes do not block. Unresolved possible source processes still +fail closed. The finalizer serializes with `prepare.lock`, rereads the +DPAPI completion receipt under that lock, and recaptures the bounded migration +inventory before applying the receipt's saved auto-start preference through the +packaged startup API. It deletes intent followed by the same validated completion +receipt, so a crash after intent deletion retries from the retained receipt. It +fails closed on source inspection, receipt, inventory, startup-preference, or +cleanup failure and leaves the receipt for restart recovery. It never starts +runtime services or invokes uninstall. + +Records use a versioned binary envelope protected by current-user DPAPI. They +bind the migration ID, source version, architecture, Windows SID, canonical +install/roaming/local directories, target Store identity, state fingerprint, +startup preference, timestamps, and record kind. Intent contains inventory +metadata; completion additionally requires the validated target version. +The migration directory has a protected ACL for the current user, SYSTEM, and +Administrators. This is a same-user boundary, not an authenticity guarantee +against a malicious process already running as that user. + +`MigrationRecordCodec.cs` is deliberately C# 5/.NET Framework compatible: +the same parser and validation policy runs in the app and in Windows PowerShell +5.1 during uninstall. Inno ships the source and `Test-InnoMigration.ps1`, waits +for the read-only check to exit, and does not load the tray executable for +uninstall. The check returns 10 for validated completion, 0 only when no receipt +is present, and 2 when a receipt exists but cannot be validated or the check +could not run. A present-but-unverifiable receipt (DPAPI failure, schema drift +from the frozen codec, or binding mismatch) preserves state rather than +guessing, so it never authorizes destructive cleanup. The check also bounds +itself with a watchdog so a stalled `Add-Type` cannot hang uninstall. + +With valid completion, normal Inno startup shows finish-migration guidance +instead of starting services. The guidance keeps Inno's installer mutex held; +dismissing it exits the app before the user uninstalls. Interactive and silent Inno uninstall skip gateway +cleanup and generated-state deletion, removing only the Inno payload and its +registrations. `Uninstall-LocalGateway.ps1` independently rechecks completion +before destructive work and propagates preservation as exit 10, not cleanup +success. Without valid completion, the existing interactive/silent choices +remain unchanged. Explicit `--uninstall --confirm-destructive` remains a +complete-removal operation and intentionally does not honor migration receipts. + +Inno acquires a read-only, read-shared handle on `store-migration\prepare.lock` +before its normal startup receipt check and retains it until process exit, including +failed startup or shutdown. The handle is deliberately not released by managed +shutdown because a failed service disposal could leave state writers alive. +Dev, packaged, and validated browse-only fixture runs do not take this Inno +runtime lease; other isolated profiles use their resolved data directory. +Uninstall also acquires a read-only, read-shared handle before effects and retains +it through payload/registration removal. +The cleanup script joins that lock (and also locks when invoked independently) +before checking the receipt, retaining its handle until cleanup exits. These +handles exclude Store's exclusive preparation/completion/finalization handle. +Only a lock held by an in-progress migration stops uninstall before effects. Any +other unavailable lock means migration state is merely unreadable, so uninstall +continues and suppresses destructive gateway cleanup rather than trapping the user +in an app they cannot remove. Neither path permits an unlocked fallback that would +allow destructive cleanup. Completion re-detects the exact source only after +acquiring the lock, +so an uninstall-first run cannot authorize a receipt from stale source evidence. +Preparation likewise re-detects source evidence under the exclusive file lock. +Both preparation and completion inspect the exact source image across all Windows +sessions under that lock, using the same fail-closed process policy as finalization. +Running sources and busy locks return the close-Inno/Retry decision; uncertain +process inspection blocks migration without publishing a record. + +The file handle, not the session-local single-instance mutex, provides continuous +cross-session exclusion for participating Inno releases. Process inspection is a +compatibility backstop for already-running older sources, not a lease preventing +an older binary from starting after the scan. Production's minimum source version +must therefore identify a verified release containing the lifetime-lock safeguard. +Do not enable migration for older nonparticipating releases. + +Before rollout, prove exact signed x64 and ARM64 packages, storage/DPAPI access, +restart recovery, current-head guidance, and manual uninstall preservation. +Passing unit or PowerShell contract tests is not signed-package migration proof. + +### Store migration consent preview + +The Store-side preview adds installation detection, startup admission, and an +explicit consent/close-Inno retry flow, and it writes protected intent and +completion records. It is not the full migration journey: runtime state adoption, +source-version enablement, and the recovery/finalization journey remain out of +scope. **Ordinary +builds remain unchanged.** There is no +production minimum source version, runtime toggle, or environment-variable +bypass. Do not enable migration by choosing the current app version as a +placeholder for a verified safeguard-containing Inno release. + +An explicit test build may set `StoreMigrationPreview=true` and +`StoreMigrationPreviewMinimumSourceVersion` to a three- or four-part numeric +test source version. This is accepted only for **Debug MSIX builds with the +production identity**; Release, unpackaged, and Dev-identity preview builds +fail the build. Sign and run these experimental packages only in a disposable +Windows VM. They must not be distributed. + +```powershell +dotnet publish .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj ` + -c Debug -r win-x64 --self-contained -m:1 ` + -p:PackageMsix=true -p:DevBuild=false -p:AppxPackageSigningEnabled=false ` + -p:StoreMigrationPreview=true ` + -p:StoreMigrationPreviewMinimumSourceVersion= +``` + +The preview: + +- Reads only the exact production Inno uninstall registration, not display-name + matches. Only the default current-user installation at + `%LOCALAPPDATA%\OpenClawTray` is supported. Custom, machine-wide, ambiguous, + inconsistent, or unreadable installations block admission. +- Requires coherent source version and executable architecture evidence. + Prerelease/informational versions do not satisfy the stable version gate. +- Reads existing intent/completion records through the shared DPAPI codec. + Invalid or inaccessible records block startup. An orphan intent requires + recovery; completion without Inno requires finalization, not fresh startup. +- Stops before production instance forwarding, settings, gateway/node/MCP + services, updates, or startup-task reconciliation when migration is needed. + All normal launch, protocol, and startup-task activations use this gate. +- Shows a localized consent decision through native **Yes / No** buttons. + The body refers directly to those buttons, without a separate action legend. + A valid Inno intent does not replace that Store-side consent. + Before acceptance, every locale discloses protected migration records, the + normal Inno startup block after successful validation, and the required manual + uninstall followed by reopening Store. It explains preservation, no automatic + uninstall, and the option to leave without starting migration. Retry guidance + permits uninstall only after the preview reports recorded completion. +- After consent, checks the production Inno mutex. While Inno is running, the + preview instructs the user to close it, choose **Yes** to retry, or choose + **No** to exit the preview. It never force-closes the source process. +- When Inno is closed, the preview acquires exclusive migration ownership, + rechecks exact source evidence, and writes a protected, DPAPI-bound inventory + intent. It then captures the inventory again, requires its fingerprint to + match the intent, and requires canonical operator credential resolution for + the active saved gateway before atomically writing a protected completion + receipt. This is not state adoption. It does not start gateway, node, or MCP + services; provision or repair gateways; delete source state; invoke uninstall; + or finalize migration. The receipt blocks normal Store startup and tells the + user to uninstall Inno manually. On a later Store start, only an exact + `NotInstalled` result plus absent canonical source payload/process/mutex + evidence permits finalization. The finalizer does not acquire the Inno-visible + mutex, serializes on `prepare.lock`, rereads and matches the durable completion + receipt, recaptures the inventory fingerprint, applies the saved auto-start + preference through the tray adapter, and clears records only after that call + succeeds. +- Preserves normal fresh-install behavior when there is no exact Inno + registration and no pending migration record. + +This admission result is not an authorization to uninstall. The consent workflow +reacquires exclusive ownership, revalidates source state, and writes completion +only after its active-gateway credential check succeeds. Runtime state adoption, +source-version enablement, and the complete recovery/finalization journey remain +release gates. + ## Release checklist 1. Start clean on current `main`. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index cf6b6d4a3..1b6aec9a4 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -95,6 +95,9 @@ required closeout lane for code changes. | Local MXC E2E | `.\scripts\validate-mxc-e2e.ps1` | MXC sandboxing, `system.run`, exec approvals, Windows node command execution, gateway setup/connect changes that affect MXC | | Product WSL setup validation | `OPENCLAW_RUN_E2E=1` with `OpenClaw.E2ETests.Setup.SetupAndConnectTests` | Tray onboarding/setup-engine changes that must prove the current product WSL install path | | Installer source checks | `.\scripts\run-proof-tests.ps1 -Project 'tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj' -Filter 'FullyQualifiedName~InstallerIssAssertionTests' -ResultName 'installer-source'` | Installer source, payload, identity, cleanup, or protocol changes; pair with the clean installer/upgrade pool for runtime claims | +| Migration preservation source checks | `dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --filter FullyQualifiedName~InnoMigrationContractTests` | Protects completion/checker-failure exits and deletion authorization, including unsafe source mutations; not a substitute for real installer proof | +| Migration record and cleanup scripts | `dotnet test .\tests\OpenClaw.Connection.Tests\OpenClaw.Connection.Tests.csproj --filter FullyQualifiedName~MigrationRecordTests` | Exercises real Windows PowerShell receipt checks and cleanup lock contention with a 30-second subprocess deadline. Timeout handling attempts process-tree termination before fixture teardown and reports output, script logs, and cleanup failures without replacing the original timeout | +| Migration startup admission | `dotnet test .\tests\OpenClaw.Connection.Tests\OpenClaw.Connection.Tests.csproj --filter "FullyQualifiedName~InnoInstallationDetectorTests\|FullyQualifiedName~StoreMigrationStartupCoordinatorTests\|FullyQualifiedName~MigrationStartupRecordReaderTests\|FullyQualifiedName~MigrationVersionPolicyTests"` | Read-only exact Inno discovery, version/architecture admission, pending-record precedence and preservation; pair with packaged preview guidance proof, not a claim of completed migration | | Legacy installer runtime ordering | `.\tests\PackagingTests\Test-InnoUninstallOrdering.ps1` | Deprecated. It targets the old `[UninstallRun]` layout, while current cleanup is owned by `[Code]`; do not use it as current installer runtime proof | Capacity-dependent Windows validation is named in diff --git a/docs/uninstall-portable.md b/docs/uninstall-portable.md index 1f1072b99..206a35e92 100644 --- a/docs/uninstall-portable.md +++ b/docs/uninstall-portable.md @@ -69,3 +69,29 @@ Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "OpenClawTray" -ErrorAction SilentlyContinue Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" -Recurse -Force -ErrorAction SilentlyContinue ``` + +--- + +## Installer Uninstall Preserved the Local Gateway + +The installer's uninstaller can report that it could not confirm whether your +data was migrated to the Store app, and that it left the local WSL gateway in +place. That is deliberate. When migration state cannot be validated, the +uninstaller preserves the gateway rather than risk destroying data that a +completed migration still depends on. No override is offered, because at that +point nothing on the machine can tell a broken check apart from a real +migration. + +The app is still uninstalled. Only the WSL distro and its generated state are +kept. + +To remove them: + +- **Before uninstalling**, open the tray and choose + **Settings -> Local Gateway -> Remove Local Gateway**. +- **After uninstalling**, run the manual cleanup above. The distro is + `OpenClawGateway` and its VHD parent directory is + `%LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway`. + +Silent uninstalls (`/VERYSILENT`) skip the message. The same outcome is written +to the Inno uninstall log, naming the distro and the preserved directory. diff --git a/installer.iss b/installer.iss index 506720732..1312af3b9 100644 --- a/installer.iss +++ b/installer.iss @@ -27,6 +27,12 @@ #define MyAppURL "https://github.com/openclaw/openclaw-windows-node" #define MyAppExeName "OpenClaw.Tray.WinUI.exe" +; Must stay equal to MigrationRecordCodec.PackageName. The uninstaller reads the +; packaged-app registration under this identity to decide whether the Store app is +; present, and a drift here would silently restore the destructive advice. +; Pinned by InnoMigrationContractTests.Installer_PinsTheStorePackageIdentity. +#define MyStorePackageName "OpenClawFoundation.OpenClaw" + ; MyAppArch should be passed via /DMyAppArch=x64 or /DMyAppArch=arm64 #ifndef MyAppArch #define MyAppArch "x64" @@ -102,7 +108,12 @@ Name: "startupicon"; Description: "Start {#MyAppName} when Windows starts"; Grou ; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs ; WSL gateway uninstall helper copied to {tmp} by [Code] during uninstall. +; Uninstall reads these during usUninstall, which runs before Inno removes +; files, so they must not carry uninsneveruninstall. Retaining them would +; strand the helpers in {app} on every uninstall that keeps the local gateway. Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "scripts\Test-InnoMigration.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "src\OpenClaw.Connection\Migration\MigrationRecordCodec.cs"; DestDir: "{app}"; Flags: ignoreversion #if vcRedist != "" Source: "{#vcRedist}"; DestDir: "{tmp}"; DestName: "vc_redist.exe"; Flags: deleteafterinstall; AfterInstall: InstallVCRuntime #endif @@ -132,6 +143,98 @@ var LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; + MigrationOperationHandle: THandle; + MigrationOperationLocked: Boolean; + MigrationOperationUnavailable: Boolean; + +function OpenMigrationOperationFile( + FileName: String; DesiredAccess, ShareMode: LongWord; SecurityAttributes: Integer; + CreationDisposition, FlagsAndAttributes: LongWord; TemplateFile: THandle): THandle; + external 'CreateFileW@kernel32.dll stdcall'; +function CloseMigrationOperationFile(Handle: THandle): Boolean; + external 'CloseHandle@kernel32.dll stdcall'; +function MigrationPathAttributes(FileName: String): LongWord; + external 'GetFileAttributesW@kernel32.dll stdcall'; + +function MigrationPathIsOrdinary(Path: String): Boolean; +var + Attributes: LongWord; + Parent: String; +begin + Result := False; + while Path <> '' do + begin + Attributes := MigrationPathAttributes(Path); + if Attributes = $FFFFFFFF then + begin + if (DLLGetLastError <> 2) and (DLLGetLastError <> 3) then + Exit; + end + else if (Attributes and $400) <> 0 then + Exit; + Parent := ExtractFileDir(Path); + if Parent = Path then + Break; + Path := Parent; + end; + Result := True; +end; + +function InitializeUninstall: Boolean; +var + Directory: String; + LockPath: String; + LastError: LongWord; +begin + Result := True; +#ifndef DevBuild + MigrationOperationUnavailable := True; + Directory := ExpandConstant('{userappdata}\{#MyInstallDir}\store-migration'); + LockPath := Directory + '\prepare.lock'; + // Kept as separate tests rather than one boolean expression: ForceDirectories must + // never run when the path check rejected a reparse point, and Pascal Script + // short-circuit behavior is not worth betting a redirected directory create on. + if not MigrationPathIsOrdinary(LockPath) then + Log('Migration state path is not an ordinary directory. Uninstall continues and preserves the local gateway.') + else if ForceDirectories(Directory) then + begin + // Shared read handles allow our cleanup child to join, but exclude Store's + // FileShare.None writer. Keep this handle through registry/payload removal. + MigrationOperationHandle := OpenMigrationOperationFile( + LockPath, $80000000, 1, 0, 4, $80, 0); + if MigrationOperationHandle <> THandle(-1) then + begin + MigrationOperationLocked := True; + MigrationOperationUnavailable := False; + end + else + begin + LastError := DLLGetLastError; + // Only a migration actively holding the lock may stop an uninstall. Any other + // failure means migration state is merely unreadable, which must never trap the + // user in an app they cannot remove. Continue and suppress destructive cleanup. + if (LastError = 32) or (LastError = 33) then + begin + Result := False; + Log('Migration state is locked by an in-progress migration. Uninstall stopped before changing the installation.'); + if not UninstallSilent() then + MsgBox('OpenClaw migration is currently running. Close the Store migration preview, then retry uninstall.', mbError, MB_OK); + end; + end; + end; + if Result and MigrationOperationUnavailable then + Log('Migration state could not be locked. Uninstall continues and preserves the local gateway.'); +#endif +end; + +procedure DeinitializeUninstall; +begin + if MigrationOperationLocked then + begin + CloseMigrationOperationFile(MigrationOperationHandle); + MigrationOperationLocked := False; + end; +end; #if vcRedist != "" procedure InstallVCRuntime; @@ -175,13 +278,174 @@ begin #endif end; +function CheckCompletedStoreMigration: Integer; +var + ResultCode: Integer; + Started: Boolean; +begin + Result := 2; + if not FileExists(ExpandConstant('{app}\Test-InnoMigration.ps1')) then + begin + Log('Migration preservation checker is missing. Generated state will be preserved.'); + Exit; + end; + Started := Exec( + ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoProfile -ExecutionPolicy Bypass -File ' + + AddQuotes(ExpandConstant('{app}\Test-InnoMigration.ps1')) + + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) + + ' -DataDirectoryName ' + AddQuotes('{#MyInstallDir}') + + ' -Architecture ' + AddQuotes('{#MyAppArch}'), + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + if Started then + Result := ResultCode; + Log('Migration preservation check returned ' + IntToStr(Result) + '.'); +end; + +// Returns 'Present', 'Absent', or 'Indeterminate', matching the vocabulary +// Test-InnoMigration.ps1 uses for the same hive. A Boolean cannot carry this: "the Store +// app is installed" and "this hive could not be read" both preserve the gateway, but they +// are not the same claim and the user must not be told the first when we only know the +// second. +function StorePackagePresence: String; +var + Names: TArrayOfString; + I: Integer; + Prefix: String; +begin + // Read directly rather than relying on the checker, because several routes to a + // preserve verdict happen when the checker could not run at all. This is the only + // signal available in those cases, and it decides what we tell the user, never + // whether we destroy anything. + Prefix := Lowercase('{#MyStorePackageName}' + '_'); + if not RegGetSubkeyNames(HKEY_CURRENT_USER, + 'Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages', + Names) then + begin + // Unreadable, so the Store app can be neither confirmed nor ruled out. + Result := 'Indeterminate'; + Exit; + end; + + if GetArrayLength(Names) = 0 then + begin + // Every real profile has hundreds of registered packages. Zero means the hive was + // tampered with or is unreadable, which is uncertainty, not absence. + Result := 'Indeterminate'; + Exit; + end; + + for I := 0 to GetArrayLength(Names) - 1 do + begin + if Pos(Prefix, Lowercase(Names[I])) = 1 then + begin + Result := 'Present'; + Exit; + end; + end; + + Result := 'Absent'; +end; + +procedure ReportStoreAppOwnsGateway; +begin + Log('Store app registration found without a migration receipt: preserving the local WSL gateway.'); + if not UninstallSilent() then + MsgBox( + 'OpenClaw from the Microsoft Store is installed on this PC and is using the local WSL gateway.' + #13#10#13#10 + + 'The gateway and its generated state were left in place, so the Store app keeps working.' + #13#10#13#10 + + 'Do not run "wsl --unregister {#MyDistroName}". That would delete the gateway the Store app is ' + + 'still using. If you want to remove it later, open OpenClaw and choose ' + + 'Settings > Local Gateway > Remove Local Gateway.', + mbInformation, MB_OK); +end; + +procedure ReportStoreAppStateUnknown; +begin + Log('Store app registration could not be read: preserving the local WSL gateway. ' + + 'The {#MyDistroName} WSL distro and ' + + ExpandConstant('{localappdata}\{#MyInstallDir}\wsl\{#MyDistroName}') + + ' were left in place.'); + if not UninstallSilent() then + MsgBox( + 'Setup could not check whether OpenClaw from the Microsoft Store is installed on this PC.' + #13#10#13#10 + + 'The local WSL gateway and its generated state were left in place, so nothing is lost.' + #13#10#13#10 + + 'Do not remove the gateway by hand until you know the Store app is not using it. To remove it ' + + 'safely, open OpenClaw and choose Settings > Local Gateway > Remove Local Gateway.', + mbInformation, MB_OK); +end; + +procedure WarnMigrationCheckUnavailable; +var + Presence: String; +begin + // Most routes here mean the check could not run, and several of them are reachable on + // a machine that has already migrated. Telling that user to run wsl --unregister would + // destroy the gateway the installed Store app is using, so ask the registry directly + // before saying anything destructive. + Presence := StorePackagePresence; + if Presence = 'Present' then + begin + ReportStoreAppOwnsGateway; + Exit; + end; + + if Presence <> 'Absent' then + begin + // Anything other than a positively observed absence is uncertainty. Preserve and say + // so, rather than handing over the command that destroys what was just preserved. + // Only a fully enumerated hive with no matching package may reach the advice below. + ReportStoreAppStateUnknown; + Exit; + end; + + Log('Migration preservation check unavailable: skipping destructive gateway cleanup. ' + + 'The {#MyDistroName} WSL distro and ' + + ExpandConstant('{localappdata}\{#MyInstallDir}\wsl\{#MyDistroName}') + + ' were left in place.'); + if not UninstallSilent() then + MsgBox( + 'Setup could not confirm whether your OpenClaw data was migrated to the Store app.' + #13#10#13#10 + + 'The local WSL gateway and its generated state were left in place so nothing is lost.' + #13#10#13#10 + + 'If you want to remove them, open OpenClaw and choose Settings > Local Gateway > ' + + 'Remove Local Gateway before uninstalling. If OpenClaw is already removed, run:' + #13#10#13#10 + + 'wsl --unregister {#MyDistroName}', + mbInformation, MB_OK); +end; + procedure EnsureLocalGatewayCleanupChoice; +var + MigrationResult: Integer; begin if LocalGatewayCleanupChoiceInitialized then Exit; LocalGatewayCleanupChoiceInitialized := True; + // Cleanup runs a child process that must join the migration lock. Without it the + // uninstall cannot prove the gateway is unowned, so preservation is the only safe answer. + if MigrationOperationUnavailable then + begin + LocalGatewayCleanupRequested := False; + WarnMigrationCheckUnavailable; + Exit; + end; + + MigrationResult := CheckCompletedStoreMigration; + if MigrationResult <> 0 then + begin + LocalGatewayCleanupRequested := False; + if MigrationResult = 10 then + Log('Completed Store migration: preserving generated state and local WSL gateway.') + else if MigrationResult = 11 then + // The checker positively identified an installed Store app, so the generic + // "could not confirm" advice would be both untrue and destructive here. + ReportStoreAppOwnsGateway + else + WarnMigrationCheckUnavailable; + Exit; + end; + if UninstallSilent() then begin LocalGatewayCleanupRequested := True; @@ -218,7 +482,7 @@ begin if not FileExists(SourceScriptPath) then begin - ResultCode := 2; + ResultCode := 102; Log('Local gateway cleanup script is missing: ' + SourceScriptPath); Result := False; Exit; @@ -229,7 +493,7 @@ begin if not CopyFile(SourceScriptPath, TempScriptPath, False) then begin - ResultCode := 3; + ResultCode := 103; Log('Failed to copy local gateway cleanup script to: ' + TempScriptPath); Result := False; Exit; @@ -241,7 +505,8 @@ begin ' -DataDirectoryName ' + AddQuotes('{#MyInstallDir}') + ' -AutoStartName ' + AddQuotes('{#MyAutoStartName}') + ' -StartupTaskName ' + AddQuotes('{#MyStartupTaskName}') + - ' -DistroName ' + AddQuotes('{#MyDistroName}'); + ' -DistroName ' + AddQuotes('{#MyDistroName}') + + ' -Architecture ' + AddQuotes('{#MyAppArch}'); Log('Running local gateway cleanup script from {tmp}.'); Result := @@ -275,6 +540,21 @@ begin UninstallProgressForm.StatusLabel.Caption := 'Removing local WSL gateway...'; Started := RunLocalGatewayCleanupOnce(ResultCode); + if Started and (ResultCode = 10) then + begin + Log('Completed Store migration detected before cleanup. Generated state will be preserved.'); + Exit; + end; + + if Started and (ResultCode = 11) then + begin + // The cleanup script re-runs the check, so the Store app can be registered between + // the initial decision and this point. That is a deliberate refusal, not a failure, + // and must not surface a Retry dialog offering to destroy the gateway again. + ReportStoreAppOwnsGateway; + Exit; + end; + if Started and (ResultCode = 0) then begin LocalGatewayCleanupSucceeded := True; @@ -282,6 +562,12 @@ begin Exit; end; + if Started and (ResultCode = 2) then + begin + WarnMigrationCheckUnavailable; + Exit; + end; + if UninstallSilent() then begin Log('Local gateway cleanup failed during silent uninstall; continuing without deleting generated state.'); diff --git a/scripts/Test-InnoMigration.ps1 b/scripts/Test-InnoMigration.ps1 new file mode 100644 index 000000000..a462c7172 --- /dev/null +++ b/scripts/Test-InnoMigration.ps1 @@ -0,0 +1,344 @@ +<# +.SYNOPSIS + Read-only check for a completed, same-user Inno-to-Store migration. +.DESCRIPTION + Exit 10 means a validated receipt requires preservation. Exit 11 means the Store app + is registered but its receipt is missing, which also requires preservation and, unlike + exit 2, tells the caller the Store app is the likely owner of the gateway so it must + not advise removing it by hand. Exit 0 means no receipt is present and no Store app is + registered. Exit 2 means the migration state could not be established; callers must not + start cleanup. A receipt that exists but does not validate is exit 2, never + exit 0: schema, DPAPI, or binding drift must not authorize destroying state. + An absent receipt is exit 0 only when the Store package is not installed for this + user. Package registration outlives the migration directory, so deleting the + receipt cannot by itself manufacture exit 0. + The record codec is the exact source compiled into OpenClaw.Connection. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$AppRoot, + [string]$DataDirectoryName = 'OpenClawTray', + [Parameter(Mandatory = $true)][ValidateSet('x64', 'arm64')][string]$Architecture, + [string]$RoamingDirectory, + [string]$LocalDirectory, + # This is the one input that can suppress the preservation signal, so it exists only so + # tests can point at an isolated repository: a developer machine usually has the real + # package installed, which would make every negative case unrunnable. The uninstaller + # builds its command line from compile-time constants and never passes this. No caller + # may ever pass a value derived from untrusted input. + # Whitespace passes ValidateNotNullOrEmpty but would be dropped by the watchdog's + # forwarding guard, leaving the child on the real default while the parent used the + # override. Reject it here so parent and child can never disagree. + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string]$PackageRepositoryKey = 'Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages', + [int]$TimeoutSeconds = 90, + [switch]$NoWatchdog +) + +$ErrorActionPreference = 'Stop' +if ($DataDirectoryName -ne 'OpenClawTray') { + Write-Verbose 'Store migration does not apply to development installs.' + exit 0 +} + +function Test-AclAuthority { + param( + [Security.AccessControl.FileSystemSecurity]$Acl, + [string[]]$Trusted + ) + + # Only a principal that can change or delete the receipt affects the decision. Read, + # list, and traverse grants are common in managed profiles and inherit into this + # directory harmlessly, so matching those would make every ordinary uninstall + # unverifiable. The mask names individual mutating bits on purpose: FullControl and + # Modify are composites that also carry the read bits, so testing against them would + # match a read-only grant. + $mutating = [int]([Security.AccessControl.FileSystemRights]::WriteData ` + -bor [Security.AccessControl.FileSystemRights]::AppendData ` + -bor [Security.AccessControl.FileSystemRights]::WriteAttributes ` + -bor [Security.AccessControl.FileSystemRights]::WriteExtendedAttributes ` + -bor [Security.AccessControl.FileSystemRights]::Delete ` + -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles ` + -bor [Security.AccessControl.FileSystemRights]::ChangePermissions ` + -bor [Security.AccessControl.FileSystemRights]::TakeOwnership) + + # An ACE may also carry the generic rights, which FileSystemRights has no names for and + # which intersect none of the specific bits above. The kernel maps GENERIC_ALL to + # FILE_ALL_ACCESS (DELETE, FILE_DELETE_CHILD, WRITE_DAC) and GENERIC_WRITE on a + # directory to FILE_ADD_FILE, so a grant in that form can delete or replace the receipt + # while every named bit reads as clear. GENERIC_READ and GENERIC_EXECUTE are harmless. + $genericAll = 0x10000000 + $genericWrite = 0x40000000 + $mutating = $mutating -bor $genericAll -bor $genericWrite + + $owner = $Acl.GetOwner([Security.Principal.SecurityIdentifier]) + if ($null -eq $owner -or $Trusted -notcontains $owner.Value) { + return $false + } + foreach ($rule in $Acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + if ($Trusted -contains $rule.IdentityReference.Value) { + continue + } + # An inherit-only entry describes children and grants nothing on this object. + if ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) { + continue + } + if ((([int]$rule.FileSystemRights) -band $mutating) -ne 0) { + return $false + } + } + return $true +} + +function Test-MigrationStateAuthority { + param([string]$Directory) + + # An absent receipt only means "no migration happened" when nobody outside the + # trusted principals could have deleted it. Ownership counts as access: an owner + # keeps implicit WRITE_DAC and can grant itself delete rights at any time. + # Uninstall creates this directory itself with a plain ForceDirectories call, so an + # unprotected DACL is ordinary here and is deliberately not treated as tampering. + # This reads ACLs through .NET types rather than Get-Acl/Get-ChildItem: the + # uninstaller can launch PowerShell where Microsoft.PowerShell.Security fails to + # autoload, and a module-load failure here would preserve every gateway. + try { + if (-not [IO.Directory]::Exists($Directory)) { + return $true + } + $trusted = @( + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value, + 'S-1-5-18', + 'S-1-5-32-544' + ) + $acls = @((New-Object IO.DirectoryInfo $Directory).GetAccessControl()) + foreach ($file in [IO.Directory]::GetFiles($Directory)) { + $acls += (New-Object IO.FileInfo $file).GetAccessControl() + } + foreach ($acl in $acls) { + if (-not (Test-AclAuthority -Acl $acl -Trusted $trusted)) { + return $false + } + } + return $true + } catch { + return $false + } +} + +function Get-StorePackageState { + param([string]$RepositoryKey) + + # The decisive check for an absent receipt. Authority evidence stored beside the + # receipt is not durable: whoever can delete completed.dpapi can usually delete the + # directory holding that evidence too, which made absence indistinguishable from + # tampering. Package registration is owned by the deployment stack rather than by + # the migration directory, so it survives that deletion. + # + # Matching on the package name alone is deliberate. A package that merely resembles + # ours can only cause preservation, never removal, so a broad match errs toward + # keeping the gateway. Only a missed match is dangerous, so the match stays wide. + # + # Read through .NET registry types for the same reason the ACL checks avoid Get-Acl: + # a module autoload failure in the uninstaller's PowerShell must not decide this. + # + # A missing or empty repository is reported as Indeterminate rather than Absent. This key + # is writable by the current user, so the principal who can delete the receipt can also + # delete the package registration that replaces it. Without that distinction, deletion + # would be the easy path to exit 0 while a merely unreadable hive preserved, which is + # backwards. Every real Windows profile has hundreds of registered packages, so zero is + # evidence of tampering, not evidence of absence. + # + # Three states rather than a boolean because the caller must say something truthful to + # the user. "The Store app is installed, keep the gateway" and "this hive is unreadable, + # so keep the gateway to be safe" are both preserve, but they are not the same claim and + # they need different exit codes and different instructions. + # + # Residual risk, accepted and not closed here: a same-user attacker can delete only our + # own package subkey and leave the rest, which still yields exit 0. Closing that needs a + # signal outside the user's write scope, and %ProgramFiles%\WindowsApps cannot serve + # because an unelevated process may not enumerate it. A foreign principal holding rights + # on the migration directory, which is the case the authority check was written for, + # cannot reach this hive at all. + + # Resolved before the try so that a rename of the codec constant fails loudly here + # instead of being swallowed into a permanent, and permanently misexplained, preserve. + $prefix = [OpenClaw.Connection.Migration.MigrationRecordCodec]::PackageName + '_' + try { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($RepositoryKey, $false) + if ($null -eq $key) { + return 'Indeterminate' + } + try { + $names = $key.GetSubKeyNames() + if ($null -eq $names -or @($names).Count -eq 0) { + return 'Indeterminate' + } + foreach ($name in $names) { + if ($name.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + return 'Present' + } + } + } finally { + $key.Dispose() + } + return 'Absent' + } catch { + # An unreadable repository is uncertainty, and uncertainty preserves the gateway. + return 'Indeterminate' + } +} + +function ConvertTo-ProcessArgument { + param([string]$Value) + + # Double any trailing backslashes so the closing quote is not escaped. + $escaped = $Value -replace '(\\+)$', '$1$1' + return '"' + ($escaped -replace '"', '\"') + '"' +} + +function Start-BoundedProcess { + param([string]$FilePath, [string]$Arguments, [int]$TimeoutMilliseconds) + + # Start-Process -PassThru does not reliably expose ExitCode once output is + # redirected, so drive the process directly instead. + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $FilePath + $psi.Arguments = $Arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $process = [System.Diagnostics.Process]::Start($psi) + # Begin both reads before waiting so a full pipe buffer cannot deadlock us. + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + try { $process.Kill() } catch {} + return [pscustomobject]@{ TimedOut = $true; ExitCode = $null; Output = '' } + } + + # The parameterless wait lets the redirected streams finish flushing. + $process.WaitForExit() + $output = (@($stdout.Result, $stderr.Result) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + return [pscustomobject]@{ TimedOut = $false; ExitCode = [int]$process.ExitCode; Output = $output } +} + +# Add-Type compiles through csc.exe and can stall on a contended %TEMP% or an +# antivirus scan. Callers cannot bound Exec/ewWaitUntilTerminated, so the check +# bounds itself by running the real work in a child process. +if (-not $NoWatchdog -and $TimeoutSeconds -gt 0) { + $watchdogResult = $null + try { + $arguments = @( + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', (ConvertTo-ProcessArgument $PSCommandPath), + '-AppRoot', (ConvertTo-ProcessArgument $AppRoot), + '-DataDirectoryName', (ConvertTo-ProcessArgument $DataDirectoryName), + '-Architecture', $Architecture, + '-NoWatchdog') + if (-not [string]::IsNullOrWhiteSpace($RoamingDirectory)) { + $arguments += @('-RoamingDirectory', (ConvertTo-ProcessArgument $RoamingDirectory)) + } + if (-not [string]::IsNullOrWhiteSpace($LocalDirectory)) { + $arguments += @('-LocalDirectory', (ConvertTo-ProcessArgument $LocalDirectory)) + } + # Must be forwarded, or the child silently falls back to the default repository + # and the parent's verdict reflects a different machine state than it was asked about. + if (-not [string]::IsNullOrWhiteSpace($PackageRepositoryKey)) { + $arguments += @('-PackageRepositoryKey', (ConvertTo-ProcessArgument $PackageRepositoryKey)) + } + $watchdogResult = Start-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -Arguments ($arguments -join ' ') ` + -TimeoutMilliseconds ($TimeoutSeconds * 1000) + } catch { + # Falling through to the inline check would reintroduce the unbounded + # Add-Type stall this watchdog exists to prevent, and the caller waits + # with ewWaitUntilTerminated. Report the uncertain verdict instead; it + # already fails closed and preserves the gateway. + Write-Warning "Migration preservation watchdog could not start: $($_.Exception.GetType().Name)." + exit 2 + } + + if ($null -ne $watchdogResult) { + if ($watchdogResult.TimedOut) { + Write-Warning "Migration preservation check did not finish within $TimeoutSeconds seconds." + exit 2 + } + # Re-emit the child's verdict so callers keep the diagnostics that + # distinguish DPAPI failure, schema drift, and binding mismatch. + if (-not [string]::IsNullOrWhiteSpace($watchdogResult.Output)) { + Write-Output $watchdogResult.Output.Trim() + } + exit $watchdogResult.ExitCode + } +} + +try { + $codecPath = Join-Path $AppRoot 'MigrationRecordCodec.cs' + Add-Type -Path $codecPath -ReferencedAssemblies 'System.Security.dll' -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($RoamingDirectory)) { + $RoamingDirectory = Join-Path ([Environment]::GetFolderPath('ApplicationData')) $DataDirectoryName + } + if ([string]::IsNullOrWhiteSpace($LocalDirectory)) { + $LocalDirectory = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) $DataDirectoryName + } + $binding = New-Object OpenClaw.Connection.Migration.MigrationBinding + $binding.InstallDirectory = [IO.Path]::GetFullPath($AppRoot) + $binding.RoamingDirectory = [IO.Path]::GetFullPath($RoamingDirectory) + $binding.LocalDirectory = [IO.Path]::GetFullPath($LocalDirectory) + $binding.Architecture = $Architecture + $binding.UserSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $receiptPath = Join-Path (Join-Path $RoamingDirectory 'store-migration') 'completed.dpapi' +} catch { + Write-Warning "Migration preservation checker could not initialize: $($_.Exception.GetType().Name)." + exit 2 +} + +try { + $null = [OpenClaw.Connection.Migration.MigrationRecordCodec]::ReadCompletion( + $receiptPath, $binding, [DateTime]::UtcNow) + Write-Output 'Validated completed Store migration. Preserve generated state and gateway.' + exit 10 +} catch { + $cause = $_.Exception.GetBaseException() + if ($cause -is [IO.FileNotFoundException] -or $cause -is [IO.DirectoryNotFoundException]) { + # An absent receipt only means "no migration happened" when the state directory + # could not have been tampered with. A foreign owner can delete completed.dpapi, + # and that deletion is indistinguishable from absence here, so refuse to treat + # unverifiable state as consent to unregister the gateway. + # + # The installed Store package is checked first because it outlives the whole + # migration directory, which holds every ACL trace the authority check reads. + $packageState = Get-StorePackageState -RepositoryKey $PackageRepositoryKey + if ($packageState -eq 'Present') { + # Distinct from exit 2 on purpose. Here we know the Store app is installed and + # is the likely owner of this gateway, so the uninstaller must not repeat its + # generic "we could not tell" advice to run wsl --unregister, which would + # destroy exactly what this branch just preserved. + Write-Warning 'The Store app is registered on this PC but its migration receipt is missing. Preserving generated state and gateway.' + exit 11 + } + if ($packageState -eq 'Indeterminate') { + Write-Warning 'Installed Store packages could not be read, so migration could not be ruled out. Preserving generated state and gateway.' + exit 2 + } + if (-not (Test-MigrationStateAuthority -Directory (Split-Path -Parent $receiptPath))) { + Write-Warning 'Migration state authority could not be verified. Preserving generated state and gateway.' + exit 2 + } + Write-Verbose 'No completed migration receipt.' + exit 0 + } + # A receipt exists but did not validate. "Present but unverifiable" is not the + # same as "absent": a future schema, a DPAPI failure, or binding drift must + # preserve state rather than authorize unregistering the gateway. + Write-Warning "Completed migration receipt could not be validated: $($cause.GetType().Name)." + exit 2 +} diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index c18062d10..97168f471 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -15,7 +15,13 @@ param( [string]$DataDirectoryName = 'OpenClawTray', [string]$AutoStartName = 'OpenClawTray', [string]$StartupTaskName = 'OpenClaw Companion', - [string]$DistroName = 'OpenClawGateway' + [string]$DistroName = 'OpenClawGateway', + [ValidateSet('x64', 'arm64')] + [string]$Architecture = $(if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }), + # Deliberately longer than the checker's own watchdog so the inner bound + # fires first and we do not orphan a child that is about to answer. + [int]$MigrationCheckTimeoutSeconds = 120, + [int]$WslTimeoutSeconds = 120 ) $ErrorActionPreference = 'Stop' @@ -32,6 +38,73 @@ if ($DistroName -notmatch '^[A-Za-z0-9._-]+$') { throw "Invalid WSL distro name '$DistroName'." } +function ConvertTo-ProcessArgument { + param([string]$Value) + + # wsl.exe matches its control flags against the raw command line without + # stripping quotes, so a quoted "--unregister" is not recognized as a flag + # and is executed as a command inside the distro instead. Quoting every + # argument therefore broke every wsl.exe call with /bin/sh: --list: not + # found and exit 127. Quote only values that actually need it. + if ([string]::IsNullOrEmpty($Value)) { + return '""' + } + if ($Value -notmatch '[\s"]') { + return $Value + } + + # Double any trailing backslashes so the closing quote is not escaped. + $escaped = $Value -replace '(\\+)$', '$1$1' + return '"' + ($escaped -replace '"', '\"') + '"' +} + +function ConvertTo-CleanProcessOutput { + param([string]$Value) + + if ([string]::IsNullOrEmpty($Value)) { + return '' + } + + # wsl.exe emits UTF-16LE on many Windows builds while the redirected stream + # is decoded as 8-bit, which interleaves a NUL between every character and + # silently defeats all output matching. Dropping NUL normalizes both the + # UTF-16 and UTF-8 variants without pinning an encoding that varies by + # Windows version. + return ($Value -replace "`0", '') +} + +function Start-BoundedProcess { + param([string]$FilePath, [string]$Arguments, [int]$TimeoutMilliseconds) + + # Start-Process -PassThru does not reliably expose ExitCode once output is + # redirected, so drive the process directly instead. + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $FilePath + $psi.Arguments = $Arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $process = [System.Diagnostics.Process]::Start($psi) + # Begin both reads before waiting so a full pipe buffer cannot deadlock us. + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + try { $process.Kill() } catch {} + return [pscustomobject]@{ TimedOut = $true; ExitCode = $null; Output = '' } + } + + # The parameterless wait lets the redirected streams finish flushing. + $process.WaitForExit() + $output = (@( + (ConvertTo-CleanProcessOutput $stdout.Result), + (ConvertTo-CleanProcessOutput $stderr.Result) + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + return [pscustomobject]@{ TimedOut = $false; ExitCode = [int]$process.ExitCode; Output = $output } +} + function Ensure-AppRoot { if (-not [string]::IsNullOrWhiteSpace($AppRoot) -and -not (Test-Path -LiteralPath $AppRoot)) { New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null @@ -564,31 +637,22 @@ function Format-Arguments { function Invoke-Wsl { param([string[]]$Arguments) - $stdoutPath = [System.IO.Path]::GetTempFileName() - $stderrPath = [System.IO.Path]::GetTempFileName() + $result = Start-BoundedProcess ` + -FilePath $script:WslPath ` + -Arguments (($Arguments | ForEach-Object { ConvertTo-ProcessArgument $_ }) -join ' ') ` + -TimeoutMilliseconds ($WslTimeoutSeconds * 1000) - try { - $process = Start-Process ` - -FilePath $script:WslPath ` - -ArgumentList $Arguments ` - -WindowStyle Hidden ` - -Wait ` - -PassThru ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath - - $stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue } else { '' } - $stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue } else { '' } - $output = (($stdout, $stderr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + # A stuck wsl.exe must not hang uninstall forever. A timed-out operation is + # indeterminate, never success: the caller must not treat it as removed. + if ($result.TimedOut) { + throw "wsl.exe $(Format-Arguments $Arguments) did not finish within $WslTimeoutSeconds seconds. The outcome is unknown." + } - Write-GatewayLog ("wsl.exe {0} exited {1}.{2}{3}" -f (Format-Arguments $Arguments), $process.ExitCode, [Environment]::NewLine, $output) + Write-GatewayLog ("wsl.exe {0} exited {1}.{2}{3}" -f (Format-Arguments $Arguments), $result.ExitCode, [Environment]::NewLine, $result.Output) - return [pscustomobject]@{ - ExitCode = [int]$process.ExitCode - Output = $output - } - } finally { - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ + ExitCode = $result.ExitCode + Output = $result.Output } } @@ -600,9 +664,16 @@ function Test-DistroNotFound { } return $Output -match 'WSL_E_DISTRO_NOT_FOUND' -or + $Output -match 'WSL_E_DEFAULT_DISTRO_NOT_FOUND' -or $Output -match 'There is no distribution with the supplied name' -or $Output -match 'The specified distribution.*(could not be found|not found)' -or - $Output -match 'distribution.*not.*found' + $Output -match 'distribution.*not.*found' -or + # A host with no WSL, or with WSL but no distributions at all, cannot be + # holding our gateway. There is nothing to remove, so this is success and + # not the alarming "could not remove the local WSL gateway" failure. + $Output -match 'Windows Subsystem for Linux is not installed' -or + $Output -match 'Windows Subsystem for Linux has no installed distributions' -or + $Output -match 'no installed distributions' } function Test-DistroListed { @@ -616,7 +687,13 @@ function Test-DistroListed { return $distros -contains $DistroName } +function Enter-DestructivePhase { + # Past this point a failure means "cleanup ran and failed", not "unknown". + $script:MigrationAdmissionPhase = $false +} + function Remove-GatewayDirectory { + Enter-DestructivePhase $gatewayDirectory = Join-Path $AppRoot "wsl\$DistroName" if (-not (Test-Path -LiteralPath $gatewayDirectory)) { @@ -648,7 +725,72 @@ function Remove-GatewayDirectory { throw "Failed to remove gateway directory '$gatewayDirectory': $lastError" } +$migrationOperationLock = $null +# Everything before the first destructive step is an admission decision. Failing +# there means "unknown", which is reported as exit 2 so the caller can tell it +# apart from a cleanup that actually ran and failed. Read-only discovery +# (locating wsl.exe, listing distros) stays inside the admission phase. +$script:MigrationAdmissionPhase = $true try { + if ($DataDirectoryName -eq 'OpenClawTray') { + $lockDirectory = Join-Path (Resolve-AppDataDir) 'store-migration' + $lockPath = Join-Path $lockDirectory 'prepare.lock' + $current = [IO.Path]::GetFullPath($lockPath) + while (-not [string]::IsNullOrEmpty($current)) { + try { + if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Migration lock paths must not contain reparse points.' + } + } catch [IO.FileNotFoundException] { + } catch [IO.DirectoryNotFoundException] { + } + $current = [IO.Path]::GetDirectoryName($current) + } + $null = [IO.Directory]::CreateDirectory($lockDirectory) + # Join the Inno parent's read lock, or protect a standalone cleanup. + # Store preparation/completion/finalization require an exclusive handle. + $migrationOperationLock = [IO.FileStream]::new( + $lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $checker = Join-Path $AppRoot 'Test-InnoMigration.ps1' + if (-not (Test-Path -LiteralPath $checker -PathType Leaf)) { + throw 'Migration preservation checker is missing. Gateway cleanup was not started.' + } + $checkerArguments = @( + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', (ConvertTo-ProcessArgument $checker), + '-AppRoot', (ConvertTo-ProcessArgument $AppRoot), + '-DataDirectoryName', (ConvertTo-ProcessArgument $DataDirectoryName), + '-Architecture', $Architecture, + '-RoamingDirectory', (ConvertTo-ProcessArgument (Resolve-AppDataDir)), + '-LocalDirectory', (ConvertTo-ProcessArgument (Resolve-LocalDataDir))) -join ' ' + $checkerResult = Start-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -Arguments $checkerArguments ` + -TimeoutMilliseconds ($MigrationCheckTimeoutSeconds * 1000) + if ($checkerResult.TimedOut) { + throw "Migration preservation check did not finish within $MigrationCheckTimeoutSeconds seconds. Gateway cleanup was not started." + } + $migrationResult = $checkerResult.ExitCode + # The checker's diagnostics are the only way to tell DPAPI failure from + # schema drift from binding mismatch. Never discard them. + if (-not [string]::IsNullOrWhiteSpace($checkerResult.Output)) { + Write-GatewayLog ("Migration preservation check output:{0}{1}" -f [Environment]::NewLine, $checkerResult.Output.Trim()) + } + Write-GatewayLog "Migration preservation check exited $migrationResult." + if ($migrationResult -eq 10) { + Write-GatewayLog 'Completed Store migration: preserving gateway and generated state.' + exit 10 + } + if ($migrationResult -eq 11) { + # Not a failure, so do not log it as one. The Store app is registered and is the + # likely owner of this gateway; the caller surfaces its own instructions. + Write-GatewayLog 'Store app is registered without a migration receipt: preserving gateway and generated state.' + exit 11 + } + if ($migrationResult -ne 0) { + throw "Migration preservation check failed (exit $migrationResult). Gateway cleanup was not started." + } + } Ensure-AppRoot Write-GatewayLog "Starting local gateway cleanup for $DistroName." @@ -673,6 +815,7 @@ try { Start-Sleep -Seconds 2 + Enter-DestructivePhase $unregisterResult = Invoke-Wsl -Arguments @('--unregister', $DistroName) if ($unregisterResult.ExitCode -ne 0 -and -not (Test-DistroNotFound $unregisterResult.Output)) { Write-GatewayResult ` @@ -694,7 +837,12 @@ try { $message = $_.Exception.Message Write-GatewayLog "Local gateway cleanup failed: $message" try { "[$(Get-Date -Format 'o')] $message" | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-GatewayResult -Succeeded $false -ExitCode 1 -Message $message + $failureExitCode = if ($script:MigrationAdmissionPhase) { 2 } else { 1 } + Write-GatewayResult -Succeeded $false -ExitCode $failureExitCode -Message $message Write-Warning $message - exit 1 + exit $failureExitCode +} finally { + if ($null -ne $migrationOperationLock) { + $migrationOperationLock.Dispose() + } } diff --git a/src/OpenClaw.Connection/Migration/InnoInstallation.cs b/src/OpenClaw.Connection/Migration/InnoInstallation.cs new file mode 100644 index 000000000..48e8e90e3 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/InnoInstallation.cs @@ -0,0 +1,29 @@ +namespace OpenClaw.Connection.Migration; + +public enum InnoInstallationStatus +{ + NotInstalled, + Detected, + Unsupported, + InspectionFailed +} + +/// +/// Read-only installation evidence, not authorization to migrate or uninstall. +/// +public sealed record InnoInstallation( + string InstallDirectory, + string ExecutablePath, + string UninstallerPath, + string Architecture, + Version Version); + +public sealed record InnoInstallationDetection( + InnoInstallationStatus Status, + InnoInstallation? Installation = null, + string? Reason = null); + +public interface IInnoInstallationDetector +{ + InnoInstallationDetection Detect(); +} diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs new file mode 100644 index 000000000..9fa4749f3 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -0,0 +1,218 @@ +using System.Diagnostics; +using System.Reflection.PortableExecutable; +using System.Runtime.Versioning; +using System.Security; +using Microsoft.Win32; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +/// +/// Collects read-only evidence for the default, same-user production installation. +/// Payload presence establishes coherence, not uninstaller provenance or migration eligibility. +/// +[SupportedOSPlatform("windows")] +public sealed class InnoInstallationDetector : IInnoInstallationDetector +{ + internal const string UninstallKey = + @"Software\Microsoft\Windows\CurrentVersion\Uninstall\{M0LTB0T-TRAY-4PP1-D3N7}_is1"; + + private readonly string _installDirectory; + private readonly IOpenClawLogger _logger; + private readonly IInnoInstallationReadSource _source; + + public InnoInstallationDetector(string localAppDataDirectory, IOpenClawLogger logger) + : this(localAppDataDirectory, logger, new InnoInstallationReadSource()) + { + } + + internal InnoInstallationDetector( + string localAppDataDirectory, IOpenClawLogger logger, IInnoInstallationReadSource source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(localAppDataDirectory); + if (!Path.IsPathFullyQualified(localAppDataDirectory)) + throw new ArgumentException("Local AppData must be an absolute path.", nameof(localAppDataDirectory)); + _installDirectory = Path.GetFullPath(Path.Combine(localAppDataDirectory, "OpenClawTray")); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _source = source ?? throw new ArgumentNullException(nameof(source)); + } + + public InnoInstallationDetection Detect() + { + try + { + // HKCU can alias the same registration in both views. Inspect HKLM even + // when HKCU is valid: machine-wide or conflicting evidence must block. + var user64 = _source.ReadRegistration(RegistryHive.CurrentUser, RegistryView.Registry64); + var user32 = _source.ReadRegistration(RegistryHive.CurrentUser, RegistryView.Registry32); + var machine64 = _source.ReadRegistration(RegistryHive.LocalMachine, RegistryView.Registry64); + var machine32 = _source.ReadRegistration(RegistryHive.LocalMachine, RegistryView.Registry32); + if (machine64 is not null || machine32 is not null) + return Unsupported("A machine-wide production Inno registration is present."); + if (user64 is null && user32 is null) + return new(InnoInstallationStatus.NotInstalled); + if (user64 is not null && user32 is not null && user64 != user32) + return Unsupported("Production Inno registrations disagree between registry views."); + + var registration = user64 ?? user32!; + if (!MigrationVersionPolicy.TryParseReleaseVersion(registration.DisplayVersion, out var version)) + return Unsupported("The Inno DisplayVersion is not a stable numeric release version."); + // Inno's default UninstallDisplayName includes the unnormalized AppVersion. + if (registration.DisplayName != $"OpenClaw Companion version {registration.DisplayVersion}" || + registration.Publisher != "OpenClaw Foundation") + return Unsupported("The Inno registration does not identify the production publisher and application."); + if (!IsDefaultInstallPath(registration.InstallLocation)) + return Unsupported("The Inno installation is not at the canonical default per-user location."); + + var executable = Path.Combine(_installDirectory, "OpenClaw.Tray.WinUI.exe"); + var uninstaller = Path.Combine(_installDirectory, "unins000.exe"); + if (!string.Equals(registration.UninstallString, $"\"{uninstaller}\"", StringComparison.OrdinalIgnoreCase)) + return Unsupported("The Inno uninstall command must contain only the quoted canonical unins000.exe path."); + + foreach (var name in new[] + { + "OpenClaw.Tray.WinUI.exe", "unins000.exe", "app-identity.txt", + "Test-InnoMigration.ps1", "MigrationRecordCodec.cs", "Uninstall-LocalGateway.ps1" + }) + { + if (!_source.IsOrdinaryFile(Path.Combine(_installDirectory, name))) + return Unsupported($"The required Inno payload {name} is missing or is not an ordinary file."); + } + + if (_source.ReadIdentity(Path.Combine(_installDirectory, "app-identity.txt")) is not + ("release" or "release\n" or "release\r\n")) + return Unsupported("The installed application identity is not release."); + var binary = _source.ReadExecutable(executable); + var architecture = binary.Machine switch + { + Machine.Amd64 => "x64", + Machine.Arm64 => "arm64", + _ => null + }; + if (architecture is null) + return Unsupported("The installed executable has an unsupported PE architecture."); + if (!MigrationVersionPolicy.TryParseReleaseVersion(binary.Version, out var binaryVersion) || + binaryVersion != version) + return Unsupported("The executable file version does not match the registered release version."); + + return new(InnoInstallationStatus.Detected, + new(_installDirectory, executable, uninstaller, architecture, version)); + } + catch (InvalidDataException ex) + { + return Unsupported(ex.Message); + } + catch (BadImageFormatException) + { + return Unsupported("The installed executable is not a valid native PE executable."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + const string reason = "The Inno installation could not be inspected."; + _logger.Error(reason, ex); + return new(InnoInstallationStatus.InspectionFailed, Reason: reason); + } + } + + private bool IsDefaultInstallPath(string? path) + { + // Inno writes InstallLocation with a trailing separator. Do not accept + // relative paths, dot segments, expanded variables, or alternate spellings. + return path is not null && + (string.Equals(path, _installDirectory, StringComparison.OrdinalIgnoreCase) || + string.Equals(path, _installDirectory + "\\", StringComparison.OrdinalIgnoreCase)); + } + + private InnoInstallationDetection Unsupported(string reason) + { + _logger.Warn(reason); + return new(InnoInstallationStatus.Unsupported, Reason: reason); + } +} + +internal sealed record InnoInstallationRegistration( + string? InstallLocation, string? DisplayVersion, string? Publisher, + string? DisplayName, string? UninstallString); + +internal sealed record InnoInstallationExecutable(Machine Machine, string? Version); + +internal interface IInnoInstallationReadSource +{ + InnoInstallationRegistration? ReadRegistration(RegistryHive hive, RegistryView view); + bool IsOrdinaryFile(string path); + string ReadIdentity(string path); + InnoInstallationExecutable ReadExecutable(string path); +} + +[SupportedOSPlatform("windows")] +internal sealed class InnoInstallationReadSource : IInnoInstallationReadSource +{ + private readonly Func _openRegistration; + + internal InnoInstallationReadSource(Func? openRegistration = null) + { + _openRegistration = openRegistration ?? OpenRegistration; + } + + public InnoInstallationRegistration? ReadRegistration(RegistryHive hive, RegistryView view) + { + using var key = _openRegistration(hive, view); + if (key is null) + return null; + return new(ReadString(key, "InstallLocation"), ReadString(key, "DisplayVersion"), + ReadString(key, "Publisher"), ReadString(key, "DisplayName"), ReadString(key, "UninstallString")); + } + + public bool IsOrdinaryFile(string path) + { + MigrationRecordCodec.RejectReparsePoints(path); + try + { + return (File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.Device)) == 0; + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + public string ReadIdentity(string path) + { + MigrationRecordCodec.RejectReparsePoints(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > 32) + throw new InvalidDataException("The installed application identity marker is malformed."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public InnoInstallationExecutable ReadExecutable(string path) + { + MigrationRecordCodec.RejectReparsePoints(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new PEReader(stream); + var headers = reader.PEHeaders; + if (headers.PEHeader is null || headers.CorHeader is not null || + (headers.CoffHeader.Characteristics & Characteristics.ExecutableImage) == 0 || + (headers.CoffHeader.Characteristics & Characteristics.Dll) != 0) + throw new InvalidDataException("The installed application is not a native PE executable."); + 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); + } + + private static RegistryKey? OpenRegistration(RegistryHive hive, RegistryView view) + { + using var root = RegistryKey.OpenBaseKey(hive, view); + return root.OpenSubKey(InnoInstallationDetector.UninstallKey, writable: false); + } + + private static string? ReadString(RegistryKey key, string name) + { + var value = key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames); + if (value is null) + return null; + if (key.GetValueKind(name) != RegistryValueKind.String || value is not string text) + throw new InvalidDataException($"The Inno registry value {name} must be a plain string."); + return text; + } +} diff --git a/src/OpenClaw.Connection/Migration/InnoSourceActivityVerifier.cs b/src/OpenClaw.Connection/Migration/InnoSourceActivityVerifier.cs new file mode 100644 index 000000000..d6923eb55 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/InnoSourceActivityVerifier.cs @@ -0,0 +1,79 @@ +using System.ComponentModel; +using System.Runtime.Versioning; +using System.Security; +using System.Security.Principal; + +namespace OpenClaw.Connection.Migration; + +public enum InnoSourceActivityStatus +{ + Stopped, + Running, + InspectionFailed +} + +public interface IInnoSourceActivityVerifier +{ + InnoSourceActivityStatus VerifyStopped(); +} + +/// +/// Inspects the exact source image across all sessions, including older instances +/// that do not participate in the runtime file lock. This scan is not itself a lease. +/// +[SupportedOSPlatform("windows")] +public sealed class InnoSourceActivityVerifier : IInnoSourceActivityVerifier +{ + private readonly string _executable; + private readonly IInnoSourceProcessInspector _processes; + + public InnoSourceActivityVerifier(string executable) + : this(executable, CreateInspector()) + { + } + + private static IInnoSourceProcessInspector CreateInspector() + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsInnoSourceProcessInspector(identity.User?.Value + ?? throw new InvalidOperationException("Cannot resolve the current Windows user.")); + } + + internal InnoSourceActivityVerifier(string executable, IInnoSourceProcessInspector processes) + { + if (!Path.IsPathFullyQualified(executable)) + throw new ArgumentException("Source executable must be absolute.", nameof(executable)); + _executable = Path.GetFullPath(executable); + _processes = processes; + } + + public InnoSourceActivityStatus VerifyStopped() + { + try + { + foreach (var process in _processes.FindSameNameProcesses()) + { + switch (process.State) + { + case InnoSourceProcessState.Exited: + case InnoSourceProcessState.OwnedByOtherUser: + continue; + case InnoSourceProcessState.ImageResolved: + if (string.IsNullOrWhiteSpace(process.ImagePath)) + return InnoSourceActivityStatus.InspectionFailed; + if (string.Equals(Path.GetFullPath(process.ImagePath), _executable, StringComparison.OrdinalIgnoreCase)) + return InnoSourceActivityStatus.Running; + continue; + default: + return InnoSourceActivityStatus.InspectionFailed; + } + } + return InnoSourceActivityStatus.Stopped; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + SecurityException or Win32Exception or InvalidOperationException or ArgumentException) + { + return InnoSourceActivityStatus.InspectionFailed; + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationCompletionReceiptWriter.cs b/src/OpenClaw.Connection/Migration/MigrationCompletionReceiptWriter.cs new file mode 100644 index 000000000..9eabfabef --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationCompletionReceiptWriter.cs @@ -0,0 +1,44 @@ +using System.Runtime.Versioning; + +namespace OpenClaw.Connection.Migration; + +/// +/// Publishes an already-validated completion receipt without replacing an existing receipt. +/// The caller owns source validation and serialization with preparation. +/// +[SupportedOSPlatform("windows")] +public sealed class MigrationCompletionReceiptWriter(MigrationBinding binding, TimeProvider? timeProvider = null) +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + public void Write(MigrationRecord receipt) + { + ArgumentNullException.ThrowIfNull(receipt); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + MigrationRecordCodec.RejectReparsePoints(directory); + if (!Directory.Exists(directory)) + throw new InvalidOperationException("Migration preparation is required before completion."); + + var receiptPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + MigrationRecordCodec.RejectReparsePoints(receiptPath); + if (File.Exists(receiptPath)) + throw new InvalidOperationException("A completion receipt already exists. Resume or recover migration before completing again."); + + var bytes = MigrationRecordCodec.Encode(receipt, _clock.GetUtcNow().UtcDateTime); + var temporaryPath = Path.Combine(directory, $".{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporaryPath, receiptPath); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationInventory.cs b/src/OpenClaw.Connection/Migration/MigrationInventory.cs new file mode 100644 index 000000000..7a3869361 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationInventory.cs @@ -0,0 +1,334 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.ExecApprovals; + +namespace OpenClaw.Connection.Migration; + +/// +/// Read-only inventory of the application data directories, not their profile parents. +/// This is evidence for preparation, not permission to adopt or delete state. Consumers +/// must capture again before acting. No credentials or payload bytes leave Capture. +/// +public sealed record MigrationInventory( + string Fingerprint, + bool AutoStart, + string RoamingDirectory, + string LocalDirectory, + ImmutableArray Entries) +{ + /// Missing files have null length and hash. Root identifies the relative-name base. + public sealed record Entry( + string Root, + string RelativeName, + string CanonicalPath, + bool Exists, + long? Length, + string? Sha256); + + private const int MaximumFileBytes = 4 * 1024 * 1024; + private const int MaximumTotalBytes = 16 * 1024 * 1024; + private const int MaximumGateways = 128; + private const string IdentityFileName = "device-key-ed25519.json"; + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Captures bounded metadata, including optional-file absence. Existing sources remain + /// open without write/delete sharing until all paths and hashes have been rechecked. + /// Throws on invalid, unsupported, changing, unreadable or reparse-point state. + /// Does not create directories, resolve WSL registrations or + /// inspect runtime/model payloads. AutoStart is false when settings are absent. + /// Setup metadata and legacy approvals await owner validation during import; + /// recording their bytes does not establish ownership or authorize a policy. + /// + public static MigrationInventory Capture(string roamingDirectory, string localDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(roamingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(localDirectory); + var roaming = Path.TrimEndingDirectorySeparator(Path.GetFullPath(roamingDirectory)); + var local = Path.TrimEndingDirectorySeparator(Path.GetFullPath(localDirectory)); + using var capture = new CaptureSession(); + try + { + CheckPath(roaming, directory: true); + CheckPath(local, directory: true); + var settings = capture.ReadJson("roaming", roaming, "settings.json"); + 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); + // 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) + .Order(StringComparer.Ordinal).ToArray(); + if (identityDirectories.Length > MaximumGateways) + throw new InvalidDataException("Too many gateway identity directories."); + + CaptureIdentities(capture, "roaming", roaming, ""); + CaptureIdentities(capture, "local", local, ""); + foreach (var id in identityDirectories) + CaptureIdentities(capture, "roaming", roaming, Path.Combine("gateways", id)); + + var approvalsPath = Path.GetFullPath(ExecApprovalsStore.ResolveFilePath(roaming)); + var approvals = capture.ReadJson( + "exec-state", Path.GetDirectoryName(approvalsPath)!, Path.GetFileName(approvalsPath)); + if (approvals is not null) + ValidateApprovals(approvalsPath); + if (!string.Equals(approvalsPath, Path.Combine(roaming, "exec-approvals.json"), StringComparison.OrdinalIgnoreCase)) + capture.ReadJson("roaming", roaming, "exec-approvals.json"); + + var localAiPaths = new LocalAiPaths(local); + var manifest = capture.ReadJson( + "local", local, Path.GetRelativePath(local, localAiPaths.ManifestPath), StringComparer.OrdinalIgnoreCase); + if (manifest is not null) + ValidateManifest(manifest.Value, localAiPaths); + // The INI is generated configuration, not a JSON schema or a model payload. + capture.Read("local", local, Path.GetRelativePath(local, localAiPaths.RouterPresetPath)); + // These are opaque SetupEngine receipts. Phase 1 neither follows their + // embedded paths nor interprets them as permission to adopt/delete a distro. + capture.ReadJson("local", local, "setup-state.json"); + capture.ReadJson("roaming", roaming, "setup-state.json"); + capture.ReadJson("local", local, "setup-managed-distro.json"); + capture.ReadJson("local", local, "windows-node-context.json"); + + capture.Recheck(); + if (!directories.SequenceEqual(GetGatewayDirectories(roaming), StringComparer.Ordinal) || + !string.Equals(approvalsPath, Path.GetFullPath(ExecApprovalsStore.ResolveFilePath(roaming)), StringComparison.Ordinal)) + throw new IOException("Migration sources changed during capture."); + + var entries = capture.Entries.OrderBy(entry => entry.Root, StringComparer.Ordinal) + .ThenBy(entry => entry.RelativeName, StringComparer.Ordinal).ToImmutableArray(); + var fingerprint = Convert.ToHexString(SHA256.HashData( + JsonSerializer.SerializeToUtf8Bytes(new { Version = 1, Roaming = roaming, Local = local, AutoStart = autoStart, Entries = entries }))); + return new(fingerprint, autoStart, roaming, local, entries); + } + catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException or InvalidOperationException or KeyNotFoundException) + { + // Parser exceptions can contain a credential-bearing value. Never propagate them. + throw new InvalidDataException("Migration source JSON or schema is invalid."); + } + } + + private static bool ValidateSettings(JsonElement root) + { + var data = root.Deserialize() ?? throw new InvalidDataException("Settings must be an object."); + return data.AutoStart; + } + + private static HashSet ValidateRegistry(JsonElement? root) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + if (root is null) + return ids; + var gateways = root.Value.GetProperty("gateways"); + if (gateways.ValueKind != JsonValueKind.Array || gateways.GetArrayLength() > MaximumGateways) + throw new InvalidDataException("Gateway registry must contain a bounded gateway array."); + foreach (var item in gateways.EnumerateArray()) + { + var record = item.Deserialize(CamelCaseOptions); + if (record is null) + throw new InvalidDataException("Gateway registry record must be an object."); + ValidateSegment(record.Id); + if (!ids.Add(record.Id)) + throw new InvalidDataException("Gateway registry contains an invalid or duplicate reference."); + if (item.TryGetProperty("identityDirName", out var identity) && + !string.Equals(identity.GetString(), record.Id, StringComparison.Ordinal)) + throw new InvalidDataException("Gateway identity directory reference does not match its ID."); + } + if (root.Value.TryGetProperty("activeId", out var active) && active.ValueKind != JsonValueKind.Null && + !ids.Contains(active.GetString() ?? "", StringComparer.Ordinal)) + throw new InvalidDataException("Active gateway reference is missing from the registry."); + return ids; + } + + private static string[] GetGatewayDirectories(string roaming) + { + var path = Path.Combine(roaming, "gateways"); + if (!CheckPath(path, directory: true)) + return []; + var names = new List(); + foreach (var child in Directory.EnumerateFileSystemEntries(path)) + { + 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 void CaptureIdentities(CaptureSession capture, string root, string directory, string relativeDirectory) + { + var relativeName = Path.Combine(relativeDirectory, IdentityFileName); + var identity = capture.ReadJson(root, directory, relativeName); + if (identity is not null) + { + // The source is already size-bounded and locked against writers. Reuse the + // canonical strict cryptographic validator without Initialize's create path. + var result = DeviceIdentity.ReadStoredDeviceToken(Path.Combine(directory, relativeDirectory)); + if (result.Status is DeviceTokenReadStatus.Corrupt or DeviceTokenReadStatus.Unreadable) + throw new InvalidDataException("Device identity is malformed or unreadable."); + } + // Historical names have no supported reader in the current application. + // Preserve their absence, but never guess a key schema for adoption. + foreach (var legacy in new[] { "device-key.json", "device.json" }) + if (capture.Read(root, directory, Path.Combine(relativeDirectory, legacy)) is not null) + throw new InvalidDataException("Unsupported legacy device identity requires explicit migration."); + } + + private static void ValidateApprovals(string path) + { + // Resolve against the canonical directory so the owner reads only the bounded, + // locked file, not an unmigrated legacy policy. No subscription starts a watcher. + var directory = Path.GetDirectoryName(path)!; + if (!string.Equals(Path.GetFullPath(ExecApprovalsStore.ResolveFilePath(directory)), path, StringComparison.Ordinal)) + throw new IOException("Exec approvals location changed during capture."); + using var store = new ExecApprovalsStore(directory, NullLogger.Instance); + var result = store.GetSnapshotReadOnlyAsync().GetAwaiter().GetResult(); + if (!result.IsSuccess || result.Snapshot is not { Exists: true } snapshot) + throw new InvalidDataException("Exec approvals schema is invalid or unsupported."); + if (!string.Equals(Path.GetFullPath(snapshot.Path), path, StringComparison.Ordinal)) + throw new IOException("Exec approvals location changed during capture."); + } + + private static void ValidateManifest(JsonElement root, LocalAiPaths paths) + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + RespectNullableAnnotations = true, + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false)); + var manifest = root.Deserialize(options) + ?? throw new InvalidDataException("Local AI receipt must be an object."); + try + { + _ = new LocalAiManifestStore(paths).ResolveAndValidate(manifest); + } + catch (InvalidDataException) + { + throw new InvalidDataException("Local AI receipt schema or path is invalid."); + } + } + + private static void ValidateSegment(string value) + { + if (string.IsNullOrWhiteSpace(value) || value is "." or ".." || + value.EndsWith('.') || value.EndsWith(' ') || value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + value.Contains('\\') || value.Contains('/')) + throw new InvalidDataException("Source contains an unsafe path segment."); + } + + private static bool CheckPath(string path, bool directory) + { + var parent = Path.GetDirectoryName(path); + if (parent is not null && parent != path) + CheckPath(parent, directory: true); + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("Migration sources cannot contain reparse points."); + if (((attributes & FileAttributes.Directory) != 0) != directory) + throw new InvalidDataException("Migration source has an unexpected file type."); + return true; + } + + private sealed class CaptureSession : IDisposable + { + private readonly List _streams = []; + private readonly List _documents = []; + private int _totalBytes; + public List Entries { get; } = []; + + public byte[]? Read(string root, string directory, string name) + { + var path = Path.GetFullPath(Path.Combine(directory, name)); + if (!CheckPath(path, directory: false)) + { + Entries.Add(new(root, name, path, false, null, null)); + return null; + } + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + _streams.Add(stream); + if (stream.Length > MaximumFileBytes || stream.Length > MaximumTotalBytes - _totalBytes) + throw new InvalidDataException("Migration source exceeds the inventory size limit."); + var bytes = new byte[(int)stream.Length]; + stream.ReadExactly(bytes); + _totalBytes += bytes.Length; + Entries.Add(new(root, name, path, true, bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)))); + return bytes; + } + + public JsonElement? ReadJson(string root, string directory, string name, StringComparer? propertyComparer = null) + { + var bytes = Read(root, directory, name); + if (bytes is null) + return null; + var content = bytes.AsMemory(); + if (content.Span.StartsWith(Encoding.UTF8.Preamble)) + content = content[Encoding.UTF8.Preamble.Length..]; + var document = JsonDocument.Parse(content); + _documents.Add(document); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Migration source JSON must be an object."); + RejectDuplicateProperties(document.RootElement, propertyComparer ?? StringComparer.Ordinal); + return document.RootElement; + } + + public void Recheck() + { + foreach (var entry in Entries) + { + if (CheckPath(entry.CanonicalPath, directory: false) != entry.Exists) + throw new IOException("Migration sources changed during capture."); + if (!entry.Exists) + continue; + using var stream = new FileStream(entry.CanonicalPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length != entry.Length || + !string.Equals(Convert.ToHexString(SHA256.HashData(stream)), entry.Sha256, StringComparison.Ordinal)) + throw new IOException("Migration sources changed during capture."); + } + } + + private static void RejectDuplicateProperties(JsonElement element, StringComparer comparer) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(comparer); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + throw new InvalidDataException("Migration source JSON has ambiguous duplicate properties."); + RejectDuplicateProperties(property.Value, comparer); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + RejectDuplicateProperties(item, comparer); + } + } + + public void Dispose() + { + foreach (var document in _documents) + document.Dispose(); + foreach (var stream in _streams) + stream.Dispose(); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs new file mode 100644 index 000000000..15d819b21 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs @@ -0,0 +1,227 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace OpenClaw.Connection.Migration; + +/// +/// Coordinates same-user Inno runtime, uninstall, and Store writes across Windows sessions. +/// Runtime readers retain their handle until shutdown; Store writers require exclusive access. +/// +[SupportedOSPlatform("windows")] +public static class MigrationOperationLock +{ + public static FileStream AcquireRuntime(MigrationBinding binding) => Open(binding, FileAccess.Read, FileShare.Read); + + internal static FileStream AcquireExclusive(MigrationBinding binding) => Open(binding, FileAccess.ReadWrite, FileShare.None); + + public static bool IsBusy(IOException exception) => (exception.HResult & 0xffff) is 32 or 33; + + private static FileStream Open(MigrationBinding binding, FileAccess access, FileShare share) + { + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User ?? throw new InvalidOperationException("Cannot resolve the current Windows user."); + if (binding.UserSid != owner.Value) + throw new InvalidOperationException("Migration locking must use the current Windows user."); + + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + MigrationRecordCodec.RejectReparsePoints(directory); + var security = new DirectorySecurity(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var sid in new[] + { + owner, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + }) + { + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + } + var info = new DirectoryInfo(directory); + if (!info.Exists) + info.Create(security); + else + { + if (RequiresHardening(info, owner)) + info.SetAccessControl(security); + ScrubExistingEntries(info, owner); + } + + var path = Path.Combine(directory, "prepare.lock"); + MigrationRecordCodec.RejectReparsePoints(path); + return new FileStream(path, FileMode.OpenOrCreate, access, share); + } + + /// + /// Rewriting the DACL on every acquisition needs WRITE_DAC and WRITE_OWNER, which an ordinary + /// launch can lack on a roaming or administratively hardened profile. Reading is cheap and + /// always permitted to the owner, so only correct a directory that actually drifted. + /// + private static bool RequiresHardening(DirectoryInfo info, SecurityIdentifier owner) + { + try + { + var current = info.GetAccessControl(); + if (!current.AreAccessRulesProtected + || current.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier actual + || actual != owner) + return true; + + return GrantsAnyForeignAccess(current, owner); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException + or IOException) + { + return true; + } + } + + /// + /// A protected DACL with the expected owner can still carry an Allow rule for an unrelated + /// principal. That principal could delete completed.dpapi, which the uninstall checker reads + /// as an absent receipt and therefore as permission to destroy the local gateway. Treat any + /// grant outside the three principals this type writes as drift worth correcting. + /// + private static bool GrantsAnyForeignAccess(FileSystemSecurity security, SecurityIdentifier owner) + { + var trusted = TrustedPrincipals(owner); + foreach (FileSystemAccessRule rule in + security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.AccessControlType != AccessControlType.Allow) + continue; + if (rule.IdentityReference is SecurityIdentifier sid && Array.IndexOf(trusted, sid) >= 0) + continue; + return true; + } + + return false; + } + + /// + /// An owner keeps implicit WRITE_DAC no matter what the DACL says, so trusted-looking rules on + /// an entry owned by another principal are not durable: that owner can rewrite the ACL and + /// delete completed.dpapi, which the uninstall checker reads as an absent receipt and therefore + /// as permission to destroy the local gateway. Ownership is part of the guarantee here for the + /// same reason it is for the directory. + /// + internal static bool RequiresEntryHardening(FileSecurity current, SecurityIdentifier owner) + => !current.AreAccessRulesProtected + || current.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier actual + || actual != owner + || GrantsAnyForeignAccess(current, owner); + + /// + /// Hardening the directory rewrites inherited access only, so an explicit Allow rule placed + /// directly on completed.dpapi or prepare.lock survives it. Deleting the receipt is the exact + /// escalation this hardening exists to prevent, so the files carry the same guarantee. + /// + private static void ScrubExistingEntries(DirectoryInfo directory, SecurityIdentifier owner) + { + foreach (var file in directory.GetFiles()) + { + FileSecurity current; + try + { + current = file.GetAccessControl(); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException + or IOException) + { + current = null!; + } + + if (current is not null && !RequiresEntryHardening(current, owner)) + continue; + + var hardened = new FileSecurity(); + hardened.SetOwner(owner); + hardened.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var sid in TrustedPrincipals(owner)) + { + hardened.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + AccessControlType.Allow)); + } + + // A foreign owner is exactly the case where the rewrite can be refused. Startup must not + // fail for state it cannot correct, so leave the entry and let the uninstall decision + // treat an unverifiable receipt as uncertain rather than as consent to remove. + try + { + file.SetAccessControl(hardened); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException + or IOException) + { + } + } + } + + /// + /// Trust asks a narrower question than repair. An entry that merely inherits the directory's + /// protected, trusted-only rules is sound even though it is not independently protected, which + /// is the ordinary state of prepare.lock right after creation. What must hold is authority: a + /// trusted owner, because an owner keeps implicit WRITE_DAC, and no grant to anyone else that + /// could change or delete the receipt. This mirrors Test-AclAuthority in + /// scripts/Test-InnoMigration.ps1, which owns the uninstall decision; keep the two in step. + /// + internal static bool EntryAuthorityIsSound(FileSystemSecurity current, SecurityIdentifier owner) + => current.GetOwner(typeof(SecurityIdentifier)) is SecurityIdentifier actual + && Array.IndexOf(TrustedPrincipals(owner), actual) >= 0 + && !GrantsForeignMutatingAccess(current, owner); + + /// + /// Only a principal that can change or delete the receipt affects the uninstall decision. + /// Read, list, and traverse grants inherit harmlessly into this directory on managed profiles, + /// and treating those as tampering would make every ordinary uninstall unverifiable. The mask + /// names individual mutating bits because FullControl and Modify are composites that also carry + /// the read bits, so testing against them would match a read-only grant. + /// + private static bool GrantsForeignMutatingAccess(FileSystemSecurity security, SecurityIdentifier owner) + { + const FileSystemRights mutating = FileSystemRights.WriteData + | FileSystemRights.AppendData + | FileSystemRights.WriteAttributes + | FileSystemRights.WriteExtendedAttributes + | FileSystemRights.Delete + | FileSystemRights.DeleteSubdirectoriesAndFiles + | FileSystemRights.ChangePermissions + | FileSystemRights.TakeOwnership; + + // An ACE may also carry the generic rights, which FileSystemRights has no names for and + // which intersect none of the specific bits above. The kernel maps GENERIC_ALL to + // FILE_ALL_ACCESS (DELETE, FILE_DELETE_CHILD, WRITE_DAC) and GENERIC_WRITE on a directory + // to FILE_ADD_FILE, so a grant in that form can delete or replace the receipt while every + // named bit reads as clear. GENERIC_READ and GENERIC_EXECUTE are harmless. + const int genericAll = 0x10000000; + const int genericWrite = 0x40000000; + const int rejected = (int)mutating | genericAll | genericWrite; + + var trusted = TrustedPrincipals(owner); + foreach (FileSystemAccessRule rule in + security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.AccessControlType != AccessControlType.Allow) + continue; + if (rule.IdentityReference is SecurityIdentifier sid && Array.IndexOf(trusted, sid) >= 0) + continue; + // An inherit-only entry describes children and grants nothing on this object. + if (rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly)) + continue; + if (((int)rule.FileSystemRights & rejected) != 0) + return true; + } + + return false; + } + + private static SecurityIdentifier[] TrustedPrincipals(SecurityIdentifier owner) => + [ + owner, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + ]; +} diff --git a/src/OpenClaw.Connection/Migration/MigrationPreparation.cs b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs new file mode 100644 index 000000000..f1a6c8559 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs @@ -0,0 +1,79 @@ +using System.Runtime.Versioning; +using System.Text.Json; + +namespace OpenClaw.Connection.Migration; + +/// +/// Prepares a read-only state inventory after explicit migration consent. +/// This does not stop Inno, import data, or authorize migration-aware uninstall. +/// +[SupportedOSPlatform("windows")] +public sealed class MigrationPreparation(MigrationBinding binding, TimeProvider? timeProvider = null) +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + public MigrationRecord Prepare(string sourceVersion) + { + using var migrationLock = AcquireLock(); + return PrepareUnderLock(sourceVersion); + } + + internal FileStream AcquireLock() => MigrationOperationLock.AcquireExclusive(binding); + + // The coordinator owns the lock through source inspection and publication. + internal MigrationRecord PrepareUnderLock(string sourceVersion) + { + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var completedPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + // Even an invalid receipt needs explicit recovery, not an overwritten attempt. + if (Path.Exists(completedPath)) + throw new InvalidOperationException("A completion receipt already exists. Resume or recover migration before preparing again."); + + var inventory = MigrationInventory.Capture(binding.RoamingDirectory, binding.LocalDirectory); + var now = _clock.GetUtcNow().UtcDateTime; + var intentPath = Path.Combine(directory, MigrationRecordCodec.IntentFileName); + MigrationRecordCodec.RejectReparsePoints(intentPath); + if (Path.Exists(intentPath)) + { + using var stream = new FileStream(intentPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > MigrationRecordCodec.MaximumRecordBytes) + throw new InvalidDataException("Invalid migration intent size."); + using var reader = new BinaryReader(stream); + var existing = MigrationRecordCodec.DecodeForRenewedConsent(reader.ReadBytes((int)stream.Length), binding, now); + if (existing.Kind != "intent") + throw new InvalidDataException("Expected a migration intent."); + if (existing.ExpiresUtc > now && existing.Fingerprint == inventory.Fingerprint && existing.SourceVersion == sourceVersion) + return existing; + } + + var record = new MigrationRecord + { + Kind = "intent", + MigrationId = Guid.NewGuid().ToString("D"), + SourceVersion = sourceVersion, + Binding = binding, + CreatedUtc = now, + ExpiresUtc = now.AddDays(30), + AutoStart = inventory.AutoStart, + Fingerprint = inventory.Fingerprint, + InventoryJson = JsonSerializer.Serialize(inventory) + }; + var bytes = MigrationRecordCodec.Encode(record, now); + var temporaryPath = Path.Combine(directory, $".{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporaryPath, intentPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + return record; + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs new file mode 100644 index 000000000..9d50211a4 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs @@ -0,0 +1,246 @@ +// Also compiled by Windows PowerShell 5.1 during Inno uninstall. Keep this file +// self-contained and compatible with C# 5 / .NET Framework. +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace OpenClaw.Connection.Migration +{ + public sealed class MigrationBinding + { + public string InstallDirectory { get; set; } + public string RoamingDirectory { get; set; } + public string LocalDirectory { get; set; } + public string Architecture { get; set; } + public string UserSid { get; set; } + + public MigrationBinding() + { + InstallDirectory = RoamingDirectory = LocalDirectory = Architecture = UserSid = ""; + } + } + + public sealed class MigrationRecord + { + public string Kind { get; set; } + public string MigrationId { get; set; } + public string SourceVersion { get; set; } + public string TargetVersion { get; set; } + public string Fingerprint { get; set; } + public string InventoryJson { get; set; } + public bool AutoStart { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } + public MigrationBinding Binding { get; set; } + + public MigrationRecord() + { + Kind = MigrationId = SourceVersion = TargetVersion = Fingerprint = InventoryJson = ""; + Binding = new MigrationBinding(); + } + } + + public static class MigrationRecordCodec + { + public const string DirectoryName = "store-migration"; + public const string IntentFileName = "intent.dpapi"; + public const string CompletionFileName = "completed.dpapi"; + public const string PackageName = "OpenClawFoundation.OpenClaw"; + public const string PackagePublisher = "CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC"; + public const int MaximumRecordBytes = 4 * 1024 * 1024; + private const string SourceId = "{M0LTB0T-TRAY-4PP1-D3N7}"; + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("OpenClaw.InnoToStore.Migration.v1"); + +#if NET10_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform("windows")] +#endif + public static byte[] Encode(MigrationRecord record, DateTime utcNow) + { + Validate(record, record.Binding, utcNow); + if (record.CreatedUtc > utcNow) + throw new InvalidDataException("Invalid migration creation time."); + byte[] plain; + using (var stream = new MemoryStream()) + { + using (var writer = new BinaryWriter(stream, Encoding.UTF8, true)) + { + writer.Write("OpenClawMigration"); + writer.Write(1); + writer.Write(SourceId); + writer.Write(PackageName); + writer.Write(PackagePublisher); + writer.Write(record.Kind); + writer.Write(record.MigrationId); + writer.Write(record.SourceVersion); + writer.Write(record.TargetVersion); + writer.Write(record.Binding.InstallDirectory); + writer.Write(record.Binding.RoamingDirectory); + writer.Write(record.Binding.LocalDirectory); + writer.Write(record.Binding.Architecture); + writer.Write(record.Binding.UserSid); + writer.Write(record.CreatedUtc.Ticks); + writer.Write(record.ExpiresUtc.Ticks); + writer.Write(record.Fingerprint); + writer.Write(record.AutoStart); + writer.Write(record.InventoryJson); + } + plain = stream.ToArray(); + } + try + { + if (plain.Length > MaximumRecordBytes / 2) + throw new InvalidDataException("Migration inventory exceeds the supported size."); + return ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser); + } + finally { Array.Clear(plain, 0, plain.Length); } + } + +#if NET10_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform("windows")] +#endif + public static MigrationRecord Decode(byte[] bytes, MigrationBinding expected, DateTime utcNow) + { + return DecodeCore(bytes, expected, utcNow, false); + } + + // Only a newly confirmed preparation may renew an expired intent. +#if NET10_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform("windows")] +#endif + public static MigrationRecord DecodeForRenewedConsent(byte[] bytes, MigrationBinding expected, DateTime utcNow) + { + return DecodeCore(bytes, expected, utcNow, true); + } + +#if NET10_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform("windows")] +#endif + private static MigrationRecord DecodeCore(byte[] bytes, MigrationBinding expected, DateTime utcNow, bool renewConsent) + { + if (bytes.Length == 0 || bytes.Length > MaximumRecordBytes) + throw new InvalidDataException("Invalid migration record size."); + var plain = ProtectedData.Unprotect(bytes, Entropy, DataProtectionScope.CurrentUser); + try + { + using (var stream = new MemoryStream(plain, false)) + using (var reader = new BinaryReader(stream, new UTF8Encoding(false, true))) + { + if (reader.ReadString() != "OpenClawMigration" || reader.ReadInt32() != 1 || + reader.ReadString() != SourceId || reader.ReadString() != PackageName || + reader.ReadString() != PackagePublisher) + throw new InvalidDataException("Unsupported migration record contract."); + var record = new MigrationRecord(); + record.Kind = reader.ReadString(); + record.MigrationId = reader.ReadString(); + record.SourceVersion = reader.ReadString(); + record.TargetVersion = reader.ReadString(); + record.Binding.InstallDirectory = reader.ReadString(); + record.Binding.RoamingDirectory = reader.ReadString(); + record.Binding.LocalDirectory = reader.ReadString(); + record.Binding.Architecture = reader.ReadString(); + record.Binding.UserSid = reader.ReadString(); + record.CreatedUtc = new DateTime(reader.ReadInt64(), DateTimeKind.Utc); + record.ExpiresUtc = new DateTime(reader.ReadInt64(), DateTimeKind.Utc); + record.Fingerprint = reader.ReadString(); + record.AutoStart = reader.ReadBoolean(); + record.InventoryJson = reader.ReadString(); + if (stream.Position != stream.Length) + throw new InvalidDataException("Migration record has unexpected trailing data."); + Validate(record, expected, utcNow, renewConsent); + return record; + } + } + finally { Array.Clear(plain, 0, plain.Length); } + } + +#if NET10_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform("windows")] +#endif + public static MigrationRecord ReadCompletion(string path, MigrationBinding expected, DateTime utcNow) + { + RejectReparsePoints(path); + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (stream.Length == 0 || stream.Length > MaximumRecordBytes) + throw new InvalidDataException("Invalid migration record size."); + using (var reader = new BinaryReader(stream)) + { + var record = Decode(reader.ReadBytes((int)stream.Length), expected, utcNow); + if (record.Kind != "completed") + throw new InvalidDataException("Migration has not completed."); + return record; + } + } + } + + public static void RejectReparsePoints(string path) + { + var current = Path.GetFullPath(path); + while (!string.IsNullOrEmpty(current)) + { + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("Migration paths must not contain reparse points."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + current = Path.GetDirectoryName(current); + } + } + + private static void Validate(MigrationRecord record, MigrationBinding expected, DateTime utcNow, bool renewConsent = false) + { + Guid id; +#if NET10_0_OR_GREATER + Version? version; +#else + Version version; +#endif + if (!Guid.TryParseExact(record.MigrationId, "D", out id) || id == Guid.Empty || + !Version.TryParse(record.SourceVersion, out version) || + (record.Binding.Architecture != "x64" && record.Binding.Architecture != "arm64") || + record.Binding.Architecture != expected.Architecture || + string.IsNullOrWhiteSpace(expected.UserSid) || record.Binding.UserSid != expected.UserSid || + !SamePath(record.Binding.InstallDirectory, expected.InstallDirectory) || + !SamePath(record.Binding.RoamingDirectory, expected.RoamingDirectory) || + !SamePath(record.Binding.LocalDirectory, expected.LocalDirectory)) + throw new InvalidDataException("Migration record does not match this installation or Windows user."); + if (record.CreatedUtc.Kind != DateTimeKind.Utc || + record.CreatedUtc < new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + throw new InvalidDataException("Invalid migration creation time."); + if (record.Fingerprint.Length != 64) + throw new InvalidDataException("Invalid migration state fingerprint."); + foreach (char character in record.Fingerprint) + if (!Uri.IsHexDigit(character)) + throw new InvalidDataException("Invalid migration state fingerprint."); + + if (record.Kind == "intent") + { + if (record.CreatedUtc > utcNow || record.ExpiresUtc != record.CreatedUtc.AddDays(30) || + (!renewConsent && utcNow >= record.ExpiresUtc) || + record.TargetVersion != "" || string.IsNullOrWhiteSpace(record.InventoryJson)) + throw new InvalidDataException("Migration intent is invalid or expired. Confirm migration again."); + } + else if (record.Kind == "completed") + { + // Neither aging nor a backward clock correction may re-enable destructive uninstall. + if (record.ExpiresUtc.Ticks != DateTime.MaxValue.Ticks || + !Version.TryParse(record.TargetVersion, out version) || record.InventoryJson != "") + throw new InvalidDataException("Invalid completed migration receipt."); + } + else + throw new InvalidDataException("Unsupported migration state."); + } + + private static bool SamePath(string actual, string expected) + { + if (string.IsNullOrWhiteSpace(actual) || string.IsNullOrWhiteSpace(expected) || + !Path.IsPathRooted(actual) || !Path.IsPathRooted(expected)) + return false; + return string.Equals(Path.GetFullPath(actual).TrimEnd('\\'), + Path.GetFullPath(expected).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs new file mode 100644 index 000000000..4c6c5aa1a --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs @@ -0,0 +1,88 @@ +using System.Runtime.Versioning; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum MigrationStartupRecordStatus +{ + None, + Intent, + Completed, + Invalid, + Unavailable +} + +public sealed record MigrationStartupRecord( + MigrationStartupRecordStatus Status, + MigrationRecord? Record = null); + +public interface IMigrationStartupRecordReader +{ + MigrationStartupRecord Read(); +} + +/// +/// Reads pending handoffs without preparing, renewing, or deleting any record. +/// An expired intent still requires an explicit migration decision, not fresh startup. +/// +[SupportedOSPlatform("windows")] +public sealed class MigrationStartupRecordReader( + MigrationBinding binding, + IOpenClawLogger logger, + TimeProvider? timeProvider = null) : IMigrationStartupRecordReader +{ + public MigrationStartupRecord Read() + { + try + { + var completed = ReadFile(MigrationRecordCodec.CompletionFileName, "completed"); + if (completed is not null) + return new(MigrationStartupRecordStatus.Completed, completed); + + var intent = ReadFile(MigrationRecordCodec.IntentFileName, "intent"); + return intent is null + ? new(MigrationStartupRecordStatus.None) + : new(MigrationStartupRecordStatus.Intent, intent); + } + catch (Exception ex) when (ex is InvalidDataException or CryptographicException or + EndOfStreamException or ArgumentException or DecoderFallbackException) + { + logger.Warn($"Store migration record requires recovery ({ex.GetType().Name})."); + return new(MigrationStartupRecordStatus.Invalid); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + logger.Error($"Store migration record inspection failed ({ex.GetType().Name})."); + return new(MigrationStartupRecordStatus.Unavailable); + } + } + + private MigrationRecord? ReadFile(string fileName, string expectedKind) + { + var path = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, fileName); + MigrationRecordCodec.RejectReparsePoints(path); + FileStream stream; + try + { + stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + + using (stream) + { + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + throw new InvalidDataException("Invalid migration startup record size."); + using var reader = new BinaryReader(stream); + var record = MigrationRecordCodec.DecodeForRenewedConsent( + reader.ReadBytes((int)stream.Length), binding, + (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime); + if (record.Kind != expectedKind) + throw new InvalidDataException("Unexpected migration startup record kind."); + return record; + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs b/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs new file mode 100644 index 000000000..4b615344b --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationVersionPolicy.cs @@ -0,0 +1,24 @@ +namespace OpenClaw.Connection.Migration; + +/// +/// Only stable, three- or four-part numeric release versions are comparable. +/// 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; + + var parts = value.Split('.'); + if (parts.Length is not (3 or 4) || + parts.Any(part => part.Length == 0 || part.Any(character => character is < '0' or > '9')) || + !Version.TryParse(value, out var parsed)) + return false; + + version = new Version(parsed.Major, parsed.Minor, parsed.Build, Math.Max(parsed.Revision, 0)); + return true; + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs new file mode 100644 index 000000000..851d6fee0 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs @@ -0,0 +1,76 @@ +using OpenClaw.Shared; +using System.Runtime.Versioning; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationPreparationState +{ + InnoRunning, + SourceChanged, + ValidationFailed, + Prepared +} + +public sealed record StoreMigrationPreparationDecision( + StoreMigrationPreparationState State, + MigrationRecord? Intent = null); + +public interface IMigrationSourceLease : IDisposable; + +public interface IMigrationSourceLeaseProvider +{ + IMigrationSourceLease? TryAcquire(); +} + +/// +/// Acquires exclusive source ownership, rechecks exact installation evidence, +/// and prepares a protected inventory. It never adopts, deletes, or completes state. +/// +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationAdoptionPreparationCoordinator( + IMigrationSourceLeaseProvider leaseProvider, + IInnoInstallationDetector detector, + MigrationPreparation preparation, + IOpenClawLogger logger, + IInnoSourceActivityVerifier? sourceActivity = null) +{ + public StoreMigrationPreparationDecision Prepare(InnoInstallation expected) + { + ArgumentNullException.ThrowIfNull(expected); + + using var lease = leaseProvider.TryAcquire(); + if (lease is null) + return new(StoreMigrationPreparationState.InnoRunning); + + try + { + using var migrationLock = preparation.AcquireLock(); + var detected = detector.Detect(); + if (detected.Status != InnoInstallationStatus.Detected || detected.Installation != expected) + return new(StoreMigrationPreparationState.SourceChanged); + + var activity = (sourceActivity ?? new InnoSourceActivityVerifier(expected.ExecutablePath)).VerifyStopped(); + if (activity == InnoSourceActivityStatus.Running) + return new(StoreMigrationPreparationState.InnoRunning); + if (activity != InnoSourceActivityStatus.Stopped) + { + logger.Error("Store migration preparation could not exclude source processes across sessions."); + return new(StoreMigrationPreparationState.ValidationFailed); + } + + var intent = preparation.PrepareUnderLock(expected.Version.ToString()); + logger.Info($"Store migration preparation completed: {intent.MigrationId}."); + return new(StoreMigrationPreparationState.Prepared, intent); + } + catch (IOException exception) when (MigrationOperationLock.IsBusy(exception)) + { + logger.Info("Store migration preparation is blocked by an active runtime or migration operation."); + return new(StoreMigrationPreparationState.InnoRunning); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + logger.Error($"Store migration preparation failed: {exception.Message}"); + return new(StoreMigrationPreparationState.ValidationFailed); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs new file mode 100644 index 000000000..f7a931a56 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs @@ -0,0 +1,133 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationCompletionState +{ + InnoRunning, + SourceChanged, + IntentUnavailable, + NoActiveGateway, + CredentialUnavailable, + ValidationFailed, + Completed +} + +public sealed record StoreMigrationCompletionDecision( + StoreMigrationCompletionState State, + MigrationRecord? Receipt = null); + +/// +/// Revalidates prepared source state under exclusive ownership and records completion. +/// It only proves a canonical active-gateway credential can resolve. It never starts, +/// repairs, or provisions gateway, node, or MCP services. +/// +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationCompletionCoordinator( + IMigrationSourceLeaseProvider leaseProvider, + IInnoInstallationDetector detector, + MigrationBinding binding, + ICredentialResolver credentialResolver, + IOpenClawLogger logger, + TimeProvider? timeProvider = null, + IInnoSourceActivityVerifier? sourceActivity = null) +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + public StoreMigrationCompletionDecision Complete(InnoInstallation expected, string targetVersion) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion); + + using var lease = leaseProvider.TryAcquire(); + if (lease is null) + return new(StoreMigrationCompletionState.InnoRunning); + + try + { + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + MigrationRecordCodec.RejectReparsePoints(directory); + var lockPath = Path.Combine(directory, "prepare.lock"); + MigrationRecordCodec.RejectReparsePoints(lockPath); + using var migrationLock = new FileStream(lockPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + // Uninstall owns this same file through source removal. Evidence from + // before acquiring the lock cannot authorize completion afterward. + var detected = detector.Detect(); + if (detected.Status != InnoInstallationStatus.Detected || detected.Installation != expected) + return new(StoreMigrationCompletionState.SourceChanged); + + var activity = (sourceActivity ?? new InnoSourceActivityVerifier(expected.ExecutablePath)).VerifyStopped(); + if (activity == InnoSourceActivityStatus.Running) + return new(StoreMigrationCompletionState.InnoRunning); + if (activity != InnoSourceActivityStatus.Stopped) + { + logger.Error("Store migration completion could not exclude source processes across sessions."); + return new(StoreMigrationCompletionState.ValidationFailed); + } + + var intent = ReadIntent(directory); + var inventory = MigrationInventory.Capture(binding.RoamingDirectory, binding.LocalDirectory); + if (intent.SourceVersion != expected.Version.ToString() || + !string.Equals(intent.Fingerprint, inventory.Fingerprint, StringComparison.Ordinal)) + { + return new(StoreMigrationCompletionState.SourceChanged); + } + var registry = new GatewayRegistry(binding.RoamingDirectory, logger: logger); + registry.Load(); + var active = registry.GetActive(); + if (active is null) + return new(StoreMigrationCompletionState.NoActiveGateway); + + var resolution = credentialResolver.ResolveOperatorDetailed( + active, registry.GetIdentityDirectory(active.Id)); + if (resolution.Credential is null) + return new(StoreMigrationCompletionState.CredentialUnavailable); + + var receipt = new MigrationRecord + { + Kind = "completed", + MigrationId = intent.MigrationId, + SourceVersion = intent.SourceVersion, + TargetVersion = targetVersion, + Fingerprint = inventory.Fingerprint, + AutoStart = inventory.AutoStart, + CreatedUtc = _clock.GetUtcNow().UtcDateTime, + ExpiresUtc = DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc), + Binding = binding + }; + new MigrationCompletionReceiptWriter(binding, _clock).Write(receipt); + logger.Info($"Store migration completion recorded: {receipt.MigrationId}."); + return new(StoreMigrationCompletionState.Completed, receipt); + } + catch (IOException exception) when (MigrationOperationLock.IsBusy(exception)) + { + logger.Info("Store migration completion is blocked by an active runtime or migration operation."); + return new(StoreMigrationCompletionState.InnoRunning); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException or InvalidOperationException or + CryptographicException) + { + logger.Error($"Store migration completion failed: {exception.Message}"); + return new(StoreMigrationCompletionState.ValidationFailed); + } + } + + private MigrationRecord ReadIntent(string directory) + { + var intentPath = Path.Combine(directory, MigrationRecordCodec.IntentFileName); + MigrationRecordCodec.RejectReparsePoints(intentPath); + using var stream = new FileStream(intentPath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + throw new InvalidDataException("Invalid migration intent size."); + using var reader = new BinaryReader(stream); + var intent = MigrationRecordCodec.Decode( + reader.ReadBytes((int)stream.Length), binding, _clock.GetUtcNow().UtcDateTime); + if (intent.Kind != "intent") + throw new InvalidDataException("Expected a migration intent."); + return intent; + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationConsentCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationConsentCoordinator.cs new file mode 100644 index 000000000..6ef9ff9c0 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationConsentCoordinator.cs @@ -0,0 +1,41 @@ +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationConsentState +{ + Declined, + WaitingForInnoExit, + ReadyForAdoption, + Blocked +} + +public sealed record StoreMigrationConsentDecision(StoreMigrationConsentState State); + +public interface IInnoInstanceProbe +{ + bool IsRunning(); +} + +/// +/// Converts Store-side consent into a safe handoff to a future adoption owner. +/// It neither acquires migration ownership nor changes source state. +/// +public sealed class StoreMigrationConsentCoordinator(IInnoInstanceProbe innoInstance) +{ + public StoreMigrationConsentDecision Begin(StoreMigrationStartupDecision admission, bool consented) + { + if (admission.State != StoreMigrationStartupState.ConsentRequired || + admission.Installation is null) + return new(StoreMigrationConsentState.Blocked); + + return consented + ? CheckInnoInstance() + : new(StoreMigrationConsentState.Declined); + } + + public StoreMigrationConsentDecision Retry() => CheckInnoInstance(); + + private StoreMigrationConsentDecision CheckInnoInstance() => + new(innoInstance.IsRunning() + ? StoreMigrationConsentState.WaitingForInnoExit + : StoreMigrationConsentState.ReadyForAdoption); +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs new file mode 100644 index 000000000..5d9e1a9b8 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs @@ -0,0 +1,457 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationFinalizationState +{ + AwaitingInnoRemoval, + InspectionFailed, + StartupPreferenceFailed, + StartupPreferenceRefused, + RecordCleanupFailed, + Finalized +} + +public sealed record StoreMigrationFinalizationDecision(StoreMigrationFinalizationState State) +{ + /// + /// A refused startup preference still finalizes the migration: Windows gave a durable answer, + /// so the receipt is cleared and launch proceeds rather than retrying on every start. + /// + /// + /// A failed startup preference also allows launch. Finalization only reaches that step once + /// the Inno source is verified removed, so the receipt no longer protects anything and a + /// cosmetic startup preference must never block the app forever. The receipt is retained, so + /// the preference is retried on the next launch. + /// + public bool AllowsNormalStartup => + State is StoreMigrationFinalizationState.Finalized + or StoreMigrationFinalizationState.StartupPreferenceRefused + or StoreMigrationFinalizationState.StartupPreferenceFailed; +} + +public enum InnoSourceRemovalStatus +{ + Removed, + SourcePresent, + InspectionFailed +} + +/// +/// Verifies that source payload and runtime evidence disappeared after its registry entry did. +/// +public interface IInnoSourceRemovalVerifier +{ + InnoSourceRemovalStatus VerifyRemoved(); +} + +/// +/// Applies the completed migration's startup preference through the host platform. +/// +public interface IStoreMigrationAutoStartApplier +{ + Task ApplyAsync(bool enabled); +} + +/// +/// Signals that the host platform durably refused the startup preference, for example when the +/// user or policy disabled startup for the app. +/// +/// +/// A refusal is an answer, not a failure. Finalization must continue and clear the receipt so the +/// user is not blocked from launching on every start. Transient failures must not use this type. +/// +public sealed class StoreMigrationAutoStartRefusedException(string message, Exception? innerException = null) + : Exception(message, innerException); + +/// +/// Captures the bounded migration inventory at finalization time. +/// +public interface IMigrationInventoryCapture +{ + MigrationInventory Capture(); +} + +/// +/// Removes completed migration records only after the Store startup preference is applied. +/// +public interface IStoreMigrationRecordCleaner +{ + void ClearCompleted(MigrationRecord receipt); +} + +internal enum InnoSourceProcessState +{ + Exited, + ImageResolved, + OwnedByOtherUser, + Unresolved +} + +internal sealed record InnoSourceProcessCandidate(InnoSourceProcessState State, string? ImagePath = null); + +internal interface IInnoSourceProcessInspector +{ + IEnumerable FindSameNameProcesses(); +} + +/// +/// Checks only the canonical source executable, uninstaller, process image, and instance mutex. +/// It never acquires or holds the source mutex. +/// +[SupportedOSPlatform("windows")] +public sealed class InnoSourceRemovalVerifier : IInnoSourceRemovalVerifier +{ + private readonly string _sourceDirectory; + private readonly string _mutexName; + private readonly IInnoSourceProcessInspector _processes; + + public InnoSourceRemovalVerifier(MigrationBinding binding, string mutexName) + : this(binding, mutexName, new WindowsInnoSourceProcessInspector(binding.UserSid)) + { + } + + internal InnoSourceRemovalVerifier( + MigrationBinding binding, + string mutexName, + IInnoSourceProcessInspector processes) + { + _sourceDirectory = CanonicalizeDirectory(binding.InstallDirectory); + _mutexName = string.IsNullOrWhiteSpace(mutexName) + ? throw new ArgumentException("Source mutex name is required.", nameof(mutexName)) + : mutexName; + _processes = processes ?? throw new ArgumentNullException(nameof(processes)); + } + + public InnoSourceRemovalStatus VerifyRemoved() + { + try + { + var executable = Path.Combine(_sourceDirectory, "OpenClaw.Tray.WinUI.exe"); + var uninstaller = Path.Combine(_sourceDirectory, "unins000.exe"); + MigrationRecordCodec.RejectReparsePoints(executable); + MigrationRecordCodec.RejectReparsePoints(uninstaller); + if (File.Exists(executable) || File.Exists(uninstaller)) + return InnoSourceRemovalStatus.SourcePresent; + + var activity = new InnoSourceActivityVerifier(executable, _processes).VerifyStopped(); + if (activity == InnoSourceActivityStatus.Running) + return InnoSourceRemovalStatus.SourcePresent; + if (activity != InnoSourceActivityStatus.Stopped) + return InnoSourceRemovalStatus.InspectionFailed; + + using var mutex = new Mutex(false, _mutexName, out var createdNew); + return createdNew ? InnoSourceRemovalStatus.Removed : InnoSourceRemovalStatus.SourcePresent; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + SecurityException or Win32Exception or + InvalidOperationException or ArgumentException) + { + return InnoSourceRemovalStatus.InspectionFailed; + } + } + + private static string CanonicalizeDirectory(string directory) + { + if (string.IsNullOrWhiteSpace(directory) || !Path.IsPathFullyQualified(directory)) + throw new ArgumentException("Source installation directory must be absolute.", nameof(directory)); + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + } +} + +[SupportedOSPlatform("windows")] +internal sealed class WindowsInnoSourceProcessInspector(string expectedUserSid) : IInnoSourceProcessInspector +{ + private const uint ProcessQueryLimitedInformation = 0x1000; + private const uint TokenQuery = 0x0008; + private const int TokenUser = 1; + private const int ErrorAccessDenied = 5; + private const int ErrorInvalidParameter = 87; + private const int ErrorInsufficientBuffer = 122; + private const int ErrorNotFound = 1168; + + public IEnumerable FindSameNameProcesses() + { + var candidates = new List(); + foreach (var process in Process.GetProcessesByName("OpenClaw.Tray.WinUI")) + { + using (process) + { + int processId; + try + { + processId = process.Id; + } + catch (InvalidOperationException) + { + candidates.Add(new(InnoSourceProcessState.Exited)); + continue; + } + + candidates.Add(Inspect(processId)); + } + } + return candidates; + } + + private InnoSourceProcessCandidate Inspect(int processId) + { + var process = OpenProcess(ProcessQueryLimitedInformation, false, processId); + if (process == IntPtr.Zero) + { + var error = Marshal.GetLastWin32Error(); + return error is ErrorInvalidParameter or ErrorNotFound + ? new(InnoSourceProcessState.Exited) + : new(InnoSourceProcessState.Unresolved); + } + + try + { + if (TryGetImagePath(process, out var imagePath)) + return new(InnoSourceProcessState.ImageResolved, imagePath); + + return TryGetOwnerSid(process, out var ownerSid) && + !string.Equals(ownerSid, expectedUserSid, StringComparison.OrdinalIgnoreCase) + ? new(InnoSourceProcessState.OwnedByOtherUser) + : new(InnoSourceProcessState.Unresolved); + } + finally + { + CloseHandle(process); + } + } + + private static bool TryGetImagePath(IntPtr process, out string? imagePath) + { + for (var capacity = 512; capacity <= 32768; capacity *= 2) + { + var buffer = new System.Text.StringBuilder(capacity); + var length = capacity; + if (QueryFullProcessImageName(process, 0, buffer, ref length)) + { + imagePath = buffer.ToString(0, length); + return true; + } + if (Marshal.GetLastWin32Error() != ErrorInsufficientBuffer) + break; + } + + imagePath = null; + return false; + } + + private static bool TryGetOwnerSid(IntPtr process, out string? ownerSid) + { + ownerSid = null; + if (!OpenProcessToken(process, TokenQuery, out var token)) + return false; + + try + { + _ = GetTokenInformation(token, TokenUser, IntPtr.Zero, 0, out var size); + if (size == 0) + return false; + + var buffer = Marshal.AllocHGlobal((int)size); + try + { + if (!GetTokenInformation(token, TokenUser, buffer, size, out _)) + return false; + var sid = Marshal.ReadIntPtr(buffer); + ownerSid = new System.Security.Principal.SecurityIdentifier(sid).Value; + return true; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + finally + { + CloseHandle(token); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryFullProcessImageName( + IntPtr process, uint flags, System.Text.StringBuilder imagePath, ref int size); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation( + IntPtr tokenHandle, int tokenInformationClass, IntPtr tokenInformation, + uint tokenInformationLength, out uint returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} + +/// +/// Captures the canonical inventory without giving finalization another owner for its policy. +/// +public sealed class MigrationInventoryCapture(MigrationBinding binding) : IMigrationInventoryCapture +{ + public MigrationInventory Capture() => + MigrationInventory.Capture(binding.RoamingDirectory, binding.LocalDirectory); +} + +/// +/// Reparse-safe final cleanup that keeps the completion receipt until all earlier cleanup succeeds. +/// +[SupportedOSPlatform("windows")] +public sealed class MigrationFinalizationRecordCleaner(MigrationBinding binding) : IStoreMigrationRecordCleaner +{ + public void ClearCompleted(MigrationRecord receipt) + { + ArgumentNullException.ThrowIfNull(receipt); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var intentPath = Path.Combine(directory, MigrationRecordCodec.IntentFileName); + var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + + MigrationRecordCodec.RejectReparsePoints(directory); + MigrationRecordCodec.RejectReparsePoints(intentPath); + MigrationRecordCodec.RejectReparsePoints(completionPath); + var durable = MigrationRecordCodec.ReadCompletion( + completionPath, binding, DateTime.UtcNow); + if (!SameReceipt(durable, receipt)) + throw new InvalidDataException("Completion receipt changed before cleanup."); + + // The receipt is the recovery anchor, so it is always deleted last. + File.Delete(intentPath); + File.Delete(completionPath); + } + + private static bool SameReceipt(MigrationRecord left, MigrationRecord right) => + left.Kind == right.Kind && + left.MigrationId == right.MigrationId && + left.SourceVersion == right.SourceVersion && + left.TargetVersion == right.TargetVersion && + left.Fingerprint == right.Fingerprint && + left.AutoStart == right.AutoStart && + left.CreatedUtc == right.CreatedUtc && + left.ExpiresUtc == right.ExpiresUtc && + left.InventoryJson == right.InventoryJson && + left.Binding.InstallDirectory == right.Binding.InstallDirectory && + left.Binding.RoamingDirectory == right.Binding.RoamingDirectory && + left.Binding.LocalDirectory == right.Binding.LocalDirectory && + left.Binding.Architecture == right.Binding.Architecture && + left.Binding.UserSid == right.Binding.UserSid; +} + +/// +/// Finalizes a previously validated handoff after exact source removal. It intentionally does +/// not acquire the Inno-visible mutex, start services, or invoke uninstallation. +/// +public sealed class StoreMigrationFinalizationCoordinator( + MigrationBinding binding, + IInnoInstallationDetector detector, + IInnoSourceRemovalVerifier sourceRemoval, + IMigrationStartupRecordReader records, + IMigrationInventoryCapture inventory, + IStoreMigrationAutoStartApplier autoStart, + IStoreMigrationRecordCleaner cleaner, + IOpenClawLogger logger) +{ + public async Task FinalizeAsync() + { + var detected = detector.Detect(); + if (detected.Status == InnoInstallationStatus.Detected) + return new(StoreMigrationFinalizationState.AwaitingInnoRemoval); + if (detected.Status is InnoInstallationStatus.Unsupported or InnoInstallationStatus.InspectionFailed) + return new(StoreMigrationFinalizationState.InspectionFailed); + if (detected.Status != InnoInstallationStatus.NotInstalled) + throw new InvalidOperationException("Unknown Inno installation detection result."); + + var sourceRemovalStatus = sourceRemoval.VerifyRemoved(); + if (sourceRemovalStatus == InnoSourceRemovalStatus.SourcePresent) + return new(StoreMigrationFinalizationState.AwaitingInnoRemoval); + if (sourceRemovalStatus == InnoSourceRemovalStatus.InspectionFailed) + return new(StoreMigrationFinalizationState.InspectionFailed); + if (sourceRemovalStatus != InnoSourceRemovalStatus.Removed) + throw new InvalidOperationException("Unknown Inno source-removal result."); + + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var lockPath = Path.Combine(directory, "prepare.lock"); + try + { + MigrationRecordCodec.RejectReparsePoints(directory); + MigrationRecordCodec.RejectReparsePoints(lockPath); + using var finalizationLock = new FileStream(lockPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var durable = records.Read(); + if (durable.Status != MigrationStartupRecordStatus.Completed || durable.Record is null) + return new(StoreMigrationFinalizationState.InspectionFailed); + + MigrationInventory current; + try + { + current = inventory.Capture(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException) + { + logger.Error($"Store migration finalization inventory failed: {exception.Message}"); + return new(StoreMigrationFinalizationState.InspectionFailed); + } + + if (!string.Equals(current.Fingerprint, durable.Record.Fingerprint, StringComparison.Ordinal)) + { + logger.Warn("Store migration finalization inventory no longer matches the completion receipt."); + return new(StoreMigrationFinalizationState.InspectionFailed); + } + + var startupRefused = false; + try + { + await autoStart.ApplyAsync(durable.Record.AutoStart).ConfigureAwait(false); + } + catch (StoreMigrationAutoStartRefusedException exception) + { + logger.Warn($"Store migration startup preference refused by Windows: {exception.Message}"); + startupRefused = true; + } + catch (Exception exception) when (exception is COMException or IOException or + UnauthorizedAccessException or InvalidOperationException) + { + logger.Error($"Store migration startup preference finalization failed: {exception.Message}"); + return new(StoreMigrationFinalizationState.StartupPreferenceFailed); + } + + try + { + cleaner.ClearCompleted(durable.Record); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException) + { + logger.Error($"Store migration record cleanup failed: {exception.Message}"); + return new(StoreMigrationFinalizationState.RecordCleanupFailed); + } + + logger.Info($"Store migration finalized: {durable.Record.MigrationId}."); + return new(startupRefused + ? StoreMigrationFinalizationState.StartupPreferenceRefused + : StoreMigrationFinalizationState.Finalized); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException) + { + logger.Error($"Store migration finalization could not acquire the preparation lock: {exception.Message}"); + return new(StoreMigrationFinalizationState.RecordCleanupFailed); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs new file mode 100644 index 000000000..43ecdf955 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs @@ -0,0 +1,96 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationStartupState +{ + Disabled, + NotRequired, + UpdateInno, + UnsupportedInstallation, + InspectionFailed, + ConsentRequired, + AwaitingInnoRemoval, + FinalizationRequired, + RecoveryRequired +} + +public sealed record StoreMigrationStartupDecision( + StoreMigrationStartupState State, + InnoInstallation? Installation = null) +{ + public bool AllowsNormalStartup => + State is StoreMigrationStartupState.Disabled or StoreMigrationStartupState.NotRequired; +} + +/// +/// Read-only admission before instance forwarding or normal app services. +/// ConsentRequired is a handoff to a future consent workflow, not permission to migrate. +/// +public sealed class StoreMigrationStartupCoordinator( + IInnoInstallationDetector detector, + IMigrationStartupRecordReader records, + IOpenClawLogger logger) +{ + public StoreMigrationStartupDecision Evaluate(bool enabled, string? minimumSourceVersion, string architecture) + { + if (!enabled) + return new(StoreMigrationStartupState.Disabled); + + if (!MigrationVersionPolicy.TryParseReleaseVersion(minimumSourceVersion, out var minimum) || + architecture is not ("x64" or "arm64")) + { + logger.Error("Store migration preview has no valid source-version or architecture policy."); + return new(StoreMigrationStartupState.InspectionFailed); + } + + var pending = records.Read(); + if (pending.Status == MigrationStartupRecordStatus.Unavailable) + return Decide(StoreMigrationStartupState.InspectionFailed); + if (pending.Status == MigrationStartupRecordStatus.Invalid) + return Decide(StoreMigrationStartupState.RecoveryRequired); + + var detected = detector.Detect(); + if (detected.Status == InnoInstallationStatus.InspectionFailed) + return Decide(StoreMigrationStartupState.InspectionFailed); + if (detected.Status == InnoInstallationStatus.Unsupported) + return Decide(StoreMigrationStartupState.UnsupportedInstallation); + + if (detected.Status == InnoInstallationStatus.NotInstalled) + { + return Decide(pending.Status switch + { + MigrationStartupRecordStatus.None => StoreMigrationStartupState.NotRequired, + MigrationStartupRecordStatus.Completed => StoreMigrationStartupState.FinalizationRequired, + _ => StoreMigrationStartupState.RecoveryRequired + }); + } + + var installation = detected.Installation + ?? throw new InvalidOperationException("Detected Inno installation has no installation evidence."); + if (installation.Architecture != architecture) + return Decide(StoreMigrationStartupState.UnsupportedInstallation, installation); + + if (pending.Status == MigrationStartupRecordStatus.Completed) + { + var completed = pending.Record + ?? throw new InvalidOperationException("Completed migration has no receipt."); + if (!MigrationVersionPolicy.TryParseReleaseVersion(completed.SourceVersion, out var sourceVersion) || + sourceVersion != installation.Version) + return Decide(StoreMigrationStartupState.RecoveryRequired, installation); + return Decide(StoreMigrationStartupState.AwaitingInnoRemoval, installation); + } + + if (installation.Version < minimum) + return Decide(StoreMigrationStartupState.UpdateInno, installation); + + // Even a valid, unexpired Inno intent does not replace Store-side consent. + return Decide(StoreMigrationStartupState.ConsentRequired, installation); + } + + private StoreMigrationStartupDecision Decide(StoreMigrationStartupState state, InnoInstallation? installation = null) + { + logger.Info($"Store migration startup admission: {state}."); + return new(state, installation); + } +} diff --git a/src/OpenClaw.Connection/OpenClaw.Connection.csproj b/src/OpenClaw.Connection/OpenClaw.Connection.csproj index baf18cf29..038ec87d8 100644 --- a/src/OpenClaw.Connection/OpenClaw.Connection.csproj +++ b/src/OpenClaw.Connection/OpenClaw.Connection.csproj @@ -20,6 +20,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fdc2b9bf0..19400cb73 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -204,6 +204,9 @@ public IntPtr GetHubWindowHandle() => private readonly SshTunnelRecoveryBudget _sshTunnelRecoveryBudget = new(); private GlobalHotkeyService? _globalHotkey; private Mutex? _mutex; + // Do not release during managed shutdown: a failed service disposal may leave state + // writers running. Windows closes this handle only when the process terminates. + private IDisposable? _innoMigrationLease; private Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue; private AppState? _appState; internal AppState? AppState => _appState; @@ -538,6 +541,12 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) return; // Environment.Exit called inside; defensive return } + if (StoreMigrationStartupGuard.ShouldStopLaunch()) + { + Exit(); + return; + } + // Check for protocol activation (MSIX packaged apps receive deep links this way) string? protocolUri = GetProtocolActivationUri(); @@ -576,6 +585,13 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) } } + // Keep Inno's AppMutex held while finish-migration guidance is visible. + if (ownsMutex && InnoMigrationStartupGuard.ShouldStopLaunch(out _innoMigrationLease)) + { + Exit(); + return; + } + _activationRouter = new ActivationRouter(AppIdentity.ProtocolScheme, DeepLinkPipeName); if (!ownsMutex) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs new file mode 100644 index 000000000..c7cbce2b4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs @@ -0,0 +1,87 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal static class InnoMigrationStartupGuard +{ + public static bool ShouldStopLaunch(out IDisposable? runtimeLease) + { + runtimeLease = null; + if (AppIdentity.IsDev || PackageHelper.IsPackaged || GatewayFixtureIsolation.IsEnabled) + return false; + + using var identity = WindowsIdentity.GetCurrent(); + var binding = new MigrationBinding + { + InstallDirectory = AppContext.BaseDirectory, + RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), + LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), + Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + UserSid = identity.User?.Value ?? "" + }; + // Acquisition must precede the receipt check, including when no receipt exists yet. + // App retains this handle until process exit, even if startup or shutdown fails. + try + { + runtimeLease = MigrationOperationLock.AcquireRuntime(binding); + } + catch (IOException ex) when (MigrationOperationLock.IsBusy(ex)) + { + Logger.Error("Migration state is locked by an in-progress migration; Inno startup blocked."); + return ShowGuidance("Migration_InnoCheckFailed"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or + InvalidDataException or ArgumentException or NotSupportedException or + InvalidOperationException or System.Security.SecurityException) + { + // An unreachable lock says nothing about whether a migration completed. Only an + // exclusive holder does, and that case is handled above. The receipt check below + // stays authoritative and fails closed on its own, so continuing here loses + // same-session coordination rather than safety. Refusing to launch would lock a + // user who never migrated out of their app over an inaccessible zero-byte file. + Logger.Warn($"Migration runtime lock unavailable ({ex.GetType().Name}); continuing without it."); + } + + var path = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName); + string resourceKey; + try + { + MigrationRecordCodec.ReadCompletion(path, binding, DateTime.UtcNow); + Logger.Info("Completed Store migration blocks normal Inno startup."); + resourceKey = "Migration_InnoCompleted"; + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + catch (Exception ex) when (ex is InvalidDataException or CryptographicException or + EndOfStreamException or ArgumentException or DecoderFallbackException) + { + Logger.Warn($"Migration completion receipt rejected ({ex.GetType().Name})."); + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Error($"Migration completion check unavailable ({ex.GetType().Name}); Inno startup blocked."); + resourceKey = "Migration_InnoCheckFailed"; + } + + return ShowGuidance(resourceKey); + } + + private static bool ShowGuidance(string resourceKey) + { + if (MessageBoxW(IntPtr.Zero, LocalizationHelper.GetString(resourceKey), + AppIdentity.DisplayName, 0x00000040) == 0) + Logger.Error($"Could not show migration startup guidance (Win32 {Marshal.GetLastWin32Error()})."); + return true; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int MessageBoxW(IntPtr window, string text, string caption, uint type); +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs new file mode 100644 index 000000000..f00d167ca --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs @@ -0,0 +1,282 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Principal; +using OpenClaw.Connection; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal static class StoreMigrationStartupGuard +{ + public static bool ShouldStopLaunch() + { +#if !STORE_MIGRATION_PREVIEW + return false; +#else + if (!PackageHelper.IsPackaged || AppIdentity.IsDev) + return false; + + var identity = global::Windows.ApplicationModel.Package.Current.Id; + if (identity.Name != MigrationRecordCodec.PackageName || + identity.Publisher != MigrationRecordCodec.PackagePublisher || + HasPathOverride()) + { + Logger.Error("Store migration preview requires the production package identity and default data paths."); + ShowGuidance("Migration_StoreUnsupported"); + return true; + } + + var localRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var binding = new MigrationBinding + { + InstallDirectory = Path.Combine(localRoot, AppIdentity.DataDirectoryName), + RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), + LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), + Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + UserSid = WindowsIdentity.GetCurrent().User?.Value ?? "" + }; + var logger = new AppLogger(); + var minimum = typeof(StoreMigrationStartupGuard).Assembly.GetCustomAttributes() + .SingleOrDefault(attribute => attribute.Key == "StoreMigrationPreviewMinimumSourceVersion")?.Value; + var detector = new InnoInstallationDetector(localRoot, logger); + var coordinator = new StoreMigrationStartupCoordinator( + detector, + new MigrationStartupRecordReader(binding, logger), + logger); + var decision = coordinator.Evaluate(true, minimum, binding.Architecture); + if (decision.AllowsNormalStartup) + return false; + + if (decision.State == StoreMigrationStartupState.FinalizationRequired) + { + var finalization = FinalizeCompletedMigration(binding, detector, logger); + if (finalization.AllowsNormalStartup) + { + // A durable refusal is the one finalization outcome the user must act on + // themselves: only Windows can re-enable the startup task. Notify, then + // continue launching, because the migration itself succeeded. + if (finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused) + ShowGuidance("Migration_StoreStartupRefused"); + return false; + } + + ShowGuidance(finalization.State switch + { + StoreMigrationFinalizationState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", + StoreMigrationFinalizationState.InspectionFailed => "Migration_StoreInspectionFailed", + _ => "Migration_StoreFinalizationFailed" + }); + return true; + } + + if (decision.State == StoreMigrationStartupState.ConsentRequired) + { + RunConsentWorkflow(decision, binding, detector, logger); + return true; + } + + ShowGuidance(decision.State switch + { + StoreMigrationStartupState.UpdateInno => "Migration_StoreUpdateRequired", + StoreMigrationStartupState.UnsupportedInstallation => "Migration_StoreUnsupported", + StoreMigrationStartupState.InspectionFailed => "Migration_StoreInspectionFailed", + StoreMigrationStartupState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", + _ => "Migration_StorePending" + }); + return true; +#endif + } + +#if STORE_MIGRATION_PREVIEW + private static bool HasPathOverride() => + new[] + { + "OPENCLAW_TRAY_DATA_DIR", "OPENCLAW_TRAY_APPDATA_DIR", + "OPENCLAW_TRAY_LOCALAPPDATA_DIR", "OPENCLAW_TRAY_LOCAL_DATA_DIR" + }.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))); + + private static void RunConsentWorkflow( + StoreMigrationStartupDecision admission, + MigrationBinding binding, + IInnoInstallationDetector detector, + IOpenClawLogger logger) + { + var coordinator = new StoreMigrationConsentCoordinator(new InnoMutexProbe()); + var consent = coordinator.Begin(admission, ShowChoice("Migration_StoreConsent")); + + while (consent.State == StoreMigrationConsentState.WaitingForInnoExit) + { + if (!ShowChoice("Migration_StoreCloseInno")) + return; + + consent = coordinator.Retry(); + } + + if (consent.State != StoreMigrationConsentState.ReadyForAdoption || + admission.Installation is null) + return; + + var preparation = new StoreMigrationAdoptionPreparationCoordinator( + new InnoMutexLeaseProvider(), detector, new MigrationPreparation(binding), logger); + while (true) + { + var result = preparation.Prepare(admission.Installation); + switch (result.State) + { + case StoreMigrationPreparationState.Prepared: + CompletePreparedMigration(admission.Installation, binding, detector, logger); + return; + case StoreMigrationPreparationState.InnoRunning: + if (!ShowChoice("Migration_StoreCloseInno")) + return; + continue; + case StoreMigrationPreparationState.SourceChanged: + ShowGuidance("Migration_StoreUnsupported"); + return; + default: + ShowGuidance("Migration_StoreValidationFailed"); + return; + } + } + } + + private static void CompletePreparedMigration( + InnoInstallation installation, + MigrationBinding binding, + IInnoInstallationDetector detector, + IOpenClawLogger logger) + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + var targetVersion = $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + var completion = new StoreMigrationCompletionCoordinator( + new InnoMutexLeaseProvider(), + detector, + binding, + new CredentialResolver(DeviceIdentityFileReader.Instance), + logger); + var result = completion.Complete(installation, targetVersion); + while (result.State == StoreMigrationCompletionState.InnoRunning) + { + if (!ShowChoice("Migration_StoreCloseInno")) + return; + result = completion.Complete(installation, targetVersion); + } + ShowGuidance(result.State switch + { + StoreMigrationCompletionState.Completed => "Migration_StoreAwaitingInnoRemoval", + StoreMigrationCompletionState.NoActiveGateway or StoreMigrationCompletionState.CredentialUnavailable + => "Migration_StoreCredentialUnavailable", + StoreMigrationCompletionState.SourceChanged => "Migration_StoreUnsupported", + _ => "Migration_StoreValidationFailed" + }); + } + + private static StoreMigrationFinalizationDecision FinalizeCompletedMigration( + MigrationBinding binding, + IInnoInstallationDetector detector, + IOpenClawLogger logger) + { + var records = new MigrationStartupRecordReader(binding, logger); + if (records.Read().Status != MigrationStartupRecordStatus.Completed) + return new(StoreMigrationFinalizationState.InspectionFailed); + + return new StoreMigrationFinalizationCoordinator( + binding, + detector, + new InnoSourceRemovalVerifier(binding, AppIdentity.MutexBaseName), + records, + new MigrationInventoryCapture(binding), + new StoreMigrationAutoStartApplier(), + new MigrationFinalizationRecordCleaner(binding), + logger) + .FinalizeAsync() + .GetAwaiter() + .GetResult(); + } + + private static bool ShowChoice(string contentKey) + { + var content = LocalizationHelper.Format(contentKey, + LocalizationHelper.GetString("Migration_StoreYes"), + LocalizationHelper.GetString("Migration_StoreNo")); + return MessageBoxW(IntPtr.Zero, content, LocalizationHelper.GetString("Migration_StorePreviewTitle"), + 0x00000124) == 6; + } + + private static void ShowGuidance(string resourceKey) + { + if (MessageBoxW(IntPtr.Zero, LocalizationHelper.GetString(resourceKey), + LocalizationHelper.GetString("Migration_StorePreviewTitle"), 0x00000040) == 0) + Logger.Error($"Could not show Store migration preview guidance (Win32 {Marshal.GetLastWin32Error()})."); + } + + private sealed class InnoMutexProbe : IInnoInstanceProbe + { + public bool IsRunning() + { + using var lease = new InnoMutexLeaseProvider().TryAcquire(); + return lease is null; + } + } + + private sealed class StoreMigrationAutoStartApplier : IStoreMigrationAutoStartApplier + { + public async Task ApplyAsync(bool enabled) + { + try + { + await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false); + } + catch (AutoStartRefusedException exception) when (exception.IsDurable) + { + throw new StoreMigrationAutoStartRefusedException(exception.Message, exception); + } + } + } + + private sealed class InnoMutexLeaseProvider : IMigrationSourceLeaseProvider + { + public IMigrationSourceLease? TryAcquire() + { + try + { + var mutex = new Mutex(true, AppIdentity.MutexBaseName, out var createdNew); + if (createdNew) + return new InnoMutexLease(mutex); + + try + { + if (mutex.WaitOne(0)) + return new InnoMutexLease(mutex); + } + catch (AbandonedMutexException) + { + return new InnoMutexLease(mutex); + } + + mutex.Dispose(); + return null; + } + catch (UnauthorizedAccessException exception) + { + Logger.Error($"Could not acquire the Inno instance mutex: {exception.Message}"); + return null; + } + } + } + + private sealed class InnoMutexLease(Mutex mutex) : IMigrationSourceLease + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int MessageBoxW(IntPtr window, string text, string caption, uint type); +#endif +} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index f689f4adc..c1e158f2b 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -58,6 +58,24 @@ win-arm64 + + + $(DefineConstants);STORE_MIGRATION_PREVIEW + + + + <_Parameter1>StoreMigrationPreviewMinimumSourceVersion + <_Parameter2>$(StoreMigrationPreviewMinimumSourceVersion) + + + + + + + None diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 5e34647da..72744c29f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -248,14 +248,16 @@ private static async Task SetPackagedAutoStartAsync(bool enable) return; } - throw new AutoStartRefusedException(state switch - { - StartupTaskState.DisabledByUser => - "Windows startup is disabled by the user. Re-enable OpenClaw Companion in Settings > Apps > Startup.", - StartupTaskState.DisabledByPolicy => - "Windows startup is disabled by policy.", - _ => $"Windows did not enable the packaged startup task (state: {state})." - }); + throw new AutoStartRefusedException( + state switch + { + StartupTaskState.DisabledByUser => + "Windows startup is disabled by the user. Re-enable OpenClaw Companion in Settings > Apps > Startup.", + StartupTaskState.DisabledByPolicy => + "Windows startup is disabled by policy.", + _ => $"Windows did not enable the packaged startup task (state: {state})." + }, + state is StartupTaskState.DisabledByUser or StartupTaskState.DisabledByPolicy); } private static void DeleteRunKey() diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs index c76d7c768..a6bbe5b48 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs @@ -31,9 +31,16 @@ internal enum AutoStartState /// internal sealed class AutoStartRefusedException : InvalidOperationException { - public AutoStartRefusedException(string message) : base(message) + public AutoStartRefusedException(string message, bool isDurable = false) : base(message) { + IsDurable = isDurable; } + + /// + /// True only when Windows gave a durable answer (DisabledByUser / DisabledByPolicy). + /// Transient non-answers and fixture refusals must stay retryable. + /// + public bool IsDurable { get; } } /// diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e931589d0..e497cf4b1 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7180,6 +7180,76 @@ Make sure the gateway is running. Local AI availability could not be verified Checking Local AI availability OpenClaw is verifying whether this device can run Local AI. This only takes a moment. + + Your OpenClaw setup was migrated to the Store version. Close this message to exit this app. Then uninstall the previous OpenClaw Companion app through Windows Settings > Apps > Installed apps and open the Store version. Your migrated setup and gateway will be preserved. + + + OpenClaw could not check whether migration to the Store version has completed. This copy cannot start safely. Close other OpenClaw processes and try again. Do not delete migration data or run gateway cleanup. + + + OpenClaw migration preview + + + A supported previous installation was found. Normal startup is paused. You can start migration, or choose Not now to continue using the previous app. + + + A supported previous installation was found. Start migration only when you are ready. + +Choosing {0} validates your setup and saves protected migration records. If validation succeeds, the previous app will be blocked from starting normally. You must then uninstall the previous app through Windows Settings > Apps > Installed apps and reopen the Store app to finish migration. Your setup and gateway will be preserved; nothing is uninstalled automatically. + +Choose {1} to exit this preview without starting migration and continue using the previous app. + + + Migrate + + + Not now + + + Close the previous OpenClaw Companion app, then choose {0} to retry. Choose {1} to exit this preview. Do not force-close the previous app or uninstall it yet. Uninstall only after this preview tells you migration completion has been recorded. + + + Retry + + + The previous app is closed and migration could proceed. This test build does not adopt your setup yet. Close this preview. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + + The previous app is closed. OpenClaw validated your current setup and saved protected migration preparation data. This test build does not adopt your setup yet. Close this preview. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + + OpenClaw could not safely validate the previous setup for migration. No completion was recorded and the previous app was not changed. Close this preview and try again later. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + + OpenClaw could not resolve a credential for the active gateway. No completion was recorded and the previous app was not changed. Close this preview and restore the active gateway configuration before retrying. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + + OpenClaw recorded migration completion and preserved your setup. Close this preview, uninstall the previous OpenClaw Companion app through Windows Settings, then open the Store version. Do not delete migration data or run gateway cleanup. + + + Yes + + + No + + + The previous app is older than this preview's required source version. Migration and normal Store startup are blocked. Use a verified migration-capable Inno release before testing migration. Your setup has not been changed. + + + This installation layout, architecture, or package configuration is not supported by the migration preview. Only the default per-user installation with a matching architecture is supported. Your setup has not been changed. + + + The migration preview could not safely inspect the previous installation or migration records. Normal startup is blocked. Close this preview and check the app log before retrying. Do not delete migration data or run gateway cleanup. + + + OpenClaw could not safely finish the completed migration. Normal startup is blocked and the migration receipt was kept for recovery. Close this preview and try again later. Do not delete migration data or run gateway cleanup. + + + OpenClaw finished the migration, but Windows would not enable OpenClaw Companion at sign-in. Your data and settings were migrated. To start OpenClaw automatically, enable OpenClaw Companion in Settings > Apps > Startup, or contact your administrator if startup is managed by policy. + + + A previous migration needs recovery or finalization. This test build cannot continue it or start normally. Close this preview. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + Copy code Code block Current model diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 549a435ad..883193063 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -7116,6 +7116,76 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c La disponibilité de l'IA locale n'a pas pu être vérifiée Vérification de la disponibilité de l'IA locale OpenClaw vérifie si cet appareil peut exécuter l'IA locale. Cela ne prend qu'un instant. + + Votre configuration OpenClaw a été migrée vers la version du Store. Fermez ce message pour quitter cette application. Désinstallez ensuite l'ancienne application OpenClaw Companion dans Paramètres Windows > Applications > Applications installées, puis ouvrez la version du Store. Votre configuration migrée et votre passerelle seront conservées. + + + OpenClaw n'a pas pu vérifier si la migration vers la version du Store est terminée. Cette copie ne peut pas démarrer en toute sécurité. Fermez les autres processus OpenClaw et réessayez. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Aperçu de la migration OpenClaw + + + Une installation précédente compatible a été trouvée. Le démarrage normal est suspendu. Vous pouvez démarrer la migration ou choisir Pas maintenant pour continuer à utiliser l'application précédente. + + + Une installation précédente compatible a été trouvée. Démarrez la migration uniquement lorsque vous êtes prêt. + +Choisir {0} valide votre configuration et enregistre des données de migration protégées. Si la validation réussit, l'application précédente ne pourra plus démarrer normalement. Vous devrez alors désinstaller l'application précédente via Paramètres Windows > Applications > Applications installées, puis rouvrir l'application du Store pour terminer la migration. Votre configuration et votre passerelle seront préservées ; rien n'est désinstallé automatiquement. + +Choisissez {1} pour quitter cet aperçu sans démarrer la migration et continuer à utiliser l'application précédente. + + + Migrer + + + Pas maintenant + + + Fermez l'application OpenClaw Companion précédente, puis choisissez {0} pour réessayer. Choisissez {1} pour quitter cet aperçu. Ne forcez pas l'application précédente à se fermer et ne la désinstallez pas encore. Désinstallez-la uniquement lorsque cet aperçu vous indique que la fin de la migration a été enregistrée. + + + Réessayer + + + L'application précédente est fermée et la migration pourrait continuer. Cette version de test n'adopte pas encore votre configuration. Fermez cet aperçu. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + L'application précédente est fermée. OpenClaw a validé votre configuration actuelle et enregistré des données de préparation de migration protégées. Cette version de test n'adopte pas encore votre configuration. Fermez cet aperçu. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu valider en toute sécurité la configuration précédente pour la migration. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et réessayez plus tard. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu résoudre un identifiant pour la passerelle active. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et restaurez la configuration de la passerelle active avant de réessayer. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw a enregistré la fin de la migration et préservé votre configuration. Fermez cet aperçu, désinstallez l'application OpenClaw Companion précédente dans les paramètres Windows, puis ouvrez la version du Store. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Oui + + + Non + + + L'application précédente est antérieure à la version source requise par cet aperçu. La migration et le démarrage normal de la version du Store sont bloqués. Utilisez une version Inno vérifiée prenant en charge la migration avant de la tester. Votre configuration n'a pas été modifiée. + + + Cette disposition d'installation, cette architecture ou cette configuration de package n'est pas prise en charge par l'aperçu de migration. Seule l'installation par utilisateur à l'emplacement par défaut avec une architecture correspondante est prise en charge. Votre configuration n'a pas été modifiée. + + + L'aperçu de migration n'a pas pu vérifier en toute sécurité l'installation précédente ou les enregistrements de migration. Le démarrage normal est bloqué. Fermez cet aperçu et consultez le journal de l'application avant de réessayer. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu terminer la migration en toute sécurité. Le démarrage normal est bloqué et le reçu de migration a été conservé pour récupération. Fermez cet aperçu et réessayez plus tard. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw a terminé la migration, mais Windows n'a pas autorisé le démarrage d'OpenClaw Companion à l'ouverture de session. Vos données et vos paramètres ont été migrés. Pour démarrer OpenClaw automatiquement, activez OpenClaw Companion dans Paramètres > Applications > Démarrage, ou contactez votre administrateur si le démarrage est géré par une stratégie. + + + Une migration précédente nécessite une récupération ou une finalisation. Cette version de test ne peut ni la poursuivre ni démarrer normalement. Fermez cet aperçu. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + Copier le code Bloc de code Modèle actuel diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index ed2c637be..a89ee705c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -7117,6 +7117,76 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil Beschikbaarheid van lokale AI kon niet worden geverifieerd Beschikbaarheid van lokale AI controleren OpenClaw controleert of dit apparaat lokale AI kan uitvoeren. Dit duurt slechts een moment. + + Uw OpenClaw-configuratie is gemigreerd naar de Store-versie. Sluit dit bericht om deze app af te sluiten. Verwijder daarna de vorige OpenClaw Companion-app via Windows-instellingen > Apps > Geïnstalleerde apps en open de Store-versie. Uw gemigreerde configuratie en gateway blijven behouden. + + + OpenClaw kon niet controleren of de migratie naar de Store-versie is voltooid. Deze versie kan niet veilig starten. Sluit andere OpenClaw-processen en probeer het opnieuw. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + OpenClaw-migratievoorbeeld + + + Er is een ondersteunde vorige installatie gevonden. Normaal opstarten is gepauzeerd. U kunt de migratie starten of Nu niet kiezen om de vorige app te blijven gebruiken. + + + Er is een ondersteunde vorige installatie gevonden. Start de migratie alleen wanneer u klaar bent. + +Als u {0} kiest, wordt uw configuratie gecontroleerd en worden beveiligde migratiegegevens opgeslagen. Als de controle slaagt, zal de vorige app niet meer normaal kunnen starten. U moet dan de vorige app verwijderen via Windows-instellingen > Apps > Geïnstalleerde apps en de Store-app opnieuw openen om de migratie af te ronden. Uw configuratie en gateway blijven behouden; er wordt niets automatisch verwijderd. + +Kies {1} om dit voorbeeld te sluiten zonder de migratie te starten en de vorige app te blijven gebruiken. + + + Migreren + + + Nu niet + + + Sluit de vorige OpenClaw Companion-app en kies daarna {0} om het opnieuw te proberen. Kies {1} om dit voorbeeld te sluiten. Forceer niet dat de vorige app stopt en verwijder deze nog niet. Verwijder de app pas nadat dit voorbeeld meldt dat de voltooiing van de migratie is vastgelegd. + + + Opnieuw proberen + + + De vorige app is gesloten en de migratie kan doorgaan. Deze testversie neemt uw configuratie nog niet over. Sluit dit voorbeeld. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + + De vorige app is gesloten. OpenClaw heeft uw huidige configuratie gevalideerd en beveiligde voorbereidingsgegevens voor de migratie opgeslagen. Deze testversie neemt uw configuratie nog niet over. Sluit dit voorbeeld. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + + OpenClaw kon de vorige configuratie niet veilig valideren voor migratie. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en probeer het later opnieuw. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + + OpenClaw kon geen referentie voor de actieve gateway oplossen. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en herstel de configuratie van de actieve gateway voordat u het opnieuw probeert. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + + OpenClaw heeft de voltooiing van de migratie vastgelegd en uw configuratie behouden. Sluit dit voorbeeld, verwijder de vorige OpenClaw Companion-app via Windows-instellingen en open daarna de Store-versie. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + Ja + + + Nee + + + De vorige app is ouder dan de bronversie die dit voorbeeld vereist. Migratie en normaal opstarten van de Store-versie zijn geblokkeerd. Gebruik een geverifieerde Inno-versie met migratieondersteuning voordat u migratie test. Uw configuratie is niet gewijzigd. + + + Deze installatielocatie, architectuur of pakketconfiguratie wordt niet ondersteund door het migratievoorbeeld. Alleen de standaardinstallatie per gebruiker met een overeenkomende architectuur wordt ondersteund. Uw configuratie is niet gewijzigd. + + + Het migratievoorbeeld kon de vorige installatie of migratierecords niet veilig controleren. Normaal opstarten is geblokkeerd. Sluit dit voorbeeld en bekijk het applogboek voordat u het opnieuw probeert. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + OpenClaw kon de voltooide migratie niet veilig afronden. Normaal opstarten is geblokkeerd en het migratiebewijs is voor herstel bewaard. Sluit dit voorbeeld en probeer het later opnieuw. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + OpenClaw heeft de migratie voltooid, maar Windows heeft OpenClaw Companion niet bij het aanmelden ingeschakeld. Uw gegevens en instellingen zijn gemigreerd. Als u OpenClaw automatisch wilt starten, schakelt u OpenClaw Companion in via Instellingen > Apps > Opstarten, of neem contact op met uw beheerder als opstarten door beleid wordt beheerd. + + + Een vorige migratie moet worden hersteld of afgerond. Deze testversie kan die migratie niet voortzetten of normaal starten. Sluit dit voorbeeld. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + Code kopiëren Codeblok Huidig model diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index be154fda8..958714649 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -7170,6 +7170,76 @@ O binário wxc-exec não pôde ser localizado. {1} Se esta for uma build de dese ID da voz por exemplo, male-qn-qingse MiniMax (nuvem, requer chave da API) + + Sua configuração do OpenClaw foi migrada para a versão da Store. Feche esta mensagem para sair deste aplicativo. Depois, desinstale o aplicativo OpenClaw Companion anterior em Configurações do Windows > Aplicativos > Aplicativos instalados e abra a versão da Store. Sua configuração migrada e seu gateway serão preservados. + + + O OpenClaw não conseguiu verificar se a migração para a versão da Store foi concluída. Esta cópia não pode iniciar com segurança. Feche os outros processos do OpenClaw e tente novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Prévia da migração do OpenClaw + + + Uma instalação anterior compatível foi encontrada. A inicialização normal está pausada. Você pode iniciar a migração ou escolher Agora não para continuar usando o aplicativo anterior. + + + Uma instalação anterior compatível foi encontrada. Inicie a migração apenas quando estiver pronto. + +Escolher {0} valida sua configuração e salva registros de migração protegidos. Se a validação for bem-sucedida, o aplicativo anterior não poderá mais iniciar normalmente. Você deverá então desinstalar o aplicativo anterior em Configurações do Windows > Aplicativos > Aplicativos instalados e reabrir o aplicativo da Store para concluir a migração. Sua configuração e seu gateway serão preservados; nada será desinstalado automaticamente. + +Escolha {1} para sair desta prévia sem iniciar a migração e continuar usando o aplicativo anterior. + + + Migrar + + + Agora não + + + Feche o aplicativo OpenClaw Companion anterior e escolha {0} para tentar novamente. Escolha {1} para sair desta prévia. Não force o encerramento do aplicativo anterior nem o desinstale ainda. Desinstale somente depois que esta prévia informar que a conclusão da migração foi registrada. + + + Tentar novamente + + + O aplicativo anterior foi fechado e a migração poderia continuar. Esta versão de teste ainda não adota sua configuração. Feche esta prévia. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + + O aplicativo anterior foi fechado. O OpenClaw validou sua configuração atual e salvou dados protegidos de preparação da migração. Esta versão de teste ainda não adota sua configuração. Feche esta prévia. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw não pôde validar com segurança a configuração anterior para a migração. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e tente novamente mais tarde. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu resolver uma credencial para o gateway ativo. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e restaure a configuração do gateway ativo antes de tentar novamente. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw registrou a conclusão da migração e preservou sua configuração. Feche esta prévia, desinstale o aplicativo OpenClaw Companion anterior nas Configurações do Windows e abra a versão da Store. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Sim + + + Não + + + O aplicativo anterior é mais antigo que a versão de origem exigida por esta prévia. A migração e a inicialização normal da versão da Store estão bloqueadas. Use uma versão Inno verificada com suporte à migração antes de testá-la. Sua configuração não foi alterada. + + + Este local de instalação, arquitetura ou configuração de pacote não é compatível com a prévia da migração. Apenas a instalação padrão por usuário com arquitetura correspondente é compatível. Sua configuração não foi alterada. + + + A prévia da migração não conseguiu verificar com segurança a instalação anterior ou os registros de migração. A inicialização normal está bloqueada. Feche esta prévia e consulte o log do aplicativo antes de tentar novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu concluir a migração com segurança. A inicialização normal está bloqueada e o recibo de migração foi preservado para recuperação. Feche esta prévia e tente novamente mais tarde. Não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw concluiu a migração, mas o Windows não permitiu iniciar o OpenClaw Companion ao entrar. Seus dados e configurações foram migrados. Para iniciar o OpenClaw automaticamente, ative o OpenClaw Companion em Configurações > Aplicativos > Inicializar ou fale com seu administrador se a inicialização for gerenciada por política. + + + Uma migração anterior precisa de recuperação ou finalização. Esta versão de teste não pode continuá-la nem iniciar normalmente. Feche esta prévia. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + Copiar código Bloco de código Modelo atual diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 7a6e3701e..61dc1e8a9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -7116,6 +7116,76 @@ 无法验证本地 AI 可用性 正在检查本地 AI 可用性 OpenClaw 正在检查此设备是否可以运行本地 AI。这只需要片刻。 + + 您的 OpenClaw 配置已迁移到 Store 版本。请关闭此消息以退出此应用,然后通过 Windows 设置 > 应用 > 已安装的应用卸载旧版 OpenClaw Companion,再打开 Store 版本。已迁移的配置和网关将予以保留。 + + + OpenClaw 无法检查是否已完成向 Store 版本的迁移。此副本无法安全启动。请关闭其他 OpenClaw 进程后重试。请勿删除迁移数据或运行网关清理。 + + + OpenClaw 迁移预览 + + + 已找到受支持的旧版安装。正常启动已暂停。您可以开始迁移,或选择“暂不”以继续使用旧版应用。 + + + 已找到受支持的旧版安装。请仅在准备好时开始迁移。 + +选择“{0}”将验证您的配置并保存受保护的迁移记录。如果验证成功,旧版应用将无法正常启动。您必须随后通过 Windows 设置 > 应用 > 已安装的应用卸载旧版应用,然后重新打开 Store 应用以完成迁移。您的配置和网关将被保留;不会自动卸载任何内容。 + +选择“{1}”可退出此预览而不开始迁移,并继续使用旧版应用。 + + + 迁移 + + + 暂不 + + + 关闭旧版 OpenClaw Companion 应用,然后选择“{0}”以重试。选择“{1}”可退出此预览。请勿强制关闭旧版应用,也请暂时不要卸载。只有在此预览提示已记录迁移完成后,才可卸载旧版应用。 + + + 重试 + + + 旧版应用已关闭,可以继续迁移。此测试版本尚不会采用您的配置。请关闭此预览。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + + 旧版应用已关闭。OpenClaw 已验证您当前的配置并保存了受保护的迁移准备数据。此测试版本尚不会采用您的配置。请关闭此预览。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + + OpenClaw 无法安全验证旧版配置是否可迁移。未记录完成状态,旧版应用也未被更改。请关闭此预览,稍后重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + + OpenClaw 无法解析活动网关的凭据。未记录完成状态,旧版应用也未被更改。请关闭此预览,恢复活动网关配置后再重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + + OpenClaw 已记录迁移完成并保留了您的配置。请关闭此预览,通过 Windows 设置卸载旧版 OpenClaw Companion 应用,然后打开 Store 版本。请勿删除迁移数据或运行网关清理。 + + + 是 + + + 否 + + + 旧版应用低于此预览要求的源版本。迁移和 Store 版本的正常启动已被阻止。请先使用经过验证且支持迁移的 Inno 版本,再测试迁移。您的配置未被更改。 + + + 迁移预览不支持此安装位置、体系结构或程序包配置。仅支持体系结构匹配的默认按用户安装。您的配置未被更改。 + + + 迁移预览无法安全检查旧版安装或迁移记录。正常启动已被阻止。请关闭此预览并查看应用日志后重试。请勿删除迁移数据或运行网关清理。 + + + OpenClaw 无法安全完成迁移。正常启动已被阻止,迁移收据已保留以供恢复。请关闭此预览,稍后重试。请勿删除迁移数据或运行网关清理。 + + + OpenClaw 已完成迁移,但 Windows 未允许在登录时启动 OpenClaw Companion。您的数据和设置均已迁移。若要自动启动 OpenClaw,请在 设置 > 应用 > 启动 中启用 OpenClaw Companion;如果启动由策略管理,请联系管理员。 + + + 之前的迁移需要恢复或完成。此测试版本无法继续该迁移或正常启动。请关闭此预览。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + 复制代码 代码块 当前模型 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 298c7ecd7..a1005986c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -7116,6 +7116,76 @@ 無法驗證本機 AI 可用性 正在檢查本機 AI 可用性 OpenClaw 正在檢查此裝置是否可以執行本機 AI。這只需要片刻。 + + 您的 OpenClaw 設定已移轉至 Store 版本。請關閉此訊息以結束此應用程式,然後透過 Windows 設定 > 應用程式 > 已安裝的應用程式解除安裝舊版 OpenClaw Companion,再開啟 Store 版本。已移轉的設定和閘道將會保留。 + + + OpenClaw 無法檢查是否已完成移轉至 Store 版本。此副本無法安全啟動。請關閉其他 OpenClaw 處理程序後再試一次。請勿刪除移轉資料或執行閘道清理。 + + + OpenClaw 移轉預覽 + + + 已找到支援的舊版安裝。正常啟動已暫停。您可以開始移轉,或選擇「現在不要」以繼續使用舊版應用程式。 + + + 已找到支援的舊版安裝。請僅在準備好時開始移轉。 + +選取「{0}」會驗證您的設定並儲存受保護的移轉記錄。如果驗證成功,舊版應用程式將無法正常啟動。您必須接著透過 Windows 設定 > 應用程式 > 已安裝的應用程式解除安裝舊版應用程式,然後重新開啟 Store 應用程式以完成移轉。您的設定和閘道將會保留;不會自動解除安裝任何內容。 + +選取「{1}」可結束此預覽而不開始移轉,並繼續使用舊版應用程式。 + + + 移轉 + + + 現在不要 + + + 關閉舊版 OpenClaw Companion 應用程式,然後選取「{0}」以重試。選取「{1}」可結束此預覽。請勿強制關閉舊版應用程式,也請暫時不要解除安裝。只有在此預覽提示已記錄移轉完成後,才可解除安裝舊版應用程式。 + + + 重試 + + + 舊版應用程式已關閉,可以繼續移轉。此測試版本尚不會採用您的設定。請關閉此預覽。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + + 舊版應用程式已關閉。OpenClaw 已驗證您目前的設定,並儲存了受保護的移轉準備資料。此測試版本尚不會採用您的設定。請關閉此預覽。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法安全驗證舊版設定是否可移轉。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,稍後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法解析作用中閘道的認證。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,還原作用中閘道設定後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + + OpenClaw 已記錄移轉完成並保留您的設定。請關閉此預覽,透過 Windows 設定解除安裝舊版 OpenClaw Companion 應用程式,然後開啟 Store 版本。請勿刪除移轉資料或執行閘道清理。 + + + 是 + + + 否 + + + 舊版應用程式低於此預覽要求的來源版本。移轉及 Store 版本的正常啟動已遭封鎖。請先使用經過驗證且支援移轉的 Inno 版本,再測試移轉。您的設定未被變更。 + + + 移轉預覽不支援此安裝位置、架構或套件設定。僅支援架構相符的預設個別使用者安裝。您的設定未被變更。 + + + 移轉預覽無法安全檢查舊版安裝或移轉記錄。正常啟動已遭封鎖。請關閉此預覽並查看應用程式記錄後重試。請勿刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法安全完成移轉。正常啟動已遭封鎖,移轉收據已保留以供復原。請關閉此預覽,稍後再試。請勿刪除移轉資料或執行閘道清理。 + + + OpenClaw 已完成移轉,但 Windows 未允許在登入時啟動 OpenClaw Companion。您的資料與設定皆已移轉。若要自動啟動 OpenClaw,請在 設定 > 應用程式 > 啟動 中啟用 OpenClaw Companion;如果啟動由原則管理,請聯絡系統管理員。 + + + 先前的移轉需要復原或完成。此測試版本無法繼續該移轉或正常啟動。請關閉此預覽。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + 複製程式碼 程式碼區塊 目前的模型 diff --git a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs new file mode 100644 index 000000000..57cb77307 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs @@ -0,0 +1,568 @@ +using System.Diagnostics; +using System.Reflection.PortableExecutable; +using System.Runtime.Versioning; +using System.Security; +using System.Security.Cryptography; +using Microsoft.Win32; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class InnoInstallationDetectorTests +{ + [Fact] + public void ProductionRegistryKey_MatchesInstallerAppIdExactly() + { + Assert.Equal( + @"Software\Microsoft\Windows\CurrentVersion\Uninstall\{M0LTB0T-TRAY-4PP1-D3N7}_is1", + InnoInstallationDetector.UninstallKey); + } + + [Fact] + public void AbsentExactRegistration_DoesNotInspectFiles() + { + using var fixture = new Fixture(); + fixture.Source.Registrations.Clear(); + fixture.Source.FileError = new InvalidOperationException("No filesystem inspection expected."); + + Assert.Equal(InnoInstallationStatus.NotInstalled, fixture.Detect().Status); + Assert.Equal(4, fixture.Source.RegistryReads.Count); + Assert.Empty(fixture.Logger.Warnings); + } + + [Theory] + [InlineData(Machine.Amd64, "x64")] + [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"); + + var result = fixture.Detect(); + + Assert.Equal(InnoInstallationStatus.Detected, result.Status); + Assert.Equal(new InnoInstallation(fixture.InstallDirectory, + fixture.Payload("OpenClaw.Tray.WinUI.exe"), fixture.Payload("unins000.exe"), + architecture, new Version(2026, 9, 17, 0)), result.Installation); + Assert.Null(result.Reason); + Assert.Equal(4, fixture.Source.RegistryReads.Count); + } + + [Fact] + public void ActualProductionInnoMetadata_IsDetected() + { + using var fixture = new Fixture(); + using var registry = new RegistryFixture(); + // VM-produced metadata, with only the user-specific install root relocated. + var registration = new InnoInstallationRegistration( + fixture.InstallDirectory + "\\", "2026.9.5.0", "OpenClaw Foundation", + "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"); + var detector = new InnoInstallationDetector(fixture.Temp.Path, fixture.Logger, registry.Source(fixture.Source)); + + var result = detector.Detect(); + + Assert.Equal(InnoInstallationStatus.Detected, result.Status); + Assert.Equal(new InnoInstallation(fixture.InstallDirectory, + fixture.Payload("OpenClaw.Tray.WinUI.exe"), fixture.Payload("unins000.exe"), + "x64", new Version(2026, 9, 5, 0)), result.Installation); + Assert.Empty(fixture.Logger.Warnings); + } + + [Fact] + public void IdenticalSharedViewAliases_AreDeduplicated() + { + using var fixture = new Fixture(); + fixture.Source.Registrations[(RegistryHive.CurrentUser, RegistryView.Registry32)] = fixture.Registration; + Assert.Equal(InnoInstallationStatus.Detected, fixture.Detect().Status); + } + + [Fact] + public void CurrentUser32ViewAlone_IsDetected() + { + using var fixture = new Fixture(); + var registration = fixture.Registration; + fixture.Source.Registrations.Clear(); + fixture.Source.Registrations[(RegistryHive.CurrentUser, RegistryView.Registry32)] = registration; + Assert.Equal(InnoInstallationStatus.Detected, fixture.Detect().Status); + } + + [Fact] + public void ConflictingViews_AreUnsupported() + { + using var fixture = new Fixture(); + fixture.Source.Registrations[(RegistryHive.CurrentUser, RegistryView.Registry32)] = + fixture.Registration with { DisplayVersion = "2026.9.18" }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData(RegistryView.Registry32, false)] + [InlineData(RegistryView.Registry64, false)] + [InlineData(RegistryView.Registry32, true)] + [InlineData(RegistryView.Registry64, true)] + public void MachineWideRegistration_BlocksEvenWithValidCurrentUser(RegistryView view, bool includeUser) + { + using var fixture = new Fixture(); + var registration = fixture.Registration; + if (!includeUser) + fixture.Source.Registrations.Clear(); + fixture.Source.Registrations[(RegistryHive.LocalMachine, view)] = registration; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("OpenClaw.Tray.WinUI.exe")] + [InlineData("unins000.exe")] + [InlineData("app-identity.txt")] + [InlineData("Test-InnoMigration.ps1")] + [InlineData("MigrationRecordCodec.cs")] + [InlineData("Uninstall-LocalGateway.ps1")] + public void StaleRegistrationWithMissingPayload_IsUnsupported(string name) + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload(name)); + fixture.AssertUnsupported(); + } + + [Fact] + public void DirectoryInsteadOfPayload_IsUnsupported() + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload("unins000.exe")); + Directory.CreateDirectory(fixture.Payload("unins000.exe")); + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1.2")] + [InlineData("1.2.3-beta")] + [InlineData("1.2.3+release")] + [InlineData(" 1.2.3")] + [InlineData("1.2.3 ")] + [InlineData("1.2.3.4.5")] + [InlineData("999999999999.1.1")] + public void MalformedRegisteredVersion_IsUnsupported(string? version) + { + using var fixture = new Fixture(); + fixture.Registration = fixture.Registration with { DisplayVersion = version }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData(null)] + [InlineData("2026.9.18")] + [InlineData("2026.9.17-beta")] + [InlineData("2026.9.17.1")] + public void BinaryVersionMustMatchNormalizedRegistryVersion(string? version) + { + using var fixture = new Fixture(); + fixture.Source.Binary = fixture.Source.Binary with { Version = version }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData(Machine.I386)] + [InlineData(Machine.ArmThumb2)] + [InlineData(Machine.Unknown)] + public void UnsupportedPeMachine_IsUnsupported(Machine machine) + { + using var fixture = new Fixture(); + fixture.Source.Binary = fixture.Source.Binary with { Machine = machine }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("relative")] + [InlineData("custom")] + [InlineData("dot")] + [InlineData("expanded")] + [InlineData("missing")] + public void NoncanonicalInstallLocation_IsUnsupported(string kind) + { + using var fixture = new Fixture(); + fixture.Registration = fixture.Registration with + { + InstallLocation = kind switch + { + "relative" => "OpenClawTray", + "custom" => fixture.Temp.Combine("Custom"), + "dot" => fixture.Temp.Combine(".", "OpenClawTray"), + "expanded" => @"%LOCALAPPDATA%\OpenClawTray", + _ => null + } + }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("relative")] + [InlineData("unquoted")] + [InlineData("outside")] + [InlineData("arguments")] + [InlineData("other-uninstaller")] + [InlineData("missing")] + public void UninstallCommandMustBeExactQuotedCanonicalPath(string kind) + { + using var fixture = new Fixture(); + fixture.Registration = fixture.Registration with + { + UninstallString = kind switch + { + "relative" => "\"unins000.exe\"", + "unquoted" => fixture.Payload("unins000.exe"), + "outside" => $"\"{fixture.Temp.Combine("unins000.exe")}\"", + "arguments" => $"\"{fixture.Payload("unins000.exe")}\" /SILENT", + "other-uninstaller" => $"\"{fixture.Payload("unins001.exe")}\"", + _ => null + } + }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("OpenClaw Companion (Dev)", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion (Dev) version 2026.9.17", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion version 2026.9.17.0", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion version 2026.9.18", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion version 2026.9.17 ", "OpenClaw Foundation")] + [InlineData("OpenClaw Companion version 2026.9.17", "Unknown")] + [InlineData(null, "OpenClaw Foundation")] + [InlineData("OpenClaw Companion version 2026.9.17", null)] + public void ProductionNameAndPublisherAreRequired(string? name, string? publisher) + { + using var fixture = new Fixture(); + fixture.Registration = fixture.Registration with { DisplayName = name, Publisher = publisher }; + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("dev")] + [InlineData("")] + [InlineData("release-dev")] + [InlineData(" release ")] + [InlineData("release\nextra")] + [InlineData("this marker exceeds the bounded identity marker length")] + public void NonReleaseOrMalformedIdentity_IsUnsupported(string identity) + { + using var fixture = new Fixture(); + File.WriteAllText(fixture.Payload("app-identity.txt"), identity); + fixture.AssertUnsupported(); + } + + [Theory] + [InlineData("release")] + [InlineData("release\n")] + [InlineData("release\r\n")] + public void BuildMarkerLineEndings_AreAccepted(string identity) + { + using var fixture = new Fixture(); + File.WriteAllText(fixture.Payload("app-identity.txt"), identity); + Assert.Equal(InnoInstallationStatus.Detected, fixture.Detect().Status); + } + + [Theory] + [InlineData("io", false)] + [InlineData("access", false)] + [InlineData("security", false)] + [InlineData("io", true)] + [InlineData("access", true)] + [InlineData("security", true)] + public void OperatingSystemErrors_AreLoggedAndFailInspection(string kind, bool files) + { + using var fixture = new Fixture(); + Exception error = kind switch + { + "io" => new IOException("read failed"), + "access" => new UnauthorizedAccessException("denied"), + _ => new SecurityException("denied") + }; + if (files) + fixture.Source.FileError = error; + else + fixture.Source.RegistryError = error; + var result = fixture.Detect(); + Assert.Equal(InnoInstallationStatus.InspectionFailed, result.Status); + Assert.Null(result.Installation); + Assert.False(string.IsNullOrEmpty(result.Reason)); + Assert.Same(error, Assert.Single(fixture.Logger.Errors)); + } + + [Fact] + public void InaccessibleMachineView_DoesNotBecomeNotInstalled() + { + using var fixture = new Fixture(); + fixture.Source.Registrations.Clear(); + fixture.Source.RegistryError = new UnauthorizedAccessException(); + fixture.Source.ErrorHive = RegistryHive.LocalMachine; + Assert.Equal(InnoInstallationStatus.InspectionFailed, fixture.Detect().Status); + Assert.Single(fixture.Logger.Errors); + } + + [Fact] + public void ProgrammingErrors_AreNotSwallowed() + { + using var fixture = new Fixture(); + fixture.Source.RegistryError = new InvalidOperationException("bug"); + Assert.Throws(() => fixture.Detect()); + } + + [Fact] + public void InvalidPePayload_IsUnsupported() + { + using var fixture = new Fixture(); + fixture.Source.InspectRealBinary = true; + fixture.AssertUnsupported(); + } + + [Fact] + public void LockedIdentityFile_FailsInspectionRatherThanReportingMissing() + { + using var fixture = new Fixture(); + using var locked = new FileStream(fixture.Payload("app-identity.txt"), + FileMode.Open, FileAccess.Read, FileShare.None); + Assert.Equal(InnoInstallationStatus.InspectionFailed, fixture.Detect().Status); + Assert.IsAssignableFrom(Assert.Single(fixture.Logger.Errors)); + } + + [Theory] + [InlineData(Machine.Amd64)] + [InlineData(Machine.Arm64)] + public void NativeAdapter_ReadsActualPeMachineAndFileVersion(Machine machine) + { + using var temp = new TempDirectory(); + var path = temp.Combine("native.exe"); + var bytes = File.ReadAllBytes(Environment.ProcessPath!); + var peOffset = BitConverter.ToInt32(bytes, 0x3c); + BitConverter.GetBytes((ushort)machine).CopyTo(bytes, peOffset + 4); + File.WriteAllBytes(path, bytes); + + var evidence = new InnoInstallationReadSource().ReadExecutable(path); + Assert.Equal(machine, evidence.Machine); + Assert.Equal(FileVersionInfo.GetVersionInfo(path).FileVersion, evidence.Version); + } + + [Fact] + public void ManagedAssembly_IsNotNativeApplicationEvidence() + { + var source = new InnoInstallationReadSource(); + Assert.Throws(() => source.ReadExecutable(typeof(InnoInstallationDetector).Assembly.Location)); + } + + [Fact] + public void ReparsePointAncestor_IsUnsupported() + { + using var fixture = new Fixture(); + var target = fixture.Temp.Combine("payload-target"); + Directory.Move(fixture.InstallDirectory, target); + // A junction needs no developer-mode or symbolic-link privilege. + var start = new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + Arguments = $"/d /c mklink /J \"{fixture.InstallDirectory}\" \"{target}\"" + }; + using var process = Process.Start(start)!; + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + try { fixture.AssertUnsupported(); } + finally { Directory.Delete(fixture.InstallDirectory); } + } + + [Fact] + public void DetectionDoesNotModifyInstallationOrReadUserState() + { + using var fixture = new Fixture(); + File.WriteAllText(fixture.Payload("settings.json"), "must not be read or modified"); + var before = Snapshot(fixture.InstallDirectory); + + Assert.Equal(InnoInstallationStatus.Detected, fixture.Detect().Status); + + Assert.Equal(before, Snapshot(fixture.InstallDirectory)); + Assert.DoesNotContain(fixture.Source.FileReads, path => path.EndsWith("settings.json")); + Assert.All(fixture.Source.FileReads, path => Assert.Equal(fixture.InstallDirectory, Path.GetDirectoryName(path))); + } + + [Fact] + public void NativeRegistryAdapter_IgnoresDevIdAndDeduplicatesReadOnlyProductionAlias() + { + using var fixture = new Fixture(); + using var registry = new RegistryFixture(); + using (var dev = registry.Root.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{M0LTB0T-TRAY-4PP1-DEV}_is1")) + WriteRegistration(dev, fixture.Registration); + var detector = new InnoInstallationDetector(fixture.Temp.Path, NullLogger.Instance, + registry.Source(fixture.Source)); + Assert.Equal(InnoInstallationStatus.NotInstalled, detector.Detect().Status); + using (var production = registry.Root.CreateSubKey(InnoInstallationDetector.UninstallKey)) + WriteRegistration(production, fixture.Registration); + + Assert.Equal(InnoInstallationStatus.Detected, detector.Detect().Status); + using var after = registry.Root.OpenSubKey(InnoInstallationDetector.UninstallKey)!; + Assert.Equal(5, after.ValueCount); + Assert.Equal(fixture.Registration.DisplayVersion, after.GetValue("DisplayVersion")); + Assert.Equal(fixture.Registration.UninstallString, after.GetValue("UninstallString")); + } + + [Theory] + [InlineData(RegistryValueKind.ExpandString)] + [InlineData(RegistryValueKind.DWord)] + [InlineData(RegistryValueKind.MultiString)] + [InlineData(RegistryValueKind.Binary)] + public void NativeRegistryAdapter_RejectsNonStringKindsWithoutExpansion(RegistryValueKind kind) + { + using var fixture = new Fixture(); + using var registry = new RegistryFixture(); + foreach (var name in new[] { "InstallLocation", "DisplayVersion", "Publisher", "DisplayName", "UninstallString" }) + { + using (var production = registry.Root.CreateSubKey(InnoInstallationDetector.UninstallKey)) + { + WriteRegistration(production, fixture.Registration); + object value = kind switch + { + RegistryValueKind.ExpandString => "%LOCALAPPDATA%\\OpenClawTray", + RegistryValueKind.DWord => 1, + RegistryValueKind.MultiString => new[] { fixture.InstallDirectory }, + _ => new byte[] { 1, 2 } + }; + production.SetValue(name, value, kind); + } + var detector = new InnoInstallationDetector(fixture.Temp.Path, fixture.Logger, registry.Source(fixture.Source)); + Assert.Equal(InnoInstallationStatus.Unsupported, detector.Detect().Status); + using var after = registry.Root.OpenSubKey(InnoInstallationDetector.UninstallKey)!; + Assert.Equal(kind, after.GetValueKind(name)); + } + Assert.Equal(5, fixture.Logger.Warnings.Count); + } + + private static string[] Snapshot(string directory) => Directory.GetFiles(directory) + .Order(StringComparer.Ordinal).Select(path => + $"{Path.GetFileName(path)}:{File.GetLastWriteTimeUtc(path).Ticks}:{Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}") + .ToArray(); + + private static void WriteRegistration(RegistryKey key, InnoInstallationRegistration registration) + { + key.SetValue("InstallLocation", registration.InstallLocation!); + key.SetValue("DisplayVersion", registration.DisplayVersion!); + key.SetValue("DisplayName", registration.DisplayName!); + key.SetValue("Publisher", registration.Publisher!); + key.SetValue("UninstallString", registration.UninstallString!); + } + + private sealed class RegistryFixture : IDisposable + { + private readonly string _path = @"Software\OpenClawDetectorTests\" + Guid.NewGuid().ToString("N"); + public RegistryKey Root { get; } + + public RegistryFixture() => Root = Registry.CurrentUser.CreateSubKey(_path); + + public FakeSource Source(FakeSource source) + { + source.RegistrySource = new InnoInstallationReadSource((hive, _) => + hive == RegistryHive.CurrentUser ? Root.OpenSubKey(InnoInstallationDetector.UninstallKey, false) : null); + return source; + } + + public void Dispose() + { + Root.Dispose(); + Registry.CurrentUser.DeleteSubKeyTree(_path); + } + } + + private sealed class Fixture : IDisposable + { + public TempDirectory Temp { get; } = new(); + public FakeSource Source { get; } = new(); + public RecordingLogger Logger { get; } = new(); + public string InstallDirectory => Temp.Combine("OpenClawTray"); + public string Payload(string name) => Path.Combine(InstallDirectory, name); + public InnoInstallationRegistration Registration + { + get => Source.Registrations[(RegistryHive.CurrentUser, RegistryView.Registry64)]; + set => Source.Registrations[(RegistryHive.CurrentUser, RegistryView.Registry64)] = value; + } + + public Fixture() + { + Directory.CreateDirectory(InstallDirectory); + foreach (var name in new[] { "OpenClaw.Tray.WinUI.exe", "unins000.exe", "Test-InnoMigration.ps1", + "MigrationRecordCodec.cs", "Uninstall-LocalGateway.ps1" }) + File.WriteAllText(Payload(name), "fixture only, never execute"); + File.WriteAllText(Payload("app-identity.txt"), "release\r\n"); + Registration = new(InstallDirectory + "\\", "2026.9.17", "OpenClaw Foundation", + "OpenClaw Companion version 2026.9.17", $"\"{Payload("unins000.exe")}\""); + } + + public InnoInstallationDetection Detect() => new InnoInstallationDetector(Temp.Path, Logger, Source).Detect(); + public void AssertUnsupported() + { + var result = Detect(); + Assert.Equal(InnoInstallationStatus.Unsupported, result.Status); + Assert.Null(result.Installation); + Assert.False(string.IsNullOrEmpty(result.Reason)); + Assert.Equal(result.Reason, Assert.Single(Logger.Warnings)); + } + public void Dispose() => Temp.Dispose(); + } + + private sealed class FakeSource : IInnoInstallationReadSource + { + private readonly InnoInstallationReadSource _files = new(); + 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 Exception? RegistryError { get; set; } + public RegistryHive? ErrorHive { get; set; } + public Exception? FileError { get; set; } + public bool InspectRealBinary { get; set; } + public InnoInstallationReadSource? RegistrySource { get; set; } + + public InnoInstallationRegistration? ReadRegistration(RegistryHive hive, RegistryView view) + { + RegistryReads.Add((hive, view)); + if (RegistryError is not null && (ErrorHive is null || ErrorHive == hive)) + throw RegistryError; + return RegistrySource is not null ? RegistrySource.ReadRegistration(hive, view) : + Registrations.GetValueOrDefault((hive, view)); + } + + public bool IsOrdinaryFile(string path) + { + FileReads.Add(path); + if (FileError is not null) + throw FileError; + return _files.IsOrdinaryFile(path); + } + + public string ReadIdentity(string path) + { + FileReads.Add(path); + return _files.ReadIdentity(path); + } + + public InnoInstallationExecutable ReadExecutable(string path) + { + FileReads.Add(path); + return InspectRealBinary ? _files.ReadExecutable(path) : Binary; + } + } + + private sealed class RecordingLogger : IOpenClawLogger + { + public List Warnings { get; } = []; + public List Errors { get; } = []; + public void Info(string message) { } + public void Debug(string message) { } + public void Warn(string message) => Warnings.Add(message); + public void Error(string message, Exception? ex = null) => Errors.Add(ex); + } +} diff --git a/tests/OpenClaw.Connection.Tests/InnoSourceActivityVerifierTests.cs b/tests/OpenClaw.Connection.Tests/InnoSourceActivityVerifierTests.cs new file mode 100644 index 000000000..3d61a5939 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/InnoSourceActivityVerifierTests.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using System.Runtime.Versioning; +using OpenClaw.Connection.Migration; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class InnoSourceActivityVerifierTests +{ + [Theory] + [InlineData(InnoSourceProcessState.ImageResolved, @"C:\source\OpenClaw.Tray.WinUI.exe", InnoSourceActivityStatus.Running)] + [InlineData(InnoSourceProcessState.ImageResolved, @"c:\SOURCE\OpenClaw.Tray.WinUI.exe", InnoSourceActivityStatus.Running)] + [InlineData(InnoSourceProcessState.ImageResolved, @"C:\other\OpenClaw.Tray.WinUI.exe", InnoSourceActivityStatus.Stopped)] + [InlineData(InnoSourceProcessState.ImageResolved, null, InnoSourceActivityStatus.InspectionFailed)] + [InlineData(InnoSourceProcessState.ImageResolved, "", InnoSourceActivityStatus.InspectionFailed)] + [InlineData(InnoSourceProcessState.Unresolved, null, InnoSourceActivityStatus.InspectionFailed)] + [InlineData(InnoSourceProcessState.OwnedByOtherUser, null, InnoSourceActivityStatus.Stopped)] + [InlineData(InnoSourceProcessState.Exited, null, InnoSourceActivityStatus.Stopped)] + public void Candidates_AreClassifiedWithoutSessionLocalAssumptions( + object state, string? image, InnoSourceActivityStatus expected) + { + var verifier = new InnoSourceActivityVerifier(@"C:\source\OpenClaw.Tray.WinUI.exe", + new Inspector(() => [new((InnoSourceProcessState)state, image)])); + Assert.Equal(expected, verifier.VerifyStopped()); + } + + [Fact] + public void EnumerationFailure_FailsClosed() + { + var verifier = new InnoSourceActivityVerifier(@"C:\source\OpenClaw.Tray.WinUI.exe", + new Inspector(() => throw new Win32Exception(5))); + Assert.Equal(InnoSourceActivityStatus.InspectionFailed, verifier.VerifyStopped()); + } + + private sealed class Inspector(Func> inspect) : IInnoSourceProcessInspector + { + public IEnumerable FindSameNameProcesses() => inspect(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs new file mode 100644 index 000000000..81ee1cd43 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationInventoryTests.cs @@ -0,0 +1,439 @@ +using System.Security.Cryptography; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenClaw.Connection.Migration; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.ExecApprovals; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[Collection("Migration inventory environment")] +public sealed class MigrationInventoryTests : IDisposable +{ + private const string GatewayId = "12345678-1234-1234-1234-123456789abc"; + private readonly TempDirectory _directory = new(Path.Combine(Environment.CurrentDirectory, ".migration-inventory-tests-")); + private readonly EnvironmentScope _environment = new(); + private string Roaming => _directory.Combine("roaming"); + private string Local => _directory.Combine("local"); + + public MigrationInventoryTests() => _environment.Set("OPENCLAW_STATE_DIR", Roaming); + + [Fact] + public void Capture_MissingOptionalState_IsStableAndCreatesNothing() + { + var first = Capture(); + var second = Capture(); + + Assert.Equal(first.Fingerprint, second.Fingerprint); + Assert.False(first.AutoStart); + Assert.All(first.Entries, entry => + { + Assert.False(entry.Exists); + Assert.Null(entry.Length); + Assert.Null(entry.Sha256); + Assert.True(Path.IsPathFullyQualified(entry.CanonicalPath)); + }); + Assert.Contains(first.Entries, entry => entry.RelativeName == "settings.json"); + Assert.Contains(first.Entries, entry => entry.RelativeName == Path.Combine("LocalAI", "state.json")); + Assert.Contains(first.Entries, entry => entry.RelativeName == "exec-approvals.json"); + Assert.Empty(Directory.EnumerateFileSystemEntries(_directory.Path)); + } + + [CollectionDefinition("Migration inventory environment", DisableParallelization = true)] + public sealed class MigrationInventoryEnvironmentCollection + { + } + + [Fact] + public void Capture_McpOnlySettings_RoundTripsWithoutCredentialsOrWrites() + { + var settings = new SettingsData { AutoStart = false, EnableMcpServer = true, EnableNodeMode = false }; + var legacy = JsonNode.Parse(settings.ToJson())!.AsObject(); + legacy["Token"] = "test"; + var content = legacy.ToJsonString(); + var path = Write(Roaming, "settings.json", content); + var timestamp = File.GetLastWriteTimeUtc(path); + var inventory = Capture(); + var json = JsonSerializer.Serialize(inventory); + var restored = JsonSerializer.Deserialize(json)!; + + Assert.False(inventory.AutoStart); + Assert.Equal(inventory.Fingerprint, restored.Fingerprint); + Assert.Equal(inventory.Entries, restored.Entries); + Assert.DoesNotContain("\"test\"", json); + Assert.Equal(content, File.ReadAllText(path)); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(path)); + var entry = Assert.Single(inventory.Entries, entry => entry.CanonicalPath == path); + Assert.Equal(Encoding.UTF8.GetByteCount(content), entry.Length); + Assert.Equal(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))), entry.Sha256); + } + + [Fact] + public void Capture_SettingsPreservesUnknownFieldsAndExactCaseOwnerSemantics() + { + var settings = JsonNode.Parse(new SettingsData { AutoStart = false }.ToJson())!.AsObject(); + settings["Token"] = "test"; + settings["RetiredSetting"] = new JsonObject { ["Work"] = true, ["work"] = false }; + settings["autoStart"] = true; + var content = settings.ToJsonString(); + var path = Write(Roaming, "settings.json", content); + + var inventory = Capture(); + + Assert.False(SettingsData.FromJson(content)!.AutoStart); + Assert.False(inventory.AutoStart); + Assert.Equal(content, File.ReadAllText(path)); + } + + [Fact] + public void Capture_OptionalCreationDeletionAndChangedBytes_ChangeFingerprint() + { + var absent = Capture(); + var path = Write(Roaming, "settings.json", """{"AutoStart":true}"""); + var present = Capture(); + File.WriteAllText(path, """{"AutoStart":false}"""); + var changed = Capture(); + File.Delete(path); + + Assert.True(present.AutoStart); + Assert.NotEqual(absent.Fingerprint, present.Fingerprint); + Assert.NotEqual(present.Fingerprint, changed.Fingerprint); + Assert.Equal(absent.Fingerprint, Capture().Fingerprint); + } + + [Fact] + public void Capture_RemoteGatewayAndIdentities_UseStrictReadOnlyReader() + { + WriteRegistry(); + var identityDirectory = Path.Combine(Roaming, "gateways", GatewayId); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + identity.StoreDeviceToken("test"); + var path = Path.Combine(identityDirectory, "device-key-ed25519.json"); + var original = File.ReadAllBytes(path); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == path && entry.Exists); + Assert.DoesNotContain("\"test\"", JsonSerializer.Serialize(inventory)); + Assert.Equal(original, File.ReadAllBytes(path)); + Assert.False(File.Exists(Path.Combine(Roaming, "device-key-ed25519.json"))); + } + + [Fact] + public void Capture_RegistryWithoutIdentity_DoesNotGenerateOne() + { + WriteRegistry(); + var inventory = Capture(); + Assert.Contains(inventory.Entries, entry => + entry.RelativeName == Path.Combine("gateways", GatewayId, "device-key-ed25519.json") && !entry.Exists); + Assert.False(Directory.Exists(Path.Combine(Roaming, "gateways"))); + } + + [Fact] + public void Capture_GatewayOwnerPathSafeId_DoesNotRequireAnInventedGuidSchema() + { + const string id = "saved-gateway"; + var registry = new GatewayRegistry(Roaming); + registry.AddOrUpdate(new GatewayRecord { Id = id, Url = "wss://example.test" }); + registry.SetActive(id); + registry.Save(); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => + entry.RelativeName == Path.Combine("gateways", id, "device-key-ed25519.json") && !entry.Exists); + registry.Load(); + Assert.Equal(id, registry.ActiveGatewayId); + Assert.Equal(id, Assert.Single(registry.GetAll()).Id); + } + + [Fact] + public void Capture_StateDirectoryOverride_IncludesCanonicalAndLegacyApprovals() + { + var state = _directory.Combine("external-state"); + _environment.Set("OPENCLAW_STATE_DIR", state); + var canonical = Write(state, "exec-approvals.json", """{"version":1,"defaults":{"security":"deny"}}"""); + var legacy = Write(Roaming, "exec-approvals.json", """{"version":1}"""); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == canonical && entry.Root == "exec-state" && entry.Exists); + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == legacy && entry.Root == "roaming" && entry.Exists); + } + + [Fact] + public async Task Capture_ApprovalsOwnerPreservesCaseDistinctAgents_WithoutRewritingPolicy() + { + using var store = new ExecApprovalsStore(Roaming, NullLogger.Instance); + var initial = await store.GetSnapshotReadOnlyAsync(); + Assert.True(initial.IsSuccess); + var written = await store.ReplaceAsync(initial.Snapshot!.Hash, new ExecApprovalsFile + { + Version = 1, + Agents = new Dictionary + { + ["Work"] = new() { Security = ExecSecurity.Deny }, + ["work"] = new() { Security = ExecSecurity.Allowlist }, + }, + }); + Assert.NotNull(written); + var path = ExecApprovalsStore.ResolveFilePath(Roaming); + var before = File.ReadAllBytes(path); + var timestamp = File.GetLastWriteTimeUtc(path); + + var inventory = Capture(); + + var read = await store.GetSnapshotReadOnlyAsync(); + Assert.True(read.IsSuccess); + var agents = read.Snapshot!.File.Agents!; + Assert.Equal(ExecSecurity.Deny, agents["Work"].Security); + Assert.Equal(ExecSecurity.Allowlist, agents["work"].Security); + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == path && entry.Exists); + Assert.Equal(before, File.ReadAllBytes(path)); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(path)); + } + + [Fact] + public async Task Capture_UnmigratedApprovals_RecordsAbsenceWithoutRunningMigration() + { + using var store = new ExecApprovalsStore(Roaming, NullLogger.Instance); + var initial = await store.GetSnapshotReadOnlyAsync(); + var written = await store.ReplaceAsync(initial.Snapshot!.Hash, new ExecApprovalsFile { Version = 1 }); + Assert.NotNull(written); + var legacyPath = ExecApprovalsStore.ResolveFilePath(Roaming); + var before = File.ReadAllBytes(legacyPath); + var state = _directory.Combine("external-state"); + _environment.Set("OPENCLAW_STATE_DIR", state); + using var redirectedStore = new ExecApprovalsStore(Roaming, NullLogger.Instance); + var ownerRead = await redirectedStore.GetSnapshotReadOnlyAsync(); + Assert.Equal(ExecApprovalsSnapshotFailureKind.LegacyMigrationRequired, ownerRead.Failure!.Kind); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == legacyPath && entry.Exists); + Assert.Contains(inventory.Entries, entry => + entry.CanonicalPath == Path.Combine(state, "exec-approvals.json") && !entry.Exists); + Assert.Equal(before, File.ReadAllBytes(legacyPath)); + Assert.False(Directory.Exists(state)); + } + + [Theory] + [InlineData("settings.json", "{")] + [InlineData("settings.json", "null")] + [InlineData("settings.json", "[]")] + [InlineData("settings.json", """{"AutoStart":"true"}""")] + [InlineData("settings.json", """{"AutoStart":true,"AutoStart":false}""")] + [InlineData("gateways.json", "{}")] + [InlineData("gateways.json", """{"gateways":null}""")] + [InlineData("gateways.json", """{"gateways":[],"activeId":"missing"}""")] + [InlineData("gateways.json", """{"gateways":[{"id":"..","url":"ws://localhost"}]}""")] + [InlineData("gateways.json", """{"gateways":[{"id":"12345678-1234-1234-1234-123456789abc","url":"wss://host","identityDirName":"other"}]}""")] + [InlineData("device-key-ed25519.json", "{}")] + [InlineData("device-key-ed25519.json", """{"PrivateKeyBase64":"test"}""")] + [InlineData("device-key.json", "{}")] + [InlineData("device.json", "{}")] + [InlineData("exec-approvals.json", """{"version":2}""")] + [InlineData("exec-approvals.json", """{"version":1,"defaults":{"security":"test"}}""")] + [InlineData("exec-approvals.json", """{"version":1,"agents":{"Work":{},"Work":{}}}""")] + public void Capture_InvalidSource_FailsWithoutModifyingOrDisclosingIt(string name, string content) + { + var path = Write(Roaming, name, content); + var error = Assert.Throws(() => Capture()); + Assert.DoesNotContain("\"test\"", error.ToString()); + Assert.Equal(content, File.ReadAllText(path)); + } + + [Theory] + [InlineData("LocalAI\\state.json", "{}")] + [InlineData("setup-state.json", "{")] + [InlineData("setup-state.json", "null")] + [InlineData("setup-managed-distro.json", "[]")] + [InlineData("windows-node-context.json", """{"Targets":[],"Targets":[]}""")] + public void Capture_InvalidLocalState_FailsWithoutChangingIt(string name, string content) + { + var path = Write(Local, name, content); + Assert.Throws(() => Capture()); + Assert.Equal(content, File.ReadAllText(path)); + } + + [Fact] + public void Capture_SetupOwnershipAndConfiguration_DoesNotInspectPayloads() + { + Write(Local, "setup-state.json", """{"DistroName":"OpenClaw","GatewayUrl":"ws://localhost:18789"}"""); + Write(Local, "setup-managed-distro.json", JsonSerializer.Serialize(new + { + SchemaVersion = 1, + DistroName = "OpenClaw", + InstallPath = Path.Combine(Local, "wsl", "OpenClaw"), + CreatedAtUtc = DateTimeOffset.UtcNow, + })); + Write(Local, "windows-node-context.json", """{"Targets":[{"DistroName":"OpenClaw","User":"user","WorkspacePath":"/home/user/workspace"}]}"""); + Write(Local, Path.Combine("LocalAI", "llama-server-models.ini"), "[model]\nmodel=models\\model.gguf"); + Write(Local, Path.Combine("wsl", "OpenClaw", "ext4.vhdx"), "not a disk image"); + Write(Local, Path.Combine("LocalAI", "models", "model.gguf"), "not a model"); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.RelativeName == "setup-managed-distro.json" && entry.Exists); + Assert.DoesNotContain(inventory.Entries, entry => entry.RelativeName.EndsWith(".vhdx") || entry.RelativeName.EndsWith(".gguf")); + } + + [Fact] + public void Capture_RemovedGateway_PreservesItsOrphanIdentityWithoutReinstatingRecord() + { + var registry = WriteRegistry(); + var identityDirectory = registry.GetIdentityDirectory(GatewayId); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + identity.StoreDeviceToken("test"); + registry.Remove(GatewayId); + registry.Save(); + var registryPath = Path.Combine(Roaming, "gateways.json"); + var identityPath = Path.Combine(identityDirectory, "device-key-ed25519.json"); + var registryBefore = File.ReadAllBytes(registryPath); + var identityBefore = File.ReadAllBytes(identityPath); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == identityPath && entry.Exists); + Assert.Equal(registryBefore, File.ReadAllBytes(registryPath)); + Assert.Equal(identityBefore, File.ReadAllBytes(identityPath)); + registry.Load(); + Assert.Empty(registry.GetAll()); + Assert.Null(registry.ActiveGatewayId); + identity.StoreDeviceToken("next"); + Assert.NotEqual(inventory.Fingerprint, Capture().Fingerprint); + } + + [Fact] + public void Capture_DuplicateGatewayIds_IsRejected() + { + var record = new { id = GatewayId, url = "wss://example.test" }; + Write(Roaming, "gateways.json", JsonSerializer.Serialize(new { gateways = new[] { record, record } })); + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_DirectoryInPlaceOfFile_IsRejected() + { + Directory.CreateDirectory(Path.Combine(Roaming, "settings.json")); + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_UnreadableLockedFile_IsRejected() + { + var path = Write(Roaming, "settings.json", "{}"); + using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + Assert.ThrowsAny(() => Capture()); + } + + [Fact] + public void Capture_OversizedFile_IsRejectedBeforeReadingPayload() + { + var path = Write(Roaming, "settings.json", "{}"); + using (var stream = File.OpenWrite(path)) + stream.SetLength(4 * 1024 * 1024 + 1); + Assert.Throws(() => Capture()); + } + + [Fact] + public void Capture_JunctionSource_IsRejectedWithoutReadingTarget() + { + var target = _directory.Combine("junction-target"); + Write(target, "settings.json", "{}"); + var start = new ProcessStartInfo("cmd.exe") + { + Arguments = $"/d /c mklink /J \"{Roaming}\" \"{target}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var process = Process.Start(start)!; + Assert.True(process.WaitForExit(10_000)); + Assert.True(process.ExitCode == 0, + $"Junction creation failed: {process.StandardOutput.ReadToEnd()}\n{process.StandardError.ReadToEnd()}"); + Assert.Throws(() => Capture()); + Assert.Equal("{}", File.ReadAllText(Path.Combine(target, "settings.json"))); + Directory.Delete(Roaming); + } + + [Fact] + public async Task Capture_ValidLocalAiReceipt_RequiresNoModelOrRuntimePayload() + { + var revision = new string('a', 40); + var manifest = new LocalAiInstallManifest + { + EngineVersion = "b1", + Architecture = "x64", + RuntimeId = "llama-server-test", + ModelCatalogId = "test-model", + SelectedGpuId = "GPU-TEST", + ExecutablePath = Path.Combine("engines", "llama-server.exe"), + RuntimeAssets = [new() + { + FileName = "runtime.zip", + SourceUrl = "https://example.invalid/runtime.zip", + SizeBytes = 1, + Sha256 = new string('a', 64), + }], + ModelPath = Path.Combine("models", "owner", "repository", revision, "model.gguf"), + ModelId = $"owner/repository@{revision}", + ModelAlias = "test-model", + ModelAsset = new() + { + FileName = "model.gguf", + SourceUrl = $"https://huggingface.co/owner/repository/resolve/{revision}/model.gguf?download=true", + SizeBytes = 1, + Sha256 = new string('b', 64), + }, + ContextLength = 4096, + }; + var paths = new LocalAiPaths(Local); + await new LocalAiManifestStore(paths).SaveAsync(manifest); + var before = File.ReadAllBytes(paths.ManifestPath); + + var inventory = Capture(); + + Assert.Contains(inventory.Entries, entry => entry.CanonicalPath == paths.ManifestPath && entry.Exists); + Assert.Equal(before, File.ReadAllBytes(paths.ManifestPath)); + Assert.False(Directory.Exists(paths.ModelsDirectory)); + Assert.False(Directory.Exists(paths.EnginesDirectory)); + } + + private MigrationInventory Capture() => MigrationInventory.Capture(Roaming, Local); + + private GatewayRegistry WriteRegistry() + { + var registry = new GatewayRegistry(Roaming); + registry.AddOrUpdate(new GatewayRecord + { + Id = GatewayId, + Url = "wss://example.test", + SharedGatewayToken = "test", + }); + registry.SetActive(GatewayId); + registry.Save(); + return registry; + } + + private static string Write(string directory, string name, string content) + { + var path = Path.Combine(directory, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content, new UTF8Encoding(false)); + return path; + } + + public void Dispose() + { + _environment.Dispose(); + _directory.Dispose(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/MigrationOperationLockTests.cs b/tests/OpenClaw.Connection.Tests/MigrationOperationLockTests.cs new file mode 100644 index 000000000..3d48ad74d --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationOperationLockTests.cs @@ -0,0 +1,349 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using OpenClaw.Connection.Migration; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class MigrationOperationLockTests +{ + [Fact] + public void FirstRuntime_CreatesProtectedLockWithoutMigrationRecords() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using var runtime = MigrationOperationLock.AcquireRuntime(binding); + using var secondRuntime = MigrationOperationLock.AcquireRuntime(binding); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + + Assert.Single(Directory.GetFiles(directory)); + Assert.True(new DirectoryInfo(directory).GetAccessControl().AreAccessRulesProtected); + Assert.Equal(binding.UserSid, new DirectoryInfo(directory).GetAccessControl() + .GetOwner(typeof(SecurityIdentifier))!.Value); + Assert.Throws(() => MigrationOperationLock.AcquireExclusive(binding)); + } + + [Fact] + public void ExclusiveOwner_BlocksRuntimeWithoutChangingRecords() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireExclusive(binding)) + Assert.Throws(() => MigrationOperationLock.AcquireRuntime(binding)); + + using var released = MigrationOperationLock.AcquireRuntime(binding); + } + + [Fact] + public void ContendedAcquisition_IsDistinguishableFromAnInaccessibleLock() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using var runtime = MigrationOperationLock.AcquireRuntime(binding); + + // Inno startup and uninstall both key their fail-closed decision off this predicate: + // a contended lock proves a migration is live, while any other failure proves nothing. + var contended = Assert.Throws(() => MigrationOperationLock.AcquireExclusive(binding)); + Assert.True(MigrationOperationLock.IsBusy(contended)); + Assert.False(MigrationOperationLock.IsBusy(new IOException("unreadable"))); + Assert.False(MigrationOperationLock.IsBusy(new FileNotFoundException())); + } + + [Fact] + public void RepeatAcquisition_LeavesAnAlreadyHardenedDirectoryUntouched() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireRuntime(binding)) { } + var directory = new DirectoryInfo( + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName)); + + // The canary must be a trusted principal. A foreign grant is drift the acquisition is + // now required to scrub, so it cannot double as proof that no rewrite happened. + using var identity = WindowsIdentity.GetCurrent(); + var marker = identity.User!; + var security = directory.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule(marker, FileSystemRights.Read, + InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow)); + directory.SetAccessControl(security); + + using var reacquired = MigrationOperationLock.AcquireRuntime(binding); + + // Surviving the marker proves no DACL rewrite happened. Rewriting on every launch + // needs WRITE_DAC and WRITE_OWNER for a privilege the acquisition does not require. + Assert.Contains( + directory.GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast(), + rule => rule.IdentityReference.Value == marker.Value + && rule.InheritanceFlags == InheritanceFlags.None); + } + + [Fact] + public void DriftedDirectorySecurity_IsRehardenedOnNextAcquisition() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireRuntime(binding)) { } + var directory = new DirectoryInfo( + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName)); + + var security = directory.GetAccessControl(); + security.SetAccessRuleProtection(isProtected: false, preserveInheritance: true); + directory.SetAccessControl(security); + Assert.False(directory.GetAccessControl().AreAccessRulesProtected); + + using var reacquired = MigrationOperationLock.AcquireRuntime(binding); + + Assert.True(directory.GetAccessControl().AreAccessRulesProtected); + } + + [Fact] + public void ProtectedButPermissiveDirectory_LosesTheForeignGrantOnNextAcquisition() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireRuntime(binding)) { } + var directory = new DirectoryInfo( + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName)); + + // A protected DACL owned by this user can still let an unrelated principal delete + // completed.dpapi, which the uninstall checker reads as "no migration happened". + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + var security = directory.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + directory.SetAccessControl(security); + Assert.True(directory.GetAccessControl().AreAccessRulesProtected); + Assert.Contains( + directory.GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast(), + rule => Equals(rule.IdentityReference, users)); + + using var reacquired = MigrationOperationLock.AcquireRuntime(binding); + + Assert.DoesNotContain( + directory.GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast(), + rule => Equals(rule.IdentityReference, users)); + } + + [Fact] + public void ExplicitForeignGrantOnTheReceipt_IsScrubbedOnNextAcquisition() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireRuntime(binding)) { } + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var receipt = new FileInfo(Path.Combine(directory, MigrationRecordCodec.CompletionFileName)); + File.WriteAllBytes(receipt.FullName, [1, 2, 3]); + + // Hardening the directory rewrites inherited access only, so an explicit ACE placed + // directly on the receipt survives it and still permits deletion. + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + var security = receipt.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.FullControl, + AccessControlType.Allow)); + receipt.SetAccessControl(security); + + using var reacquired = MigrationOperationLock.AcquireRuntime(binding); + + Assert.DoesNotContain( + receipt.GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast(), + rule => Equals(rule.IdentityReference, users)); + } + + [Fact] + public void ProtectedReceiptOwnedByAnotherPrincipal_StillRequiresHardening() + { + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User!; + var foreign = new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null); + + // Trusted-looking rules are not durable when someone else owns the file: an owner + // keeps implicit WRITE_DAC and can rewrite this ACL, then delete the receipt. + var security = new FileSecurity(); + security.SetOwner(foreign); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var sid in new[] + { + owner, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + }) + { + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + AccessControlType.Allow)); + } + + Assert.True(MigrationOperationLock.RequiresEntryHardening(security, owner)); + + security.SetOwner(owner); + Assert.False(MigrationOperationLock.RequiresEntryHardening(security, owner)); + } + + [Fact] + public void ReceiptOwnedByAnotherPrincipal_IsNotSoundAuthorityEvenWhenInherited() + { + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User!; + var foreign = new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null); + var security = new FileSecurity(); + security.AddAccessRule(new FileSystemAccessRule(owner, FileSystemRights.FullControl, + AccessControlType.Allow)); + + // Inheriting trusted rules is fine, which is why prepare.lock passes. A foreign owner + // is not, because that owner can rewrite the ACL and delete the receipt afterwards. + security.SetOwner(owner); + Assert.True(MigrationOperationLock.EntryAuthorityIsSound(security, owner)); + + security.SetOwner(foreign); + Assert.False(MigrationOperationLock.EntryAuthorityIsSound(security, owner)); + } + + [Theory] + [InlineData(FileSystemRights.ReadAndExecute, true)] + [InlineData(FileSystemRights.ListDirectory, true)] + [InlineData(FileSystemRights.Delete, false)] + [InlineData(FileSystemRights.WriteData, false)] + [InlineData(FileSystemRights.ChangePermissions, false)] + [InlineData(FileSystemRights.TakeOwnership, false)] + [InlineData(FileSystemRights.FullControl, false)] + public void ForeignGrantBreaksAuthorityOnlyWhenItCanChangeTheReceipt( + FileSystemRights granted, bool expectedSound) + { + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User!; + var security = new FileSecurity(); + security.SetOwner(owner); + security.AddAccessRule(new FileSystemAccessRule(owner, FileSystemRights.FullControl, + AccessControlType.Allow)); + + // Managed profiles inherit read and list grants into %APPDATA%. A principal that cannot + // write or delete cannot have removed the receipt, so treating those as tampering would + // make every ordinary uninstall unverifiable and strand the gateway forever. + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), granted, + AccessControlType.Allow)); + + Assert.Equal(expectedSound, MigrationOperationLock.EntryAuthorityIsSound(security, owner)); + } + + // Generic rights have no names in FileSystemRights and intersect none of the specific bits, + // so a mask built only from named rights reads GENERIC_ALL as harmless. The kernel maps it to + // FILE_ALL_ACCESS, which includes DELETE and WRITE_DAC. These ACEs are built through SDDL + // because the FileSystemRights enum cannot express the raw values. + [Theory] + [InlineData("(A;;GA;;;BG)", false)] + [InlineData("(A;;GW;;;BG)", false)] + [InlineData("(A;;GR;;;BG)", true)] + [InlineData("(A;;GX;;;BG)", true)] + public void GenericRightsCountAsMutatingEvenWithoutNamedBits(string ace, bool expectedSound) + { + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User!; + var security = new FileSecurity(); + security.SetSecurityDescriptorSddlForm( + $"O:{owner.Value}G:{owner.Value}D:(A;;FA;;;{owner.Value}){ace}"); + + Assert.Equal(expectedSound, MigrationOperationLock.EntryAuthorityIsSound(security, owner)); + } + + [Fact] + public void WrongUserBinding_IsRejectedBeforeCreatingDirectory() + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + binding.UserSid = "S-1-5-18"; + Assert.Throws(() => MigrationOperationLock.AcquireRuntime(binding)); + Assert.False(Directory.Exists(binding.RoamingDirectory)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RuntimeInAnotherProcess_ExcludesStoreUntilNormalExitOrCrash(bool crash) + { + using var temp = new TempDirectory(); + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + using (MigrationOperationLock.AcquireRuntime(binding)) { } + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.Environment["OPENCLAW_TEST_LOCK"] = + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, "prepare.lock"); + const string script = """ + $ErrorActionPreference = 'Stop' + $lease = [IO.File]::Open($env:OPENCLAW_TEST_LOCK, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + [Console]::Out.WriteLine('ready') + [Console]::Out.Flush() + [Console]::ReadLine() | Out-Null + $lease.Dispose() + """; + foreach (var argument in new[] { "-NoProfile", "-EncodedCommand", + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)) }) + start.ArgumentList.Add(argument); + + using var child = Process.Start(start)!; + var errors = child.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + Assert.Equal("ready", await child.StandardOutput.ReadLineAsync(timeout.Token)); + Assert.False(child.HasExited); + using (MigrationOperationLock.AcquireRuntime(binding)) + { + Assert.Throws(() => MigrationOperationLock.AcquireExclusive(binding)); + // Independent uninstall readers coexist with a running source. + using var uninstall = File.Open(start.Environment["OPENCLAW_TEST_LOCK"]!, + FileMode.Open, FileAccess.Read, FileShare.Read); + } + Assert.Throws(() => MigrationOperationLock.AcquireExclusive(binding)); + if (crash) + child.Kill(); + else + await child.StandardInput.WriteLineAsync("exit"); + await child.WaitForExitAsync(timeout.Token); + if (!crash) + Assert.True(child.ExitCode == 0, await errors); + using var exclusive = await AcquireAfterExitAsync(binding); + } + finally + { + if (!child.HasExited) + { + child.Kill(); + await child.WaitForExitAsync(); + } + } + } + + private static async Task AcquireAfterExitAsync(MigrationBinding binding) + { + var elapsed = Stopwatch.StartNew(); + while (true) + { + try + { + return MigrationOperationLock.AcquireExclusive(binding); + } + catch (IOException exception) when (MigrationOperationLock.IsBusy(exception) && + elapsed.Elapsed < TimeSpan.FromSeconds(5)) + { + // Wait for kernel handle cleanup after forcible process termination. + await Task.Delay(25); + } + } + } +} diff --git a/tests/OpenClaw.Connection.Tests/MigrationPreparationTests.cs b/tests/OpenClaw.Connection.Tests/MigrationPreparationTests.cs new file mode 100644 index 000000000..2d4443dd0 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationPreparationTests.cs @@ -0,0 +1,136 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using OpenClaw.Connection.Migration; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +[Collection("Migration preparation")] +public sealed class MigrationPreparationTests +{ + [Fact] + public void Preparation_IsProtectedAtomicAndIdempotent() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + using var environment = IsolateApprovals(); + File.WriteAllText(Path.Combine(binding.RoamingDirectory, "settings.json"), "{\"AutoStart\":true}"); + var preparation = new MigrationPreparation(binding); + var first = preparation.Prepare("2026.9.17.0"); + var second = preparation.Prepare("2026.9.17.0"); + Assert.Equal(first.MigrationId, second.MigrationId); + Assert.Equal(first.Fingerprint, second.Fingerprint); + Assert.True(first.AutoStart); + Assert.Equal(first.CreatedUtc.AddDays(30), first.ExpiresUtc); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var bytes = File.ReadAllBytes(Path.Combine(directory, MigrationRecordCodec.IntentFileName)); + var decoded = MigrationRecordCodec.Decode(bytes, binding, DateTime.UtcNow); + Assert.Equal(first.MigrationId, decoded.MigrationId); + Assert.Equal("intent", decoded.Kind); + Assert.Empty(Directory.GetFiles(directory, "*.tmp")); + Assert.False(File.Exists(Path.Combine(directory, MigrationRecordCodec.CompletionFileName))); + var acl = new DirectoryInfo(directory).GetAccessControl(); + Assert.True(acl.AreAccessRulesProtected); + Assert.Equal(binding.UserSid, Assert.IsType(acl.GetOwner(typeof(SecurityIdentifier))).Value); + var allowed = new[] { binding.UserSid, "S-1-5-18", "S-1-5-32-544" }; + foreach (FileSystemAccessRule rule in acl.GetAccessRules(true, true, typeof(SecurityIdentifier))) + Assert.Contains(rule.IdentityReference.Value, allowed); + } + + [Fact] + public void ChangedSource_CreatesFreshConsentInventoryWithoutMutatingSource() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + using var environment = IsolateApprovals(); + var settings = Path.Combine(binding.RoamingDirectory, "settings.json"); + File.WriteAllText(settings, "{\"AutoStart\":false}"); + var preparation = new MigrationPreparation(binding); + var first = preparation.Prepare("2026.9.17.0"); + File.WriteAllText(settings, "{\"AutoStart\":true}"); + var second = preparation.Prepare("2026.9.17.0"); + Assert.NotEqual(first.MigrationId, second.MigrationId); + Assert.NotEqual(first.Fingerprint, second.Fingerprint); + Assert.Equal("{\"AutoStart\":true}", File.ReadAllText(settings)); + } + + [Fact] + public void CorruptIntent_IsNotSilentlyReplaced() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + using var environment = IsolateApprovals(); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, MigrationRecordCodec.IntentFileName); + File.WriteAllBytes(path, [1, 2, 3]); + Assert.Throws(() => + new MigrationPreparation(binding).Prepare("2026.9.17.0")); + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(path)); + } + + [Fact] + public void ExistingCompletion_PreventsNewPreparationEvenIfUnreadable() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, MigrationRecordCodec.CompletionFileName), "invalid"); + Assert.Throws(() => new MigrationPreparation(binding).Prepare("2026.9.17.0")); + Assert.False(File.Exists(Path.Combine(directory, MigrationRecordCodec.IntentFileName))); + } + + [Fact] + public void RenewedConsent_ReplacesExpiredIntentButNotBeforeExpiry() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + using var environment = IsolateApprovals(); + var now = new DateTimeOffset(2026, 9, 17, 0, 0, 0, TimeSpan.Zero); + var first = new MigrationPreparation(binding, new FixedClock(now)).Prepare("2026.9.17.0"); + var reused = new MigrationPreparation(binding, new FixedClock(now.AddDays(29))).Prepare("2026.9.17.0"); + var renewed = new MigrationPreparation(binding, new FixedClock(now.AddDays(30))).Prepare("2026.9.17.0"); + Assert.Equal(first.MigrationId, reused.MigrationId); + Assert.NotEqual(first.MigrationId, renewed.MigrationId); + Assert.Equal(now.AddDays(60).UtcDateTime, renewed.ExpiresUtc); + } + + [Fact] + public void ConcurrentPreparation_IsRejectedWithoutReplacingIntent() + { + using var temp = new TempDirectory(); + var binding = CreateBinding(temp); + using var environment = IsolateApprovals(); + var preparation = new MigrationPreparation(binding); + var first = preparation.Prepare("2026.9.17.0"); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var path = Path.Combine(directory, MigrationRecordCodec.IntentFileName); + var bytes = File.ReadAllBytes(path); + using var locked = new FileStream(Path.Combine(directory, "prepare.lock"), + FileMode.Open, FileAccess.ReadWrite, FileShare.None); + Assert.Throws(() => preparation.Prepare("2026.9.17.0")); + Assert.Equal(bytes, File.ReadAllBytes(path)); + } + + private static MigrationBinding CreateBinding(TempDirectory temp) + { + var binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + Directory.CreateDirectory(binding.RoamingDirectory); + Directory.CreateDirectory(binding.LocalDirectory); + return binding; + } + + private static EnvironmentScope IsolateApprovals() => + new EnvironmentScope().Set("OPENCLAW_STATE_DIR", null).Set("OPENCLAW_HOME", null); + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} + +[CollectionDefinition("Migration preparation", DisableParallelization = true)] +public sealed class MigrationPreparationCollection; diff --git a/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs b/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs new file mode 100644 index 000000000..7db17b793 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationRecordTests.cs @@ -0,0 +1,605 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using OpenClaw.Connection.Migration; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class MigrationRecordTests +{ + private static readonly DateTime Now = new(2026, 9, 17, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData("intent")] + [InlineData("completed")] + public void ProtectedRecord_RoundTripsWithoutPlaintext(string kind) + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, kind); + var bytes = MigrationRecordCodec.Encode(record, Now); + Assert.DoesNotContain(record.InventoryJson.Length > 0 ? record.InventoryJson : record.MigrationId, + Encoding.UTF8.GetString(bytes)); + var decoded = MigrationRecordCodec.Decode(bytes, record.Binding, Now); + Assert.Equal(record.MigrationId, decoded.MigrationId); + Assert.Equal(record.Kind, decoded.Kind); + Assert.Equal(record.InventoryJson, decoded.InventoryJson); + Assert.Equal(record.Fingerprint, decoded.Fingerprint); + Assert.Equal(record.AutoStart, decoded.AutoStart); + } + + [Fact] + public void Intent_ExpiresAtThirtyDays_ButCanBeReadForNewConsent() + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "intent"); + var bytes = MigrationRecordCodec.Encode(record, Now); + Assert.Equal("intent", MigrationRecordCodec.Decode(bytes, record.Binding, Now.AddDays(30).AddTicks(-1)).Kind); + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now.AddDays(30))); + Assert.Equal(record.MigrationId, + MigrationRecordCodec.DecodeForRenewedConsent(bytes, record.Binding, Now.AddDays(31)).MigrationId); + } + + [Theory] + [InlineData(-1)] + [InlineData(-3600)] + [InlineData(-315360000)] + [InlineData(315360000)] + public void Completion_RemainsValidRegardlessOfClockChanges(int secondsAfterCompletion) + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "completed"); + var bytes = MigrationRecordCodec.Encode(record, Now); + var receiptPath = temp.Combine("completed.dpapi"); + File.WriteAllBytes(receiptPath, bytes); + + var decoded = MigrationRecordCodec.ReadCompletion( + receiptPath, record.Binding, Now.AddSeconds(secondsAfterCompletion)); + + Assert.Equal("completed", decoded.Kind); + Assert.Equal(record.MigrationId, decoded.MigrationId); + Assert.Equal(record.CreatedUtc, decoded.CreatedUtc); + Assert.Equal(bytes, File.ReadAllBytes(receiptPath)); + } + + [Fact] + public void Intent_StillRejectsFutureCreationAfterClockRollback() + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "intent"); + var bytes = MigrationRecordCodec.Encode(record, Now); + + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now.AddSeconds(-1))); + Assert.Throws(() => + MigrationRecordCodec.DecodeForRenewedConsent(bytes, record.Binding, Now.AddSeconds(-1))); + } + + [Theory] + [InlineData("user")] + [InlineData("install")] + [InlineData("roaming")] + [InlineData("local")] + [InlineData("architecture")] + public void Receipt_IsBoundToUserAndInstallation(string field) + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "completed"); + var bytes = MigrationRecordCodec.Encode(record, Now); + switch (field) + { + case "user": record.Binding.UserSid = "S-1-5-18"; break; + case "install": record.Binding.InstallDirectory += "-other"; break; + case "roaming": record.Binding.RoamingDirectory += "-other"; break; + case "local": record.Binding.LocalDirectory += "-other"; break; + case "architecture": record.Binding.Architecture = "arm64"; break; + } + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now)); + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now.AddSeconds(-1))); + } + + [Theory] + [InlineData("kind")] + [InlineData("id")] + [InlineData("fingerprint")] + [InlineData("version")] + [InlineData("future")] + [InlineData("expiry")] + public void MalformedReceipt_CannotBeEncoded(string field) + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "completed"); + switch (field) + { + case "kind": record.Kind = "importing"; break; + case "id": record.MigrationId = ""; break; + case "fingerprint": record.Fingerprint = new string('z', 64); break; + case "version": record.TargetVersion = ""; break; + case "future": record.CreatedUtc = Now.AddDays(1); break; + case "expiry": record.ExpiresUtc = Now.AddDays(30); break; + } + Assert.Throws(() => MigrationRecordCodec.Encode(record, Now)); + } + + [Fact] + public void CorruptProtectedReceipt_IsRejected() + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "completed"); + var bytes = MigrationRecordCodec.Encode(record, Now); + bytes[^1] ^= 0x80; + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now)); + } + + [Fact] + public void WrongPackageContract_IsRejected() + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "completed"); + var entropy = Encoding.UTF8.GetBytes("OpenClaw.InnoToStore.Migration.v1"); + var plain = ProtectedData.Unprotect(MigrationRecordCodec.Encode(record, Now), entropy, DataProtectionScope.CurrentUser); + var name = Encoding.UTF8.GetBytes(MigrationRecordCodec.PackageName); + var index = plain.AsSpan().IndexOf(name); + Assert.True(index >= 0); + plain[index] = (byte)'X'; + var bytes = ProtectedData.Protect(plain, entropy, DataProtectionScope.CurrentUser); + Assert.Throws(() => MigrationRecordCodec.Decode(bytes, record.Binding, Now)); + } + + [Fact] + public void Intent_CannotAuthorizeUninstall() + { + using var temp = new TempDirectory(); + var record = CreateRecord(temp, "intent"); + var path = temp.Combine("completed.dpapi"); + File.WriteAllBytes(path, MigrationRecordCodec.Encode(record, Now)); + Assert.Throws(() => MigrationRecordCodec.ReadCompletion(path, record.Binding, Now)); + } + + [Theory] + [InlineData("completed", 10, false)] + [InlineData("intent", 2, false)] + [InlineData("missing", 0, false)] + [InlineData("missing-readonly-foreign", 0, false)] + [InlineData("missing-tampered", 2, false)] + [InlineData("corrupt", 2, false)] + [InlineData("wrong-path", 2, false)] + [InlineData("missing-codec", 2, false)] + [InlineData("completed", 10, true)] + [InlineData("intent", 2, true)] + [InlineData("corrupt", 2, true)] + [InlineData("wrong-path", 2, true)] + public async Task WindowsPowerShellChecker_UsesTheSameContract(string kind, int expectedExit, bool clockRollback) + { + using var temp = new TempDirectory(); + var root = RepositoryRoot(); + var record = CreateRecord(temp, kind == "intent" ? "intent" : "completed"); + Directory.CreateDirectory(record.Binding.InstallDirectory); + if (kind != "missing-codec") + File.Copy(Path.Combine(root, "src", "OpenClaw.Connection", "Migration", "MigrationRecordCodec.cs"), + Path.Combine(record.Binding.InstallDirectory, "MigrationRecordCodec.cs")); + // Encode at an injected later time to simulate rollback without changing Windows' clock. + record.CreatedUtc = clockRollback ? DateTime.UtcNow.AddHours(1) : DateTime.UtcNow.AddMinutes(-1); + if (kind == "intent") + record.ExpiresUtc = record.CreatedUtc.AddDays(30); + var directory = Path.Combine(record.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + if (kind == "missing-readonly-foreign") + { + // Managed profiles inherit read/list grants into %APPDATA%. A principal that + // cannot write or delete cannot have removed the receipt, so cleanup stays + // authorized; treating this as tampering would trap every ordinary uninstall. + var readable = new DirectoryInfo(directory); + var granted = readable.GetAccessControl(); + granted.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), + FileSystemRights.ReadAndExecute, AccessControlType.Allow)); + readable.SetAccessControl(granted); + } + if (kind == "missing-tampered") + { + // A principal with access here could have deleted the receipt, so an absent + // receipt is no longer evidence that migration never happened. + var info = new DirectoryInfo(directory); + var security = info.GetAccessControl(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), + FileSystemRights.FullControl, AccessControlType.Allow)); + info.SetAccessControl(security); + } + if (!kind.StartsWith("missing", StringComparison.Ordinal) || kind == "missing-codec") + { + var bytes = kind == "corrupt" ? new byte[] { 1, 2, 3 } : MigrationRecordCodec.Encode(record, record.CreatedUtc); + File.WriteAllBytes(Path.Combine(directory, MigrationRecordCodec.CompletionFileName), bytes); + } + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + // Point the checker at an isolated repository holding only unrelated packages. The + // developer machines and self-hosted agents that run this suite may have the real Store + // package installed, which legitimately preserves the gateway and would make the + // no-migration cases below fail on those machines and pass everywhere else. + using var repository = new StoreMigrationPackagePresenceTests.TempPackageRepository(); + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", + Path.Combine(root, "scripts", "Test-InnoMigration.ps1"), + "-AppRoot", record.Binding.InstallDirectory, + "-Architecture", kind == "wrong-path" ? "arm64" : "x64", + "-RoamingDirectory", record.Binding.RoamingDirectory, + "-LocalDirectory", record.Binding.LocalDirectory, + "-PackageRepositoryKey", repository.KeyPath + }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + await AssertScriptExitAsync(process, expectedExit); + } + + // Ownership is the P1 class: an owner keeps implicit WRITE_DAC and can delete the receipt + // whatever the DACL says. Creating a genuinely foreign-owned file needs privileges CI lacks, + // so the pure predicate is lifted out of the script by AST and driven with in-memory ACLs. + // Each case fails if the corresponding clause is removed, so this covers the PowerShell copy + // of the rule rather than only the C# mirror. + [Theory] + [InlineData("trusted-owner-clean", true)] + [InlineData("foreign-owner", false)] + [InlineData("foreign-readonly-grant", true)] + [InlineData("foreign-delete-grant", false)] + [InlineData("foreign-write-grant", false)] + [InlineData("foreign-full-control-grant", false)] + [InlineData("foreign-inherit-only-grant", true)] + [InlineData("foreign-generic-all-grant", false)] + [InlineData("foreign-generic-write-grant", false)] + [InlineData("foreign-generic-read-grant", true)] + [InlineData("foreign-deny-grant", true)] + public async Task PowerShellAuthorityPredicate_AcceptsOnlyNonMutatingForeignGrants( + string scenario, bool expectedSound) + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(RepositoryRoot(), "scripts", "Test-InnoMigration.ps1"); + var probePath = Path.Combine(temp.Path, "probe.ps1"); + File.WriteAllText(probePath, $$""" + $ErrorActionPreference = 'Stop' + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + '{{scriptPath.Replace("'", "''")}}', [ref]$null, [ref]$null) + $fn = $ast.FindAll({ $args[0] -is + [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | + Where-Object { $_.Name -eq 'Test-AclAuthority' } + if (-not $fn) { Write-Error 'Test-AclAuthority not found'; exit 3 } + Invoke-Expression $fn.Extent.Text + + $me = [Security.Principal.WindowsIdentity]::GetCurrent().User + $trusted = @($me.Value, 'S-1-5-18', 'S-1-5-32-544') + $guests = New-Object Security.Principal.SecurityIdentifier( + [Security.Principal.WellKnownSidType]::BuiltinGuestsSid, $null) + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetOwner($me) + $scenario = '{{scenario}}' + switch ($scenario) { + 'foreign-owner' { $acl.SetOwner($guests) } + 'foreign-readonly-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'ReadAndExecute', 'Allow'))) + } + 'foreign-delete-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'Delete', 'Allow'))) + } + 'foreign-write-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'Write', 'Allow'))) + } + 'foreign-full-control-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'FullControl', 'Allow'))) + } + 'foreign-inherit-only-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'FullControl', 'ContainerInherit, ObjectInherit', 'InheritOnly', + 'Allow'))) + } + 'foreign-deny-grant' { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $guests, 'FullControl', 'Deny'))) + } + 'foreign-generic-all-grant' { + $acl.SetSecurityDescriptorSddlForm( + "O:$($me.Value)G:$($me.Value)D:(A;;FA;;;$($me.Value))(A;;GA;;;BG)") + } + 'foreign-generic-write-grant' { + $acl.SetSecurityDescriptorSddlForm( + "O:$($me.Value)G:$($me.Value)D:(A;;FA;;;$($me.Value))(A;;GW;;;BG)") + } + 'foreign-generic-read-grant' { + $acl.SetSecurityDescriptorSddlForm( + "O:$($me.Value)G:$($me.Value)D:(A;;FA;;;$($me.Value))(A;;GR;;;BG)") + } + } + $actual = [bool](Test-AclAuthority -Acl $acl -Trusted $trusted) + $expected = [bool]::Parse('{{expectedSound}}') + if ($actual -ne $expected) { + Write-Error "scenario $scenario expected $expected but got $actual" + exit 1 + } + exit 0 + """); + var start = new ProcessStartInfo(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in new[] + { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", probePath }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + await AssertScriptExitAsync(process, 0); + } + + // The AST test proves the predicate is correct, not that the shipped decision still runs it. + // A refactor that stopped calling it would leave every case above green while the uninstall + // branch silently returned to authorizing destruction, so pin the wiring too. + [Fact] + public void PowerShellChecker_RoutesTheAbsentReceiptBranchThroughTheAuthorityPredicate() + { + var script = File.ReadAllText( + Path.Combine(RepositoryRoot(), "scripts", "Test-InnoMigration.ps1")); + + var gathering = FunctionBody(script, "Test-MigrationStateAuthority"); + Assert.Contains("Test-AclAuthority", gathering, StringComparison.Ordinal); + + var absentBranch = script[script.IndexOf("FileNotFoundException", StringComparison.Ordinal)..]; + var exitZero = absentBranch.IndexOf("exit 0", StringComparison.Ordinal); + var authorityCall = absentBranch.IndexOf("Test-MigrationStateAuthority", StringComparison.Ordinal); + Assert.True(authorityCall >= 0, "The absent-receipt branch must consult the authority check."); + Assert.True(authorityCall < exitZero, + "The authority check must gate exit 0 rather than follow it."); + } + + private static string FunctionBody(string script, string name) + { + var start = script.IndexOf($"function {name} {{", StringComparison.Ordinal); + Assert.True(start >= 0, $"{name} was not found in the script."); + var next = script.IndexOf("\nfunction ", start + 1, StringComparison.Ordinal); + return next < 0 ? script[start..] : script[start..next]; + } + + [Theory] + [InlineData(null, 10, false)] + [InlineData(FileShare.Read, 10, false)] + [InlineData(FileShare.None, 2, false)] + [InlineData(null, 10, true)] + [InlineData(FileShare.Read, 10, true)] + public async Task CleanupScript_CompletedReceiptPreservesFilesWithoutCallingWsl( + FileShare? parentShare, int expectedExit, bool clockRollback) + { + using var temp = new TempDirectory(); + var root = RepositoryRoot(); + var record = CreateRecord(temp, "completed"); + record.CreatedUtc = clockRollback ? DateTime.UtcNow.AddHours(1) : DateTime.UtcNow.AddMinutes(-1); + Directory.CreateDirectory(record.Binding.InstallDirectory); + Directory.CreateDirectory(record.Binding.LocalDirectory); + File.Copy(Path.Combine(root, "src", "OpenClaw.Connection", "Migration", "MigrationRecordCodec.cs"), + Path.Combine(record.Binding.InstallDirectory, "MigrationRecordCodec.cs")); + File.Copy(Path.Combine(root, "scripts", "Test-InnoMigration.ps1"), + Path.Combine(record.Binding.InstallDirectory, "Test-InnoMigration.ps1")); + var directory = Path.Combine(record.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, MigrationRecordCodec.CompletionFileName), + MigrationRecordCodec.Encode(record, record.CreatedUtc)); + using var parentLock = parentShare is { } share + ? new FileStream(Path.Combine(directory, "prepare.lock"), FileMode.OpenOrCreate, + share == FileShare.Read ? FileAccess.Read : FileAccess.ReadWrite, share) + : null; + var sentinel = Path.Combine(record.Binding.RoamingDirectory, "settings.json"); + File.WriteAllText(sentinel, "{\"testSentinel\":true}"); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.Environment["OPENCLAW_TRAY_DATA_DIR"] = record.Binding.RoamingDirectory; + start.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"] = record.Binding.LocalDirectory; + start.Environment["OPENCLAW_STATE_DIR"] = temp.Combine("approvals"); + start.Environment.Remove("OPENCLAW_TRAY_LOCALAPPDATA_DIR"); + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", + Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"), + "-AppRoot", record.Binding.InstallDirectory, "-Architecture", "x64", + "-AutoStartName", "OpenClawMigrationTest-" + Guid.NewGuid().ToString("N"), + "-StartupTaskName", "OpenClawMigrationTest-" + Guid.NewGuid().ToString("N"), + // Never point an integration fixture at the user's real gateway, even on regression. + "-DistroName", "OpenClawMigrationTest-" + Guid.NewGuid().ToString("N") + }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + await AssertScriptExitAsync(process, expectedExit, + Path.Combine(record.Binding.InstallDirectory, "uninstall-gateway-wsl.log")); + Assert.Equal("{\"testSentinel\":true}", File.ReadAllText(sentinel)); + Assert.DoesNotContain("Starting local gateway cleanup", File.ReadAllText( + Path.Combine(record.Binding.InstallDirectory, "uninstall-gateway-wsl.log"))); + parentLock?.Dispose(); + using var releasedLock = new FileStream(Path.Combine(directory, "prepare.lock"), + FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ScriptTimeout_TerminatesProcessTreeAndPreservesFailureDiagnostics(bool lockLog) + { + using var temp = new TempDirectory(); + var logPath = temp.Combine("cleanup.log"); + File.WriteAllText(logPath, "cleanup checkpoint"); + using var logLock = lockLog ? File.Open(logPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None) : null; + var readyName = @"Local\OpenClawMigrationTest-" + Guid.NewGuid().ToString("N"); + using var ready = new EventWaitHandle(false, EventResetMode.ManualReset, readyName); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.Environment["OPENCLAW_TEST_READY"] = readyName; + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-Command", + """ + $ErrorActionPreference = 'Stop' + $child = Start-Process -FilePath "$PSHOME\powershell.exe" -NoNewWindow -PassThru ` + -ArgumentList '-NoProfile -NonInteractive -Command Start-Sleep -Seconds 60' + [Console]::Out.WriteLine([string]$child.Id) + [Console]::Out.WriteLine('timeout stdout') + [Console]::Error.WriteLine('timeout stderr') + $ready = [Threading.EventWaitHandle]::OpenExisting($env:OPENCLAW_TEST_READY) + $null = $ready.Set() + $ready.Dispose() + Start-Sleep -Seconds 60 + """ + }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + Process? child = null; + try + { + Assert.True(await Task.Run(() => ready.WaitOne(TimeSpan.FromSeconds(30))), + "Timeout fixture did not signal readiness."); + var childId = await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotNull(childId); + child = Process.GetProcessById(int.Parse(childId)); + var error = await Assert.ThrowsAsync(() => + AssertScriptExitAsync(process, 0, logPath, TimeSpan.FromMilliseconds(250))); + + Assert.True(process.HasExited); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(child.HasExited); + Assert.IsAssignableFrom(error.InnerException); + Assert.Contains("timeout stdout", error.Message); + Assert.Contains("timeout stderr", error.Message); + Assert.Contains(lockLog ? "Script log unavailable: IOException" : "cleanup checkpoint", error.Message); + } + finally + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + if (child is { HasExited: false }) + child.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + if (child is not null) + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { child?.Dispose(); } + } + } + + private static async Task AssertScriptExitAsync(Process process, int expectedExit, + string? diagnosticLog = null, TimeSpan? deadline = null) + { + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + var limit = deadline ?? TimeSpan.FromSeconds(30); + using var timeout = new CancellationTokenSource(limit); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException exception) when (timeout.IsCancellationRequested) + { + // Stop cleanup before the caller releases its lock or deletes the fixture. + var cleanup = "Root process exit confirmed."; + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception error) when (error is System.ComponentModel.Win32Exception or + InvalidOperationException or TimeoutException) + { + cleanup = $"Process cleanup failed: {error.GetType().Name}: {error.Message}"; + } + var output = await Task.WhenAll( + ReadTimeoutOutputAsync(stdout), ReadTimeoutOutputAsync(stderr)); + var log = "(no script log was requested)"; + if (diagnosticLog is not null) + { + try { log = File.ReadAllText(diagnosticLog); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + log = $"Script log unavailable: {error.GetType().Name}: {error.Message}"; + } + } + throw new TimeoutException( + $"Migration script PID {process.Id} exceeded {limit.TotalSeconds:g} seconds.\n" + + $"{cleanup}\nStandard output:\n{output[0]}\nStandard error:\n{output[1]}\nScript log:\n{log}", + exception); + } + await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(process.ExitCode == expectedExit, $"{await stdout}\n{await stderr}\nExit: {process.ExitCode}"); + } + + private static async Task ReadTimeoutOutputAsync(Task output) + { + try { return await output.WaitAsync(TimeSpan.FromSeconds(5)); } + catch (Exception error) when (error is TimeoutException or IOException or ObjectDisposedException) + { + // A pipe read can fault later when the caller disposes the timed-out process. + _ = output.ContinueWith(completed => { _ = completed.Exception; }, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return $"Output unavailable: {error.GetType().Name}: {error.Message}"; + } + } + + internal static MigrationRecord CreateRecord(TempDirectory temp, string kind) + { + return new MigrationRecord + { + Kind = kind, + MigrationId = Guid.NewGuid().ToString("D"), + SourceVersion = "2026.9.17.0", + TargetVersion = kind == "completed" ? "2026.9.18.0" : "", + Fingerprint = new string('a', 64), + InventoryJson = kind == "intent" ? "{\"sensitiveInventory\":\"test-only\"}" : "", + AutoStart = true, + CreatedUtc = Now, + ExpiresUtc = kind == "completed" ? DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc) : Now.AddDays(30), + Binding = new MigrationBinding + { + InstallDirectory = temp.Combine("install"), + RoamingDirectory = temp.Combine("roaming"), + LocalDirectory = temp.Combine("local"), + Architecture = "x64", + UserSid = WindowsIdentity.GetCurrent().User!.Value + } + }; + } + + internal static string RepositoryRoot() + { + var directory = new DirectoryInfo(Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") ?? AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "installer.iss"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); + } +} diff --git a/tests/OpenClaw.Connection.Tests/MigrationStartupRecordReaderTests.cs b/tests/OpenClaw.Connection.Tests/MigrationStartupRecordReaderTests.cs new file mode 100644 index 000000000..b029b737b --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationStartupRecordReaderTests.cs @@ -0,0 +1,175 @@ +using System.Runtime.Versioning; +using System.Security.Principal; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class MigrationStartupRecordReaderTests +{ + private static readonly DateTime Now = new(2026, 9, 21, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void MissingRecords_DoesNotCreateAnyDirectory() + { + using var fixture = new Fixture(); + + Assert.Equal(MigrationStartupRecordStatus.None, fixture.Read().Status); + Assert.False(Directory.Exists(fixture.Binding.RoamingDirectory)); + } + + [Theory] + [InlineData("intent", MigrationStartupRecordStatus.Intent)] + [InlineData("completed", MigrationStartupRecordStatus.Completed)] + public void ValidRecord_IsReadWithoutMutation(string kind, MigrationStartupRecordStatus expected) + { + using var fixture = new Fixture(); + var path = fixture.Write(kind); + var before = File.ReadAllBytes(path); + var timestamp = File.GetLastWriteTimeUtc(path); + + var result = fixture.Read(); + + Assert.Equal(expected, result.Status); + Assert.Equal(kind, result.Record!.Kind); + Assert.Equal(before, File.ReadAllBytes(path)); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(path)); + Assert.Single(Directory.GetFiles(Path.GetDirectoryName(path)!)); + } + + [Fact] + public void ExpiredIntent_RemainsPendingWithoutRenewingConsent() + { + using var fixture = new Fixture(); + var path = fixture.Write("intent", Now.AddDays(-31)); + var before = File.ReadAllBytes(path); + + var result = fixture.Read(); + + Assert.Equal(MigrationStartupRecordStatus.Intent, result.Status); + Assert.Equal(Now.AddDays(-1), result.Record!.ExpiresUtc); + Assert.Equal(before, File.ReadAllBytes(path)); + } + + [Fact] + public void Completion_TakesPrecedenceOverOldOrCorruptIntent() + { + using var fixture = new Fixture(); + fixture.Write("completed"); + File.WriteAllBytes(Path.Combine(fixture.Directory, MigrationRecordCodec.IntentFileName), [1, 2, 3]); + + Assert.Equal(MigrationStartupRecordStatus.Completed, fixture.Read().Status); + } + + [Fact] + public void CorruptCompletion_DoesNotFallBackToValidIntent() + { + using var fixture = new Fixture(); + fixture.Write("intent"); + File.WriteAllBytes(Path.Combine(fixture.Directory, MigrationRecordCodec.CompletionFileName), [1, 2, 3]); + + Assert.Equal(MigrationStartupRecordStatus.Invalid, fixture.Read().Status); + } + + [Theory] + [InlineData("intent", "completed.dpapi")] + [InlineData("completed", "intent.dpapi")] + public void WrongRecordKind_RequiresRecovery(string kind, string destination) + { + using var fixture = new Fixture(); + var original = fixture.Write(kind); + File.Move(original, Path.Combine(fixture.Directory, destination)); + + Assert.Equal(MigrationStartupRecordStatus.Invalid, fixture.Read().Status); + } + + [Theory] + [InlineData("user")] + [InlineData("architecture")] + [InlineData("install")] + public void WrongBinding_RequiresRecovery(string field) + { + using var fixture = new Fixture(); + fixture.Write("completed"); + switch (field) + { + case "user": fixture.Binding.UserSid = "S-1-5-18"; break; + case "architecture": fixture.Binding.Architecture = "arm64"; break; + case "install": fixture.Binding.InstallDirectory += "-different"; break; + } + + Assert.Equal(MigrationStartupRecordStatus.Invalid, fixture.Read().Status); + } + + [Fact] + public void LockedRecord_IsUnavailableNotAbsent() + { + using var fixture = new Fixture(); + var path = fixture.Write("completed"); + using var locked = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + Assert.Equal(MigrationStartupRecordStatus.Unavailable, fixture.Read().Status); + } + + [Theory] + [InlineData(0)] + [InlineData(MigrationRecordCodec.MaximumRecordBytes + 1)] + public void InvalidSize_RequiresRecovery(int size) + { + using var fixture = new Fixture(); + var path = fixture.Write("intent"); + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + stream.SetLength(size); + + Assert.Equal(MigrationStartupRecordStatus.Invalid, fixture.Read().Status); + } + + private sealed class Fixture : IDisposable + { + private readonly TempDirectory _temp = new(); + public MigrationBinding Binding { get; } + public string Directory => Path.Combine(Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + + public Fixture() + { + Binding = new MigrationBinding + { + InstallDirectory = _temp.Combine("install"), + RoamingDirectory = _temp.Combine("roaming"), + LocalDirectory = _temp.Combine("local"), + Architecture = "x64", + UserSid = WindowsIdentity.GetCurrent().User!.Value + }; + } + + public string Write(string kind, DateTime? created = null) + { + var time = created ?? Now.AddMinutes(-1); + var record = new MigrationRecord + { + Kind = kind, MigrationId = Guid.NewGuid().ToString("D"), + SourceVersion = "2026.9.1", TargetVersion = kind == "completed" ? "2026.9.2" : "", + CreatedUtc = time, + ExpiresUtc = kind == "completed" ? DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc) : time.AddDays(30), + Fingerprint = new string('a', 64), InventoryJson = kind == "intent" ? "{}" : "", + Binding = Binding + }; + System.IO.Directory.CreateDirectory(Directory); + var path = Path.Combine(Directory, kind == "intent" ? MigrationRecordCodec.IntentFileName : MigrationRecordCodec.CompletionFileName); + File.WriteAllBytes(path, MigrationRecordCodec.Encode(record, time)); + return path; + } + + public MigrationStartupRecord Read() => + new MigrationStartupRecordReader(Binding, NullLogger.Instance, new Clock()).Read(); + + public void Dispose() => _temp.Dispose(); + } + + private sealed class Clock : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } +} diff --git a/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs b/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs new file mode 100644 index 000000000..2ab727633 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/MigrationVersionPolicyTests.cs @@ -0,0 +1,32 @@ +using OpenClaw.Connection.Migration; + +namespace OpenClaw.Connection.Tests; + +public sealed class MigrationVersionPolicyTests +{ + [Theory] + [InlineData("2026.9.1", "2026.9.1.0")] + [InlineData("2026.9.1.0", "2026.9.1.0")] + [InlineData("2026.9.1.12", "2026.9.1.12")] + public void StableRelease_NormalizesMissingRevision(string text, string expected) + { + Assert.True(MigrationVersionPolicy.TryParseReleaseVersion(text, out var version)); + Assert.Equal(Version.Parse(expected), version); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("2026.9")] + [InlineData("2026.9.1.0.1")] + [InlineData("2026.9.1-beta.1")] + [InlineData("2026.9.1+sha")] + [InlineData(" 2026.9.1")] + [InlineData("2026.9.1 ")] + [InlineData("2026.9.-1")] + [InlineData("2026..1")] + [InlineData("2026.9.2147483648")] + [InlineData("2026.9.\u0661")] + public void AmbiguousOrPrereleaseVersion_CannotAuthorizeMigration(string? text) => + Assert.False(MigrationVersionPolicy.TryParseReleaseVersion(text, out _)); +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationAdoptionPreparationCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationAdoptionPreparationCoordinatorTests.cs new file mode 100644 index 000000000..75bd9cc62 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationAdoptionPreparationCoordinatorTests.cs @@ -0,0 +1,154 @@ +using System.Runtime.Versioning; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +[Collection("Migration preparation")] +public sealed class StoreMigrationAdoptionPreparationCoordinatorTests +{ + [Fact] + public void BusyInno_DoesNotInspectOrPrepare() + { + using var temp = new TempDirectory(); + var coordinator = new StoreMigrationAdoptionPreparationCoordinator( + new LeaseProvider(null), + new Detector(() => throw new InvalidOperationException("Must not inspect while source is busy.")), + CreatePreparation(temp, out _), + NullLogger.Instance); + + var result = coordinator.Prepare(Expected()); + + Assert.Equal(StoreMigrationPreparationState.InnoRunning, result.State); + } + + [Fact] + public void ChangedSource_StopsBeforePreparation() + { + using var temp = new TempDirectory(); + var coordinator = new StoreMigrationAdoptionPreparationCoordinator( + new LeaseProvider(new Lease()), + new Detector(() => new(InnoInstallationStatus.Detected, Expected() with { Architecture = "arm64" })), + CreatePreparation(temp, out var binding), + NullLogger.Instance); + + var result = coordinator.Prepare(Expected()); + + Assert.Equal(StoreMigrationPreparationState.SourceChanged, result.State); + Assert.False(File.Exists(Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.IntentFileName))); + } + + [Fact] + public void ValidatedSource_PreparesIntentOnlyWhileLeaseIsHeld() + { + using var temp = new TempDirectory(); + using var environment = new EnvironmentScope().Set("OPENCLAW_STATE_DIR", null).Set("OPENCLAW_HOME", null); + var lease = new Lease(); + var expected = Expected(); + var preparation = CreatePreparation(temp, out var binding); + File.WriteAllText(Path.Combine(binding.RoamingDirectory, "settings.json"), """{"AutoStart":true}"""); + var coordinator = new StoreMigrationAdoptionPreparationCoordinator( + new LeaseProvider(lease), + new Detector(() => new(InnoInstallationStatus.Detected, expected)), + preparation, + NullLogger.Instance, + new Activity(() => InnoSourceActivityStatus.Stopped)); + + var result = coordinator.Prepare(expected); + + Assert.Equal(StoreMigrationPreparationState.Prepared, result.State); + Assert.NotNull(result.Intent); + Assert.True(lease.Disposed); + var intent = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.IntentFileName); + Assert.True(File.Exists(intent)); + Assert.False(File.Exists(Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName))); + Assert.Contains("\"AutoStart\":true", File.ReadAllText(Path.Combine(binding.RoamingDirectory, "settings.json"))); + } + + [Theory] + [InlineData(InnoSourceActivityStatus.Running, StoreMigrationPreparationState.InnoRunning)] + [InlineData(InnoSourceActivityStatus.InspectionFailed, StoreMigrationPreparationState.ValidationFailed)] + [InlineData(InnoSourceActivityStatus.Stopped, StoreMigrationPreparationState.Prepared)] + public void CrossSessionInspection_RunsUnderExclusiveLockBeforePublication( + InnoSourceActivityStatus activity, StoreMigrationPreparationState expectedState) + { + using var temp = new TempDirectory(); + using var environment = new EnvironmentScope().Set("OPENCLAW_STATE_DIR", null).Set("OPENCLAW_HOME", null); + var preparation = CreatePreparation(temp, out var binding); + var lease = new Lease(); + var coordinator = new StoreMigrationAdoptionPreparationCoordinator( + new LeaseProvider(lease), + new Detector(() => + { + Assert.Throws(() => MigrationOperationLock.AcquireRuntime(binding)); + return new(InnoInstallationStatus.Detected, Expected()); + }), + preparation, NullLogger.Instance, + new Activity(() => + { + Assert.Throws(() => MigrationOperationLock.AcquireRuntime(binding)); + return activity; + })); + + Assert.Equal(expectedState, coordinator.Prepare(Expected()).State); + Assert.True(lease.Disposed); + Assert.Equal(activity == InnoSourceActivityStatus.Stopped, + File.Exists(Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.IntentFileName))); + using var released = MigrationOperationLock.AcquireRuntime(binding); + } + + [Fact] + public void RuntimeLease_BlocksPreparationBeforeDetectionOrInventory() + { + using var temp = new TempDirectory(); + var preparation = CreatePreparation(temp, out var binding); + using var runtime = MigrationOperationLock.AcquireRuntime(binding); + var coordinator = new StoreMigrationAdoptionPreparationCoordinator( + new LeaseProvider(new Lease()), + new Detector(() => throw new InvalidOperationException("Must not inspect a running source.")), + preparation, NullLogger.Instance); + + Assert.Equal(StoreMigrationPreparationState.InnoRunning, coordinator.Prepare(Expected()).State); + Assert.False(File.Exists(Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.IntentFileName))); + } + + private static MigrationPreparation CreatePreparation(TempDirectory temp, out MigrationBinding binding) + { + binding = MigrationRecordTests.CreateRecord(temp, "intent").Binding; + Directory.CreateDirectory(binding.RoamingDirectory); + Directory.CreateDirectory(binding.LocalDirectory); + return new MigrationPreparation(binding); + } + + private static InnoInstallation Expected() => + new(@"C:\fixture\OpenClawTray", @"C:\fixture\OpenClawTray\OpenClaw.Tray.WinUI.exe", + @"C:\fixture\OpenClawTray\unins000.exe", "x64", new Version(2026, 9, 5, 0)); + + private sealed class Detector(Func detect) : IInnoInstallationDetector + { + public InnoInstallationDetection Detect() => detect(); + } + + private sealed class LeaseProvider(IMigrationSourceLease? lease) : IMigrationSourceLeaseProvider + { + public IMigrationSourceLease? TryAcquire() => lease; + } + + private sealed class Lease : IMigrationSourceLease + { + public bool Disposed { get; private set; } + public void Dispose() => Disposed = true; + } + + private sealed class Activity(Func verify) : IInnoSourceActivityVerifier + { + public InnoSourceActivityStatus VerifyStopped() => verify(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationCompletionCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationCompletionCoordinatorTests.cs new file mode 100644 index 000000000..4911e2a8e --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationCompletionCoordinatorTests.cs @@ -0,0 +1,290 @@ +using System.Runtime.Versioning; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +[Collection("Migration preparation")] +public sealed class StoreMigrationCompletionCoordinatorTests +{ + [Fact] + public void NoActiveGateway_FailsClosedWithoutWritingReceipt() + { + using var fixture = new Fixture(); + fixture.Prepare(); + + var result = fixture.Complete(); + + Assert.Equal(StoreMigrationCompletionState.NoActiveGateway, result.State); + fixture.AssertNoReceipt(); + } + + [Fact] + public void UnresolvedActiveGateway_FailsClosedWithoutWritingReceipt() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(); + fixture.Prepare(); + + var result = fixture.Complete(); + + Assert.Equal(StoreMigrationCompletionState.CredentialUnavailable, result.State); + fixture.AssertNoReceipt(); + } + + [Fact] + public void ChangedPreparedInventory_CannotComplete() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + File.WriteAllText(Path.Combine(fixture.Binding.RoamingDirectory, "settings.json"), """{"AutoStart":true}"""); + + var result = fixture.Complete(); + + Assert.Equal(StoreMigrationCompletionState.SourceChanged, result.State); + fixture.AssertNoReceipt(); + } + + [Fact] + public void ResolvedDeviceCredential_WritesAtomicCompletionWithoutDeletingIntent() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared", deviceToken: "paired-device"); + var intent = fixture.Prepare(); + + var result = fixture.Complete(); + + var receipt = Assert.IsType(result.Receipt); + Assert.Equal(StoreMigrationCompletionState.Completed, result.State); + Assert.Equal(intent.MigrationId, receipt.MigrationId); + Assert.Equal(intent.SourceVersion, receipt.SourceVersion); + Assert.Equal("2026.9.18.0", receipt.TargetVersion); + Assert.Equal(DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc), receipt.ExpiresUtc); + var completionPath = Path.Combine(fixture.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName); + var completed = MigrationRecordCodec.ReadCompletion(completionPath, fixture.Binding, DateTime.UtcNow); + Assert.Equal(intent.MigrationId, completed.MigrationId); + Assert.True(File.Exists(Path.Combine(fixture.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.IntentFileName))); + Assert.Empty(Directory.GetFiles(Path.Combine(fixture.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName), + "*.tmp")); + } + + [Fact] + public void ExistingReceipt_IsNeverReplaced() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + var completed = Assert.IsType(fixture.Complete().Receipt); + var completionPath = Path.Combine(fixture.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName); + var original = File.ReadAllBytes(completionPath); + + Assert.Throws(() => + new MigrationCompletionReceiptWriter(fixture.Binding).Write(completed)); + + Assert.Equal(original, File.ReadAllBytes(completionPath)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(completionPath)!, "*.tmp")); + } + + [Fact] + public void UninstallReadHandles_BlockCompletionUntilSourceRemoval() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + var detector = new CallbackDetector(() => new(InnoInstallationStatus.NotInstalled)); + + using (fixture.OpenUninstallLock()) + { + // The Inno parent and cleanup child can coexist, but Store cannot + // publish completion after the uninstaller's preservation check. + using (fixture.OpenUninstallLock()) + { + Assert.Equal(StoreMigrationCompletionState.InnoRunning, fixture.Complete(detector).State); + Assert.Equal(0, detector.Calls); + fixture.AssertNoReceipt(); + } + Assert.Equal(StoreMigrationCompletionState.InnoRunning, fixture.Complete(detector).State); + } + + Assert.Equal(StoreMigrationCompletionState.SourceChanged, fixture.Complete(detector).State); + Assert.Equal(1, detector.Calls); + fixture.AssertNoReceipt(); + } + + [Fact] + public void Completion_OwnsExclusiveLockBeforeRecheckingSource() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + var detector = new CallbackDetector(() => + { + Assert.Throws(() => fixture.OpenUninstallLock()); + return new(InnoInstallationStatus.Detected, fixture.Installation); + }); + + Assert.Equal(StoreMigrationCompletionState.Completed, fixture.Complete(detector).State); + using var uninstall = fixture.OpenUninstallLock(); + Assert.True(File.Exists(Path.Combine(fixture.Binding.RoamingDirectory, + MigrationRecordCodec.DirectoryName, MigrationRecordCodec.CompletionFileName))); + } + + [Fact] + public void FailedCompletion_ReleasesLockForUninstall() + { + using var fixture = new Fixture(); + fixture.Prepare(); + + Assert.Equal(StoreMigrationCompletionState.NoActiveGateway, fixture.Complete().State); + using var uninstall = fixture.OpenUninstallLock(); + fixture.AssertNoReceipt(); + } + + [Theory] + [InlineData(InnoSourceActivityStatus.Running, StoreMigrationCompletionState.InnoRunning)] + [InlineData(InnoSourceActivityStatus.InspectionFailed, StoreMigrationCompletionState.ValidationFailed)] + [InlineData(InnoSourceActivityStatus.Stopped, StoreMigrationCompletionState.Completed)] + public void CrossSessionInspection_RunsUnderExclusiveLockBeforeCompletion( + InnoSourceActivityStatus activity, StoreMigrationCompletionState expectedState) + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + var result = fixture.Complete(activity: new Activity(() => + { + Assert.Throws(() => MigrationOperationLock.AcquireRuntime(fixture.Binding)); + return activity; + })); + + Assert.Equal(expectedState, result.State); + if (activity != InnoSourceActivityStatus.Stopped) + fixture.AssertNoReceipt(); + using var released = MigrationOperationLock.AcquireRuntime(fixture.Binding); + } + + [Fact] + public void SourceRestartBetweenPreparationAndCompletion_BlocksUntilExit() + { + using var fixture = new Fixture(); + fixture.AddActiveGateway(sharedToken: "shared"); + fixture.Prepare(); + using (MigrationOperationLock.AcquireRuntime(fixture.Binding)) + { + Assert.Equal(StoreMigrationCompletionState.InnoRunning, fixture.Complete().State); + fixture.AssertNoReceipt(); + } + + Assert.Equal(StoreMigrationCompletionState.Completed, fixture.Complete().State); + using var runtime = MigrationOperationLock.AcquireRuntime(fixture.Binding); + var receipt = MigrationRecordCodec.ReadCompletion( + Path.Combine(fixture.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName), fixture.Binding, DateTime.UtcNow); + Assert.Equal("completed", receipt.Kind); + } + + private sealed class Fixture : IDisposable + { + private readonly TempDirectory _temp = new(); + private readonly EnvironmentScope _environment; + private readonly InnoInstallation _installation = new( + @"C:\fixture\OpenClawTray", @"C:\fixture\OpenClawTray\OpenClaw.Tray.WinUI.exe", + @"C:\fixture\OpenClawTray\unins000.exe", "x64", new Version(2026, 9, 17, 0)); + + public Fixture() + { + _environment = new EnvironmentScope().Set("OPENCLAW_STATE_DIR", null).Set("OPENCLAW_HOME", null); + Binding = MigrationRecordTests.CreateRecord(_temp, "intent").Binding; + Directory.CreateDirectory(Binding.RoamingDirectory); + Directory.CreateDirectory(Binding.LocalDirectory); + File.WriteAllText(Path.Combine(Binding.RoamingDirectory, "settings.json"), """{"AutoStart":false}"""); + } + + public MigrationBinding Binding { get; } + public InnoInstallation Installation => _installation; + + public FileStream OpenUninstallLock() => new( + Path.Combine(Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, "prepare.lock"), + FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read); + + public void AddActiveGateway(string? sharedToken = null, string? deviceToken = null) + { + const string gatewayId = "12345678-1234-1234-1234-123456789abc"; + var registry = new GatewayRegistry(Binding.RoamingDirectory); + registry.AddOrUpdate(new GatewayRecord + { + Id = gatewayId, + Url = "wss://gateway.example.test", + SharedGatewayToken = sharedToken, + }); + registry.SetActive(gatewayId); + registry.Save(); + if (deviceToken is not null) + { + var identity = new DeviceIdentity(registry.GetIdentityDirectory(gatewayId)); + identity.Initialize(); + identity.StoreDeviceToken(deviceToken); + } + } + + public MigrationRecord Prepare() => + new MigrationPreparation(Binding).Prepare(_installation.Version.ToString()); + + public StoreMigrationCompletionDecision Complete(IInnoInstallationDetector? detector = null, + IInnoSourceActivityVerifier? activity = null) => + new StoreMigrationCompletionCoordinator( + new LeaseProvider(), + detector ?? new Detector(_installation), + Binding, + new CredentialResolver(DeviceIdentityFileReader.Instance), + NullLogger.Instance, + sourceActivity: activity ?? new Activity(() => InnoSourceActivityStatus.Stopped)) + .Complete(_installation, "2026.9.18.0"); + + public void AssertNoReceipt() => + Assert.False(File.Exists(Path.Combine(Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, + MigrationRecordCodec.CompletionFileName))); + + public void Dispose() + { + _environment.Dispose(); + _temp.Dispose(); + } + } + + private sealed class LeaseProvider : IMigrationSourceLeaseProvider + { + public IMigrationSourceLease TryAcquire() => new Lease(); + } + + private sealed class Activity(Func verify) : IInnoSourceActivityVerifier + { + public InnoSourceActivityStatus VerifyStopped() => verify(); + } + + private sealed class Lease : IMigrationSourceLease + { + public void Dispose() { } + } + + private sealed class Detector(InnoInstallation installation) : IInnoInstallationDetector + { + public InnoInstallationDetection Detect() => new(InnoInstallationStatus.Detected, installation); + } + + private sealed class CallbackDetector(Func detect) : IInnoInstallationDetector + { + public int Calls { get; private set; } + + public InnoInstallationDetection Detect() + { + Calls++; + return detect(); + } + } +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationConsentCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationConsentCoordinatorTests.cs new file mode 100644 index 000000000..77bde536a --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationConsentCoordinatorTests.cs @@ -0,0 +1,63 @@ +using OpenClaw.Connection.Migration; + +namespace OpenClaw.Connection.Tests; + +public sealed class StoreMigrationConsentCoordinatorTests +{ + [Fact] + public void DecliningEligibleAdmission_DoesNotProbeOrAuthorizeAdoption() + { + var coordinator = new StoreMigrationConsentCoordinator(new Probe(() => + throw new InvalidOperationException("A declined consent must not inspect Inno."))); + + var result = coordinator.Begin(EligibleAdmission(), consented: false); + + Assert.Equal(StoreMigrationConsentState.Declined, result.State); + } + + [Fact] + public void AcceptingEligibleAdmission_WaitsWhileInnoRuns() + { + var coordinator = new StoreMigrationConsentCoordinator(new Probe(() => true)); + + var result = coordinator.Begin(EligibleAdmission(), consented: true); + + Assert.Equal(StoreMigrationConsentState.WaitingForInnoExit, result.State); + } + + [Fact] + public void Retry_RechecksInnoInsteadOfCachingTheFirstObservation() + { + var isRunning = true; + var coordinator = new StoreMigrationConsentCoordinator(new Probe(() => isRunning)); + + Assert.Equal(StoreMigrationConsentState.WaitingForInnoExit, coordinator.Retry().State); + isRunning = false; + + Assert.Equal(StoreMigrationConsentState.ReadyForAdoption, coordinator.Retry().State); + } + + [Theory] + [InlineData(StoreMigrationStartupState.UpdateInno)] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation)] + [InlineData(StoreMigrationStartupState.RecoveryRequired)] + public void NonEligibleAdmission_CannotEnterConsentFlow(StoreMigrationStartupState state) + { + var coordinator = new StoreMigrationConsentCoordinator(new Probe(() => + throw new InvalidOperationException("Blocked admission must not inspect Inno."))); + + var result = coordinator.Begin(new(state), consented: true); + + Assert.Equal(StoreMigrationConsentState.Blocked, result.State); + } + + private static StoreMigrationStartupDecision EligibleAdmission() => + new(StoreMigrationStartupState.ConsentRequired, new InnoInstallation( + @"C:\fixture\OpenClawTray", @"C:\fixture\OpenClawTray\OpenClaw.Tray.WinUI.exe", + @"C:\fixture\OpenClawTray\unins000.exe", "x64", new Version(2026, 9, 5))); + + private sealed class Probe(Func isRunning) : IInnoInstanceProbe + { + public bool IsRunning() => isRunning(); + } +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs new file mode 100644 index 000000000..c740ae07d --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs @@ -0,0 +1,448 @@ +using System.Runtime.Versioning; +using System.Security.Principal; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationFinalizationCoordinatorTests +{ + [Fact] + public void SameNameProcessAtCanonicalSourcePath_BlocksRemoval() + { + using var fixture = new Fixture(); + + var result = VerifyProcesses(fixture, new InnoSourceProcessCandidate( + InnoSourceProcessState.ImageResolved, + Path.Combine(fixture.Binding.InstallDirectory, "OpenClaw.Tray.WinUI.exe"))); + + Assert.Equal(InnoSourceRemovalStatus.SourcePresent, result); + } + + [Fact] + public void SameNameProcessAtUnrelatedPath_DoesNotBlockRemoval() + { + using var fixture = new Fixture(); + + var result = VerifyProcesses(fixture, new InnoSourceProcessCandidate( + InnoSourceProcessState.ImageResolved, + @"C:\OtherUser\OpenClaw.Tray.WinUI.exe")); + + Assert.Equal(InnoSourceRemovalStatus.Removed, result); + } + + [Fact] + public void InaccessibleSameNameProcessOwnedByOtherUser_DoesNotBlockRemoval() + { + using var fixture = new Fixture(); + + var result = VerifyProcesses(fixture, new InnoSourceProcessCandidate(InnoSourceProcessState.OwnedByOtherUser)); + + Assert.Equal(InnoSourceRemovalStatus.Removed, result); + } + + [Fact] + public void UnresolvedSameNameProcess_FailsClosed() + { + using var fixture = new Fixture(); + + var result = VerifyProcesses(fixture, new InnoSourceProcessCandidate(InnoSourceProcessState.Unresolved)); + + Assert.Equal(InnoSourceRemovalStatus.InspectionFailed, result); + } + + [Fact] + public void ExitedSameNameProcess_DoesNotBlockRemoval() + { + using var fixture = new Fixture(); + + var result = VerifyProcesses(fixture, new InnoSourceProcessCandidate(InnoSourceProcessState.Exited)); + + Assert.Equal(InnoSourceRemovalStatus.Removed, result); + } + + [Fact] + public async Task RegistryGoneButSourcePayloadRemains_WaitsWithoutMutation() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + File.WriteAllText(Path.Combine(fixture.Binding.InstallDirectory, "OpenClaw.Tray.WinUI.exe"), "source"); + var autoStart = new AutoStart(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new InnoSourceRemovalVerifier(fixture.Binding, $"OpenClawMigrationTest-{Guid.NewGuid():N}"), + autoStart); + + Assert.Equal(StoreMigrationFinalizationState.AwaitingInnoRemoval, result.State); + Assert.Equal(0, autoStart.Calls); + Assert.True(File.Exists(fixture.IntentPath)); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task RegistryGoneButSourceRuntimeEvidenceRemains_WaitsWithoutMutation() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var autoStart = new AutoStart(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.SourcePresent), + autoStart); + + Assert.Equal(StoreMigrationFinalizationState.AwaitingInnoRemoval, result.State); + Assert.Equal(0, autoStart.Calls); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Theory] + [InlineData(InnoInstallationStatus.Unsupported)] + [InlineData(InnoInstallationStatus.InspectionFailed)] + public async Task UnverifiableRegistrySource_FailsClosedAndKeepsReceipt(InnoInstallationStatus status) + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + + var result = await fixture.FinalizeAsync(new(status)); + + Assert.Equal(StoreMigrationFinalizationState.InspectionFailed, result.State); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task UncertainSourceRemoval_FailsClosedAndKeepsReceipt() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.InspectionFailed)); + + Assert.Equal(StoreMigrationFinalizationState.InspectionFailed, result.State); + Assert.True(File.Exists(fixture.IntentPath)); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task StaleCompletionReceiptWithChangedInventory_BlocksBeforeStartupPreferenceOrCleanup() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var autoStart = new AutoStart(); + var cleaner = new Cleaner(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + autoStart, + cleaner, + new Inventory(new string('b', 64))); + + Assert.Equal(StoreMigrationFinalizationState.InspectionFailed, result.State); + Assert.Equal(0, autoStart.Calls); + Assert.Equal(0, cleaner.Calls); + Assert.True(File.Exists(fixture.IntentPath)); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task StartupPreferenceFailure_KeepsReceiptAndDoesNotCleanRecords() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var cleaner = new Cleaner(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + new AutoStart(new InvalidOperationException("Startup task unavailable")), + cleaner); + + Assert.Equal(StoreMigrationFinalizationState.StartupPreferenceFailed, result.State); + // The receipt is kept so the preference retries next launch, but a failed + // cosmetic preference must never block the app: Inno removal is already proven. + Assert.True(result.AllowsNormalStartup); + Assert.Equal(0, cleaner.Calls); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task StartupPreferenceRefusal_CleansReceiptAndAllowsStartup() + { + using var fixture = new Fixture(); + fixture.WriteRecords(autoStart: true); + var cleaner = new Cleaner(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + new AutoStart(new StoreMigrationAutoStartRefusedException( + "Windows startup is disabled by the user.")), + cleaner); + + Assert.Equal(StoreMigrationFinalizationState.StartupPreferenceRefused, result.State); + Assert.True(result.AllowsNormalStartup); + Assert.Equal(1, cleaner.Calls); + } + + [Fact] + public async Task StartupPreferenceRefusal_RemovesReceiptSoLaunchIsNotBlockedAgain() + { + using var fixture = new Fixture(); + fixture.WriteRecords(autoStart: true); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + new AutoStart(new StoreMigrationAutoStartRefusedException( + "Windows startup is disabled by policy."))); + + Assert.Equal(StoreMigrationFinalizationState.StartupPreferenceRefused, result.State); + Assert.True(result.AllowsNormalStartup); + Assert.False(File.Exists(fixture.IntentPath)); + Assert.False(File.Exists(fixture.CompletionPath)); + Assert.Equal(MigrationStartupRecordStatus.None, fixture.ReadRecords().Status); + } + + [Fact] + public async Task ProvenRemoval_AppliesSavedPreferenceOnceCleansSameReceiptAndAllowsStartup() + { + using var fixture = new Fixture(); + fixture.WriteRecords(autoStart: true); + var autoStart = new AutoStart(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + autoStart); + + Assert.Equal(StoreMigrationFinalizationState.Finalized, result.State); + Assert.True(result.AllowsNormalStartup); + Assert.Equal(1, autoStart.Calls); + Assert.True(autoStart.LastEnabled); + Assert.False(File.Exists(fixture.IntentPath)); + Assert.False(File.Exists(fixture.CompletionPath)); + Assert.Equal(MigrationStartupRecordStatus.None, fixture.ReadRecords().Status); + } + + [Fact] + public void Cleaner_RejectsChangedReceiptBeforeDeletingAnything() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var changed = fixture.Receipt; + changed.Fingerprint = new string('b', 64); + + Assert.Throws(() => + new MigrationFinalizationRecordCleaner(fixture.Binding).ClearCompleted(changed)); + + Assert.True(File.Exists(fixture.IntentPath)); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task CleanupInterruptionAfterIntentDeletion_RetriesFromReceipt() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var interrupted = new IntentDeletingCleaner(fixture.IntentPath); + + var first = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + new AutoStart(), + interrupted); + Assert.Equal(StoreMigrationFinalizationState.RecordCleanupFailed, first.State); + Assert.True(File.Exists(fixture.CompletionPath)); + + var second = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + new AutoStart()); + + Assert.Equal(StoreMigrationFinalizationState.Finalized, second.State); + Assert.False(File.Exists(fixture.IntentPath)); + Assert.False(File.Exists(fixture.CompletionPath)); + } + + [Fact] + public async Task ConcurrentFinalization_OnlyLockOwnerAppliesStartupPreference() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var firstAutoStart = new GateAutoStart(); + var first = fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + firstAutoStart); + await firstAutoStart.Started.Task; + + var secondAutoStart = new AutoStart(); + var second = await fixture.FinalizeAsync( + new(InnoInstallationStatus.NotInstalled), + new SourceRemoval(InnoSourceRemovalStatus.Removed), + secondAutoStart); + firstAutoStart.Continue.TrySetResult(); + var firstResult = await first; + + Assert.Equal(StoreMigrationFinalizationState.RecordCleanupFailed, second.State); + Assert.Equal(0, secondAutoStart.Calls); + Assert.Equal(StoreMigrationFinalizationState.Finalized, firstResult.State); + Assert.Equal(1, firstAutoStart.Calls); + } + + private static InnoSourceRemovalStatus VerifyProcesses( + Fixture fixture, + params InnoSourceProcessCandidate[] candidates) => + new InnoSourceRemovalVerifier( + fixture.Binding, + $"OpenClawMigrationTest-{Guid.NewGuid():N}", + new ProcessInspector(candidates)).VerifyRemoved(); + + private sealed class Fixture : IDisposable + { + private static readonly DateTime Now = new(2026, 9, 21, 0, 0, 0, DateTimeKind.Utc); + private readonly TempDirectory _temp = new(); + + public Fixture() + { + Binding = new MigrationBinding + { + InstallDirectory = _temp.Combine("install"), + RoamingDirectory = _temp.Combine("roaming"), + LocalDirectory = _temp.Combine("local"), + Architecture = "x64", + UserSid = WindowsIdentity.GetCurrent().User!.Value + }; + System.IO.Directory.CreateDirectory(Binding.InstallDirectory); + } + + public MigrationBinding Binding { get; } + public MigrationRecord Receipt { get; private set; } = null!; + public string Directory => Path.Combine(Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + public string IntentPath => Path.Combine(Directory, MigrationRecordCodec.IntentFileName); + public string CompletionPath => Path.Combine(Directory, MigrationRecordCodec.CompletionFileName); + + public void WriteRecords(bool autoStart = false) + { + System.IO.Directory.CreateDirectory(Directory); + File.WriteAllBytes(Path.Combine(Directory, "prepare.lock"), []); + File.WriteAllBytes(IntentPath, MigrationRecordCodec.Encode(Record("intent", autoStart), Now)); + Receipt = Record("completed", autoStart); + File.WriteAllBytes(CompletionPath, MigrationRecordCodec.Encode(Receipt, Now)); + } + + public MigrationStartupRecord ReadRecords() => + new MigrationStartupRecordReader(Binding, NullLogger.Instance, new Clock()).Read(); + + public Task FinalizeAsync( + InnoInstallationDetection detection, + IInnoSourceRemovalVerifier? sourceRemoval = null, + IStoreMigrationAutoStartApplier? autoStart = null, + IStoreMigrationRecordCleaner? cleaner = null, + IMigrationInventoryCapture? inventory = null) => + new StoreMigrationFinalizationCoordinator( + Binding, + new Detector(detection), + sourceRemoval ?? new SourceRemoval(InnoSourceRemovalStatus.Removed), + new MigrationStartupRecordReader(Binding, NullLogger.Instance, new Clock()), + inventory ?? new Inventory(Receipt.Fingerprint), + autoStart ?? new AutoStart(), + cleaner ?? new MigrationFinalizationRecordCleaner(Binding), + NullLogger.Instance).FinalizeAsync(); + + private MigrationRecord Record(string kind, bool autoStart) => new() + { + Kind = kind, + MigrationId = "f1d81447-a535-4f63-85e8-6d2cd7cae61f", + SourceVersion = "2026.9.1", + TargetVersion = kind == "completed" ? "2026.9.2" : "", + Fingerprint = new string('a', 64), + InventoryJson = kind == "intent" ? "{}" : "", + AutoStart = autoStart, + CreatedUtc = Now, + ExpiresUtc = kind == "completed" ? DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc) : Now.AddDays(30), + Binding = Binding + }; + + public void Dispose() => _temp.Dispose(); + } + + private sealed class Detector(InnoInstallationDetection detection) : IInnoInstallationDetector + { + public InnoInstallationDetection Detect() => detection; + } + + private sealed class SourceRemoval(InnoSourceRemovalStatus status) : IInnoSourceRemovalVerifier + { + public InnoSourceRemovalStatus VerifyRemoved() => status; + } + + private sealed class ProcessInspector( + IEnumerable candidates) : IInnoSourceProcessInspector + { + public IEnumerable FindSameNameProcesses() => candidates; + } + + private sealed class Inventory(string fingerprint) : IMigrationInventoryCapture + { + public MigrationInventory Capture() => new(fingerprint, false, "", "", []); + } + + private class AutoStart(Exception? failure = null) : IStoreMigrationAutoStartApplier + { + public int Calls { get; private set; } + public bool LastEnabled { get; private set; } + + public virtual Task ApplyAsync(bool enabled) + { + Calls++; + LastEnabled = enabled; + return failure is null ? Task.CompletedTask : Task.FromException(failure); + } + } + + private sealed class GateAutoStart : AutoStart + { + public TaskCompletionSource Started { get; } = new(); + public TaskCompletionSource Continue { get; } = new(); + + public override async Task ApplyAsync(bool enabled) + { + await base.ApplyAsync(enabled); + Started.TrySetResult(); + await Continue.Task.ConfigureAwait(false); + } + } + + private sealed class Cleaner(Exception? failure = null) : IStoreMigrationRecordCleaner + { + public int Calls { get; private set; } + + public void ClearCompleted(MigrationRecord receipt) + { + Calls++; + if (failure is not null) + throw failure; + } + } + + private sealed class IntentDeletingCleaner(string intentPath) : IStoreMigrationRecordCleaner + { + public void ClearCompleted(MigrationRecord receipt) + { + File.Delete(intentPath); + throw new IOException("Simulated interruption after intent deletion."); + } + } + + private sealed class Clock : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2026, 9, 21, 0, 0, 0, TimeSpan.Zero); + } +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationPackagePresenceTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationPackagePresenceTests.cs new file mode 100644 index 000000000..727707ed2 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationPackagePresenceTests.cs @@ -0,0 +1,240 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.Principal; +using Microsoft.Win32; +using OpenClaw.Connection.Migration; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +/// +/// Covers the signal that keeps a deleted completion receipt from authorizing gateway removal. +/// An earlier attempt stored that signal in HKCU, which cannot work: the app that writes it is +/// MSIX packaged, so the write is virtualized into the package's private hive and is invisible +/// to the unpackaged uninstaller. Package registration is read the same way from both sides. +/// +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationPackagePresenceTests +{ + // The P1 itself. Deleting the receipt, and optionally the whole directory holding the ACL + // evidence the authority check reads, previously produced cleanup-authorizing exit 0. + // This drives the real Windows PowerShell 5.1 checker the uninstaller runs. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeletedReceipt_WithStorePackageInstalled_PreservesGateway(bool removeDirectory) + { + using var temp = new TempDirectory(); + using var repository = new TempPackageRepository(); + repository.AddPackage(MigrationRecordCodec.PackageName + "_2026.9.23.0_x64__rfcbke2p71se2"); + var record = StageChecker(temp); + var directory = Path.Combine(record.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + + // The attacker deletes the receipt, leaving state that looks like a machine + // that never migrated. + if (removeDirectory) + Directory.Delete(directory, recursive: true); + + // Exit 11, not 2: the checker positively identified a registered Store app, so the + // uninstaller must say so rather than repeat its generic "could not confirm" advice, + // which tells the user to run wsl --unregister and destroy this very gateway. + Assert.Equal(11, await RunCheckerAsync(record, temp, repository)); + } + + // Without the package the same fixture must still report "no migration", or the tests above + // would pass for the wrong reason and every ordinary uninstall would strand a gateway. + // Both directory states are covered because the deleted-directory case is the one that + // reaches the authority check that previously returned true for a missing directory. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeletedReceipt_WithoutStorePackage_ReportsNoMigration(bool removeDirectory) + { + using var temp = new TempDirectory(); + using var repository = new TempPackageRepository(); + var record = StageChecker(temp); + var directory = Path.Combine(record.Binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + Directory.CreateDirectory(directory); + if (removeDirectory) + Directory.Delete(directory, recursive: true); + + Assert.Equal(0, await RunCheckerAsync(record, temp, repository)); + } + + // A populated repository that holds no package of ours must not preserve, or any machine + // with apps installed would keep every gateway forever. + [Fact] + public async Task UnrelatedPackages_DoNotPreserveTheGateway() + { + using var temp = new TempDirectory(); + using var repository = new TempPackageRepository(); + repository.AddPackage("NotOpenClawFoundation.OpenClaw_1.0.0.0_x64__abcdefghijklm"); + var record = StageChecker(temp); + + Assert.Equal(0, await RunCheckerAsync(record, temp, repository)); + } + + // The repository is writable by the current user, so the principal who can delete the receipt + // can also delete the package registration that replaces it as the completion signal. An + // empty or absent repository must therefore read as tampering, not as "no Store app". + // Every real profile has hundreds of registered packages. + [Fact] + public async Task EmptyRepository_PreservesGateway() + { + using var temp = new TempDirectory(); + using var repository = new TempPackageRepository(seedUnrelated: false); + var record = StageChecker(temp); + + Assert.Equal(2, await RunCheckerAsync(record, temp, repository)); + } + + [Fact] + public async Task MissingRepository_PreservesGateway() + { + using var temp = new TempDirectory(); + using var repository = new TempPackageRepository(); + var record = StageChecker(temp); + + Assert.Equal(2, await RunCheckerAsync( + record, temp, repository, keyPathOverride: repository.KeyPath + "\\does-not-exist")); + } + + // Every behavioural test overrides the repository key, so nothing else proves the shipped + // default names a real key. A typo or a trailing-separator regression there would make the + // lookup fail on every real machine, reopening the vulnerability with no attacker involved, + // while the rest of this class stayed green. The literal is asserted unconditionally; the + // live probe is advisory because a service account, container, or freshly provisioned CI + // profile can legitimately lack the AppX stack, and that must not fail the build. + [Fact] + public void DefaultRepositoryKey_IsTheAppModelRepository() + { + const string expected = + "Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\CurrentVersion\\AppModel\\Repository\\Packages"; + var script = File.ReadAllText(Path.Combine( + MigrationRecordTests.RepositoryRoot(), "scripts", "Test-InnoMigration.ps1")); + var match = System.Text.RegularExpressions.Regex.Match( + script, @"\$PackageRepositoryKey\s*=\s*'([^']+)'"); + + Assert.True(match.Success, "The checker must declare a default package repository key."); + Assert.Equal(expected, match.Groups[1].Value); + + using var key = Registry.CurrentUser.OpenSubKey(expected, writable: false); + // Advisory rather than required: a service account, container image, or freshly + // provisioned CI profile can legitimately lack the AppX stack, and that must not + // fail the build. Where the key does exist it must be populated, which is the + // premise the empty-means-tampering hardening rests on. + Assert.True(key is null || key.SubKeyCount > 0, + "The AppModel repository exists but is empty, which the checker treats as tampering."); + } + + // A refactor that stopped consulting package presence would leave the fixture tests green on + // a developer machine while the shipped decision returned to authorizing destruction. + [Fact] + public void Checker_GatesExitZeroOnPackagePresence() + { + var script = File.ReadAllText(Path.Combine( + MigrationRecordTests.RepositoryRoot(), "scripts", "Test-InnoMigration.ps1")); + var absentBranch = script[script.IndexOf("FileNotFoundException", StringComparison.Ordinal)..]; + var presence = absentBranch.IndexOf("Get-StorePackageState", StringComparison.Ordinal); + var exitZero = absentBranch.IndexOf("exit 0", StringComparison.Ordinal); + + Assert.True(presence >= 0, "The absent-receipt branch must consult package presence."); + Assert.True(presence < exitZero, "Package presence must gate exit 0 rather than follow it."); + } + + private static MigrationRecord StageChecker(TempDirectory temp) + { + var root = MigrationRecordTests.RepositoryRoot(); + var record = MigrationRecordTests.CreateRecord(temp, "completed"); + record.CreatedUtc = DateTime.UtcNow.AddMinutes(-1); + Directory.CreateDirectory(record.Binding.InstallDirectory); + Directory.CreateDirectory(record.Binding.LocalDirectory); + Directory.CreateDirectory(record.Binding.RoamingDirectory); + File.Copy(Path.Combine(root, "src", "OpenClaw.Connection", "Migration", "MigrationRecordCodec.cs"), + Path.Combine(record.Binding.InstallDirectory, "MigrationRecordCodec.cs")); + File.Copy(Path.Combine(root, "scripts", "Test-InnoMigration.ps1"), + Path.Combine(record.Binding.InstallDirectory, "Test-InnoMigration.ps1")); + return record; + } + + private static async Task RunCheckerAsync( + MigrationRecord record, TempDirectory temp, TempPackageRepository repository, + string? keyPathOverride = null) + { + var start = new ProcessStartInfo(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + start.Environment["OPENCLAW_STATE_DIR"] = temp.Combine("approvals"); + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", + Path.Combine(record.Binding.InstallDirectory, "Test-InnoMigration.ps1"), + "-AppRoot", record.Binding.InstallDirectory, + "-Architecture", record.Binding.Architecture, + "-RoamingDirectory", record.Binding.RoamingDirectory, + "-LocalDirectory", record.Binding.LocalDirectory, + "-PackageRepositoryKey", keyPathOverride ?? repository.KeyPath + }) + start.ArgumentList.Add(argument); + + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + throw new TimeoutException($"Checker did not exit. stdout={await stdout} stderr={await stderr}"); + } + return process.ExitCode; + } + + /// + /// An isolated stand-in for the AppModel package repository. Tests cannot install or remove a + /// real MSIX package, and the developer machine running them usually has the real package + /// installed, which would make the negative cases unrunnable against the live repository. + /// Unrelated packages are seeded by default because a real profile always has hundreds, and + /// the checker treats an empty repository as tampering rather than as proof of absence. + /// + internal sealed class TempPackageRepository : IDisposable + { + private readonly string _root = "Software\\OpenClawTests\\" + Guid.NewGuid().ToString("n"); + + public string KeyPath => _root + "\\Packages"; + + public TempPackageRepository(bool seedUnrelated = true) + { + Registry.CurrentUser.CreateSubKey(KeyPath, writable: true)?.Dispose(); + if (!seedUnrelated) + return; + AddPackage("Microsoft.WindowsCalculator_11.2_x64__8wekyb3d8bbwe"); + AddPackage("Contoso.Example_1.0.0.0_x64__abcdefghijklm"); + } + + public void AddPackage(string packageFullName) + => Registry.CurrentUser.CreateSubKey(KeyPath + "\\" + packageFullName, writable: true)?.Dispose(); + + public void Dispose() + { + try + { + Registry.CurrentUser.DeleteSubKeyTree(_root, throwOnMissingSubKey: false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + } +} diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs new file mode 100644 index 000000000..03c1c797b --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs @@ -0,0 +1,158 @@ +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Tests; + +public sealed class StoreMigrationStartupCoordinatorTests +{ + [Fact] + public void Disabled_DoesNotInspectInstallationOrRecords() + { + var coordinator = new StoreMigrationStartupCoordinator( + new Detector(() => throw new InvalidOperationException("Must not read registry.")), + new Records(() => throw new InvalidOperationException("Must not read state.")), + NullLogger.Instance); + + var result = coordinator.Evaluate(false, null, "unknown"); + + Assert.Equal(StoreMigrationStartupState.Disabled, result.State); + Assert.True(result.AllowsNormalStartup); + } + + [Theory] + [InlineData(null, "x64")] + [InlineData("", "x64")] + [InlineData("2026.9.1-beta.1", "x64")] + [InlineData("2026.9.1", "x86")] + public void InvalidPolicy_BlocksBeforeInspection(string? minimum, string architecture) + { + var coordinator = new StoreMigrationStartupCoordinator( + new Detector(() => throw new InvalidOperationException("Must not read registry.")), + new Records(() => throw new InvalidOperationException("Must not read state.")), + NullLogger.Instance); + + var result = coordinator.Evaluate(true, minimum, architecture); + + Assert.Equal(StoreMigrationStartupState.InspectionFailed, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Theory] + [InlineData(MigrationStartupRecordStatus.None, StoreMigrationStartupState.NotRequired, true)] + [InlineData(MigrationStartupRecordStatus.Intent, StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(MigrationStartupRecordStatus.Completed, StoreMigrationStartupState.FinalizationRequired, false)] + [InlineData(MigrationStartupRecordStatus.Invalid, StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(MigrationStartupRecordStatus.Unavailable, StoreMigrationStartupState.InspectionFailed, false)] + public void AbsentInno_OnlyFreshStateAllowsNormalStartup( + MigrationStartupRecordStatus pending, StoreMigrationStartupState expected, bool allowsNormal) + { + var result = Evaluate(new(InnoInstallationStatus.NotInstalled), new(pending)); + + Assert.Equal(expected, result.State); + Assert.Equal(allowsNormal, result.AllowsNormalStartup); + } + + [Theory] + [InlineData(InnoInstallationStatus.Unsupported, StoreMigrationStartupState.UnsupportedInstallation)] + [InlineData(InnoInstallationStatus.InspectionFailed, StoreMigrationStartupState.InspectionFailed)] + public void FailedDiscovery_DoesNotFallThroughToFreshStartup( + InnoInstallationStatus discovery, StoreMigrationStartupState expected) + { + var result = Evaluate(new(discovery), new(MigrationStartupRecordStatus.None)); + + Assert.Equal(expected, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Theory] + [InlineData("2026.8.31.0", "x64", "x64", StoreMigrationStartupState.UpdateInno)] + [InlineData("2026.9.1.0", "x64", "x64", StoreMigrationStartupState.ConsentRequired)] + [InlineData("2026.9.2.0", "x64", "x64", StoreMigrationStartupState.ConsentRequired)] + [InlineData("2026.9.1.0", "arm64", "arm64", StoreMigrationStartupState.ConsentRequired)] + [InlineData("2026.9.1.0", "x64", "arm64", StoreMigrationStartupState.UnsupportedInstallation)] + [InlineData("2026.9.1.0", "arm64", "x64", StoreMigrationStartupState.UnsupportedInstallation)] + public void StableVersionAndArchitecture_AreGatedBeforeConsent( + string version, string sourceArchitecture, string targetArchitecture, StoreMigrationStartupState expected) + { + var result = Evaluate(Detected(version, sourceArchitecture), new(MigrationStartupRecordStatus.None), targetArchitecture); + + Assert.Equal(expected, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Fact] + public void ExistingIntent_StillRequiresStoreConsent() + { + var result = Evaluate(Detected(), new(MigrationStartupRecordStatus.Intent, new MigrationRecord { Kind = "intent" })); + + Assert.Equal(StoreMigrationStartupState.ConsentRequired, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Fact] + public void ValidCompletion_BlocksNormalStartupWhileInnoRemains() + { + var result = Evaluate(Detected(), new(MigrationStartupRecordStatus.Completed, + new MigrationRecord { Kind = "completed", SourceVersion = "2026.9.1" })); + + Assert.Equal(StoreMigrationStartupState.AwaitingInnoRemoval, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Fact] + public void CompletionForDifferentSourceVersion_RequiresRecovery() + { + var result = Evaluate(Detected(), new(MigrationStartupRecordStatus.Completed, + new MigrationRecord { Kind = "completed", SourceVersion = "2026.8.1" })); + + Assert.Equal(StoreMigrationStartupState.RecoveryRequired, result.State); + Assert.False(result.AllowsNormalStartup); + } + + [Theory] + [InlineData(MigrationStartupRecordStatus.Invalid, StoreMigrationStartupState.RecoveryRequired)] + [InlineData(MigrationStartupRecordStatus.Unavailable, StoreMigrationStartupState.InspectionFailed)] + public void BadRecords_BlockWithoutInspectingOrRenewingSource( + MigrationStartupRecordStatus status, StoreMigrationStartupState expected) + { + var coordinator = new StoreMigrationStartupCoordinator( + new Detector(() => throw new InvalidOperationException("Must not inspect installation.")), + new Records(() => new(status)), NullLogger.Instance); + + Assert.Equal(expected, coordinator.Evaluate(true, "2026.9.1", "x64").State); + } + + [Fact] + public void Retry_ReevaluatesEvidenceRatherThanCachingAdmission() + { + var installation = Detected(); + var coordinator = new StoreMigrationStartupCoordinator( + new Detector(() => installation), + new Records(() => new(MigrationStartupRecordStatus.None)), NullLogger.Instance); + Assert.Equal(StoreMigrationStartupState.ConsentRequired, coordinator.Evaluate(true, "2026.9.1", "x64").State); + + installation = new(InnoInstallationStatus.InspectionFailed); + + Assert.Equal(StoreMigrationStartupState.InspectionFailed, coordinator.Evaluate(true, "2026.9.1", "x64").State); + } + + private static InnoInstallationDetection Detected(string version = "2026.9.1.0", string architecture = "x64") => + new(InnoInstallationStatus.Detected, new InnoInstallation( + @"C:\fixture\OpenClawTray", @"C:\fixture\OpenClawTray\OpenClaw.Tray.WinUI.exe", + @"C:\fixture\OpenClawTray\unins000.exe", architecture, Version.Parse(version))); + + private static StoreMigrationStartupDecision Evaluate( + InnoInstallationDetection installation, MigrationStartupRecord record, string architecture = "x64") => + new StoreMigrationStartupCoordinator(new Detector(() => installation), + new Records(() => record), NullLogger.Instance).Evaluate(true, "2026.9.1", architecture); + + private sealed class Detector(Func read) : IInnoInstallationDetector + { + public InnoInstallationDetection Detect() => read(); + } + + private sealed class Records(Func read) : IMigrationStartupRecordReader + { + public MigrationStartupRecord Read() => read(); + } +} diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs new file mode 100644 index 000000000..276db0492 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -0,0 +1,837 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using OpenClaw.Connection.Migration; +using Xunit.Sdk; + +namespace OpenClaw.Tray.Tests; + +public sealed class InnoMigrationContractTests +{ + [Fact] + public void StorePreviewGuard_PrecedesInstanceForwardingAndNormalServices() + { + var app = Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + var launch = app[app.IndexOf("private async Task OnLaunchedAsync", StringComparison.Ordinal)..]; + var guard = launch.IndexOf("StoreMigrationStartupGuard.ShouldStopLaunch()", StringComparison.Ordinal); + Assert.True(guard > launch.IndexOf("await CliUninstallHandler.RunAsync", StringComparison.Ordinal)); + Assert.True(guard < launch.IndexOf("GetProtocolActivationUri()", StringComparison.Ordinal)); + Assert.True(guard < launch.IndexOf("_mutex = new Mutex(", StringComparison.Ordinal)); + Assert.True(guard < launch.IndexOf("new ActivationRouter(", StringComparison.Ordinal)); + Assert.True(guard < launch.IndexOf("new SettingsManager()", StringComparison.Ordinal)); + Assert.DoesNotContain("new InnoInstallationDetector(", app); + Assert.DoesNotContain("new MigrationStartupRecordReader(", app); + Assert.DoesNotContain("new StoreMigrationStartupCoordinator(", app); + + var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); + Assert.Matches(@"#if !STORE_MIGRATION_PREVIEW\s+return false;\s+#else", helper); + Assert.Contains("new StoreMigrationStartupCoordinator(", helper); + Assert.Contains("new StoreMigrationConsentCoordinator(", helper); + Assert.Contains("decision.AllowsNormalStartup", helper); + Assert.Contains("MigrationRecordCodec.PackageName", helper); + Assert.Contains("MigrationRecordCodec.PackagePublisher", helper); + Assert.DoesNotContain("File.Write", helper); + Assert.DoesNotContain("SetPackagedAutoStartAsync", helper); + Assert.Contains("Migration_StoreConsent", helper); + Assert.Contains("Migration_StoreCloseInno", helper); + Assert.Contains("new InnoMutexLeaseProvider()", helper); + Assert.Contains("new StoreMigrationAdoptionPreparationCoordinator(", helper); + Assert.Contains("new MigrationPreparation(binding)", helper); + Assert.Contains("new StoreMigrationCompletionCoordinator(", helper); + Assert.Contains("while (result.State == StoreMigrationCompletionState.InnoRunning)", helper); + Assert.Contains("new StoreMigrationFinalizationCoordinator(", helper); + Assert.Contains("new MigrationFinalizationRecordCleaner(", helper); + Assert.Contains("new InnoSourceRemovalVerifier(binding, AppIdentity.MutexBaseName)", helper); + Assert.Contains("new MigrationInventoryCapture(binding)", helper); + Assert.Contains("records.Read().Status != MigrationStartupRecordStatus.Completed", helper); + Assert.Contains("AutoStartManager.SetAutoStartAsync(enabled)", helper); + Assert.Contains("Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false)", helper); + // A Windows startup refusal is a durable answer, not a transient failure. The applier must + // translate it so finalization clears the receipt instead of blocking every future launch. + Assert.Contains("catch (AutoStartRefusedException exception) when (exception.IsDurable)", helper); + Assert.Contains( + "throw new StoreMigrationAutoStartRefusedException(exception.Message, exception)", + helper); + Assert.Contains("new CredentialResolver(DeviceIdentityFileReader.Instance)", helper); + Assert.Contains("0x00000124", helper); + Assert.DoesNotContain("TaskDialogIndirect", helper); + Assert.Contains("Migration_StoreValidationFailed", helper); + Assert.Contains("Migration_StoreAwaitingInnoRemoval", helper); + Assert.Contains("Migration_StoreFinalizationFailed", helper); + Assert.Contains("Migration_StoreCredentialUnavailable", helper); + Assert.DoesNotContain("Process.Kill", helper); + + var finalizer = Read("src", "OpenClaw.Connection", "Migration", + "StoreMigrationFinalizationCoordinator.cs"); + Assert.Contains("IStoreMigrationAutoStartApplier", finalizer); + Assert.Contains("IInnoSourceRemovalVerifier", finalizer); + Assert.Contains("MigrationInventory.Capture", finalizer); + Assert.Contains("new Mutex(false, _mutexName, out var createdNew)", finalizer); + Assert.DoesNotContain("IMigrationSourceLeaseProvider", finalizer); + Assert.DoesNotContain("InnoMutex", finalizer); + Assert.DoesNotContain("WaitOne", finalizer); + Assert.DoesNotContain("ReleaseMutex", finalizer); + Assert.DoesNotContain("SettingsManager", finalizer); + Assert.DoesNotContain("CliUninstall", finalizer); + } + + /// + /// The migration lock coordinates with a Store migration that most users will never run. + /// It must never become a reason an ordinary user cannot start or uninstall the app: only + /// a live contended lock may block, and an unreadable one must degrade instead. + /// + [Fact] + public void InaccessibleMigrationLock_BlocksNeitherStartupNorUninstall() + { + var guard = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "InnoMigrationStartupGuard.cs"); + var acquire = guard.IndexOf("MigrationOperationLock.AcquireRuntime(binding)", StringComparison.Ordinal); + var busy = guard.IndexOf("catch (IOException ex) when (MigrationOperationLock.IsBusy(ex))", + StringComparison.Ordinal); + Assert.True(busy > acquire, "Only a contended lock may block Inno startup."); + var receipt = guard.IndexOf("MigrationRecordCodec.ReadCompletion", StringComparison.Ordinal); + var degrade = guard.IndexOf("continuing without it", StringComparison.Ordinal); + Assert.InRange(degrade, busy, receipt); + // The degraded path must fall through to the receipt check rather than showing guidance, + // because ShowGuidance returns true and would stop the launch it is meant to preserve. + Assert.DoesNotContain("ShowGuidance", guard[degrade..receipt]); + + var installer = Read("installer.iss"); + var uninstall = installer[installer.IndexOf("function InitializeUninstall", StringComparison.Ordinal)..]; + uninstall = uninstall[..uninstall.IndexOf("procedure DeinitializeUninstall", StringComparison.Ordinal)]; + Assert.Matches(@"if \(LastError = 32\) or \(LastError = 33\) then\s+begin\s+Result := False;", uninstall); + Assert.Single(Regex.Matches(uninstall, @"Result := False;")); + Assert.Contains("MigrationOperationUnavailable := True;", uninstall); + + // An unavailable lock must still suppress destructive cleanup, since the cleanup child + // cannot join a lock the uninstaller never obtained. + var choice = installer[installer.IndexOf("procedure EnsureLocalGatewayCleanupChoice", StringComparison.Ordinal)..]; + var suppression = choice.IndexOf("if MigrationOperationUnavailable then", StringComparison.Ordinal); + Assert.InRange(suppression, 0, choice.IndexOf("MigrationResult := CheckCompletedStoreMigration", + StringComparison.Ordinal)); + Assert.Contains("WarnMigrationCheckUnavailable;", choice[suppression..]); + } + + /// + /// Preservation is a dead end unless the notice carries the removal path itself. No document + /// in this repository is linked from the uninstaller, and the user may have no app left. + /// + [Fact] + public void PreservationNotice_CarriesItsOwnRemovalInstructions() + { + var installer = Read("installer.iss"); + var warn = installer[installer.IndexOf("procedure WarnMigrationCheckUnavailable", StringComparison.Ordinal)..]; + warn = warn[..warn.IndexOf("procedure EnsureLocalGatewayCleanupChoice", StringComparison.Ordinal)]; + + Assert.Contains("wsl --unregister {#MyDistroName}", warn); + Assert.Contains("Settings > Local Gateway > ", warn); + Assert.DoesNotContain("uninstall documentation", warn); + // The silent path skips the dialog, so the log line is the only audit trail an + // enterprise administrator gets. It must name what was left behind. + var log = warn[..warn.IndexOf("if not UninstallSilent()", StringComparison.Ordinal)]; + Assert.Contains("{#MyDistroName}", log); + Assert.Contains(@"{localappdata}\{#MyInstallDir}\wsl\{#MyDistroName}", log); + + var doc = Read("docs", "uninstall-portable.md"); + Assert.Contains("Installer Uninstall Preserved the Local Gateway", doc); + Assert.Contains("wsl --unregister OpenClawGateway", doc); + } + + [Fact] + public void StorePreviewBuildGate_RequiresExplicitNonShippingConfiguration() + { + var project = System.Xml.Linq.XDocument.Parse(Read("src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var define = project.Descendants("DefineConstants") + .Single(element => element.Value.Contains("STORE_MIGRATION_PREVIEW", StringComparison.Ordinal)); + Assert.Equal("'$(StoreMigrationPreview)' == 'true'", define.Parent!.Attribute("Condition")!.Value); + var guard = project.Descendants("Target").Single(element => (string?)element.Attribute("Name") == "ValidateStoreMigrationPreview"); + Assert.Equal("'$(StoreMigrationPreview)' == 'true'", (string?)guard.Attribute("Condition")); + var configurationError = guard.Elements("Error").First(); + Assert.Equal("'$(Configuration)' != 'Debug' or '$(PackageMsix)' != 'true' or '$(DevBuild)' == 'true'", + (string?)configurationError.Attribute("Condition")); + Assert.Contains("StoreMigrationPreviewMinimumSourceVersion", guard.ToString()); + Assert.DoesNotContain(project.Descendants("StoreMigrationPreview"), element => element.Value == "true"); + Assert.Empty(project.Descendants("StoreMigrationPreviewMinimumSourceVersion")); + } + + /// + /// A durable startup refusal is invisible otherwise: finalization clears the receipt and the + /// app launches normally, so the only signal the user ever gets that OpenClaw will never start + /// at sign-in again would be a log line. The notice must be shown, and it must not block launch. + /// + [Fact] + public void StartupRefusal_NotifiesTheUserWithoutBlockingLaunch() + { + var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); + var branch = helper[helper.IndexOf("if (finalization.AllowsNormalStartup)", StringComparison.Ordinal)..]; + var notice = branch.IndexOf(@"ShowGuidance(""Migration_StoreStartupRefused"")", StringComparison.Ordinal); + Assert.True(notice > 0, "The refusal notice must be raised inside the allows-normal-startup branch."); + Assert.Contains( + "finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused", + branch[..notice]); + Assert.True(branch.IndexOf("return false;", StringComparison.Ordinal) > notice, + "The refusal notice must be followed by return false so startup still proceeds."); + Assert.DoesNotContain("StartupPreferenceFailed", helper); + } + + [Theory] + [InlineData("en-us", "Settings > Apps > Startup")] + [InlineData("fr-fr", "Paramètres > Applications > Démarrage")] + [InlineData("nl-nl", "Instellingen > Apps > Opstarten")] + [InlineData("pt-br", "Configurações > Aplicativos > Inicializar")] + [InlineData("zh-cn", "设置 > 应用 > 启动")] + [InlineData("zh-tw", "設定 > 應用程式 > 啟動")] + public void StartupRefusalNotice_PointsAtTheWindowsStartupSettingInEveryLocale( + string locale, string startupSetting) + { + var resources = System.Xml.Linq.XDocument.Parse( + Read("src", "OpenClaw.Tray.WinUI", "Strings", locale, "Resources.resw")); + var notice = resources.Root!.Elements("data") + .Single(element => (string?)element.Attribute("name") == "Migration_StoreStartupRefused") + .Element("value")!.Value; + + Assert.Contains(startupSetting, notice); + Assert.Contains("OpenClaw Companion", notice); + Assert.DoesNotContain("—", notice); + } + + [Theory] + [InlineData("en-us", "blocked from starting normally", "uninstall the previous app", "This preview will not change your setup yet.", "This preview has not changed your setup.")] + [InlineData("fr-fr", "ne pourra plus démarrer normalement", "désinstaller l'application précédente", "Cet aperçu ne modifiera pas encore votre configuration.", "Cet aperçu n'a pas modifié votre configuration.")] + [InlineData("nl-nl", "niet meer normaal kunnen starten", "de vorige app verwijderen", "Dit voorbeeld wijzigt uw configuratie nog niet.", "Dit voorbeeld heeft uw configuratie niet gewijzigd.")] + [InlineData("pt-br", "não poderá mais iniciar normalmente", "desinstalar o aplicativo anterior", "Esta prévia ainda não alterará sua configuração.", "Esta prévia não alterou sua configuração.")] + [InlineData("zh-cn", "旧版应用将无法正常启动", "卸载旧版应用", "此预览尚不会更改您的配置。", "此预览未更改您的配置。")] + [InlineData("zh-tw", "舊版應用程式將無法正常啟動", "解除安裝舊版應用程式", "此預覽尚不會變更您的設定。", "此預覽未變更您的設定。")] + public void Consent_DisclosesStartupBlockAndRequiredRemovalInEveryLocale( + string locale, string startupBlock, string removal, string oldConsent, string oldRetry) + { + var resources = System.Xml.Linq.XDocument.Parse( + Read("src", "OpenClaw.Tray.WinUI", "Strings", locale, "Resources.resw")); + string Value(string key) => resources.Root!.Elements("data") + .Single(element => (string?)element.Attribute("name") == key).Element("value")!.Value; + + var yes = Value("Migration_StoreYes"); + var no = Value("Migration_StoreNo"); + Assert.DoesNotContain(":", yes + no); + Assert.DoesNotContain(":", yes + no); + var consent = string.Format(Value("Migration_StoreConsent"), yes, no); + var retry = string.Format(Value("Migration_StoreCloseInno"), yes, no); + Assert.Contains(startupBlock, consent); + Assert.Contains(removal, consent); + Assert.Contains(yes, consent); + Assert.Contains(no, consent); + Assert.Contains(yes, retry); + Assert.Contains(no, retry); + Assert.Contains("{0}", Value("Migration_StoreConsent")); + Assert.Contains("{1}", Value("Migration_StoreConsent")); + Assert.Contains("{0}", Value("Migration_StoreCloseInno")); + Assert.Contains("{1}", Value("Migration_StoreCloseInno")); + Assert.DoesNotContain(oldConsent, consent); + Assert.DoesNotContain(oldRetry, Value("Migration_StoreCloseInno")); + if (locale == "en-us") + { + Assert.Contains("protected migration records", consent); + Assert.Contains("If validation succeeds", consent); + Assert.Contains("reopen the Store app to finish migration", consent); + Assert.Contains("Your setup and gateway will be preserved", consent); + Assert.Contains("nothing is uninstalled automatically", consent); + Assert.Contains("without starting migration", consent); + Assert.Contains("Uninstall only after", Value("Migration_StoreCloseInno")); + } + } + + [Fact] + public void StorePreviewChoices_UseNativeYesNoWithoutActionLegends() + { + var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); + var choice = helper[helper.IndexOf("private static bool ShowChoice(", StringComparison.Ordinal).. + helper.IndexOf("private static void ShowGuidance(", StringComparison.Ordinal)]; + Assert.Contains("ShowChoice(string contentKey)", choice); + Assert.Contains("LocalizationHelper.Format(contentKey,", choice); + Assert.DoesNotContain("primaryKey", choice); + Assert.DoesNotContain("secondaryKey", choice); + Assert.DoesNotContain("\\r\\n", choice); + Assert.Contains("0x00000124) == 6", choice); + } + + [Fact] + public void CompletedMigrationGuard_PrecedesSettingsAndActivation() + { + var app = Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + var launch = app[app.IndexOf("private async Task OnLaunchedAsync", StringComparison.Ordinal)..]; + var guard = launch.IndexOf("InnoMigrationStartupGuard.ShouldStopLaunch(out _innoMigrationLease)", StringComparison.Ordinal); + Assert.True(guard > launch.IndexOf("await CliUninstallHandler.RunAsync", StringComparison.Ordinal)); + Assert.True(guard > launch.IndexOf("_mutex = new Mutex(true, mutexName", StringComparison.Ordinal)); + Assert.Contains("if (ownsMutex && InnoMigrationStartupGuard.ShouldStopLaunch(out _innoMigrationLease))", launch); + Assert.True(guard < launch.IndexOf("new ActivationRouter(", StringComparison.Ordinal)); + Assert.True(guard < launch.IndexOf("new SettingsManager()", StringComparison.Ordinal)); + Assert.DoesNotContain("MigrationRecordCodec", app); + var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "InnoMigrationStartupGuard.cs"); + Assert.Contains("AppIdentity.IsDev || PackageHelper.IsPackaged", helper); + Assert.Contains("MigrationRecordCodec.ReadCompletion", helper); + Assert.Contains("Migration_InnoCompleted", helper); + Assert.Contains("GatewayFixtureIsolation.IsEnabled", helper); + var acquire = helper.IndexOf("runtimeLease = MigrationOperationLock.AcquireRuntime(binding)", StringComparison.Ordinal); + Assert.True(acquire > 0 && acquire < helper.IndexOf("MigrationRecordCodec.ReadCompletion", StringComparison.Ordinal)); + var lockFailure = helper[acquire..helper.IndexOf("var path =", StringComparison.Ordinal)]; + Assert.Contains("InvalidDataException", lockFailure); + Assert.Contains("return ShowGuidance(\"Migration_InnoCheckFailed\")", lockFailure); + Assert.DoesNotContain("_innoMigrationLease", Read("src", "OpenClaw.Tray.WinUI", "App.AppShutdownCoordinator.cs")); + Assert.DoesNotContain("_innoMigrationLease?.Dispose", app); + } + + [Fact] + public void Installer_ChecksCompletionBeforeChoiceAndNeverDeletesPreservedState() + { + var installer = Read("installer.iss"); + Assert.Contains("Source: \"scripts\\Uninstall-LocalGateway.ps1\"", installer); + Assert.Contains("Source: \"scripts\\Test-InnoMigration.ps1\"", installer); + Assert.Contains("Source: \"src\\OpenClaw.Connection\\Migration\\MigrationRecordCodec.cs\"", installer); + AssertPreservationGuards(installer); + } + + [Theory] + [InlineData("if MigrationResult <> 0 then", "if MigrationResult = 10 then")] + [InlineData("LocalGatewayCleanupRequested := False;", + "LocalGatewayCleanupRequested := False;\n LocalGatewayCleanupSucceeded := True;")] + [InlineData(" Exit;\n end;\n\n if UninstallSilent()", " end;\n\n if UninstallSilent()")] + [InlineData(" if not LocalGatewayCleanupRequested then\n Exit;", "")] + [InlineData("if Started and (ResultCode = 10) then", "if Started and (ResultCode = 11) then")] + [InlineData(" end;\n\n if Started and (ResultCode = 0) then", + " LocalGatewayCleanupSucceeded := True;\n end;\n\n if Started and (ResultCode = 0) then")] + [InlineData("if Started and (ResultCode = 0) then", "if ResultCode = 0 then")] + [InlineData(" if not LocalGatewayCleanupSucceeded then\n Exit;", "")] + public void Installer_PreservationContractsRejectUnsafeMutations(string original, string replacement) + { + var installer = Read("installer.iss").ReplaceLineEndings("\n"); + Assert.Contains(original, installer); + var mutated = installer.Replace(original, replacement, StringComparison.Ordinal); + Assert.ThrowsAny(() => AssertPreservationGuards(mutated)); + } + + private static void AssertPreservationGuards(string installer) + { + // These source contracts intentionally pin the small Pascal safety branches. + // Runtime installer proof is still required; matching keywords alone is not enough. + Assert.Matches( + @"MigrationResult := CheckCompletedStoreMigration;\s+" + + @"if MigrationResult <> 0 then\s+begin\s+" + + @"LocalGatewayCleanupRequested := False;\s+" + + @"if MigrationResult = 10 then\s+Log\('[^']*'\)\s+" + + @"else if MigrationResult = 11 then\s+(?://[^\n]*\n\s*)*ReportStoreAppOwnsGateway\s+" + + @"else\s+WarnMigrationCheckUnavailable;\s+Exit;\s+end;\s+if UninstallSilent\(\)", + installer); + // An unverifiable migration state must be reported, not silently swallowed, and a + // registered Store app must divert before the wsl --unregister advice is shown. + // Anything that is not a positively observed absence diverts to the uncertainty + // message, so the destructive advice can never be the fall-through default. + Assert.Matches( + @"procedure WarnMigrationCheckUnavailable;\s+var\s+Presence: String;\s+begin\s+" + + @"(?://[^\n]*\n\s*)*" + + @"Presence := StorePackagePresence;\s+" + + @"if Presence = 'Present' then\s+begin\s+ReportStoreAppOwnsGateway;\s+Exit;\s+end;\s+" + + @"if Presence <> 'Absent' then\s+begin\s+(?://[^\n]*\n\s*)*" + + @"ReportStoreAppStateUnknown;\s+Exit;\s+end;\s+" + + @"Log\([\s\S]*?\);\s+" + + @"if not UninstallSilent\(\) then\s+MsgBox\(", + installer); + // A known-installed Store app gets its own report, so the generic path's + // wsl --unregister advice can never reach the state it would destroy. + Assert.Matches( + @"procedure ReportStoreAppOwnsGateway;\s+begin\s+" + + @"Log\([\s\S]*?\);\s+" + + @"if not UninstallSilent\(\) then\s+MsgBox\(", + installer); + Assert.Matches( + @"begin\s+if not LocalGatewayCleanupRequested then\s+Exit;\s+" + + @"LocalGatewayCleanupSucceeded := False;\s+repeat", + installer); + Assert.Matches( + @"if Started and \(ResultCode = 10\) then\s+begin\s+" + + @"Log\('[^']*'\);\s+Exit;\s+end;\s+" + + @"if Started and \(ResultCode = 11\) then\s+begin\s+" + + @"(?://[^\n]*\n\s*)*ReportStoreAppOwnsGateway;\s+Exit;\s+end;\s+" + + @"if Started and \(ResultCode = 0\) then\s+begin\s+" + + @"LocalGatewayCleanupSucceeded := True;\s+Log\('[^']*'\);\s+Exit;\s+end;", + installer); + Assert.Single(Regex.Matches(installer, @"LocalGatewayCleanupSucceeded\s*:=\s*True;")); + Assert.Matches( + @"procedure DeleteGeneratedAppState;\s+begin\s+" + + @"if not LocalGatewayCleanupSucceeded then\s+Exit;\s+" + + @"if DelTree\(ExpandConstant\('\{app\}'\), True, True, True\) then", + installer); + } + + [Fact] + public void CleanupScript_RechecksReceiptBeforeAnyDestructiveWork() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + var main = script[script.LastIndexOf("\ntry {", StringComparison.Ordinal)..]; + Assert.True(main.IndexOf("Test-InnoMigration.ps1", StringComparison.Ordinal) < + main.IndexOf("$script:WslPath = Get-WslExePath", StringComparison.Ordinal)); + Assert.Contains("if ($migrationResult -eq 10)", main); + Assert.Contains("exit 10", main); + Assert.Contains("if ($migrationResult -ne 0)", main); + Assert.True(main.IndexOf("$migrationOperationLock = [IO.FileStream]::new(", StringComparison.Ordinal) < + main.IndexOf("$checker =", StringComparison.Ordinal)); + Assert.Contains("[IO.FileMode]::OpenOrCreate, [IO.FileAccess]::Read, [IO.FileShare]::Read", main); + Assert.Matches(@"finally\s*\{\s*if \(\$null -ne \$migrationOperationLock\) \{\s*" + + @"\$migrationOperationLock.Dispose\(\)", main); + var logger = script[script.IndexOf("function Write-GatewayLog", StringComparison.Ordinal).. + script.IndexOf("function Add-CleanupWarning", StringComparison.Ordinal)]; + Assert.DoesNotContain("Test-InnoMigration", logger); + } + + [Fact] + public void CleanupScript_BoundsEveryChildProcessWait() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + // A hung wsl.exe or checker must not block uninstall forever, and a + // timed-out operation is indeterminate rather than successful. + Assert.DoesNotMatch(@"-Wait\b", script); + Assert.DoesNotMatch(@"(?m)^\s*(\$\w+ = )?Start-Process\b", script); + Assert.Contains("$process.WaitForExit($TimeoutMilliseconds)", script); + Assert.Contains("-TimeoutMilliseconds ($WslTimeoutSeconds * 1000)", script); + Assert.Contains("-TimeoutMilliseconds ($MigrationCheckTimeoutSeconds * 1000)", script); + Assert.Contains("did not finish within $WslTimeoutSeconds seconds", script); + Assert.Contains("did not finish within $MigrationCheckTimeoutSeconds seconds", script); + } + + [Fact] + public void CleanupScript_KeepsCheckerDiagnostics() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + // Exit 2 is an irreversible-decision input. Losing the checker's stderr + // reduces every support case to an opaque exit code. + Assert.Contains("Migration preservation check output:", script); + Assert.Contains("Migration preservation check exited $migrationResult.", script); + // Child arguments must use the shared quoting helper, not hand-rolled quotes. + Assert.Contains("'-AppRoot', (ConvertTo-ProcessArgument $AppRoot)", script); + AssertBoundedProcessHelper(script); + } + + [Fact] + public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1").ReplaceLineEndings("\n"); + // Read-only discovery stays inside the admission phase; only the two + // genuinely destructive steps leave it. A deleted flag flip must fail here. + Assert.Matches( + @"function Enter-DestructivePhase \{\s+#[^\n]*\n\s+\$script:MigrationAdmissionPhase = \$false\s+\}", + script); + Assert.Matches( + @"function Remove-GatewayDirectory \{\s+Enter-DestructivePhase", + script); + Assert.Matches( + @"Enter-DestructivePhase\s+\$unregisterResult = Invoke-Wsl -Arguments @\('--unregister'", + script); + Assert.Equal(2, Regex.Matches(script, @"^\s*Enter-DestructivePhase\s*$", + RegexOptions.Multiline).Count); + Assert.Matches( + @"\$failureExitCode = if \(\$script:MigrationAdmissionPhase\) \{ 2 \} else \{ 1 \}", + script); + Assert.Contains("exit $failureExitCode", script); + // Locating wsl.exe and listing distros destroy nothing, so they must not + // sit past the admission boundary in the main flow. + var main = script[script.LastIndexOf("\ntry {", StringComparison.Ordinal)..]; + Assert.DoesNotMatch( + @"Enter-DestructivePhase[\s\S]*?\$script:WslPath = Get-WslExePath", + main); + } + + [Fact] + public void Installer_RoutesCheckerUncertaintyToPreservationNotRetry() + { + var installer = Read("installer.iss"); + // Exit 2 means "unknown", so offering Retry against an unchanging verdict + // is misleading. It must reach the preservation notice instead. + Assert.Matches( + @"if Started and \(ResultCode = 2\) then\s+begin\s+" + + @"WarnMigrationCheckUnavailable;\s+Exit;\s+end;", + installer); + // The installer's own failure codes must not collide with the script contract. + Assert.DoesNotMatch(@"ResultCode := [0-9];", installer); + // The preservation notice must not name files uninstall is about to delete. + Assert.DoesNotContain("run Uninstall-LocalGateway.ps1 from", installer); + // These helpers run during usUninstall, before Inno removes files, so they + // need no retention flag. Retaining them stranded all three in {app} on + // every uninstall that kept the local gateway, including the ordinary + // non-migration "keep my gateway" choice. + foreach (var helper in new[] + { + "scripts\\Uninstall-LocalGateway.ps1", "scripts\\Test-InnoMigration.ps1", + "src\\OpenClaw.Connection\\Migration\\MigrationRecordCodec.cs" + }) + Assert.DoesNotMatch( + Regex.Escape($"Source: \"{helper}\"") + @"[^\n]*uninsneveruninstall", + installer); + } + + // The preserve path must not hand the user the command that destroys what it just + // preserved. Exit 11 means "the Store app is registered and is probably using this + // gateway", so routing it back to the generic could-not-confirm advice, which tells + // the user to run wsl --unregister, would reopen the data loss by other means. + [Fact] + public void Installer_DoesNotAdviseUnregisteringWhenTheStoreAppOwnsTheGateway() + { + var installer = Read("installer.iss"); + var procedure = installer[installer.IndexOf( + "procedure ReportStoreAppOwnsGateway;", StringComparison.Ordinal)..]; + // Terminate at the next procedure, not at WarnMigrationCheckUnavailable, so this + // stays pinned to the ownership message alone. Slicing past a neighbouring + // procedure would let these assertions be satisfied by the wrong one. + procedure = procedure[..procedure.IndexOf( + "procedure ReportStoreAppStateUnknown;", StringComparison.Ordinal)]; + + Assert.Contains("Do not run", procedure); + // The destructive instruction belongs only to the genuinely-uncertain path. + Assert.DoesNotContain("If OpenClaw is already removed", procedure); + + var choice = installer[installer.IndexOf( + "procedure EnsureLocalGatewayCleanupChoice;", StringComparison.Ordinal)..]; + var elevenBranch = choice.IndexOf("MigrationResult = 11", StringComparison.Ordinal); + Assert.True(elevenBranch >= 0, "Exit 11 must have its own branch."); + Assert.True( + choice.IndexOf("ReportStoreAppOwnsGateway", elevenBranch, StringComparison.Ordinal) >= 0, + "Exit 11 must route to the Store-app-owns-gateway message."); + } + + // Every route to "could not confirm" is reachable on a machine that has already + // migrated: an undecryptable receipt, a watchdog that cannot start, a missing + // PowerShell. In those states the generic advice tells the user to run + // wsl --unregister, which destroys the gateway the installed Store app is using. + // Package presence must gate the message itself, not just the one branch where the + // checker managed to report exit 11. + [Fact] + public void Installer_ChecksForTheStoreAppBeforeAdvisingUnregister() + { + var installer = Read("installer.iss"); + var warn = installer[installer.IndexOf( + "procedure WarnMigrationCheckUnavailable;", StringComparison.Ordinal)..]; + warn = warn[..warn.IndexOf("procedure EnsureLocalGatewayCleanupChoice;", StringComparison.Ordinal)]; + + var guard = warn.IndexOf("Presence := StorePackagePresence;", StringComparison.Ordinal); + Assert.True(guard >= 0, "The uncertain path must consult package presence."); + + // Match the advice itself, not the explanatory comment that also names the command. + var destructive = warn.IndexOf("wsl --unregister {#MyDistroName}'", StringComparison.Ordinal); + Assert.True(destructive > 0, "The uncertain path still owns the unregister advice."); + Assert.True( + guard < destructive, + "The package-presence guard must precede the destructive advice."); + Assert.True( + warn.IndexOf("ReportStoreAppOwnsGateway", guard, StringComparison.Ordinal) < destructive, + "A registered Store app must divert to the non-destructive message."); + // Only a positively observed absence may reach the unregister advice. Expressing the + // safe route as "anything that is not Absent" keeps preservation the default, so an + // unrecognized state cannot fall through to permanent data loss guidance. + Assert.Contains("if Presence <> 'Absent' then", warn[guard..destructive]); + Assert.True( + warn.IndexOf("ReportStoreAppStateUnknown", guard, StringComparison.Ordinal) < destructive, + "Uncertainty must divert before the destructive advice."); + // Without the early exit the guard would fall through and print both messages. + Assert.Contains("Exit;", warn[guard..destructive]); + } + + // Absence is only meaningful if it is read from the identity the Store app actually + // registers under, and only if the unreadable and empty shapes fail closed. The + // Packages key is user-writable, so an empty enumeration is tampering, not absence. + [Fact] + public void Installer_PinsTheStorePackageIdentity() + { + var installer = Read("installer.iss"); + Assert.Contains( + $"#define MyStorePackageName \"{MigrationRecordCodec.PackageName}\"", + installer); + + var function = installer[installer.IndexOf( + "function StorePackagePresence: String;", StringComparison.Ordinal)..]; + function = function[..function.IndexOf( + "procedure ReportStoreAppOwnsGateway;", StringComparison.Ordinal)]; + + Assert.Contains("AppModel\\Repository\\Packages", function); + Assert.Contains("'{#MyStorePackageName}'", function); + // Case-insensitive prefix match anchored at position 1, so a package merely + // containing the name cannot masquerade as ours. + Assert.Contains("Pos(Prefix, Lowercase(Names[I])) = 1", function); + + var unreadable = function.IndexOf("if not RegGetSubkeyNames", StringComparison.Ordinal); + var empty = function.IndexOf("if GetArrayLength(Names) = 0 then", StringComparison.Ordinal); + Assert.True(unreadable >= 0 && empty > unreadable); + // Both uncertain shapes must yield Indeterminate. That still suppresses the + // destructive advice, but it must not be reported to the user as a known + // installation, because neither shape observed one. + Assert.Contains("Result := 'Indeterminate';", function[unreadable..empty]); + Assert.Contains("Result := 'Indeterminate';", function[empty..]); + // Present is reachable only from an actual prefix match, never from uncertainty. + Assert.Single(Regex.Matches(function, @"Result := 'Present';")); + // The only Absent is the fully-enumerated, no-match outcome at the end. + Assert.Equal( + function.LastIndexOf("Result := 'Absent';", StringComparison.Ordinal), + function.IndexOf("Result := 'Absent';", StringComparison.Ordinal)); + } + + // Preserving the gateway and claiming to know why are different acts. The unreadable + // and tampered-hive shapes observe no package at all, so the notice for them must not + // assert that the Store app is installed and using the gateway, and must still + // withhold the wsl --unregister advice that the genuinely-absent path owns. + [Fact] + public void Installer_DescribesAnUnreadablePackageRegistryAsUncertainNotOwned() + { + var installer = Read("installer.iss"); + var unknown = installer[installer.IndexOf( + "procedure ReportStoreAppStateUnknown;", StringComparison.Ordinal)..]; + unknown = unknown[..unknown.IndexOf( + "procedure WarnMigrationCheckUnavailable;", StringComparison.Ordinal)]; + + // No ownership claim: the phrases the Present path uses must not appear here. + Assert.DoesNotContain("is installed on this PC and is using", unknown); + Assert.DoesNotContain("keeps working", unknown); + Assert.Contains("could not check whether", unknown); + // Uncertainty still preserves, and still refuses to hand over the destructive command. + Assert.Contains("left in place", unknown); + Assert.DoesNotContain("wsl --unregister", unknown); + Assert.Contains("Settings > Local Gateway > Remove Local Gateway", unknown); + + // The silent path shows no dialog, so the log line is the only audit trail. It must + // name what was left behind, exactly as the genuinely-absent path does. + var log = unknown[..unknown.IndexOf("if not UninstallSilent()", StringComparison.Ordinal)]; + Assert.Contains("could not be read", log); + Assert.Contains("{#MyDistroName}", log); + Assert.Contains(@"{localappdata}\{#MyInstallDir}\wsl\{#MyDistroName}", log); + Assert.Contains("were left in place", log); + } + + [Fact] + public void Checker_ReportsUncertaintyWhenItsWatchdogCannotStart() + { + var script = Read("scripts", "Test-InnoMigration.ps1"); + var start = script.IndexOf("-TimeoutMilliseconds ($TimeoutSeconds * 1000)", StringComparison.Ordinal); + var handler = script[start..script.IndexOf("if ($null -ne $watchdogResult)", start, StringComparison.Ordinal)]; + // Falling back to the inline Add-Type check would reintroduce the unbounded + // stall the watchdog exists to prevent, and installer.iss waits for this + // process with ewWaitUntilTerminated. + Assert.Contains("exit 2", handler); + Assert.DoesNotContain("$watchdogResult = $null", handler); + } + + [Fact] + public void Checker_TreatsUnverifiableReceiptAsPreserveNotAbsent() + { + var script = Read("scripts", "Test-InnoMigration.ps1"); + var handler = script[script.LastIndexOf("} catch {", StringComparison.Ordinal)..]; + // Only a genuinely absent receipt may authorize the existing uninstall + // policy. Schema, DPAPI, or binding drift must fail closed. + Assert.Contains("[IO.FileNotFoundException]", handler); + Assert.Contains("[IO.DirectoryNotFoundException]", handler); + Assert.DoesNotContain("CryptographicException", handler); + Assert.DoesNotContain("InvalidDataException", handler); + Assert.Single(Regex.Matches(handler, @"exit 0")); + Assert.Contains("exit 2", handler); + Assert.Contains("-TimeoutMilliseconds ($TimeoutSeconds * 1000)", script); + // $PSHOME resolves to the PowerShell 7 directory under pwsh, which has no + // powershell.exe. The watchdog must name Windows PowerShell explicitly. + Assert.DoesNotContain("Join-Path $PSHOME", script); + Assert.Contains("System32\\WindowsPowerShell\\v1.0\\powershell.exe", script); + Assert.Contains("$watchdogResult.Output.Trim()", script); + AssertBoundedProcessHelper(script); + } + + private static void AssertBoundedProcessHelper(string script) + { + // Start-Process -PassThru returns a null ExitCode once output is redirected, + // which silently turned every verdict into exit 0. Drive the process directly, + // and start both reads before waiting so a full pipe cannot deadlock. + Assert.Contains("$psi.UseShellExecute = $false", script); + Assert.Contains("[System.Diagnostics.Process]::Start($psi)", script); + Assert.Matches( + @"\$stdout = \$process\.StandardOutput\.ReadToEndAsync\(\)\s+" + + @"\$stderr = \$process\.StandardError\.ReadToEndAsync\(\)\s+\r?\n?\s*" + + @"if \(-not \$process\.WaitForExit\(\$TimeoutMilliseconds\)\)", + script); + Assert.Contains("ExitCode = [int]$process.ExitCode", script); + } + + [Fact] + public void Installer_HoldsMigrationLockThroughEntireUninstall() + { + var installer = Read("installer.iss"); + var initialize = installer[installer.IndexOf("function InitializeUninstall:", StringComparison.Ordinal).. + installer.IndexOf("procedure DeinitializeUninstall;", StringComparison.Ordinal)]; + Assert.Contains("#ifndef DevBuild", initialize); + Assert.Contains(@"{userappdata}\{#MyInstallDir}\store-migration", initialize); + Assert.Contains(@"'\prepare.lock'", initialize); + Assert.Contains("if not MigrationPathIsOrdinary(LockPath) then", initialize); + Assert.Contains("else if ForceDirectories(Directory) then", initialize); + // A rejected reparse path must never reach ForceDirectories, so the two checks + // stay sequential rather than relying on Pascal Script short-circuit evaluation. + Assert.DoesNotContain("MigrationPathIsOrdinary(LockPath) and", initialize); + Assert.Matches(@"OpenMigrationOperationFile\(\s*LockPath, \$80000000, 1, 0, 4, \$80, 0\)", initialize); + Assert.Matches( + @"if MigrationOperationHandle <> THandle\(-1\) then\s+begin\s+" + + @"MigrationOperationLocked := True;\s+MigrationOperationUnavailable := False;", + initialize); + Assert.Matches(@"procedure DeinitializeUninstall;\s*begin\s*if MigrationOperationLocked then\s*begin\s*" + + @"CloseMigrationOperationFile\(MigrationOperationHandle\);", installer); + Assert.Single(Regex.Matches(installer, @"CloseMigrationOperationFile\(MigrationOperationHandle\)")); + } + + [Fact] + public void RecordPackageIdentity_MatchesStoreManifest() + { + var manifest = System.Xml.Linq.XDocument.Parse(Read("src", "OpenClaw.Tray.WinUI", "Package.appxmanifest")); + var identity = manifest.Root!.Elements().Single(element => element.Name.LocalName == "Identity"); + Assert.Equal(OpenClaw.Connection.Migration.MigrationRecordCodec.PackageName, identity.Attribute("Name")!.Value); + Assert.Equal(OpenClaw.Connection.Migration.MigrationRecordCodec.PackagePublisher, identity.Attribute("Publisher")!.Value); + } + + [Fact] + public async Task GatewayUninstall_PassesWslControlFlagsUnquoted() + { + // Real VM proof showed every wsl.exe call failing with + // "/bin/sh: --list: not found" and exit 127. wsl.exe matches its control + // flags against the raw command line, so a quoted "--unregister" is run + // as a command inside the distro and the gateway is never unregistered. + // Source-text assertions cannot catch this; exercise the real function. + var result = await RunGatewayProbeAsync(ProcessArgumentProbe); + Assert.Contains("CONTRACT-OK", result, StringComparison.Ordinal); + } + + [Fact] + public async Task GatewayUninstall_TreatsAWslLessHostAsNothingToRemove() + { + // Real uninstall proof on a host without WSL showed wsl.exe writing + // UTF-16LE into an 8-bit-decoded pipe, so every output pattern matched + // NUL-interleaved text and never fired. Exercise the real functions + // rather than asserting on source text, which cannot catch that. + var result = await RunGatewayProbeAsync(GatewayOutputProbe); + Assert.Contains("CONTRACT-OK", result, StringComparison.Ordinal); + } + + private static async Task RunGatewayProbeAsync(string probe) + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var target = Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"); + var probePath = Path.Combine(Path.GetTempPath(), $"openclaw-gateway-contract-{Guid.NewGuid():N}.ps1"); + File.WriteAllText(probePath, probe.Replace("", target, StringComparison.Ordinal)); + + try + { + var powershell = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var startInfo = new ProcessStartInfo(powershell) + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", probePath }) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); + return $"{await standardOutput}{Environment.NewLine}{await standardError}"; + } + finally + { + try { File.Delete(probePath); } catch (IOException) { } + } + } + + private const string ProcessArgumentProbe = @" +$ErrorActionPreference = 'Stop' +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile('', [ref]$tokens, [ref]$errors) +$found = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-ProcessArgument' +}.GetNewClosure(), $true) +if (-not $found) { throw 'Missing function ConvertTo-ProcessArgument.' } +. ([scriptblock]::Create($found.Extent.Text)) + +foreach ($flag in @('--list', '--quiet', '--terminate', '--unregister', '-File', '-AppRoot')) { + $actual = ConvertTo-ProcessArgument $flag + if ($actual -ne $flag) { + throw ""wsl.exe control flag must stay unquoted: $flag became $actual."" + } +} + +# A bare distro name needs no quotes either, and quoting it is what wsl.exe +# rejected in the real uninstall proof. +if ((ConvertTo-ProcessArgument 'OpenClawGateway') -ne 'OpenClawGateway') { + throw 'A simple value must not be quoted.' +} + +# Values that genuinely need quoting must still be protected, or a path with a +# space would split into two arguments. +if ((ConvertTo-ProcessArgument 'C:\Program Files\OpenClaw') -ne '""C:\Program Files\OpenClaw""') { + throw 'A value containing a space must be quoted.' +} +if ((ConvertTo-ProcessArgument 'C:\dir with space\') -ne '""C:\dir with space\\""') { + throw 'A trailing backslash must be doubled so it cannot escape the closing quote.' +} +if ((ConvertTo-ProcessArgument 'say ""hi""') -ne '""say \""hi\""""') { + throw 'An embedded quote must be escaped.' +} +if ((ConvertTo-ProcessArgument '') -ne '""""') { + throw 'An empty argument must survive as an empty quoted token.' +} + +Write-Output 'CONTRACT-OK' +"; + + + private const string GatewayOutputProbe = @" +$ErrorActionPreference = 'Stop' +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile('', [ref]$tokens, [ref]$errors) +foreach ($name in @('ConvertTo-CleanProcessOutput', 'Test-DistroNotFound')) { + $found = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }.GetNewClosure(), $true) + if (-not $found) { throw ""Missing function $name."" } + . ([scriptblock]::Create($found.Extent.Text)) +} + +function New-Utf16Bleed { + param([string]$Text) + return (-join ($Text.ToCharArray() | ForEach-Object { ""$_`0"" })) +} + +$raw = New-Utf16Bleed 'The Windows Subsystem for Linux is not installed.' +if (Test-DistroNotFound $raw) { + throw 'NUL-interleaved output must not match directly; sanitizing is what makes detection work.' +} + +$clean = ConvertTo-CleanProcessOutput $raw +if ($clean -ne 'The Windows Subsystem for Linux is not installed.') { + throw ""Sanitizer did not normalize UTF-16 output: $clean"" +} +if (-not (Test-DistroNotFound $clean)) { + throw 'A host without WSL holds no gateway, so uninstall must treat it as nothing to remove.' +} +if (-not (Test-DistroNotFound (ConvertTo-CleanProcessOutput (New-Utf16Bleed 'There is no distribution with the supplied name.')))) { + throw 'Existing distro-not-found detection regressed.' +} +if (Test-DistroNotFound 'Access is denied.') { + throw 'A genuine failure must never be reported as already removed.' +} + +Write-Output 'CONTRACT-OK' +"; + + private static string Read(params string[] segments) => + File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(segments).ToArray())); +} diff --git a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 b/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 index a4b60b4f2..4ad826572 100644 --- a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 +++ b/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 @@ -575,6 +575,35 @@ try { Add-Step "secondary-hook-check" "Warning" "Cannot perform secondary check: uninstall log not found." } + # The uninstaller only runs the cleanup hook when it has decided the gateway is + # unowned. Since the Inno-to-Store migration gate landed, a machine with the Store + # app registered (every developer box, and any self-hosted runner that has ever + # installed it) makes that decision "preserve", so the hook is skipped by design and + # the ordering this test exists to check never happens. Read the decision back out of + # the log and report SKIP, because reporting FAIL would be a false alarm that trains + # people to ignore this suite. + # + # Only the two known-preserve verdicts qualify. Exit 2 means the check itself could not + # be established, which is exactly the kind of breakage this suite should still fail on. + # + # The verdict is authoritative on its own and is deliberately not gated on + # $hookConfirmed: Inno logs the deletion of Uninstall-LocalGateway.ps1 during every + # uninstall, so the secondary name scan above can report the hook as "confirmed" on a + # machine where it never ran. + $preserveVerdict = $null + if ($ordering.log_found) { + $rawLogForGate = Get-Content -LiteralPath $uninstallLog -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + # Take the last verdict, not the first. The uninstaller can log this line more than + # once (the cleanup script re-runs the check), and only the final one describes the + # decision that actually governed this run. + $verdictMatches = [regex]::Matches( + [string]$rawLogForGate, 'Migration preservation check returned\s+(\d+)') + if ($verdictMatches.Count -gt 0) { + $code = [int]$verdictMatches[$verdictMatches.Count - 1].Groups[1].Value + if ($code -ne 0) { $preserveVerdict = $code } + } + } + # Determine pass/fail/skip if (-not $ordering.log_found) { # No log = can't confirm ordering; FAIL with guidance @@ -587,6 +616,19 @@ try { $notes = $ordering.notes $exitCode = $EXIT_PASS } + elseif ($preserveVerdict -eq 10 -or $preserveVerdict -eq 11) { + $reason = if ($preserveVerdict -eq 10) { + "a completed Store migration receipt was found" + } else { + "the Store app is registered on this machine" + } + $finalVerdict = "SKIP" + $notes = "Gateway cleanup was intentionally skipped because $reason " + + "(migration check exit $preserveVerdict), so the [UninstallRun] ordering " + + "could not be exercised. Run this test on a machine with no OpenClaw Store " + + "package and no migration receipt to get a conclusive result." + $exitCode = $EXIT_SKIP + } elseif (-not $hookConfirmed) { $finalVerdict = "FAIL" $notes = "Hook not confirmed in log. [UninstallRun] entry may be missing or not triggered."