diff --git a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh index 7c62ba318..72f51d02d 100755 --- a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh +++ b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh @@ -47,7 +47,7 @@ usage() { "Usage: $0 [--check|--apply] [options]" \ "" \ "Default is a remote no-write check/plan against fixed target ${TARGET}." \ - "Apply requires --apply plus --trace-id, --run-id, and --work-item-id." \ + "Check and apply require --trace-id, --run-id, and --work-item-id." \ "" \ "Options:" \ " --check No-write transport and envelope check (default)." \ @@ -139,12 +139,13 @@ done [[ "${CONNECT_TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]] || fail "invalid_connect_timeout" (( CONNECT_TIMEOUT_SECONDS >= 1 && CONNECT_TIMEOUT_SECONDS <= 30 )) || fail "invalid_connect_timeout" +readonly SAFE_ID_PATTERN='^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$' +[[ "${TRACE_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "${MODE}_trace_id_missing_or_invalid" +[[ "${RUN_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "${MODE}_run_id_missing_or_invalid" +[[ "${WORK_ITEM_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "${MODE}_work_item_id_missing_or_invalid" + if [[ "${MODE}" == "apply" ]]; then REMOTE_EXECUTION_TIMEOUT_SECONDS="2400" - readonly SAFE_ID_PATTERN='^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$' - [[ "${TRACE_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_trace_id_missing_or_invalid" - [[ "${RUN_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_run_id_missing_or_invalid" - [[ "${WORK_ITEM_ID}" =~ ${SAFE_ID_PATTERN} ]] || fail "apply_work_item_id_missing_or_invalid" fi for dependency in git python3 ssh scp mktemp hostname timeout; do @@ -208,6 +209,7 @@ trap cleanup EXIT HUP INT TERM ENVELOPE_PATH="${WORK_DIR}/envelope.json" BOOTSTRAP_PATH="${WORK_DIR}/remote-bootstrap.ps1" BOOTSTRAP_PAYLOAD_PATH="${WORK_DIR}/remote-bootstrap.ps1.gz.b64" +CHECK_REMOTE_RECEIPT_PATH="${WORK_DIR}/check-remote-receipt.json" python3 - \ "${SOURCE_ROOT}" \ @@ -561,12 +563,65 @@ cleanup_remote_payload() { >/dev/null 2>&1 } +project_check_receipt_identity() { + python3 - \ + "${CHECK_REMOTE_RECEIPT_PATH}" \ + "${TRACE_ID}" \ + "${RUN_ID}" \ + "${WORK_ITEM_ID}" <<'PY' +from __future__ import annotations + +import json +import sys +from pathlib import Path + +receipt_path = Path(sys.argv[1]) +trace_id, run_id, work_item_id = sys.argv[2:5] +try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) +except (OSError, UnicodeError, json.JSONDecodeError): + raise SystemExit(65) + +if not isinstance(receipt, dict): + raise SystemExit(65) +if receipt.get("schemaVersion") != "agent99_remote_atomic_deploy_receipt_v1": + raise SystemExit(65) +if receipt.get("mode") != "check": + raise SystemExit(65) +if receipt.get("status") not in {"check_ready", "check_failed_no_apply"}: + raise SystemExit(65) + +identity = { + "traceId": trace_id, + "runId": run_id, + "workItemId": work_item_id, +} +for field, expected in identity.items(): + if field in receipt and receipt[field] != expected: + raise SystemExit(65) + receipt[field] = expected + +sys.stdout.write(json.dumps(receipt, separators=(",", ":"), ensure_ascii=True) + "\n") +PY +} + if [[ "${MODE}" == "check" ]]; then set +e timeout --signal=TERM --kill-after=10 "${REMOTE_EXECUTION_TIMEOUT_SECONDS}s" \ - ssh "${SSH_OPTIONS[@]}" "${TARGET}" "${REMOTE_CHECK_COMMAND}" "${CHECK_REMOTE_RECEIPT_PATH}" status=$? set -e + set +e + project_check_receipt_identity + projection_status=$? + set -e + if [[ "${projection_status}" -ne 0 ]]; then + printf 'agent99_transport_error=check_receipt_identity_projection_failed\n' >&2 + if [[ "${status}" -eq 0 ]]; then + exit 65 + fi + fi exit "${status}" fi diff --git a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py index 5edc6ebd1..a228d554f 100644 --- a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py +++ b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json import os import re import shlex @@ -55,9 +56,9 @@ def test_sender_is_valid_shell_with_check_as_the_default() -> None: assert 'MODE="check"' in source assert "--apply" in source assert 'MODE="apply"' in source - assert "apply_trace_id_missing_or_invalid" in source - assert "apply_run_id_missing_or_invalid" in source - assert "apply_work_item_id_missing_or_invalid" in source + assert 'fail "${MODE}_trace_id_missing_or_invalid"' in source + assert 'fail "${MODE}_run_id_missing_or_invalid"' in source + assert 'fail "${MODE}_work_item_id_missing_or_invalid"' in source assert 'MODE_SELECTION="default"' in source assert 'REMOTE_EXECUTION_TIMEOUT_SECONDS="180"' in source assert 'REMOTE_EXECUTION_TIMEOUT_SECONDS="2400"' in source @@ -291,6 +292,34 @@ def test_receiver_has_single_flight_idempotency_and_no_secret_receipt_boundary() assert "[bool]$prior.relayReload.newGenerationVerified" in source +def test_check_and_apply_receipts_project_the_same_run_identity() -> None: + sender = SENDER.read_text(encoding="utf-8") + projector = sender[ + sender.index("project_check_receipt_identity()") : sender.index( + 'if [[ "${MODE}" == "check" ]]' + ) + ] + assert '"traceId": trace_id' in projector + assert '"runId": run_id' in projector + assert '"workItemId": work_item_id' in projector + assert "receipt[field] != expected" in projector + assert "raise SystemExit(65)" in projector + + receiver = RECEIVER.read_text(encoding="utf-8") + success_receipt = receiver[ + receiver.index("$successReceipt = [pscustomobject]@{") : receiver.index( + "Write-AgentRemoteDeployReceipt $receiptPath $successReceipt" + ) + ] + assert "traceId = $traceId" in success_receipt + assert "runId = $runId" in success_receipt + assert "workItemId = $workItemId" in success_receipt + assert "secretValueRead = $false" in success_receipt + assert "privateKeyValueRead = $false" in success_receipt + assert "tokenValueRead = $false" in success_receipt + assert "environmentSecretRead = $false" in success_receipt + + def test_receiver_creates_canonical_success_receipt_once_with_atomic_no_replace() -> None: source = RECEIVER.read_text(encoding="utf-8") writer = source[ @@ -586,6 +615,39 @@ def test_apply_without_all_three_identities_fails_before_ssh() -> None: assert "known_hosts_file_missing" not in result.stderr +def test_check_requires_complete_safe_same_run_identity_before_ssh() -> None: + cases = ( + ((), "check_trace_id_missing_or_invalid"), + (("--trace-id", "trace-fixture"), "check_run_id_missing_or_invalid"), + ( + ("--trace-id", "trace-fixture", "--run-id", "run-fixture"), + "check_work_item_id_missing_or_invalid", + ), + ( + ( + "--trace-id", + "trace fixture", + "--run-id", + "run-fixture", + "--work-item-id", + "AIA-SRE-P0-20260715", + ), + "check_trace_id_missing_or_invalid", + ), + ) + + for identity_args, expected_error in cases: + result = subprocess.run( + ["bash", str(SENDER), "--check", *identity_args], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 64 + assert expected_error in result.stderr + assert "known_hosts_file_missing" not in result.stderr + + def test_check_mode_uses_bounded_control_command_without_ssh_stdin( tmp_path: Path, ) -> None: @@ -610,7 +672,13 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin( "#!/usr/bin/env bash\n" f"printf '%s\\n' \"$@\" >{shlex.quote(str(args_path))}\n" f"wc -c | tr -d ' ' >{shlex.quote(str(bytes_path))}\n" - "printf '%s\\n' '{\"status\":\"check_ready\"}'\n", + 'if [[ -n "${FAKE_CHECK_RECEIPT:-}" ]]; then\n' + " printf '%s\\n' \"$FAKE_CHECK_RECEIPT\"\n" + "else\n" + " printf '%s\\n' " + '\'{"schemaVersion":"agent99_remote_atomic_deploy_receipt_v1",' + '"status":"check_ready","mode":"check"}\'\n' + "fi\n", encoding="utf-8", ) fake_timeout = fake_bin / "timeout" @@ -647,6 +715,12 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin( "bash", str(SENDER), "--check", + "--trace-id", + "trace-fixture", + "--run-id", + "run-fixture", + "--work-item-id", + "AIA-SRE-P0-20260715", "--known-hosts-file", str(known_hosts), "--identity-file", @@ -659,7 +733,11 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin( ) assert result.returncode == 0, result.stderr - assert '"status":"check_ready"' in result.stdout + receipt = json.loads(result.stdout) + assert receipt["status"] == "check_ready" + assert receipt["traceId"] == "trace-fixture" + assert receipt["runId"] == "run-fixture" + assert receipt["workItemId"] == "AIA-SRE-P0-20260715" args = args_path.read_text(encoding="utf-8") remote_command = args.splitlines()[-1] assert "BatchMode=yes" in args @@ -673,6 +751,9 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin( assert "agent99-contract-check.ps1" in check_script assert "Get-FileHash" in check_script assert "runtimeContract" in check_script + assert "trace-fixture" not in check_script + assert "run-fixture" not in check_script + assert "AIA-SRE-P0-20260715" not in check_script assert "$ready=[bool]($c.executed-and$c.ok-and$c.exitCode-eq 0)" in check_script assert "'check_failed_no_apply'" in check_script assert "if(-not$ready){exit 65}" in check_script @@ -692,6 +773,30 @@ def test_check_mode_uses_bounded_control_command_without_ssh_stdin( stdin_bytes = int(bytes_path.read_text(encoding="utf-8")) assert stdin_bytes == 0 + conflicting_environment = environment.copy() + conflicting_environment["FAKE_CHECK_RECEIPT"] = json.dumps( + { + "schemaVersion": "agent99_remote_atomic_deploy_receipt_v1", + "status": "check_ready", + "mode": "check", + "traceId": "different-trace", + }, + separators=(",", ":"), + ) + conflicting_result = subprocess.run( + result.args, + check=False, + capture_output=True, + text=True, + env=conflicting_environment, + ) + assert conflicting_result.returncode == 65 + assert conflicting_result.stdout == "" + assert ( + "agent99_transport_error=check_receipt_identity_projection_failed" + in conflicting_result.stderr + ) + def test_apply_uses_ephemeral_file_transport_and_cleans_up_without_ssh_stdin( tmp_path: Path,