Files
awoooi/agent99-submit-request.ps1
Your Name 3a81d9bedc
Some checks failed
CD Pipeline / select-latest-carrier (push) Successful in 2m2s
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 6m29s
CD Pipeline / revalidate-deploy-carrier (push) Successful in 29s
CD Pipeline / build-and-deploy (push) Failing after 42s
CD Pipeline / revalidate-post-deploy-carrier (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
fix(agent99): harden control loop recovery
2026-07-19 13:32:44 +08:00

511 lines
18 KiB
PowerShell

param(
[string]$Instruction = "",
[string]$InstructionBase64 = "",
[string]$Mode = "",
[switch]$ControlledApply,
[switch]$RunNow,
[string]$Source = "agent99-submit-request",
[string]$RequestedBy = "",
[string]$RequestedByBase64 = "",
[string]$ExternalId = "",
[string]$ReplyChatId = "",
[string]$AgentRoot = "C:\Wooo\Agent99"
)
$ErrorActionPreference = "Stop"
function ConvertFrom-AgentBase64Argument {
param([string]$Value, [string]$FieldName, [int]$MaximumBytes)
try {
$bytes = [Convert]::FromBase64String($Value)
if ($bytes.Length -gt $MaximumBytes) { throw "${FieldName}_too_large" }
$encoding = New-Object Text.UTF8Encoding($false, $true)
$text = $encoding.GetString($bytes)
if ($text.IndexOf([char]0) -ge 0) { throw "${FieldName}_nul_invalid" }
$text
} catch {
throw "${FieldName}_base64_invalid"
}
}
if ($Instruction -and $InstructionBase64) { throw "instruction_transport_ambiguous" }
if ($RequestedBy -and $RequestedByBase64) { throw "requested_by_transport_ambiguous" }
if ($InstructionBase64) { $Instruction = ConvertFrom-AgentBase64Argument $InstructionBase64 "instruction" 8192 }
if ($RequestedByBase64) { $RequestedBy = ConvertFrom-AgentBase64Argument $RequestedByBase64 "requested_by" 1024 }
if (-not $Instruction) {
$Instruction = Read-Host "Agent99 request"
}
if (-not $Instruction) {
throw "Instruction is required."
}
$allowedModes = @("Status", "Recover", "StartVMs", "PublicSmoke", "HarborRepair", "AwoooRepair", "Perf", "LoadShed", "SelfCheck", "BackupCheck")
function Test-AgentContainsChar {
param(
[string]$Text,
[int[]]$Codes
)
foreach ($code in $Codes) {
if ($Text.IndexOf([string][char]$code) -ge 0) {
return $true
}
}
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 Write-AgentDurableJson {
param(
[object]$Value,
[string]$Path,
[string]$ExpectedId,
[string]$ExpectedExternalId
)
$temporaryPath = "$Path.tmp-$PID-$([guid]::NewGuid().ToString('N'))"
$stream = $null
$writer = $null
try {
$json = $Value | ConvertTo-Json -Depth 10
$encoding = New-Object Text.UTF8Encoding($false)
$stream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$writer = New-Object IO.StreamWriter($stream, $encoding)
$writer.Write($json)
$writer.Flush()
$stream.Flush($true)
$writer.Dispose(); $writer = $null
$stream.Dispose(); $stream = $null
$temporaryHash = (Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
$readbackHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($readbackHash -ne $temporaryHash) { throw "durable_json_hash_mismatch" }
$readback = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ([string]$readback.id -ne $ExpectedId) { throw "durable_json_id_mismatch" }
if ([string]$readback.externalId -ne $ExpectedExternalId) { throw "durable_json_external_id_mismatch" }
[pscustomobject]@{
ok = $true
path = $Path
sha256 = $readbackHash
id = [string]$readback.id
externalId = [string]$readback.externalId
}
} finally {
if ($writer) { $writer.Dispose() }
if ($stream) { $stream.Dispose() }
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
function Read-AgentDurableIngressArtifact {
param(
[string]$Path,
[string]$ExpectedId,
[string]$ExpectedExternalId,
[string]$ExpectedSource,
[string]$ExpectedInstruction,
[string]$ExpectedMode,
[bool]$ExpectedControlledApply
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "ingress_artifact_missing" }
if ((Get-Item -LiteralPath $Path).Length -gt 262144) { throw "ingress_artifact_oversized" }
$hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
$readback = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$modeValue = if ($readback.PSObject.Properties["mode"]) {
[string]$readback.mode
} elseif ($readback.PSObject.Properties["resolvedMode"]) {
[string]$readback.resolvedMode
} else {
""
}
if (
[string]$readback.id -ne $ExpectedId -or
[string]$readback.externalId -ne $ExpectedExternalId -or
[string]$readback.source -ne $ExpectedSource -or
[string]$readback.instruction -ne $ExpectedInstruction -or
$modeValue -ne $ExpectedMode -or
-not $readback.PSObject.Properties["controlledApply"] -or
$readback.controlledApply -isnot [bool] -or
[bool]$readback.controlledApply -ne $ExpectedControlledApply
) {
throw "ingress_artifact_identity_conflict"
}
[pscustomobject]@{
ok = $true
existing = $true
path = $Path
sha256 = $hash
id = [string]$readback.id
externalId = [string]$readback.externalId
}
}
function Get-AgentIngressLifecycleReceipt {
param(
[string]$QueueDirectory,
[string]$RequestId,
[string]$ExpectedExternalId,
[string]$ExpectedSource,
[string]$ExpectedInstruction,
[string]$ExpectedMode,
[bool]$ExpectedControlledApply
)
$processedDirectory = Join-Path $QueueDirectory "processed"
$failedDirectory = Join-Path $QueueDirectory "failed"
$known = @(
[pscustomobject]@{ state = "pending"; path = (Join-Path $QueueDirectory "$RequestId.json") },
[pscustomobject]@{ state = "running"; path = (Join-Path $QueueDirectory "running-$RequestId.json") },
[pscustomobject]@{ state = "processed"; path = (Join-Path $processedDirectory "processed-$RequestId.json") },
[pscustomobject]@{ state = "failed"; path = (Join-Path $failedDirectory "failed-$RequestId.json") }
)
if (Test-Path -LiteralPath $failedDirectory -PathType Container) {
foreach ($stale in @(Get-ChildItem -LiteralPath $failedDirectory -File -Filter "stale-*-*$RequestId.json" -ErrorAction SilentlyContinue)) {
$known += [pscustomobject]@{ state = "stale_quarantined"; path = $stale.FullName }
}
}
$present = @($known | Where-Object { Test-Path -LiteralPath $_.path -PathType Leaf } | Sort-Object path -Unique)
if ($present.Count -gt 1) { throw "ingress_lifecycle_ambiguous" }
if ($present.Count -eq 0) { return $null }
$receipt = Read-AgentDurableIngressArtifact `
-Path $present[0].path `
-ExpectedId $RequestId `
-ExpectedExternalId $ExpectedExternalId `
-ExpectedSource $ExpectedSource `
-ExpectedInstruction $ExpectedInstruction `
-ExpectedMode $ExpectedMode `
-ExpectedControlledApply $ExpectedControlledApply
$receipt | Add-Member -NotePropertyName lifecycleState -NotePropertyValue ([string]$present[0].state) -Force
$receipt
}
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,
[string]$ExplicitMode
)
if ($ExplicitMode) {
if ($ExplicitMode -notin $allowedModes) {
throw "Mode is not allowlisted: $ExplicitMode"
}
return $ExplicitMode
}
$lower = $Text.ToLowerInvariant()
$mentionsPublic = ($lower -match "public|smoke|website|site|route|url|http|502" -or (Test-AgentContainsChar $Text @(0x7DB2, 0x7AD9)))
$wantsRecovery = ($lower -match "recover|reboot|restore|restart|startup|repair|fix|502" -or (Test-AgentContainsChar $Text @(0x6062, 0x5FA9, 0x91CD, 0x555F, 0x4FEE)))
if ($mentionsPublic -and $wantsRecovery) {
return "Recover"
}
if ($mentionsPublic) {
return "PublicSmoke"
}
if ($wantsRecovery) {
return "Recover"
}
if ($lower -match "vm|vmware|startvm|start vm|start vms" -or (Test-AgentContainsChar $Text @(0x865B, 0x64EC, 0x4E3B, 0x6A5F))) {
return "StartVMs"
}
if ($lower -match "cpu|load|memory|ram|disk|perf|performance|pressure" -or (Test-AgentContainsChar $Text @(0x6548, 0x80FD, 0x78C1, 0x789F, 0x8CA0, 0x8F09))) {
return "Perf"
}
if ($lower -match "backup|snapshot|restore drill|cron" -or (Test-AgentContainsChar $Text @(0x5099, 0x4EFD))) {
return "BackupCheck"
}
if ($lower -match "self|agent|health|selfcheck" -or (Test-AgentContainsChar $Text @(0x81EA, 0x6AA2, 0x5065, 0x5EB7))) {
return "SelfCheck"
}
if ($lower -match "harbor|registry") {
return "HarborRepair"
}
if ($lower -match "awoooi|k3s|pod|pods") {
return "AwoooRepair"
}
return "Status"
}
$modeName = Resolve-AgentRequestMode $Instruction $Mode
$queueDir = Join-Path $AgentRoot "queue"
$requestDir = Join-Path $AgentRoot "requests"
$evidenceDir = Join-Path $AgentRoot "evidence"
New-Item -ItemType Directory -Force -Path $queueDir, $requestDir, $evidenceDir | Out-Null
$deterministicIngress = [bool]$ExternalId
$ingressDigest = Get-AgentSha256 (([string]$Source) + "`n" + ([string]$ExternalId))
$id = if ($deterministicIngress) {
"request-" + $ingressDigest.Substring(0, 32)
} else {
"request-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + ([guid]::NewGuid().ToString("N").Substring(0, 8))
}
$ingressMutex = $null
$ingressMutexAcquired = $false
try {
$ingressMutex = New-Object Threading.Mutex($false, ("Global\Wooo-Agent99-Submit-" + $ingressDigest.Substring(0, 32)))
try {
$ingressMutexAcquired = [bool]$ingressMutex.WaitOne([TimeSpan]::FromSeconds(30))
} catch [Threading.AbandonedMutexException] {
$ingressMutexAcquired = $true
}
if (-not $ingressMutexAcquired) { throw "ingress_single_writer_timeout" }
$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
resolvedMode = $modeName
controlledApply = [bool]$ControlledApply
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)
}
$requestPath = Join-Path $requestDir "$id.json"
$queuePath = Join-Path $queueDir "$id.json"
$requestReceipt = if (Test-Path -LiteralPath $requestPath -PathType Leaf) {
Read-AgentDurableIngressArtifact $requestPath $id $ExternalId $Source $Instruction $modeName ([bool]$ControlledApply)
} else {
Write-AgentDurableJson $request $requestPath $id $ExternalId
}
$queueItem = [pscustomobject]@{
id = $id
mode = $modeName
controlledApply = [bool]$ControlledApply
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)
}
$queueReceipt = Get-AgentIngressLifecycleReceipt $queueDir $id $ExternalId $Source $Instruction $modeName ([bool]$ControlledApply)
if (-not $queueReceipt) {
$queueReceipt = Write-AgentDurableJson $queueItem $queuePath $id $ExternalId
$queueReceipt | Add-Member -NotePropertyName existing -NotePropertyValue $false -Force
$queueReceipt | Add-Member -NotePropertyName lifecycleState -NotePropertyValue "pending" -Force
}
$submitEvidence = Join-Path $evidenceDir "agent99-submit-$id.json"
$result = [pscustomobject]@{
ok = $true
id = $id
instruction = $Instruction
resolvedMode = $modeName
controlledApply = [bool]$ControlledApply
externalId = $ExternalId
requestPath = $requestPath
queuePath = $queuePath
queueLifecyclePath = [string]$queueReceipt.path
queueLifecycleState = [string]$queueReceipt.lifecycleState
idempotentReplay = [bool]$queueReceipt.existing
durableRequest = [bool]$requestReceipt.ok
requestSha256 = [string]$requestReceipt.sha256
durableQueue = [bool]$queueReceipt.ok
queueSha256 = [string]$queueReceipt.sha256
submitEvidencePath = $submitEvidence
runNow = [bool]$RunNow
automationRunId = $dispatchIdentity.automationRunId
traceId = $dispatchIdentity.traceId
workItemId = $dispatchIdentity.workItemId
idempotencyKey = $dispatchIdentity.idempotencyKey
canonicalDigest = $dispatchIdentity.canonicalDigest
controlDispatchStatus = "not_requested"
controlExitCode = $null
}
if ($RunNow) {
try {
Start-ScheduledTask -TaskPath "\" -TaskName "Wooo-Agent99-Control-Loop" -ErrorAction Stop
$result.controlDispatchStatus = "scheduled_task_signaled"
} catch {
$result.ok = $false
$result.controlDispatchStatus = "scheduled_task_signal_failed"
$result.controlExitCode = -1
}
}
$submitReceipt = Write-AgentDurableJson $result $submitEvidence $id $ExternalId
$result | Add-Member -NotePropertyName submitEvidenceSha256 -NotePropertyValue ([string]$submitReceipt.sha256) -Force
$result | ConvertTo-Json -Depth 8
} finally {
if ($ingressMutexAcquired -and $ingressMutex) { try { $ingressMutex.ReleaseMutex() } catch {} }
if ($ingressMutex) { try { $ingressMutex.Dispose() } catch {} }
}