[CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet("Check", "Apply", "Verify")] [string]$Mode, [Parameter(Mandatory = $true)] [string]$SourceRevision, [Parameter(Mandatory = $true)] [string]$RunId, [string]$AgentRoot = "C:\Wooo\Agent99" ) $ErrorActionPreference = "Stop" $ExpectedAgentRoot = "C:\Wooo\Agent99" $TargetHost = "192.168.0.110" $TargetUser = "wooo" $GiteaSourceUrl = "https://gitea.wooo.work/wooo/awoooi.git" $GitPath = "C:\Program Files\Git\cmd\git.exe" $IdentityFile = Join-Path $ExpectedAgentRoot "keys\agent99_ed25519" $EvidenceRoot = Join-Path $ExpectedAgentRoot "evidence\host110-backup-runtime" $SourceRootBase = Join-Path $ExpectedAgentRoot "deploy" $TransportMutexName = "Global\WoooAgent99Host110BackupRuntimeV2" $ExpectedFileCount = 18 $ExpectedRemoteStageFileCount = 21 $RuntimeRelativePaths = @( "scripts/backup/common.sh", "scripts/backup/backup-all.sh", "scripts/backup/backup-host188-products.sh", "scripts/backup/verify-host188-products-backup.sh", "scripts/backup/verify-host188-products-archive.py", "scripts/backup/backup-gitea.sh", "scripts/backup/gitea-full-backup-restore-drill.sh", "scripts/backup/backup-configs.sh", "scripts/backup/backup-awoooi.sh", "scripts/backup/backup-awoooi-frequent.sh", "scripts/backup/backup-clawbot.sh", "scripts/backup/backup-sentry.sh", "scripts/backup/check-backup-integrity.sh", "scripts/backup/sync-offsite-backups.sh", "scripts/backup/backup-offsite-readiness-gate.sh", "scripts/backup/verify-offsite-full-sync.sh", "scripts/backup/enforce-latest-only-retention.sh", "scripts/ops/backup-health-textfile-exporter.py" ) $ExecutorRelativePath = "scripts/backup/host110-backup-runtime-executor.sh" $VerifierRelativePath = "scripts/backup/verify-host110-backup-runtime.py" function Test-Agent99SafeIdentity { param([string]$Value) return [bool]($Value -and $Value -match "^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") } function Write-Agent99ImmutableJson { param([string]$Path, [object]$Value) if (Test-Path -LiteralPath $Path) { throw "run_identity_evidence_exists" } $temporary = "$Path.tmp-$PID-$([Guid]::NewGuid().ToString('N'))" $encoding = New-Object System.Text.UTF8Encoding($false) try { $json = $Value | ConvertTo-Json -Depth 12 [IO.File]::WriteAllText($temporary, $json + [Environment]::NewLine, $encoding) [IO.File]::Move($temporary, $Path) } finally { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue | Out-Null } } function Invoke-Agent99BoundedProcess { param( [string]$FilePath, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$WorkingDirectory = "", [string]$StandardInputPath = "" ) $token = [Guid]::NewGuid().ToString("N") $stdoutPath = Join-Path $env:TEMP "agent99-host110-backup-$token.out" $stderrPath = Join-Path $env:TEMP "agent99-host110-backup-$token.err" $stopwatch = [Diagnostics.Stopwatch]::StartNew() $process = $null try { $start = @{ FilePath = $FilePath ArgumentList = $Arguments NoNewWindow = $true RedirectStandardOutput = $stdoutPath RedirectStandardError = $stderrPath PassThru = $true } if ($WorkingDirectory) { $start.WorkingDirectory = $WorkingDirectory } if ($StandardInputPath) { $start.RedirectStandardInput = $StandardInputPath } $process = Start-Process @start $null = $process.Handle if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { try { & taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null } catch {} if (-not $process.WaitForExit(2000) -and -not $process.HasExited) { $process.Kill() $process.WaitForExit(2000) | Out-Null } return [pscustomobject]@{ ok = $false exitCode = -2 reason = "transport_timeout" elapsedMs = $stopwatch.ElapsedMilliseconds stdout = "" stderrPresent = $false } } $process.WaitForExit() | Out-Null $process.Refresh() $stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw } else { "" } $stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw } else { "" } if ($stdout.Length -gt 65536 -or $stderr.Length -gt 65536) { return [pscustomobject]@{ ok = $false exitCode = -3 reason = "transport_output_oversized" elapsedMs = $stopwatch.ElapsedMilliseconds stdout = "" stderrPresent = [bool]$stderr } } return [pscustomobject]@{ ok = [bool]($process.ExitCode -eq 0) exitCode = [int]$process.ExitCode reason = if ($process.ExitCode -eq 0) { "completed" } else { "process_failed" } elapsedMs = $stopwatch.ElapsedMilliseconds stdout = [string]$stdout stderrPresent = [bool]$stderr } } catch { return [pscustomobject]@{ ok = $false exitCode = -1 reason = "transport_exception" elapsedMs = $stopwatch.ElapsedMilliseconds stdout = "" stderrPresent = $false errorType = $_.Exception.GetType().Name } } finally { $stopwatch.Stop() Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue | Out-Null if ($process) { $process.Dispose() } } } function Invoke-Agent99BoundedSsh { param([string]$RemoteCommand, [int]$TimeoutSeconds, [string]$StandardInputPath = "") $arguments = @( "-i", $IdentityFile, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "NumberOfPasswordPrompts=0", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", "-o", "ConnectionAttempts=1", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=2", "-l", $TargetUser, $TargetHost, $RemoteCommand ) return Invoke-Agent99BoundedProcess "ssh.exe" $arguments $TimeoutSeconds "" $StandardInputPath } function Invoke-Agent99BoundedScp { param([string[]]$LocalPaths, [string]$RemoteDirectory, [int]$TimeoutSeconds) $arguments = @( "-i", $IdentityFile, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "NumberOfPasswordPrompts=0", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", "-o", "ConnectionAttempts=1" ) $arguments += $LocalPaths $arguments += "${TargetUser}@${TargetHost}:${RemoteDirectory}/" return Invoke-Agent99BoundedProcess "scp.exe" $arguments $TimeoutSeconds } function Invoke-Agent99Git { param([string[]]$Arguments, [int]$TimeoutSeconds, [string]$WorkingDirectory = "") $previousPrompt = $env:GIT_TERMINAL_PROMPT try { $env:GIT_TERMINAL_PROMPT = "0" return Invoke-Agent99BoundedProcess $GitPath $Arguments $TimeoutSeconds $WorkingDirectory } finally { $env:GIT_TERMINAL_PROMPT = $previousPrompt } } function Assert-Agent99ProcessOk { param([object]$Result, [string]$Reason) if (-not $Result.ok) { throw $Reason } } function New-Agent99SourcePackage { param([string]$SourceRoot, [string]$ManifestPath) if (Test-Path -LiteralPath $SourceRoot) { throw "run_identity_source_stage_exists" } $clone = Invoke-Agent99Git @( "-c", "credential.helper=", "-c", "core.autocrlf=false", "clone", "--quiet", "--no-tags", "--depth", "200", "--branch", "main", $GiteaSourceUrl, $SourceRoot ) 180 $SourceRootBase Assert-Agent99ProcessOk $clone "gitea_source_fetch_failed" $headResult = Invoke-Agent99Git @("rev-parse", "HEAD") 30 $SourceRoot Assert-Agent99ProcessOk $headResult "gitea_source_head_failed" $sourceHead = ([string]$headResult.stdout).Trim().ToLowerInvariant() if ($sourceHead -notmatch "^[0-9a-f]{40}$") { throw "gitea_source_head_invalid" } $commitResult = Invoke-Agent99Git @("cat-file", "-e", "$SourceRevision`^{commit}") 30 $SourceRoot Assert-Agent99ProcessOk $commitResult "source_revision_not_fetched" $ancestorResult = Invoke-Agent99Git @("merge-base", "--is-ancestor", $SourceRevision, $sourceHead) 30 $SourceRoot Assert-Agent99ProcessOk $ancestorResult "source_revision_not_on_gitea_main" $allPaths = @($RuntimeRelativePaths) + @($ExecutorRelativePath, $VerifierRelativePath) $checkoutResult = Invoke-Agent99Git (@("-c", "core.autocrlf=false", "checkout", "--quiet", $SourceRevision, "--") + $allPaths) 60 $SourceRoot Assert-Agent99ProcessOk $checkoutResult "source_revision_checkout_failed" $rows = @() $localPaths = @() foreach ($relativePath in $RuntimeRelativePaths) { $path = Join-Path $SourceRoot ($relativePath -replace "/", "\") if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "runtime_source_file_missing" } $rows += [ordered]@{ name = [IO.Path]::GetFileName($path) sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() } $localPaths += $path } if (($rows.name | Sort-Object -Unique).Count -ne $ExpectedFileCount) { throw "runtime_source_file_set_invalid" } $executorPath = Join-Path $SourceRoot ($ExecutorRelativePath -replace "/", "\") $verifierPath = Join-Path $SourceRoot ($VerifierRelativePath -replace "/", "\") if (-not (Test-Path -LiteralPath $executorPath -PathType Leaf)) { throw "executor_source_file_missing" } if (-not (Test-Path -LiteralPath $verifierPath -PathType Leaf)) { throw "verifier_source_file_missing" } $executorDigest = (Get-FileHash -LiteralPath $executorPath -Algorithm SHA256).Hash.ToLowerInvariant() $verifierDigest = (Get-FileHash -LiteralPath $verifierPath -Algorithm SHA256).Hash.ToLowerInvariant() $manifest = [ordered]@{ schemaVersion = "agent99_host110_backup_runtime_source_manifest_v1" sourceRevision = $SourceRevision sourceHead = $sourceHead runId = $RunId executorSha256 = $executorDigest verifierSha256 = $verifierDigest files = $rows } $encoding = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllText($ManifestPath, ($manifest | ConvertTo-Json -Depth 8 -Compress), $encoding) $localPaths += $executorPath, $verifierPath, $ManifestPath return [pscustomobject]@{ sourceHead = $sourceHead executorSha256 = $executorDigest verifierSha256 = $verifierDigest verifierPath = $verifierPath manifestPath = $ManifestPath manifestBase64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($ManifestPath)) localPaths = $localPaths } } function Get-Agent99ExecutorResult { param([object]$Transport, [string]$ExpectedMode) if (-not $Transport.ok) { throw "host110_${ExpectedMode}_transport_failed" } try { $parsed = [string]$Transport.stdout | ConvertFrom-Json } catch { throw "host110_${ExpectedMode}_receipt_invalid" } if ( [string]$parsed.schemaVersion -ne "agent99_host110_backup_runtime_executor_v1" -or [string]$parsed.mode -ne $ExpectedMode.ToLowerInvariant() -or -not [bool]$parsed.ok -or [string]$parsed.sourceRevision -ne $SourceRevision -or [int]$parsed.fileCount -ne $ExpectedFileCount -or [int]$parsed.backupScriptFileCount -ne 17 -or -not [bool]$parsed.backupHealthExporterIncluded ) { throw "host110_${ExpectedMode}_receipt_contract_failed" } if ($ExpectedMode -eq "Apply") { if ( [string]$parsed.verifier.schemaVersion -ne "agent99_host110_backup_runtime_verifier_v1" -or -not [bool]$parsed.verifier.ok -or [bool]$parsed.verifier.remoteWritePerformed -or -not [bool]$parsed.verifier.selfIdentityVerified ) { throw "host110_apply_independent_verifier_contract_failed" } } return $parsed } function Get-Agent99VerifierResult { param([object]$Transport, [object]$SourcePackage) if (-not $Transport.ok) { throw "host110_verify_transport_failed" } try { $parsed = [string]$Transport.stdout | ConvertFrom-Json } catch { throw "host110_verify_receipt_invalid" } if ( [string]$parsed.schemaVersion -ne "agent99_host110_backup_runtime_verifier_v1" -or -not [bool]$parsed.ok -or [string]$parsed.sourceRevision -ne $SourceRevision -or [string]$parsed.runId -ne $RunId -or [int]$parsed.fileCount -ne $ExpectedFileCount -or [int]$parsed.backupScriptFileCount -ne 17 -or -not [bool]$parsed.backupHealthExporterIncluded -or [bool]$parsed.remoteWritePerformed -or [string]$parsed.verifierSha256 -ne [string]$SourcePackage.verifierSha256 ) { throw "host110_verify_receipt_contract_failed" } return $parsed } function Invoke-Agent99ReadOnlyPreflight { param([object]$SourcePackage) $command = 'test "$(id -un)" = wooo && hostname -I | tr " " "\n" | grep -qx 192.168.0.110 && test -d /backup/scripts && test ! -L /backup/scripts && test -d /home/wooo/scripts && test ! -L /home/wooo/scripts && command -v timeout >/dev/null && command -v flock >/dev/null && command -v python3 >/dev/null && printf HOST110_BACKUP_PREFLIGHT_OK=1' $transport = Invoke-Agent99BoundedSsh $command 45 if (-not $transport.ok -or ([string]$transport.stdout).Trim() -ne "HOST110_BACKUP_PREFLIGHT_OK=1") { throw "host110_read_only_preflight_failed" } return [pscustomobject]@{ schemaVersion = "agent99_host110_backup_runtime_preflight_v1" ok = $true sourceRevision = $SourceRevision sourceHead = [string]$SourcePackage.sourceHead fileCount = $ExpectedFileCount backupScriptFileCount = 17 backupHealthExporterIncluded = $true remoteWritePerformed = $false } } function Invoke-Agent99NoWriteVerifier { param([object]$SourcePackage) $command = "timeout --signal=TERM --kill-after=5s 60s python3 - --manifest-base64 $($SourcePackage.manifestBase64) --destination /backup/scripts --exporter-destination /home/wooo/scripts/backup-health-textfile-exporter.py --source-revision $SourceRevision --run-id $RunId --expected-verifier-sha256 $($SourcePackage.verifierSha256)" $transport = Invoke-Agent99BoundedSsh $command 90 ([string]$SourcePackage.verifierPath) return Get-Agent99VerifierResult $transport $SourcePackage } function Remove-Agent99RemoteStage { param([string]$RemoteStage, [bool]$Required) $cleanup = Invoke-Agent99BoundedSsh "rm -rf -- $RemoteStage && test ! -e $RemoteStage" 45 if ($Required -and -not $cleanup.ok) { throw "remote_source_stage_cleanup_failed" } return [bool]$cleanup.ok } if ($AgentRoot.TrimEnd("\") -ne $ExpectedAgentRoot) { throw "agent_root_override_forbidden" } $SourceRevision = $SourceRevision.ToLowerInvariant() if ($SourceRevision -notmatch "^[0-9a-f]{40}$") { throw "invalid_source_revision" } if (-not (Test-Agent99SafeIdentity $RunId)) { throw "invalid_run_id" } if (-not (Test-Path -LiteralPath $IdentityFile -PathType Leaf)) { throw "agent99_ssh_identity_unavailable" } if (-not (Test-Path -LiteralPath $GitPath -PathType Leaf)) { throw "windows_git_unavailable" } if (-not (Get-Command ssh.exe -ErrorAction SilentlyContinue)) { throw "windows_openssh_unavailable" } if (-not (Get-Command scp.exe -ErrorAction SilentlyContinue)) { throw "windows_openscp_unavailable" } New-Item -ItemType Directory -Force -Path $EvidenceRoot, $SourceRootBase | Out-Null $evidencePath = Join-Path $EvidenceRoot "agent99-host110-backup-runtime-$RunId.json" if (Test-Path -LiteralPath $evidencePath -PathType Leaf) { try { $existing = Get-Content -LiteralPath $evidencePath -Raw | ConvertFrom-Json } catch { throw "run_identity_evidence_invalid" } if ([string]$existing.sourceRevision -ne $SourceRevision -or [string]$existing.mode -ne $Mode) { throw "run_identity_conflict" } $existing | ConvertTo-Json -Compress -Depth 12 if (-not [bool]$existing.ok) { exit 1 } exit 0 } $sourceRoot = Join-Path $SourceRootBase "host110-backup-source-$RunId" $manifestPath = Join-Path $sourceRoot "agent99-host110-backup-runtime-manifest.json" $remoteStage = "/tmp/agent99-host110-backup-runtime-$RunId" $startedAt = [DateTime]::UtcNow $mutex = [Threading.Mutex]::new($false, $TransportMutexName) $acquired = $false $remoteStageCreated = $false $sourcePackage = $null $check = $null $apply = $null $verify = $null $terminalStatus = "failed" $errorCode = "" $cleanupVerified = $false try { try { $acquired = [bool]$mutex.WaitOne(30000) } catch [Threading.AbandonedMutexException] { $acquired = $true } if (-not $acquired) { throw "host110_backup_runtime_transport_busy" } $sourcePackage = New-Agent99SourcePackage $sourceRoot $manifestPath $check = Invoke-Agent99ReadOnlyPreflight $sourcePackage if ($Mode -eq "Apply") { $createStage = Invoke-Agent99BoundedSsh "test ! -e $remoteStage && install -d -m 700 $remoteStage" 45 Assert-Agent99ProcessOk $createStage "remote_source_stage_identity_conflict" $remoteStageCreated = $true $copyStage = Invoke-Agent99BoundedScp $sourcePackage.localPaths $remoteStage 120 Assert-Agent99ProcessOk $copyStage "remote_source_stage_copy_failed" $stageCheck = Invoke-Agent99BoundedSsh "chmod 700 $remoteStage/host110-backup-runtime-executor.sh $remoteStage/verify-host110-backup-runtime.py && test `$(find $remoteStage -mindepth 1 -maxdepth 1 -type f | wc -l) -eq $ExpectedRemoteStageFileCount" 45 Assert-Agent99ProcessOk $stageCheck "remote_source_stage_contract_failed" $executorPath = "$remoteStage/host110-backup-runtime-executor.sh" $command = "timeout --signal=TERM --kill-after=10s 300s $executorPath --mode apply --source-revision $SourceRevision --run-id $RunId --source-stage $remoteStage" $applyTransport = Invoke-Agent99BoundedSsh $command 330 $apply = Get-Agent99ExecutorResult $applyTransport "Apply" $verify = $apply.verifier $cleanupVerified = Remove-Agent99RemoteStage $remoteStage $true $remoteStageCreated = $false $terminalStatus = "verified" } elseif ($Mode -eq "Verify") { $verify = Invoke-Agent99NoWriteVerifier $sourcePackage $terminalStatus = "readback_ok" $cleanupVerified = $true } else { $terminalStatus = "readback_ok" $cleanupVerified = $true } } catch { $errorCode = [string]$_.Exception.Message } finally { if ($remoteStageCreated) { try { $cleanupVerified = Remove-Agent99RemoteStage $remoteStage $false } catch {} } if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } $mutex.Dispose() } $result = [pscustomobject]@{ schemaVersion = "agent99_host110_backup_runtime_broker_receipt_v2" ok = [bool]($terminalStatus -ne "failed") status = $terminalStatus mode = $Mode sourceRevision = $SourceRevision sourceHead = if ($sourcePackage) { [string]$sourcePackage.sourceHead } else { "" } runId = $RunId targetHost = $TargetHost sourceTransport = "windows99_gitea_exact_revision_manifest" executor = "host110_backup_runtime_executor" verifier = "independent_host110_backup_runtime_python_readback" decisionProvider = "deterministic_only" criticProvider = "deterministic_only" agentAction = if ($Mode -eq "Apply") { "controlled_apply" } else { "readback" } check = $check apply = $apply verify = $verify cleanupVerified = $cleanupVerified errorCode = $errorCode elapsedSeconds = [math]::Round(([DateTime]::UtcNow - $startedAt).TotalSeconds, 3) evidence = $evidencePath secretValuesRead = $false rawSessionStored = $false } try { Write-Agent99ImmutableJson $evidencePath $result } finally { if (Test-Path -LiteralPath $sourceRoot) { Remove-Item -LiteralPath $sourceRoot -Recurse -Force -ErrorAction SilentlyContinue } } $result | ConvertTo-Json -Compress -Depth 12 if (-not $result.ok) { exit 1 }