param( [Parameter(Mandatory = $true)] [string]$StableSourceRoot, [string]$TaskName = "VignettePublicRuntimeWatchdog", [int]$IntervalMinutes = 5, [string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", [Parameter(Mandatory = $true)] [string]$UserUploadDir, [Parameter(Mandatory = $true)] [string]$UserUploadManifestPath, [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-f]{64}$")] [string]$ExpectedUserUploadManifestSha256, [Parameter(Mandatory = $true)] [string]$UserUploadWriteFreezePath, [string[]]$AdditionalPublicHealthUrls = @(), [switch]$SkipPublicHealth, [switch]$SkipCloudflaredRestart, [string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe", [string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml", [string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health", [switch]$InitiallyDisabled, [switch]$RunNow ) $ErrorActionPreference = "Stop" if ($IntervalMinutes -lt 1) { throw "IntervalMinutes must be 1 or greater" } if ($InitiallyDisabled -and $RunNow) { throw "InitiallyDisabled and RunNow cannot be combined" } $resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path $installerScript = Join-Path $resolvedSourceRoot "scripts\install-public-runtime-task.ps1" $watchScript = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1" $startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1" $voiceSidecarProbe = Join-Path $resolvedSourceRoot "scripts\probe-public-voice-sidecars.py" $taskLauncher = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime-task.vbs" $uploadRootContract = Join-Path $resolvedSourceRoot "scripts\public-runtime-upload-root.ps1" $uploadRootProbe = Join-Path $resolvedSourceRoot "scripts\probe-public-runtime-upload-root.py" $uploadManifestProbe = Join-Path $resolvedSourceRoot "scripts\validate-public-runtime-upload-manifest.py" $databaseIdentityHelper = Join-Path $resolvedSourceRoot "scripts\public_runtime_database_identity.py" function Invoke-GitText { param([string[]]$Arguments) $value = & git.exe -C $resolvedSourceRoot @Arguments if ($LASTEXITCODE -ne 0) { throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')" } return (@($value) -join [Environment]::NewLine).Trim() } foreach ($requiredScript in @( $installerScript, $watchScript, $startScript, $voiceSidecarProbe, $taskLauncher, $uploadRootContract, $uploadRootProbe, $uploadManifestProbe, $databaseIdentityHelper )) { if (!(Test-Path -LiteralPath $requiredScript -PathType Leaf)) { throw "Public runtime script not found at $requiredScript" } } $runningInstaller = (Resolve-Path -LiteralPath $PSCommandPath).Path if (-not [string]::Equals( $runningInstaller, (Resolve-Path -LiteralPath $installerScript).Path, [System.StringComparison]::OrdinalIgnoreCase )) { throw "Watchdog installer is not executing from the pinned stable source root" } $gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel") $resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path if (-not [string]::Equals( $resolvedGitRoot, $resolvedSourceRoot, [System.StringComparison]::OrdinalIgnoreCase )) { throw "Stable source root does not match its Git toplevel" } $symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD $symbolicHeadExit = $LASTEXITCODE if ($symbolicHeadExit -eq 0) { throw "Stable source must be a detached HEAD, not branch $symbolicHead" } if ($symbolicHeadExit -ne 1) { throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)" } $dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal") if ($dirty) { throw "Stable source is not clean; refusing watchdog installation" } foreach ($relativePath in @( "scripts/install-public-runtime-task.ps1", "scripts/watch-public-runtime.ps1", "scripts/start-public-runtime.ps1", "scripts/probe-public-voice-sidecars.py", "scripts/watch-public-runtime-task.vbs", "scripts/public-runtime-upload-root.ps1", "scripts/probe-public-runtime-upload-root.py", "scripts/validate-public-runtime-upload-manifest.py", "scripts/public_runtime_database_identity.py", "apps/api/app/upload_storage.py", "apps/api/app/upload_runtime.py" )) { Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null } $sourceCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD") $sourceTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}") $watchdogSha256 = (Get-FileHash -LiteralPath $watchScript -Algorithm SHA256).Hash.ToLowerInvariant() $startScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant() $resolvedPython = (Resolve-Path -LiteralPath $Python).Path $resolvedCloudflared = (Resolve-Path -LiteralPath $Cloudflared).Path $resolvedCloudflaredConfig = (Resolve-Path -LiteralPath $CloudflaredConfig).Path $pythonSha256 = (Get-FileHash -LiteralPath $resolvedPython -Algorithm SHA256).Hash.ToLowerInvariant() $cloudflaredSha256 = (Get-FileHash -LiteralPath $resolvedCloudflared -Algorithm SHA256).Hash.ToLowerInvariant() $cloudflaredConfigSha256 = (Get-FileHash -LiteralPath $resolvedCloudflaredConfig -Algorithm SHA256).Hash.ToLowerInvariant() . $uploadRootContract $resolvedUserUploadDir = Resolve-PublicRuntimeUploadRoot ` -SourceRoot $resolvedSourceRoot ` -UploadRoot $UserUploadDir ` -ProbeWritable $resolvedUserUploadManifestPath = Resolve-PublicRuntimePrivateStatePath ` -SourceRoot $resolvedSourceRoot ` -UploadRoot $resolvedUserUploadDir ` -StatePath $UserUploadManifestPath ` -RequireFile $resolvedUserUploadWriteFreezePath = Resolve-PublicRuntimePrivateStatePath ` -SourceRoot $resolvedSourceRoot ` -UploadRoot $resolvedUserUploadDir ` -StatePath $UserUploadWriteFreezePath $uploadManifestProof = Test-PublicRuntimeUploadManifest ` -PythonPath $Python ` -ProbePath $uploadManifestProbe ` -UploadRoot $resolvedUserUploadDir ` -ManifestPath $resolvedUserUploadManifestPath ` -ExpectedManifestSha256 $ExpectedUserUploadManifestSha256 ` -ExpectedWriteFreezePath $resolvedUserUploadWriteFreezePath if (-not $uploadManifestProof.Ok) { throw "Refusing task installation: public upload migration receipt or current DB inventory is invalid" } $wscript = Join-Path $env:SystemRoot "System32\wscript.exe" if (!(Test-Path -LiteralPath $wscript -PathType Leaf)) { throw "wscript.exe not found at $wscript" } $userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name # powershell.exe 를 액션으로 직접 등록하면 -WindowStyle Hidden 이어도 conhost 창이 # 매 실행 번쩍인다. 5분 주기 워치독에서는 그게 곧 "5분마다 뜨는 창"이 된다. # wscript 런처를 거쳐 Run(cmd, 0) 으로 띄우면 창이 생성되지 않는다. $actionArguments = @( "-File `"$watchScript`"", "-StableSourceRoot `"$resolvedSourceRoot`"", "-ExpectedSourceCommit $sourceCommit", "-ExpectedSourceTree $sourceTree", "-ExpectedWatchdogSha256 $watchdogSha256", "-ExpectedStartScriptSha256 $startScriptSha256", "-ExpectedPythonSha256 $pythonSha256", "-ExpectedCloudflaredSha256 $cloudflaredSha256", "-ExpectedCloudflaredConfigSha256 $cloudflaredConfigSha256", "-Python `"$resolvedPython`"", "-Cloudflared `"$resolvedCloudflared`"", "-CloudflaredConfig `"$resolvedCloudflaredConfig`"", "-UserUploadDir `"$resolvedUserUploadDir`"", "-UserUploadManifestPath `"$resolvedUserUploadManifestPath`"", "-ExpectedUserUploadManifestSha256 $ExpectedUserUploadManifestSha256", "-UserUploadWriteFreezePath `"$resolvedUserUploadWriteFreezePath`"", "-PublicHealthUrl `"$PublicHealthUrl`"" ) if ($SkipPublicHealth) { $actionArguments += "-SkipPublicHealth" } if ($SkipCloudflaredRestart) { $actionArguments += "-SkipCloudflaredRestart" } if ($AdditionalPublicHealthUrls.Count -gt 0) { $escapedUrls = $AdditionalPublicHealthUrls | ForEach-Object { "`"$_`"" } $actionArguments += "-AdditionalPublicHealthUrls $($escapedUrls -join ',')" } $action = New-ScheduledTaskAction ` -Execute $wscript ` -Argument ("`"$taskLauncher`" " + ($actionArguments -join " ")) ` -WorkingDirectory $resolvedSourceRoot $logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId $repeatTrigger = New-ScheduledTaskTrigger ` -Once ` -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) $settings = New-ScheduledTaskSettingsSet ` -Disable:$InitiallyDisabled ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Minutes 60) ` -MultipleInstances IgnoreNew ` -StartWhenAvailable ` -WakeToRun $principal = New-ScheduledTaskPrincipal ` -UserId $userId ` -LogonType Interactive ` -RunLevel Limited $description = "Runs Vignette public runtime watchdog as $userId from detached clean commit $sourceCommit. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets." $task = New-ScheduledTask ` -Action $action ` -Trigger @($logonTrigger, $repeatTrigger) ` -Settings $settings ` -Principal $principal ` -Description $description Register-ScheduledTask ` -TaskName $TaskName ` -TaskPath "\" ` -InputObject $task ` -Force ` | Out-Null if ($InitiallyDisabled) { $registered = Get-ScheduledTask -TaskName $TaskName -TaskPath "\" -ErrorAction Stop if ([bool]$registered.Settings.Enabled -or [string]$registered.State -eq "Running") { throw "New watchdog task did not remain disabled and idle" } } Write-Output "Installed scheduled task '$TaskName' for $userId" Write-Output "Action: $wscript `"$taskLauncher`" $($actionArguments -join ' ')" Write-Output "Pinned source: root=$resolvedSourceRoot commit=$sourceCommit tree=$sourceTree" Write-Output "Pinned scripts: watchdog_sha256=$watchdogSha256 start_sha256=$startScriptSha256" Write-Output "Pinned user upload root: $resolvedUserUploadDir" Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon" if ($AdditionalPublicHealthUrls.Count -gt 0) { Write-Output "Additional public health URLs: $($AdditionalPublicHealthUrls -join ', ')" } if ($RunNow) { Start-ScheduledTask -TaskName $TaskName -TaskPath "\" Write-Output "Started scheduled task '$TaskName'" }