From 73d0d94c7fc8f43627e82774fbfd7a6d1e898fbf Mon Sep 17 00:00:00 2001 From: ogt Date: Tue, 14 Jul 2026 14:04:18 +0800 Subject: [PATCH] fix(agent99): bind local controlled dispatch identity --- agent99-contract-check.ps1 | 2 + agent99-submit-request.ps1 | 171 ++++++++++++++++++ ...t_agent99_recovery_coordinator_contract.py | 26 +++ 3 files changed, 199 insertions(+) diff --git a/agent99-contract-check.ps1 b/agent99-contract-check.ps1 index bc8333ff7..5586e2f74 100644 --- a/agent99-contract-check.ps1 +++ b/agent99-contract-check.ps1 @@ -93,6 +93,7 @@ $telegram = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-telegram-inbo $bootstrap = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-bootstrap.ps1"), [Text.Encoding]::UTF8) $deployer = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-deploy.ps1"), [Text.Encoding]::UTF8) $taskRegistration = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-register-tasks.ps1"), [Text.Encoding]::UTF8) +$submitRequest = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-submit-request.ps1"), [Text.Encoding]::UTF8) Add-Check "transport:dedicated_identity" ($control.Contains("sshIdentityFile") -and $control.Contains("IdentitiesOnly=yes")) "dedicated SSH identity is enforced" Add-Check "transport:jump_first" ($control.Contains("preferJumpHost") -and $control.Contains("ssh_jump_fallback")) "preferred jump with bounded fallback is present" @@ -105,6 +106,7 @@ Add-Check "recovery:slo" ($control.Contains("recovery_slo_result") -and $control Add-Check "recovery:full_sop_coordinator" ($control.Contains("Invoke-AgentRecoveryCoordinatorReadback") -and $control.Contains("full_sop_scorecard") -and $control.Contains("SCORECARD_FRESH_REBOOT_WINDOW") -and $control.Contains('scope = $recoveryScope')) "Agent99 cannot close reboot recovery without the 110 full-SOP scorecard" Add-Check "recovery:host111_wol_executor" ($control.Contains("Invoke-AgentExternalHostRecovery") -and $control.Contains("Send-AgentWakeOnLan") -and $control.Contains('Get-AgentObjectValue $externalHost "recoveryAction" "wake_on_lan"') -and $control.Contains('New-AgentRecoveryPhase "external_host_recovery"') -and $control.Contains('New-AgentOutcomeCheck "external_hosts_recovered"')) "Host111 has an allowlisted WOL executor with independent ping/TCP verification" Add-Check "recovery:startup_queue_closure" ($taskRegistration.Contains('$submitRequest') -and $taskRegistration.Contains('-Mode Recover -ControlledApply -RunNow') -and $taskRegistration.Contains('-Source agent99-startup-recovery')) "startup recovery runs through queue, outcome, callback, Telegram lifecycle, and KM/RAG" +Add-Check "recovery:startup_controlled_identity" ($submitRequest.Contains("New-AgentControlledDispatchIdentity") -and $submitRequest.Contains("New-AgentUuidV5") -and $submitRequest.Contains("agent99_controlled_dispatch_identity_v1") -and $submitRequest.Contains("canonicalDigest") -and $submitRequest.Contains("singleFlightKey") -and $submitRequest.Contains("approvalId")) "local and startup controlled requests carry the canonical AWOOOI dispatch identity" Add-Check "recovery:host112_immutable_apply_closure" ($control.Contains("managerApplyVerified") -and $control.Contains("durableReceiptVerified") -and $control.Contains('durableReceiptTerminal -eq "verified_healthy"') -and $control.Contains('durableReadbackReceiptLinkMatch') -and $control.Contains('durableReadbackTerminalMatch') -and $control.Contains('artifactTransactionStatus -eq "committed_verified_pair"') -and $control.Contains('artifactTransactionPriorPairVerified') -and $control.Contains('managerAttemptCount -eq "1"') -and $control.Contains('managerStartExit -eq "0"') -and $control.Contains('afterVerifierRole = "independent_second_verifier_only"')) "Host112 apply closure binds transport, fixed WAL, attributed start, immutable receipt/readback and independent verifier" Add-Check "recovery:host112_direct_transport" ($control.Contains("Invoke-AgentHost112SshText") -and $control.Contains('expectedUser = "kali"') -and $control.Contains('route = "direct_host112_kali"') -and $deployer.Contains('name = "host112_canonical_direct_route"') -and $bootstrap.Contains("Ensure-AgentHost112CanonicalSshConfig")) "Host112 recovery uses the canonical 99 direct key route" Add-Check "recovery:host112_canonical_vmx" ($deployer.Contains('name = "host112_canonical_vmx_path"') -and $deployer.Contains('Get-AgentHost112CanonicalVm $policyConfigPath') -and $deployer.Contains('Test-Path -LiteralPath $canonicalHost112Vmx -PathType Leaf') -and $bootstrap.Contains('Get-AgentHost112CanonicalVm $PolicyPath') -and $bootstrap.Contains('Set-AgentBootstrapProperty $config "vms" @(@($nonHost112Vms) + @($canonicalHost112Vm))')) "Host112 VM identity is normalized from one staged policy row and fails closed when absent" diff --git a/agent99-submit-request.ps1 b/agent99-submit-request.ps1 index 679194c37..4dd1ddcc3 100644 --- a/agent99-submit-request.ps1 +++ b/agent99-submit-request.ps1 @@ -35,6 +35,124 @@ function Test-AgentContainsChar { return $false } +function ConvertTo-AgentStableJson { + param([System.Collections.IDictionary]$Value) + return (ConvertTo-Json -InputObject $Value -Depth 8 -Compress) +} + +function Get-AgentSha256 { + param([string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Convert-AgentGuidToNetworkBytes { + param([guid]$Guid) + [byte[]]$bytes = $Guid.ToByteArray() + return [byte[]]@( + $bytes[3], $bytes[2], $bytes[1], $bytes[0], + $bytes[5], $bytes[4], $bytes[7], $bytes[6], + $bytes[8], $bytes[9], $bytes[10], $bytes[11], + $bytes[12], $bytes[13], $bytes[14], $bytes[15] + ) +} + +function New-AgentUuidV5 { + param( + [guid]$Namespace, + [string]$Name + ) + [byte[]]$namespaceBytes = Convert-AgentGuidToNetworkBytes $Namespace + [byte[]]$nameBytes = [Text.Encoding]::UTF8.GetBytes($Name) + [byte[]]$inputBytes = $namespaceBytes + $nameBytes + $sha1 = [System.Security.Cryptography.SHA1]::Create() + try { + [byte[]]$hash = $sha1.ComputeHash($inputBytes) + } finally { + $sha1.Dispose() + } + $hash[6] = [byte](($hash[6] -band 0x0f) -bor 0x50) + $hash[8] = [byte](($hash[8] -band 0x3f) -bor 0x80) + $hex = -join @($hash[0..15] | ForEach-Object { $_.ToString("x2") }) + $uuidText = "{0}-{1}-{2}-{3}-{4}" -f $hex.Substring(0, 8), $hex.Substring(8, 4), $hex.Substring(12, 4), $hex.Substring(16, 4), $hex.Substring(20, 12) + return [guid]::Parse($uuidText) +} + +function New-AgentControlledDispatchIdentity { + param( + [string]$RequestId, + [string]$RequestSource, + [string]$RequestExternalId + ) + + $projectId = "awoooi" + $incidentId = "agent99-local-$RequestId" + $sourceFingerprint = Get-AgentSha256 "$RequestSource|$RequestExternalId|$RequestId" + $routeId = "agent99_local_submit" + $executionGeneration = "1" + $approvalId = "" + $workItemId = "agent99-local:$RequestId" + $normalized = [ordered]@{ + approval_id = $approvalId + execution_generation = $executionGeneration + incident_id = $incidentId + project_id = $projectId + route_id = $routeId + source_fingerprint = $sourceFingerprint + work_item_id = $workItemId + } + $canonical = ConvertTo-AgentStableJson $normalized + $digest = Get-AgentSha256 $canonical + $singleFlightCanonical = ConvertTo-AgentStableJson ([ordered]@{ + incident_id = $incidentId + project_id = $projectId + route_id = $routeId + source_fingerprint = $sourceFingerprint + }) + $singleFlightDigest = Get-AgentSha256 $singleFlightCanonical + $runId = (New-AgentUuidV5 -Namespace ([guid]::Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")) -Name "agent99-controlled-dispatch:$canonical").ToString() + $traceId = "00-$($digest.Substring(0, 32))-$($digest.Substring(32, 16))-01" + $idempotencyKey = "agent99-controlled:$digest" + $singleFlightKey = "agent99-controlled-flight:$singleFlightDigest" + $public = [ordered]@{ + approval_id = $approvalId + execution_generation = $executionGeneration + idempotency_key = $idempotencyKey + incident_id = $incidentId + lock_owner = $runId + project_id = $projectId + route_id = $routeId + run_id = $runId + schema_version = "agent99_controlled_dispatch_identity_v1" + single_flight_key = $singleFlightKey + source_fingerprint = $sourceFingerprint + trace_id = $traceId + work_item_id = $workItemId + } + + return [pscustomobject]@{ + schemaVersion = [string]$public.schema_version + projectId = $projectId + incidentId = $incidentId + sourceFingerprint = $sourceFingerprint + dispatchRouteId = $routeId + executionGeneration = $executionGeneration + approvalId = $approvalId + automationRunId = $runId + traceId = $traceId + workItemId = $workItemId + idempotencyKey = $idempotencyKey + singleFlightKey = $singleFlightKey + lockOwner = $runId + canonicalDigest = Get-AgentSha256 (ConvertTo-AgentStableJson $public) + } +} + function Resolve-AgentRequestMode { param( [string]$Text, @@ -98,6 +216,25 @@ $evidenceDir = Join-Path $AgentRoot "evidence" New-Item -ItemType Directory -Force -Path $queueDir, $requestDir, $evidenceDir | Out-Null $id = "request-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + ([guid]::NewGuid().ToString("N").Substring(0, 8)) +$dispatchIdentity = [pscustomobject]@{ + schemaVersion = "" + projectId = "" + incidentId = "" + sourceFingerprint = "" + dispatchRouteId = "" + executionGeneration = "" + approvalId = "" + automationRunId = "" + traceId = "" + workItemId = "" + idempotencyKey = "" + singleFlightKey = "" + lockOwner = "" + canonicalDigest = "" +} +if ($ControlledApply) { + $dispatchIdentity = New-AgentControlledDispatchIdentity -RequestId $id -RequestSource $Source -RequestExternalId $ExternalId +} $request = [pscustomobject]@{ id = $id instruction = $Instruction @@ -106,6 +243,20 @@ $request = [pscustomobject]@{ requestedBy = if ($RequestedBy) { $RequestedBy } else { $env:USERNAME } source = $Source externalId = $ExternalId + dispatchIdentitySchemaVersion = $dispatchIdentity.schemaVersion + automationRunId = $dispatchIdentity.automationRunId + traceId = $dispatchIdentity.traceId + workItemId = $dispatchIdentity.workItemId + idempotencyKey = $dispatchIdentity.idempotencyKey + executionGeneration = $dispatchIdentity.executionGeneration + incidentId = $dispatchIdentity.incidentId + approvalId = $dispatchIdentity.approvalId + projectId = $dispatchIdentity.projectId + sourceFingerprint = $dispatchIdentity.sourceFingerprint + dispatchRouteId = $dispatchIdentity.dispatchRouteId + singleFlightKey = $dispatchIdentity.singleFlightKey + lockOwner = $dispatchIdentity.lockOwner + canonicalDigest = $dispatchIdentity.canonicalDigest createdAt = (Get-Date -Format o) } @@ -119,10 +270,25 @@ $request | ConvertTo-Json -Depth 8 | Set-Content -Path $requestPath -Encoding UT requestedBy = if ($RequestedBy) { $RequestedBy } else { $env:USERNAME } source = $Source externalId = $ExternalId + correlationKey = $dispatchIdentity.idempotencyKey reason = "operator_instruction" instruction = $Instruction requestPath = $requestPath replyChatId = $ReplyChatId + dispatchIdentitySchemaVersion = $dispatchIdentity.schemaVersion + automationRunId = $dispatchIdentity.automationRunId + traceId = $dispatchIdentity.traceId + workItemId = $dispatchIdentity.workItemId + idempotencyKey = $dispatchIdentity.idempotencyKey + executionGeneration = $dispatchIdentity.executionGeneration + incidentId = $dispatchIdentity.incidentId + approvalId = $dispatchIdentity.approvalId + projectId = $dispatchIdentity.projectId + sourceFingerprint = $dispatchIdentity.sourceFingerprint + dispatchRouteId = $dispatchIdentity.dispatchRouteId + singleFlightKey = $dispatchIdentity.singleFlightKey + lockOwner = $dispatchIdentity.lockOwner + canonicalDigest = $dispatchIdentity.canonicalDigest createdAt = (Get-Date -Format o) } | ConvertTo-Json -Depth 8 | Set-Content -Path $queuePath -Encoding UTF8 @@ -135,6 +301,11 @@ $result = [pscustomobject]@{ requestPath = $requestPath queuePath = $queuePath runNow = [bool]$RunNow + automationRunId = $dispatchIdentity.automationRunId + traceId = $dispatchIdentity.traceId + workItemId = $dispatchIdentity.workItemId + idempotencyKey = $dispatchIdentity.idempotencyKey + canonicalDigest = $dispatchIdentity.canonicalDigest controlExitCode = $null } diff --git a/scripts/reboot-recovery/tests/test_agent99_recovery_coordinator_contract.py b/scripts/reboot-recovery/tests/test_agent99_recovery_coordinator_contract.py index 5232c5668..d7f1c692b 100644 --- a/scripts/reboot-recovery/tests/test_agent99_recovery_coordinator_contract.py +++ b/scripts/reboot-recovery/tests/test_agent99_recovery_coordinator_contract.py @@ -37,6 +37,32 @@ def test_host111_is_in_recovery_executor_and_all_host_readiness_scope() -> None: assert '-Source agent99-startup-recovery' in tasks +def test_startup_and_desktop_controlled_requests_bind_canonical_dispatch_identity() -> None: + submit = read("agent99-submit-request.ps1") + + assert "function New-AgentControlledDispatchIdentity" in submit + assert "function New-AgentUuidV5" in submit + assert '6ba7b811-9dad-11d1-80b4-00c04fd430c8' in submit + assert 'agent99-controlled-dispatch:$canonical' in submit + assert 'schema_version = "agent99_controlled_dispatch_identity_v1"' in submit + for field in ( + "automationRunId", + "traceId", + "workItemId", + "idempotencyKey", + "executionGeneration", + "incidentId", + "approvalId", + "projectId", + "sourceFingerprint", + "dispatchRouteId", + "singleFlightKey", + "lockOwner", + "canonicalDigest", + ): + assert f"{field} = $dispatchIdentity.{field}" in submit + + def test_coordinator_covers_all_hosts_and_full_recovery_evidence() -> None: control = read("agent99-control-plane.ps1")