diff --git a/agents/windows/.gitattributes b/agents/windows/.gitattributes index 9f88a27..9a3e5d8 100644 --- a/agents/windows/.gitattributes +++ b/agents/windows/.gitattributes @@ -1,2 +1,4 @@ *.ps1 -text + +*.ps1 whitespace=cr-at-eol diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 index 57a812f..8d9e97f 100644 --- a/agents/windows/MinerOpsAgentService.ps1 +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -55,6 +55,7 @@ function Invoke-AgentApi { $headers = @{ "X-MinerOps-Agent-Secret" = $secret + "X-MinerOps-Agent-ID" = [string]$config.agent_id } if ($Body -ne $null) { @@ -94,7 +95,7 @@ function Get-PendingAgentCommands { $config = Read-AgentConfig $agentId = [string]$config.agent_id - $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending?agent_id=$agentId" + $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending" if ($result.commands) { return @($result.commands) @@ -981,8 +982,8 @@ function Invoke-MinerOpsApi3Service { [string]$ServiceAction ) - $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" - $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "" $portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433" try { @@ -1019,322 +1020,322 @@ function Invoke-MinerOpsApi3Service { return $result } - -function Normalize-MinerOpsIpAddress { - param( - [string]$IpAddress - ) - - $value = ([string]$IpAddress).Trim() - - if ($value.Contains("/")) { - $value = $value.Split("/")[0] - } - - return $value -} - - -function Invoke-MinerOpsApi3AuthenticatedCommand { - param( - [Parameter(Mandatory=$true)] - [string]$IpAddress, - - [Parameter(Mandatory=$true)] - [string]$Command, - - [object]$Param = $null - ) - - $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super" - $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super" - $portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433" - - try { - $port = [int]$portRaw - } catch { - $port = 4433 - } - - $saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port - $salt = [string]$saltInfo.salt - $timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp - - $payload = @{ - cmd = $Command - ts = $timestamp - token = $token - account = $account - } - - if ($null -ne $Param) { - $payload["param"] = $Param - } - - $result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload - - $result["salt_response"] = $saltInfo.salt_response - $result["api3_command"] = $Command - $result["api3_param"] = $Param - $result["api3_account"] = $account - - if ([int]$result.response.code -ne 0) { - throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)" - } - - return $result -} - -function Get-MinerOpsPayloadValue { - param( - [Parameter(Mandatory=$true)] $Payload, - [Parameter(Mandatory=$true)] [string] $Name - ) - - if ($null -eq $Payload) { - return $null - } - - if ($Payload -is [hashtable]) { - if ($Payload.ContainsKey($Name)) { - return $Payload[$Name] - } - - return $null - } - - $property = $Payload.PSObject.Properties[$Name] - - if ($property) { - return $property.Value - } - - return $null -} - -function Invoke-MinerOpsLegacy4028Command { - param( - [Parameter(Mandatory=$true)] [string] $IpAddress, - [Parameter(Mandatory=$true)] [string] $CommandJson, - [int] $Port = 4028, - [int] $TimeoutMs = 7000 - ) - - $normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress - - $client = New-Object System.Net.Sockets.TcpClient - $connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null) - - if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { - try { $client.Close() } catch {} - throw "Legacy 4028 connection timed out to $normalizedIp`:$Port" - } - - $client.EndConnect($connectResult) - - try { - $stream = $client.GetStream() - $stream.ReadTimeout = $TimeoutMs - $stream.WriteTimeout = $TimeoutMs - - $bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson) - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush() - - Start-Sleep -Milliseconds 500 - - $buffer = New-Object byte[] 65535 - $builder = New-Object System.Text.StringBuilder - - do { - $read = $stream.Read($buffer, 0, $buffer.Length) - - if ($read -gt 0) { - [void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)) - } - } while ($stream.DataAvailable) - - $rawResponse = $builder.ToString().Trim([char]0).Trim() - - $parsedResponse = $null - - try { - if ($rawResponse) { - $parsedResponse = $rawResponse | ConvertFrom-Json - } - } catch { - $parsedResponse = $null - } - - return @{ - success = $true - control_method = "legacy_4028" - port = $Port - request_json = $CommandJson - raw_response = $rawResponse - parsed_response = $parsedResponse - } - } - finally { - try { $stream.Close() } catch {} - try { $client.Close() } catch {} - } -} - -function Invoke-MinerOpsM30Legacy4028Test { - param( - [Parameter(Mandatory=$true)] $Payload - ) - - if ($Payload -is [string]) { - $Payload = $Payload | ConvertFrom-Json - } - - $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address" - - if (-not $ipAddress) { - $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip" - } - - if (-not $ipAddress) { - throw "Legacy 4028 test failed: no IP address in payload." - } - - $legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json" - $legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command" - - if (-not $legacyJson) { - if (-not $legacyCommand) { - throw "Legacy 4028 test failed: no legacy command in payload." - } - - $legacyJson = '{"command":"' + $legacyCommand + '"}' - } - - Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand" - - $result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028 - - return @{ - success = $true - action = "legacy_4028" - test_lab = "m30" - ip_address = $ipAddress - legacy_4028_command = $legacyCommand - legacy_4028_request = $legacyJson - legacy_4028_response = $result.raw_response - legacy_4028_parsed = $result.parsed_response - control_method = "legacy_4028" - } -} + +function Normalize-MinerOpsIpAddress { + param( + [string]$IpAddress + ) + + $value = ([string]$IpAddress).Trim() + + if ($value.Contains("/")) { + $value = $value.Split("/")[0] + } + + return $value +} + + +function Invoke-MinerOpsApi3AuthenticatedCommand { + param( + [Parameter(Mandatory=$true)] + [string]$IpAddress, + + [Parameter(Mandatory=$true)] + [string]$Command, + + [object]$Param = $null + ) + + $account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "" + $password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "" + $portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433" + + try { + $port = [int]$portRaw + } catch { + $port = 4433 + } + + $saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port + $salt = [string]$saltInfo.salt + $timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp + + $payload = @{ + cmd = $Command + ts = $timestamp + token = $token + account = $account + } + + if ($null -ne $Param) { + $payload["param"] = $Param + } + + $result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload + + $result["salt_response"] = $saltInfo.salt_response + $result["api3_command"] = $Command + $result["api3_param"] = $Param + $result["api3_account"] = $account + + if ([int]$result.response.code -ne 0) { + throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)" + } + + return $result +} + +function Get-MinerOpsPayloadValue { + param( + [Parameter(Mandatory=$true)] $Payload, + [Parameter(Mandatory=$true)] [string] $Name + ) + + if ($null -eq $Payload) { + return $null + } + + if ($Payload -is [hashtable]) { + if ($Payload.ContainsKey($Name)) { + return $Payload[$Name] + } + + return $null + } + + $property = $Payload.PSObject.Properties[$Name] + + if ($property) { + return $property.Value + } + + return $null +} + +function Invoke-MinerOpsLegacy4028Command { + param( + [Parameter(Mandatory=$true)] [string] $IpAddress, + [Parameter(Mandatory=$true)] [string] $CommandJson, + [int] $Port = 4028, + [int] $TimeoutMs = 7000 + ) + + $normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress + + $client = New-Object System.Net.Sockets.TcpClient + $connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null) + + if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { + try { $client.Close() } catch {} + throw "Legacy 4028 connection timed out to $normalizedIp`:$Port" + } + + $client.EndConnect($connectResult) + + try { + $stream = $client.GetStream() + $stream.ReadTimeout = $TimeoutMs + $stream.WriteTimeout = $TimeoutMs + + $bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + + Start-Sleep -Milliseconds 500 + + $buffer = New-Object byte[] 65535 + $builder = New-Object System.Text.StringBuilder + + do { + $read = $stream.Read($buffer, 0, $buffer.Length) + + if ($read -gt 0) { + [void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)) + } + } while ($stream.DataAvailable) + + $rawResponse = $builder.ToString().Trim([char]0).Trim() + + $parsedResponse = $null + + try { + if ($rawResponse) { + $parsedResponse = $rawResponse | ConvertFrom-Json + } + } catch { + $parsedResponse = $null + } + + return @{ + success = $true + control_method = "legacy_4028" + port = $Port + request_json = $CommandJson + raw_response = $rawResponse + parsed_response = $parsedResponse + } + } + finally { + try { $stream.Close() } catch {} + try { $client.Close() } catch {} + } +} + +function Invoke-MinerOpsM30Legacy4028Test { + param( + [Parameter(Mandatory=$true)] $Payload + ) + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address" + + if (-not $ipAddress) { + $ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip" + } + + if (-not $ipAddress) { + throw "Legacy 4028 test failed: no IP address in payload." + } + + $legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json" + $legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command" + + if (-not $legacyJson) { + if (-not $legacyCommand) { + throw "Legacy 4028 test failed: no legacy command in payload." + } + + $legacyJson = '{"command":"' + $legacyCommand + '"}' + } + + Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand" + + $result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028 + + return @{ + success = $true + action = "legacy_4028" + test_lab = "m30" + ip_address = $ipAddress + legacy_4028_command = $legacyCommand + legacy_4028_request = $legacyJson + legacy_4028_response = $result.raw_response + legacy_4028_parsed = $result.parsed_response + control_method = "legacy_4028" + } +} function Run-MinerControlCommand { - - param( - [Parameter(Mandatory=$true)] - [object]$Payload + + param( + [Parameter(Mandatory=$true)] + [object]$Payload ) - # MinerOps M30 legacy 4028 test path - if ($Payload -is [string]) { - try { - $Payload = $Payload | ConvertFrom-Json - } catch { - } - } - - if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) { - return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload - } - - - if ($Payload -is [string]) { - $Payload = $Payload | ConvertFrom-Json - } - - if ($null -eq $Payload) { - throw "Miner control payload was empty." - } - - $action = ([string]$Payload.action).ToLower() - $minerId = [string]$Payload.miner_id - $macAddress = [string]$Payload.mac_address - $dryRun = [bool]$Payload.dry_run - - $ipAddress = ([string]$Payload.ip_address).Trim() - - if ($ipAddress.Contains("/")) { - $ipAddress = $ipAddress.Split("/")[0] - } - - if ([string]::IsNullOrWhiteSpace($ipAddress)) { - throw "Miner control payload did not include ip_address." - } - - $serviceAction = "" - $api3Command = "" - $api3Param = $null - $performanceMode = "" - - if ($action -eq "reboot") { - $serviceAction = "restart" - $api3Command = "set.miner.service" - } elseif ($action -eq "suspend") { - $serviceAction = "stop" - $api3Command = "set.miner.service" - } elseif ($action -eq "resume") { - $serviceAction = "start" - $api3Command = "set.miner.service" - } elseif ($action -eq "performance_mode") { - $performanceMode = ([string]$Payload.performance_mode).ToLower() - - if ($performanceMode -in @("low", "normal", "high")) { - $api3Command = "set.miner.power_mode" - $api3Param = $performanceMode - } else { - throw "Invalid performance_mode: $performanceMode" - } - } else { - throw "Unsupported miner control action: $action" - } - - Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun" - - if ($dryRun) { - return @{ - ok = $true - dry_run = $true - action = $action - api3_command = $api3Command - api3_service_action = $serviceAction - performance_mode = $performanceMode - ip_address = $ipAddress - miner_id = $minerId - mac_address = $macAddress - message = "Dry run only. No miner command sent." - } - } - - if ($action -eq "performance_mode") { - $api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param - } else { - $api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction - } - - return @{ - ok = $true - dry_run = $false - action = $action - api3_command = $api3Command - api3_service_action = $serviceAction - performance_mode = $performanceMode - ip_address = $ipAddress - miner_id = $minerId - mac_address = $macAddress - message = "API3 miner control command sent by Windows Agent." - api3_result = $api3Result - } -} + # MinerOps M30 legacy 4028 test path + if ($Payload -is [string]) { + try { + $Payload = $Payload | ConvertFrom-Json + } catch { + } + } + + if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) { + return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload + } + + + if ($Payload -is [string]) { + $Payload = $Payload | ConvertFrom-Json + } + + if ($null -eq $Payload) { + throw "Miner control payload was empty." + } + + $action = ([string]$Payload.action).ToLower() + $minerId = [string]$Payload.miner_id + $macAddress = [string]$Payload.mac_address + $dryRun = [bool]$Payload.dry_run + + $ipAddress = ([string]$Payload.ip_address).Trim() + + if ($ipAddress.Contains("/")) { + $ipAddress = $ipAddress.Split("/")[0] + } + + if ([string]::IsNullOrWhiteSpace($ipAddress)) { + throw "Miner control payload did not include ip_address." + } + + $serviceAction = "" + $api3Command = "" + $api3Param = $null + $performanceMode = "" + + if ($action -eq "reboot") { + $serviceAction = "restart" + $api3Command = "set.miner.service" + } elseif ($action -eq "suspend") { + $serviceAction = "stop" + $api3Command = "set.miner.service" + } elseif ($action -eq "resume") { + $serviceAction = "start" + $api3Command = "set.miner.service" + } elseif ($action -eq "performance_mode") { + $performanceMode = ([string]$Payload.performance_mode).ToLower() + + if ($performanceMode -in @("low", "normal", "high")) { + $api3Command = "set.miner.power_mode" + $api3Param = $performanceMode + } else { + throw "Invalid performance_mode: $performanceMode" + } + } else { + throw "Unsupported miner control action: $action" + } + + Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun" + + if ($dryRun) { + return @{ + ok = $true + dry_run = $true + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "Dry run only. No miner command sent." + } + } + + if ($action -eq "performance_mode") { + $api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param + } else { + $api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction + } + + return @{ + ok = $true + dry_run = $false + action = $action + api3_command = $api3Command + api3_service_action = $serviceAction + performance_mode = $performanceMode + ip_address = $ipAddress + miner_id = $minerId + mac_address = $macAddress + message = "API3 miner control command sent by Windows Agent." + api3_result = $api3Result + } +} function Process-PendingCommands { @@ -1416,6 +1417,10 @@ function Process-PendingCommands { return $commands.Count } +$controlModule = Join-Path $PSScriptRoot "MinerOpsControl.ps1" +if (!(Test-Path $controlModule)) { throw "Missing typed control module: $controlModule" } +. $controlModule + Write-AgentLog "MinerOps Agent service script started." Save-AgentState -Status "starting" -Message "Agent starting." diff --git a/agents/windows/MinerOpsControl.ps1 b/agents/windows/MinerOpsControl.ps1 new file mode 100644 index 0000000..55387d7 --- /dev/null +++ b/agents/windows/MinerOpsControl.ps1 @@ -0,0 +1,201 @@ +# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1. + +function Get-MinerOpsAgentVersion { + $versionPath = Join-Path $PSScriptRoot "VERSION" + if (Test-Path $versionPath) { + return ([string](Get-Content $versionPath -Raw)).Trim() + } + return "unknown" +} + +function Get-MinerOpsControlCredentials { + $config = Read-AgentConfig + $account = ([string]$config.api3_account).Trim() + $password = [string]$config.api3_password + $port = 4433 + if ($config.api3_port) { + try { $port = [int]$config.api3_port } catch { $port = 4433 } + } + return @{ + available = (-not [string]::IsNullOrWhiteSpace($account)) -and + (-not [string]::IsNullOrWhiteSpace($password)) + account = $account + password = $password + port = $port + } +} + +function Invoke-MinerOpsControlApi3Command { + param( + [Parameter(Mandatory=$true)][string]$IpAddress, + [Parameter(Mandatory=$true)][string]$Command, + [object]$Param = $null + ) + $credentials = Get-MinerOpsControlCredentials + if (-not $credentials.available) { throw "credentials_missing" } + + $saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port + $timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password ` + -Salt ([string]$saltInfo.salt) -Timestamp $timestamp + $payload = @{ + cmd = $Command + ts = $timestamp + token = $token + account = $credentials.account + } + if ($null -ne $Param) { $payload["param"] = $Param } + $response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress ` + -Port $credentials.port -Payload $payload + if ([int]$response.response.code -ne 0) { throw "command_rejected" } + return $response +} + +function New-MinerOpsControlResult { + param( + [bool]$Accepted, + [bool]$VerificationAttempted, + [string]$VerificationStatus, + [string]$ObservedMode = "", + [string]$FailureCode = "", + [string]$QueueStatus = "completed" + ) + return [ordered]@{ + schema_version = 1 + command_accepted = $Accepted + verification_attempted = $VerificationAttempted + verification_status = $VerificationStatus + observed_service_state = $null + observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null } + failure_code = if ($FailureCode) { $FailureCode } else { $null } + _queue_status = $QueueStatus + } +} + +function Run-MinerControlCommand { + param([Parameter(Mandatory=$true)][object]$Payload) + + try { + if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json } + $action = ([string]$Payload.action).Trim().ToLower() + $ipAddress = ([string]$Payload.ip_address).Trim() + $requested = ([string]$Payload.requested_value).Trim().ToLower() + if (-not $requested -and $Payload.performance_mode) { + $requested = ([string]$Payload.performance_mode).Trim().ToLower() + } + if ($action -notin @("suspend", "resume", "performance_mode")) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "invalid_action" -QueueStatus "failed" + } + if ([string]::IsNullOrWhiteSpace($ipAddress)) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "target_missing" -QueueStatus "failed" + } + $credentials = Get-MinerOpsControlCredentials + if (-not $credentials.available) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "credentials_missing" -QueueStatus "failed" + } + + if ($action -eq "performance_mode") { + if ($requested -notin @("low", "normal", "high")) { + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode "invalid_mode" -QueueStatus "failed" + } + Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "set.miner.power_mode" -Param $requested | Out-Null + + $observed = "" + $readUnavailable = $false + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "get.miner.setting" + $candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower() + if ($candidate -in @("low", "normal", "high")) { + $observed = $candidate + if ($observed -eq $requested) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "confirmed" ` + -ObservedMode $observed + } + } + } catch { + if ($_.Exception.Message -match "unsupported|unknown_command") { + $readUnavailable = $true + break + } + } + if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 } + } + if ($readUnavailable) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "unavailable" + } + if ($observed) { + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true ` + -VerificationStatus "verification_failed" -ObservedMode $observed + } + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $true -VerificationStatus "unconfirmed" + } + + $serviceAction = if ($action -eq "suspend") { "stop" } else { "start" } + Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress ` + -Command "set.miner.service" -Param $serviceAction | Out-Null + return New-MinerOpsControlResult -Accepted $true ` + -VerificationAttempted $false -VerificationStatus "unconfirmed" + } catch { + $failure = if ($_.Exception.Message -eq "credentials_missing") { + "credentials_missing" + } else { "write_rejected" } + return New-MinerOpsControlResult -Accepted $false ` + -VerificationAttempted $false -VerificationStatus "unavailable" ` + -FailureCode $failure -QueueStatus "failed" + } +} + +function Send-AgentCommandResult { + param( + [string]$CommandId, + [string]$Status, + [object]$ResultPayload = $null, + [string]$ErrorMessage = $null + ) + if ($ResultPayload -and $ResultPayload._queue_status) { + $Status = [string]$ResultPayload._queue_status + $ResultPayload.Remove("_queue_status") + $ErrorMessage = if ($ResultPayload.failure_code) { + [string]$ResultPayload.failure_code + } else { $null } + } + $body = [ordered]@{ + command_id = $CommandId + status = $Status + result_payload = $ResultPayload + error_message = $ErrorMessage + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body +} + +function Send-AgentCheckin { + $config = Read-AgentConfig + $credentials = Get-MinerOpsControlCredentials + $body = [ordered]@{ + agent_id = [string]$config.agent_id + site_id = [string]$config.site_id + site_name = [string]$config.site_name + hostname = $env:COMPUTERNAME + version = Get-MinerOpsAgentVersion + ip_address = "" + control_available = [bool]$credentials.available + control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" } + local_time = (Get-Date).ToString("o") + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body +} diff --git a/agents/windows/VERSION b/agents/windows/VERSION new file mode 100644 index 0000000..dadcca1 --- /dev/null +++ b/agents/windows/VERSION @@ -0,0 +1 @@ +1.0.0-alpha diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 new file mode 100644 index 0000000..a0abec4 --- /dev/null +++ b/agents/windows/install.ps1 @@ -0,0 +1,62 @@ +#Requires -RunAsAdministrator +$ErrorActionPreference = "Stop" + +$Target = "C:\MinerOpsAgent" +$BackupRoot = Join-Path $Target "backups" +$Backup = Join-Path $BackupRoot (Get-Date -Format "yyyyMMdd-HHmmss") +$RuntimeFiles = @( + "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", + "VERSION" +) + +New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null +$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) }) +if ($existing.Count -gt 0) { + New-Item -ItemType Directory -Force -Path $Backup | Out-Null + foreach ($name in $existing) { + Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force + } +} + +try { + foreach ($name in $RuntimeFiles) { + $source = Join-Path $PSScriptRoot $name + if (!(Test-Path $source)) { throw "Deployment source is missing $name" } + Copy-Item $source (Join-Path $Target $name) -Force + } + + $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $startup = New-ScheduledTaskTrigger -AtStartup + $fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) + + $definitions = @( + @{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system }, + @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null }, + @{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system }, + @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system } + ) + foreach ($definition in $definitions) { + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` + (Join-Path $Target $definition.Script) + $action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments + if ($definition.Principal) { + Register-ScheduledTask -TaskName $definition.Name -Action $action ` + -Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null + } else { + Register-ScheduledTask -TaskName $definition.Name -Action $action ` + -Trigger $definition.Trigger -Force | Out-Null + } + } + Write-Host "MinerOps Windows Agent installed. Existing config, state, and logs were preserved." +} catch { + if (Test-Path $Backup) { + foreach ($name in $existing) { + Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force + } + } + throw +} diff --git a/agents/windows/tests/Test-MinerOpsControl.ps1 b/agents/windows/tests/Test-MinerOpsControl.ps1 new file mode 100644 index 0000000..c1cf328 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsControl.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = "Stop" +$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 } +$script:calls = @() +$script:outcomes = @() +$script:outcomeIndex = 0 + +function Read-AgentConfig { return $script:config } +. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1") + +function Invoke-MinerOpsControlApi3Command { + param([string]$IpAddress, [string]$Command, [object]$Param = $null) + $script:calls += [pscustomobject]@{ command = $Command; param = $Param } + $outcome = $script:outcomes[$script:outcomeIndex] + $script:outcomeIndex += 1 + if ($outcome -is [System.Exception]) { throw $outcome } + return $outcome +} + +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." } +} +function Reset-Harness([object[]]$Outcomes) { + $script:calls = @() + $script:outcomes = @($Outcomes) + $script:outcomeIndex = 0 +} +function Response([string]$Mode = "") { + return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } } +} + +$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" } + +$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 } +Reset-Harness @() +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code" +Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3" + +$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 } +Reset-Harness @((Response), (Response "normal")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "confirmed" "Performance confirmation" +Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write" + +Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "verification_failed" "Contradictory mode" +Assert-Equal $script:calls.Count 4 "One write and three bounded reads" + +Reset-Harness @((Response), (Response), (Response), (Response)) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads" + +Reset-Harness @((Response), [System.Exception]::new("unknown_command")) +$result = Run-MinerControlCommand -Payload $payload +Assert-Equal $result.verification_status "unavailable" "Unavailable read path" + +foreach ($action in @("suspend", "resume")) { + Reset-Harness @((Response)) + $result = Run-MinerControlCommand -Payload @{ + action = $action; requested_value = $null; ip_address = "192.0.2.1" + } + Assert-Equal $result.command_accepted $true "$action accepted" + Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed" + Assert-Equal $result.observed_service_state $null "$action has no invented state" + Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write" +} + +Reset-Harness @([System.Exception]::new("vendor response included secret")) +$result = Run-MinerControlCommand -Payload @{ + action = "suspend"; requested_value = $null; ip_address = "192.0.2.1" +} +Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized" +$json = $result | ConvertTo-Json -Compress +if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" } + +Reset-Harness @() +$result = Run-MinerControlCommand -Payload @{ + action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1" +} +Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code" +Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3" + +Write-Host "MinerOps typed Agent control tests passed." diff --git a/agents/windows/uninstall.ps1 b/agents/windows/uninstall.ps1 new file mode 100644 index 0000000..0de7e6d --- /dev/null +++ b/agents/windows/uninstall.ps1 @@ -0,0 +1,21 @@ +#Requires -RunAsAdministrator +$ErrorActionPreference = "Stop" + +$Target = "C:\MinerOpsAgent" +$Tasks = @( + "MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller" +) +foreach ($name in $Tasks) { + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue +} + +$RuntimeFiles = @( + "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", + "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", + "MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1", + "VERSION" +) +foreach ($name in $RuntimeFiles) { + Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue +} +Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved." diff --git a/backend/alembic/versions/0015_miner_control_foundation.py b/backend/alembic/versions/0015_miner_control_foundation.py new file mode 100644 index 0000000..583e9c1 --- /dev/null +++ b/backend/alembic/versions/0015_miner_control_foundation.py @@ -0,0 +1,137 @@ +"""add miner control foundation + +Revision ID: 0015_miner_control_foundation +Revises: 0014_repair_parts_tracking +Create Date: 2026-08-24 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0015_miner_control_foundation" +down_revision: Union[str, None] = "0014_repair_parts_tracking" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled") + + +def _format_values(values: object) -> str: + return ", ".join(str(value) for value in (values or [])) + + +def upgrade() -> None: + connection = op.get_bind() + unknown_statuses = connection.execute( + sa.text( + """ + SELECT array_agg(DISTINCT command_status ORDER BY command_status) + FROM minerops_agent_command_queue + WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled') + """ + ), + ).scalar() + if unknown_statuses: + raise RuntimeError( + "Cannot upgrade to 0015: minerops_agent_command_queue contains " + f"unsupported command_status values: {_format_values(unknown_statuses)}. " + "Correct those rows to pending, claimed, completed, failed, or cancelled " + "before retrying the migration." + ) + + duplicate_live_miners = connection.execute( + sa.text( + """ + SELECT request_payload ->> 'miner_id' AS miner_id + FROM minerops_agent_command_queue + WHERE command_type = 'miner_control' + AND command_status IN ('pending', 'claimed') + AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL + GROUP BY request_payload ->> 'miner_id' + HAVING count(*) > 1 + ORDER BY request_payload ->> 'miner_id' + """ + ) + ).scalars().all() + if duplicate_live_miners: + raise RuntimeError( + "Cannot upgrade to 0015: more than one pending or claimed miner_control " + "command exists for miner UUID(s): " + f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate " + "live commands before retrying the migration; no queue history was changed." + ) + + op.add_column( + "minerops_site_agents", + sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_minerops_site_agents_site_id", + "minerops_site_agents", + "sites", + ["site_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_index( + "uq_minerops_site_agents_site_id", + "minerops_site_agents", + ["site_id"], + unique=True, + postgresql_where=sa.text("site_id IS NOT NULL"), + ) + op.create_check_constraint( + "ck_minerops_agent_command_queue_status", + "minerops_agent_command_queue", + "command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')", + ) + op.create_index( + "uq_agent_queue_live_miner_control", + "minerops_agent_command_queue", + [sa.text("(request_payload ->> 'miner_id')")], + unique=True, + postgresql_where=sa.text( + "command_type = 'miner_control' " + "AND command_status IN ('pending', 'claimed')" + ), + ) + op.create_index( + "idx_agent_queue_miner_control_history", + "minerops_agent_command_queue", + [ + sa.text("(request_payload ->> 'miner_id')"), + sa.text("created_at DESC"), + sa.text("id DESC"), + ], + postgresql_where=sa.text("command_type = 'miner_control'"), + ) + + +def downgrade() -> None: + op.drop_index( + "idx_agent_queue_miner_control_history", + table_name="minerops_agent_command_queue", + ) + op.drop_index( + "uq_agent_queue_live_miner_control", + table_name="minerops_agent_command_queue", + ) + op.drop_constraint( + "ck_minerops_agent_command_queue_status", + "minerops_agent_command_queue", + type_="check", + ) + op.drop_index( + "uq_minerops_site_agents_site_id", + table_name="minerops_site_agents", + ) + op.drop_constraint( + "fk_minerops_site_agents_site_id", + "minerops_site_agents", + type_="foreignkey", + ) + op.drop_column("minerops_site_agents", "site_id") diff --git a/backend/app/agent_commands/__init__.py b/backend/app/agent_commands/__init__.py new file mode 100644 index 0000000..30c60af --- /dev/null +++ b/backend/app/agent_commands/__init__.py @@ -0,0 +1 @@ +"""Authenticated Agent command transport for MinerOps.""" diff --git a/backend/app/agent_commands/dependencies.py b/backend/app/agent_commands/dependencies.py new file mode 100644 index 0000000..94c1bd7 --- /dev/null +++ b/backend/app/agent_commands/dependencies.py @@ -0,0 +1,37 @@ +"""Dependencies for the authenticated Agent queue protocol.""" + +import hmac +from typing import Annotated + +from fastapi import Header, HTTPException + +from app.agent_commands.repository import AgentCommandRepository +from app.agent_commands.service import AgentCommandService +from app.config import settings + + +def get_agent_command_service() -> AgentCommandService: + return AgentCommandService(AgentCommandRepository()) + + +def require_agent_identity( + agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None, + provided_secret: Annotated[ + str | None, Header(alias="X-MinerOps-Agent-Secret") + ] = None, +) -> str: + expected_id = settings.minerops_agent_id.strip() + expected_secret = settings.minerops_agent_shared_secret.strip() + if not expected_id or not expected_secret: + raise HTTPException(status_code=503, detail="Agent authentication is not configured") + + secret = (provided_secret or "").strip() + if not secret or not hmac.compare_digest(secret, expected_secret): + raise HTTPException(status_code=401, detail="Invalid or missing Agent secret") + + identity = (agent_id or "").strip() + if not identity: + raise HTTPException(status_code=401, detail="Missing Agent identity") + if not hmac.compare_digest(identity, expected_id): + raise HTTPException(status_code=403, detail="Agent identity is not authorized") + return identity diff --git a/backend/app/agent_commands/exceptions.py b/backend/app/agent_commands/exceptions.py new file mode 100644 index 0000000..3b7aa2d --- /dev/null +++ b/backend/app/agent_commands/exceptions.py @@ -0,0 +1,17 @@ +"""Domain-neutral Agent command protocol errors.""" + + +class AgentCommandError(Exception): + """Base Agent command protocol error.""" + + +class AgentCommandNotFound(AgentCommandError): + """The submitted command identifier is unknown.""" + + +class AgentCommandConflict(AgentCommandError): + """The queue row cannot accept the requested transition.""" + + +class AgentResultValidationError(AgentCommandError): + """The Agent result does not match its command contract.""" diff --git a/backend/app/agent_commands/models.py b/backend/app/agent_commands/models.py new file mode 100644 index 0000000..dd8585f --- /dev/null +++ b/backend/app/agent_commands/models.py @@ -0,0 +1,57 @@ +"""Typed contracts for the Agent queue protocol.""" + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class AgentCommand(BaseModel): + command_id: str + command_type: str + request_payload: dict[str, Any] + created_at: datetime + + +class AgentCommandListResponse(BaseModel): + ok: Literal[True] = True + commands: list[AgentCommand] + + +class MinerControlResultPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] + command_accepted: bool + verification_attempted: bool + verification_status: Literal[ + "confirmed", "verification_failed", "unconfirmed", "unavailable" + ] + observed_service_state: None = None + observed_performance_mode: Literal["low", "normal", "high"] | None = None + failure_code: str | None = Field( + default=None, max_length=80, pattern=r"^[a-z0-9_]+$" + ) + + @model_validator(mode="after") + def validate_truthfulness(self) -> "MinerControlResultPayload": + if self.verification_status == "confirmed" and not self.verification_attempted: + raise ValueError("confirmed results require verification_attempted") + if not self.command_accepted and not self.failure_code: + raise ValueError("rejected commands require a failure_code") + return self + + +class AgentCommandResultRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + command_id: str = Field(min_length=1, max_length=128) + status: Literal["completed", "failed"] + result_payload: dict[str, Any] = Field(default_factory=dict) + error_message: str | None = Field(default=None, max_length=255) + + +class AgentCommandResultResponse(BaseModel): + ok: Literal[True] = True + command_id: str + status: Literal["completed", "failed"] diff --git a/backend/app/agent_commands/repository.py b/backend/app/agent_commands/repository.py new file mode 100644 index 0000000..1909f54 --- /dev/null +++ b/backend/app/agent_commands/repository.py @@ -0,0 +1,130 @@ +"""PostgreSQL persistence for Agent command transport.""" + +from collections.abc import Callable +from typing import Any + +import psycopg +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] + +CLAIM_COMMANDS_SQL = """ +WITH candidates AS ( + SELECT id + FROM minerops_agent_command_queue + WHERE agent_id = %s + AND command_status = 'pending' + ORDER BY created_at, id + FOR UPDATE SKIP LOCKED + LIMIT %s +) +UPDATE minerops_agent_command_queue AS queue +SET command_status = 'claimed', claimed_at = now() +FROM candidates +WHERE queue.id = candidates.id + AND queue.command_status = 'pending' +RETURNING queue.id AS command_id, queue.command_type, + queue.request_payload, queue.created_at +""" + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class AgentCommandRepository: + """Own queue locking and immutable terminal transitions.""" + + def __init__(self, connection_factory: ConnectionFactory | None = None): + self._connection_factory = connection_factory or default_connection_factory + + def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit)) + rows = [dict(row) for row in cur.fetchall()] + connection.commit() + return rows + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def get_command(self, command_id: str) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, agent_id, command_type, command_status, request_payload + FROM minerops_agent_command_queue WHERE id = %s + """, + (command_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def finish_command( + self, + command_id: str, + agent_id: str, + status: str, + result_payload: dict[str, Any], + error_message: str | None, + ) -> None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE minerops_agent_command_queue + SET command_status = %s, result_payload = %s, + error_message = %s, completed_at = now() + WHERE id = %s AND agent_id = %s AND command_status = 'claimed' + RETURNING id + """, + (status, Jsonb(result_payload), error_message, command_id, agent_id), + ) + if cur.fetchone(): + connection.commit() + return + + cur.execute( + """ + SELECT agent_id, command_status + FROM minerops_agent_command_queue WHERE id = %s + """, + (command_id,), + ) + row = cur.fetchone() + connection.rollback() + if not row: + raise AgentCommandNotFound("Agent command not found") + if row["agent_id"] != agent_id: + raise AgentCommandConflict("Agent command belongs to another Agent") + raise AgentCommandConflict( + f"Agent command is {row['command_status']}, not claimed" + ) + except (AgentCommandNotFound, AgentCommandConflict): + raise + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/backend/app/agent_commands/router.py b/backend/app/agent_commands/router.py new file mode 100644 index 0000000..8c27086 --- /dev/null +++ b/backend/app/agent_commands/router.py @@ -0,0 +1,57 @@ +"""HTTP boundary for authenticated Agent command transport.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity +from app.agent_commands.exceptions import ( + AgentCommandConflict, + AgentCommandNotFound, + AgentResultValidationError, +) +from app.agent_commands.models import ( + AgentCommandListResponse, + AgentCommandResultRequest, + AgentCommandResultResponse, +) +from app.agent_commands.service import AgentCommandService + + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"]) +AgentIdentity = Annotated[str, Depends(require_agent_identity)] +AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)] + + +@router.get("/pending", response_model=AgentCommandListResponse) +def claim_pending_commands( + request: Request, agent_id: AgentIdentity, service: AgentService +) -> AgentCommandListResponse: + before_claim = getattr( + request.app.state, "agent_command_before_claim", None + ) + if callable(before_claim): + try: + before_claim() + except Exception: + logger.exception("Scheduled command materialization failed before Agent claim") + return AgentCommandListResponse(commands=service.claim(agent_id)) + + +@router.post("/result", response_model=AgentCommandResultResponse) +def submit_command_result( + request: AgentCommandResultRequest, + agent_id: AgentIdentity, + service: AgentService, +) -> AgentCommandResultResponse: + try: + service.submit_result(agent_id, request) + except AgentCommandNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except AgentCommandConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except AgentResultValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return AgentCommandResultResponse(command_id=request.command_id, status=request.status) diff --git a/backend/app/agent_commands/service.py b/backend/app/agent_commands/service.py new file mode 100644 index 0000000..8148478 --- /dev/null +++ b/backend/app/agent_commands/service.py @@ -0,0 +1,87 @@ +"""Agent command transport coordination and result validation.""" + +import re + +from pydantic import ValidationError + +from app.agent_commands.exceptions import ( + AgentCommandConflict, + AgentCommandNotFound, + AgentResultValidationError, +) +from app.agent_commands.models import ( + AgentCommand, + AgentCommandResultRequest, + MinerControlResultPayload, +) +from app.agent_commands.repository import AgentCommandRepository + + +class AgentCommandService: + def __init__(self, repository: AgentCommandRepository): + self.repository = repository + + def claim(self, agent_id: str) -> list[AgentCommand]: + return [ + AgentCommand.model_validate(row) + for row in self.repository.claim_pending(agent_id) + ] + + @staticmethod + def _legacy_error(value: str | None) -> str | None: + if not value: + return None + sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value) + return sanitized[:255] + + def submit_result( + self, agent_id: str, request: AgentCommandResultRequest + ) -> None: + command = self.repository.get_command(request.command_id) + if not command: + raise AgentCommandNotFound("Agent command not found") + if command["agent_id"] != agent_id: + raise AgentCommandConflict("Agent command belongs to another Agent") + if command["command_status"] != "claimed": + raise AgentCommandConflict( + f"Agent command is {command['command_status']}, not claimed" + ) + + result_payload = request.result_payload + error_message = self._legacy_error(request.error_message) + if command["command_type"] == "miner_control": + try: + result = MinerControlResultPayload.model_validate(request.result_payload) + except ValidationError as exc: + raise AgentResultValidationError( + "Invalid typed miner-control result" + ) from exc + if request.status == "completed" and not result.command_accepted: + raise AgentResultValidationError( + "Completed miner-control results must be accepted" + ) + if request.status == "failed" and result.command_accepted: + raise AgentResultValidationError( + "Failed miner-control results cannot be accepted" + ) + action = (command.get("request_payload") or {}).get("action") + if ( + action in {"suspend", "resume"} + and result.command_accepted + and result.verification_status != "unconfirmed" + ): + raise AgentResultValidationError( + "Accepted suspend and resume results must remain unconfirmed" + ) + result_payload = result.model_dump(mode="json") + error_message = ( + result.failure_code.replace("_", " ") if result.failure_code else None + ) + + self.repository.finish_command( + request.command_id, + agent_id, + request.status, + result_payload, + error_message, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 32348d6..84c56e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,9 @@ class Settings(BaseSettings): redis_url: str = "redis://redis:6379/0" + minerops_agent_id: str = "plexus-windows-agent" + minerops_agent_shared_secret: str = "" + jwt_secret_key: str jwt_access_token_minutes: int = 30 jwt_refresh_token_days: int = 14 diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py index c06723d..65b985b 100644 --- a/backend/app/frontend/miner_detail_view.py +++ b/backend/app/frontend/miner_detail_view.py @@ -48,6 +48,38 @@ def miner_detail_content() -> str:
Agent-routed actions
++ Loading factual capability and recent operations. +
+ +| Action | Requested | Status | +Verification | Created |
|---|
Physical context
- - - -
-Paste this into C:\\MinerOpsAgent\\agent-config.json if you need to recreate the agent config.
- +