param( [string]$TrainingDir = 'D:\qiwei-training', [string]$TrialCredentialEnv = 'E:\workspace\QIWEI-skill\.env.local', [int]$Port = 4435 ) $ErrorActionPreference = 'Stop' function Read-EnvValue([string]$path, [string]$key) { $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($key))\s*=" } | Select-Object -Last 1 if (-not $line) { return '' } return ($line -replace "^$([regex]::Escape($key))\s*=\s*", '').Trim().Trim('"').Trim("'") } function Wait-Http([string]$url, [int]$seconds = 30) { $deadline = (Get-Date).AddSeconds($seconds) do { try { return Invoke-RestMethod -Uri $url -TimeoutSec 5 } catch { Start-Sleep -Milliseconds 750 } } while ((Get-Date) -lt $deadline) throw "Timed out waiting for $url" } $trialToken = Read-EnvValue $TrialCredentialEnv 'QIWEI_AUTH_TOKEN' if (-not $trialToken.StartsWith('r:')) { throw 'Trial credential must be an r: session credential' } $root = Join-Path $env:TEMP ("qiwei-real-switch-" + [guid]::NewGuid().ToString('N')) $exe = Join-Path $root 'qiwei-workbench.exe' $envPath = Join-Path $root '.env.local' $process = $null New-Item -ItemType Directory -Path $root | Out-Null try { Copy-Item (Join-Path $TrainingDir 'qiwei-workbench.exe') $exe Copy-Item (Join-Path $TrainingDir 'qiwei.runtime.config.mjs') (Join-Path $root 'qiwei.runtime.config.mjs') Copy-Item (Join-Path $TrainingDir '.env.local') $envPath New-Item -ItemType Directory -Path (Join-Path $root 'web') | Out-Null Copy-Item (Join-Path $TrainingDir 'web\*') (Join-Path $root 'web') New-Item -ItemType Directory -Path (Join-Path $root 'knowledge') | Out-Null Copy-Item (Join-Path $TrainingDir 'knowledge\*') (Join-Path $root 'knowledge') -Recurse $oldUid = Read-EnvValue $envPath 'QIWEI_UID' $oldGuid = Read-EnvValue $envPath 'QIWEI_GUID' if (-not $oldUid) { throw 'Formal fixture is missing uid' } $process = Start-Process -FilePath $exe -ArgumentList 'dashboard','--port',"$Port" -WorkingDirectory $root -PassThru -WindowStyle Hidden $before = Wait-Http "http://127.0.0.1:$Port/api/status" $body = @{ token = $trialToken } | ConvertTo-Json -Compress $switch = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/auth/token" -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 45 Start-Sleep -Seconds 2 $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 10 $after = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/status" -TimeoutSec 30 $healthAfterStatus = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 10 $newUid = Read-EnvValue $envPath 'QIWEI_UID' $newGuid = Read-EnvValue $envPath 'QIWEI_GUID' $savedToken = Read-EnvValue $envPath 'QIWEI_AUTH_TOKEN' $diagnostic = [ordered]@{ endpointStatus = $switch.status accountReset = [bool]$switch.summary.accountReset oldUidPresent = [bool]$oldUid oldGuidPresent = [bool]$oldGuid uidRotated = [bool]$newUid -and $newUid -ne $oldUid newGuidPresent = [bool]$newGuid activeUidPresent = [bool]$health.data.activeAccountUid switchedSource = $after.data.subscription.summary.source credentialPersisted = $savedToken -eq $trialToken } if ($switch.status -ne 'ok') { throw "Credential switch endpoint did not succeed: $($diagnostic | ConvertTo-Json -Compress)" } if ($switch.summary.accountReset -ne $true) { throw "Cross-account switch did not reset the old device: $($diagnostic | ConvertTo-Json -Compress)" } if (-not $newUid -or $newUid -eq $oldUid -or $newGuid) { throw "Old uid/guid remain in package env: $($diagnostic | ConvertTo-Json -Compress)" } if ($health.data.activeAccountUid) { throw "Old uid remains active in the long-lived dashboard: $($diagnostic | ConvertTo-Json -Compress)" } if ($savedToken -ne $trialToken) { throw "Trial credential was not persisted: $($diagnostic | ConvertTo-Json -Compress)" } if ($after.data.subscription.summary.source -ne 'trial') { throw "Switched credential is not classified as trial: $($diagnostic | ConvertTo-Json -Compress)" } [ordered]@{ initialSource = $before.data.subscription.summary.source initialUidPresent = [bool]$oldUid initialGuidPresent = [bool]$oldGuid endpointStatus = $switch.status accountReset = [bool]$switch.summary.accountReset switchedSource = $after.data.subscription.summary.source trialActive = [bool]$after.data.subscription.summary.trialActive seats = $after.data.subscription.summary.seats usedSeats = $after.data.subscription.summary.usedSeats persistedCredentialKind = if ($savedToken.StartsWith('r:')) { 'session' } else { 'other' } uidRotated = [bool]$newUid -and $newUid -ne $oldUid guidCleared = -not [bool]$newGuid activeUidCleared = -not [bool]$health.data.activeAccountUid freshUidActivatedAfterStatus = $healthAfterStatus.data.activeAccountUid -eq $newUid upstreamDeviceConfigured = [bool]$after.data.login.summary.configured loginStarted = $false } | ConvertTo-Json -Depth 6 } finally { if ($process -and -not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $exe } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 500 $resolvedRoot = [IO.Path]::GetFullPath($root) $resolvedTemp = [IO.Path]::GetFullPath($env:TEMP) if ($resolvedRoot.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path $resolvedRoot)) { Remove-Item -LiteralPath $resolvedRoot -Recurse -Force } }