diff --git a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 index 9049046c3..a239de3cd 100644 --- a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 +++ b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 @@ -648,7 +648,7 @@ try { $expectedTransportPayloadMode = if ($mode -eq "apply") { "ephemeral_file_delete_before_receiver" } else { - "stdin_sentinel" + "control_command_no_stdin" } if ($transportPayloadMode -ne $expectedTransportPayloadMode) { throw "invalid_transport_payload_mode" } if ($transportPayloadContainsSecrets) { throw "transport_payload_secret_contract_failed" } diff --git a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh index f72fbb61c..5f4b18d5d 100755 --- a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh +++ b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh @@ -263,7 +263,7 @@ envelope = { "transportPayloadMode": ( "ephemeral_file_delete_before_receiver" if mode == "apply" - else "stdin_sentinel" + else "control_command_no_stdin" ), "transportPayloadContainsSecrets": False, "mode": mode, @@ -405,45 +405,132 @@ if [[ -n "${IDENTITY_FILE}" ]]; then SCP_OPTIONS+=( -i "${IDENTITY_FILE}" ) fi -readonly REMOTE_COMMAND="$(python3 - <<'PY' +readonly REMOTE_CHECK_COMMAND="$(python3 - "${ENVELOPE_PATH}" <<'PY' from __future__ import annotations import base64 +import hashlib +import json +import sys +from pathlib import Path -decoder = r'''$sentinel = "__AWOOOI_AGENT99_REMOTE_BOOTSTRAP_END_V1__" -$lines = New-Object System.Collections.Generic.List[string] -$lineCount = 0 -$charCount = 0 -$terminated = $false -while (($line = [Console]::In.ReadLine()) -ne $null) { - if ($line -ceq $sentinel) { - $terminated = $true - break +envelope = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +source_revision = envelope["sourceRevision"] +manifest_sha256 = envelope["manifestSha256"] +fixed_names = [row["name"] for row in envelope["files"]] +fixed_rows = ",\n ".join("'" + name.replace("'", "''") + "'" for name in fixed_names) +stage_identity = f"check||||{source_revision}" +stage_token = hashlib.sha256(stage_identity.encode("utf-8")).hexdigest()[:20] +script = r'''$ErrorActionPreference = "Stop" +$agentRoot = "C:\Wooo\Agent99" +$binDir = Join-Path $agentRoot "bin" +$manifestPath = Join-Path $agentRoot "state\runtime-manifest.json" +$fixedFiles = @( + __FIXED_ROWS__ +) +$current = [pscustomobject]@{ + exists = $false + sourceRevision = "" + runtimeMatched = $false + recordedRuntimeMatched = $false + fileCount = 0 + mismatchCount = -1 + recordedMismatchCount = -1 + parseError = "" +} +if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { + try { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $rows = @($manifest.files) + $byName = @{} + $shapeValid = [bool]($rows.Count -eq $fixedFiles.Count) + foreach ($row in $rows) { + $name = [string]$row.name + if (-not $name -or $byName.ContainsKey($name)) { + $shapeValid = $false + continue + } + $byName[$name] = $row + } + $actualMismatchCount = 0 + foreach ($name in $fixedFiles) { + if (-not $byName.ContainsKey($name)) { + $shapeValid = $false + $actualMismatchCount += 1 + continue + } + $recordedSha = ([string]$byName[$name].runtimeSha256).ToLowerInvariant() + $runtimePath = Join-Path $binDir $name + $actualSha = if (Test-Path -LiteralPath $runtimePath -PathType Leaf) { + (Get-FileHash -LiteralPath $runtimePath -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + "missing" + } + if (-not $recordedSha -or $recordedSha -ne $actualSha) { + $actualMismatchCount += 1 + } + } + $runtimeMatched = [bool]( + $shapeValid -and + [bool]$manifest.runtimeMatched -and + [int]$manifest.fileCount -eq $fixedFiles.Count -and + [int]$manifest.mismatchCount -eq 0 -and + $actualMismatchCount -eq 0 + ) + $current = [pscustomobject]@{ + exists = $true + sourceRevision = [string]$manifest.sourceRevision + runtimeMatched = $runtimeMatched + recordedRuntimeMatched = [bool]$manifest.runtimeMatched + fileCount = [int]$manifest.fileCount + mismatchCount = $actualMismatchCount + recordedMismatchCount = [int]$manifest.mismatchCount + parseError = "" + } + } catch { + $current.exists = $true + $current.parseError = $_.Exception.GetType().Name } - $lineCount += 1 - $charCount += $line.Length + 2 - if ($lineCount -gt 8 -or $charCount -gt 1048576) { - throw "remote_bootstrap_input_limit_exceeded" +} +[pscustomobject]@{ + schemaVersion = "agent99_remote_atomic_deploy_receipt_v1" + status = "check_ready" + mode = "check" + sourceRevision = "__SOURCE_REVISION__" + expectedRuntimeFileCount = 14 + manifestSha256 = "__MANIFEST_SHA256__" + plannedStagingPath = "C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__" + runtimeManifest = $current + livePreflight = [pscustomobject]@{ executed = $false; reason = "apply_only_after_validate_only" } + transportPayloadMode = "control_command_no_stdin" + transientTransportPayloadStored = $false + transientTransportPayloadDeletedBeforeReceiver = $false + transportPayloadContainsSecrets = $false + operationBoundaries = [pscustomobject]@{ + remoteWritePerformed = $false + liveRuntimeWritePerformed = $false + livePromotionAttempted = $false + livePromotionPerformed = $false + secretValueRead = $false + privateKeyValueRead = $false + tokenValueRead = $false + environmentSecretRead = $false + uiInteraction = $false + vmPowerChange = $false + hostReboot = $false + serviceRestart = $false + scheduledTaskRestart = $false + scheduledTaskModification = $false } - [void]$lines.Add($line) -} -if (-not $terminated) { - throw "remote_bootstrap_sentinel_missing" -} -$payload = [string]::Join("", $lines) -$compressed = [Convert]::FromBase64String(($payload -replace '\s', '')) -$inputStream = New-Object IO.MemoryStream(,$compressed) -$gzip = New-Object IO.Compression.GzipStream($inputStream, [IO.Compression.CompressionMode]::Decompress) -$reader = New-Object IO.StreamReader($gzip, [Text.Encoding]::UTF8) -try { - $inputText = $reader.ReadToEnd() -} finally { - $reader.Dispose() - $gzip.Dispose() - $inputStream.Dispose() -} -& ([ScriptBlock]::Create($inputText))''' -encoded = base64.b64encode(decoder.encode("utf-16le")).decode("ascii") + nextSafeAction = "rerun_with_apply_and_trace_run_work_item_after_source_commit_and_transport_check" +} | ConvertTo-Json -Compress -Depth 8''' +script = ( + script.replace("__FIXED_ROWS__", fixed_rows) + .replace("__SOURCE_REVISION__", source_revision) + .replace("__MANIFEST_SHA256__", manifest_sha256) + .replace("__STAGE_TOKEN__", stage_token) +) +encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") print( "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass " f"-EncodedCommand {encoded}" @@ -569,7 +656,7 @@ cleanup_remote_payload() { if [[ "${MODE}" == "check" ]]; then set +e timeout --signal=TERM --kill-after=10 "${REMOTE_EXECUTION_TIMEOUT_SECONDS}s" \ - ssh "${SSH_OPTIONS[@]}" "${TARGET}" "${REMOTE_COMMAND}" <"${BOOTSTRAP_PAYLOAD_PATH}" + ssh "${SSH_OPTIONS[@]}" "${TARGET}" "${REMOTE_CHECK_COMMAND}" 8" in source assert '"\\n".join(payload_lines)' in source assert "remote_bootstrap_payload_line_contract_failed" in source - assert "$lineCount -gt 8" in source + assert "$payloadLines.Count -gt 8" in source assert "$charCount -gt 1048576" in source - assert '<"${BOOTSTRAP_PAYLOAD_PATH}"' in source + assert '<"${BOOTSTRAP_PAYLOAD_PATH}"' not in source assert "rawEnvelope" not in source assert "sshpass" not in source @@ -580,7 +583,7 @@ def test_apply_without_all_three_identities_fails_before_ssh() -> None: assert "known_hosts_file_missing" not in result.stderr -def test_check_mode_builds_and_streams_bundle_over_bounded_fake_transport( +def test_check_mode_uses_bounded_control_command_without_ssh_stdin( tmp_path: Path, ) -> None: fake_bin = tmp_path / "bin" @@ -660,8 +663,8 @@ def test_check_mode_builds_and_streams_bundle_over_bounded_fake_transport( assert "PasswordAuthentication=no" in args assert "Administrator@192.168.0.99" in args assert str(identity) in args - payload_bytes = int(bytes_path.read_text(encoding="utf-8")) - assert 50_000 < payload_bytes < 1_048_576 + stdin_bytes = int(bytes_path.read_text(encoding="utf-8")) + assert stdin_bytes == 0 def test_apply_uses_ephemeral_file_transport_and_cleans_up_without_ssh_stdin(