Author SHA1 Message Date
lussierj e898f283ab security: contain physical miner commands in Stage A
Disable physical control through legacy HTTP/M30 producers, typed admission,
queue dispatch and upgraded Windows executors. Restrict diagnostic transports
to exact read-only operations, with ordinal PowerShell validation before sockets.

Retire unsupported pending/claimed work with atomic, idempotent security audits.
Preserve prior delivery evidence and report unknown outcomes after a prior claim,
including explicitly repaired pending rows. Include containment, cancellation,
concurrency and malformed-payload regressions and the old-Agent cutover runbook.

Validation: independent final application-security revalidation PASS. Two unchanged
baseline source-assertion failures are documented as exceptions. Production release
still requires native Windows validation and verified fencing/stop/drain/upgrade;
server cancellation alone cannot revoke a cached command held by an old Agent.
2026-10-15 20:39:45 +00:00
lussierj 2d89e80410 fix(windows): defer missing tray task during trust reset 2026-10-14 13:50:36 +00:00
lussierj 97a0139e3d feat(windows): add recoverable existing-agent credential upgrade 2026-10-14 13:50:19 +00:00
lussierj 3cfb0d1abe feat(agent): add durable existing-agent credential transitions 2026-10-14 13:50:07 +00:00
lussierj abf5654a25 feat: add self-enrolling Windows Agent package 2026-08-28 17:50:39 +00:00
lussierj b9b5e5e36f feat: add agent management 2026-08-27 17:00:56 +00:00
lussierj d9f0da694b feat: add secure agent enrollment 2026-08-26 21:31:09 +00:00
lussierj 82309db79a feat: add agent zones 2026-08-26 15:48:59 +00:00
120 changed files with 25978 additions and 2767 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# MinerOps repository-root Docker build context
.git
.env
.env.*
!.env.example
!.env.dev.example
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.dump
**/*.sql.gz
backend/app/main.py.bak*
+21 -1
View File
@@ -23,6 +23,24 @@ function Invoke-MinerCommandRaw {
[int]$TimeoutMs = 2500
)
# This transport accepts only the exact queries used by this enrichment script.
# Validate before creating a socket; callers cannot reinterpret a write as a read.
$readCommands = @("summary", "devs", "edevs", "stats", "pools", "devdetails",
"version", "get_version", "get_miner_info")
# PowerShell containment operators use culture comparisons, even with -c.
# Match ordinally and serialize the canonical literal; never trim/normalize.
$approvedCommand = $null
foreach ($candidate in $readCommands) {
if ([string]::Equals($candidate, $Command, [StringComparison]::Ordinal)) {
$approvedCommand = $candidate
break
}
}
if ($Port -ne 4028 -or $null -eq $approvedCommand) {
throw "unsupported_diagnostic_operation_stage_a"
}
$requestJson = @{ cmd = $approvedCommand } | ConvertTo-Json -Compress
$client = $null
try {
@@ -38,7 +56,7 @@ function Invoke-MinerCommandRaw {
$client.SendTimeout = $TimeoutMs
$stream = $client.GetStream()
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
$payload = [System.Text.Encoding]::ASCII.GetBytes($requestJson + "`n")
$stream.Write($payload, 0, $payload.Length)
try {
@@ -144,6 +162,7 @@ function Find-MacAddress {
$config = Read-AgentConfig
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
$secret = [string]$config.agent_secret
$agentId = [string]$config.agent_id
$commands = @(
"summary",
@@ -237,6 +256,7 @@ $response = Invoke-RestMethod `
-Method Post `
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
-Headers @{
"X-MinerOps-Agent-ID" = $agentId
"X-MinerOps-Agent-Secret" = $secret
} `
-ContentType "application/json" `
File diff suppressed because it is too large. Load diff
+2
View File
@@ -204,9 +204,11 @@ $testButton.Add_Click({
}
$hub = [string]$config.hub_url
$agentId = [string]$config.agent_id
$secret = [string]$config.agent_secret
$headers = @{
"X-MinerOps-Agent-ID" = $agentId
"X-MinerOps-Agent-Secret" = $secret
}
+17 -388
View File
@@ -78,6 +78,14 @@ function Send-AgentCheckin {
local_time = (Get-Date).ToString("o")
}
if (-not [string]::IsNullOrWhiteSpace([string]$config.site_id)) {
$body["site_id"] = [string]$config.site_id
}
if (-not [string]::IsNullOrWhiteSpace([string]$config.agent_zone_id)) {
$body["agent_zone_id"] = [string]$config.agent_zone_id
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
}
@@ -735,61 +743,7 @@ function Invoke-MinerPlainCommand {
[int]$TimeoutMs = 2500
)
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
throw "Connect timeout to $IpAddress`:$Port"
}
$client.EndConnect($async)
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$stream = $client.GetStream()
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
$stream.Write($payload, 0, $payload.Length)
try {
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
} catch {
}
$buffer = New-Object byte[] 65536
$chunks = New-Object System.Collections.ArrayList
$started = Get-Date
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
if (-not $stream.DataAvailable) {
Start-Sleep -Milliseconds 20
continue
}
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
break
}
for ($i = 0; $i -lt $read; $i++) {
[void]$chunks.Add([byte]$buffer[$i])
}
if ($chunks.Count -gt 1048576) {
break
}
}
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
} finally {
if ($client -ne $null) {
$client.Close()
}
}
throw "physical_control_disabled_stage_a"
}
function Get-MinerOpsApi3Credential {
@@ -886,61 +840,7 @@ function Invoke-MinerOpsApi3Payload {
[int]$TimeoutMs = 10000
)
$client = $null
$started = Get-Date
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
throw "API3 connect timeout to $IpAddress`:$Port"
}
$client.EndConnect($async)
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$json = $Payload | ConvertTo-Json -Depth 20 -Compress
$requestBytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$lengthBytes = [System.BitConverter]::GetBytes([UInt32]$requestBytes.Length)
$stream = $client.GetStream()
$stream.Write($lengthBytes, 0, $lengthBytes.Length)
$stream.Write($requestBytes, 0, $requestBytes.Length)
$responseLengthBytes = Read-MinerOpsExactBytes -Stream $stream -Length 4 -TimeoutMs $TimeoutMs
$responseLength = [System.BitConverter]::ToUInt32($responseLengthBytes, 0)
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
throw "API3 response length looked invalid: $responseLength"
}
$responseBytes = Read-MinerOpsExactBytes -Stream $stream -Length ([int]$responseLength) -TimeoutMs $TimeoutMs
$responseText = [System.Text.Encoding]::UTF8.GetString($responseBytes)
$responseJson = $responseText | ConvertFrom-Json
return @{
sent = $true
protocol = "whatsminer_api3"
target_ip = $IpAddress
target_port = $Port
duration_seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 3)
request = @{
cmd = $Payload.cmd
param = $Payload.param
account = $Payload.account
has_token = [bool]$Payload.token
}
response = $responseJson
}
} finally {
if ($client -ne $null) {
$client.Close()
}
}
throw "physical_control_disabled_stage_a"
}
function Get-MinerOpsApi3Salt {
@@ -981,43 +881,7 @@ function Invoke-MinerOpsApi3Service {
[ValidateSet("start", "stop", "restart")]
[string]$ServiceAction
)
$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
}
$command = "set.miner.service"
$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
param = $ServiceAction
ts = $timestamp
token = $token
account = $account
}
$result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload
$result["salt_response"] = $saltInfo.salt_response
$result["api3_command"] = $command
$result["api3_param"] = $ServiceAction
$result["api3_account"] = $account
if ([int]$result.response.code -ne 0) {
throw "API3 $command $ServiceAction failed: $($result.response | ConvertTo-Json -Compress)"
}
return $result
throw "physical_control_disabled_stage_a"
}
@@ -1046,45 +910,7 @@ function Invoke-MinerOpsApi3AuthenticatedCommand {
[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
throw "physical_control_disabled_stage_a"
}
function Get-MinerOpsPayloadValue {
@@ -1121,113 +947,14 @@ function Invoke-MinerOpsLegacy4028Command {
[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 {}
}
throw "physical_control_disabled_stage_a"
}
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"
}
throw "physical_control_disabled_stage_a"
}
function Run-MinerControlCommand {
@@ -1236,105 +963,7 @@ function Run-MinerControlCommand {
[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
}
throw "physical_control_disabled_stage_a"
}
@@ -1360,8 +989,8 @@ function Process-PendingCommands {
Write-AgentLog "Command completed: $commandId"
} elseif ($commandType -eq "miner_control") {
$result = Run-MinerControlCommand -Payload $cmd.request_payload
Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null
Write-AgentLog "Miner control command completed: $commandId"
Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload $result | Out-Null
Write-AgentLog "Miner control rejected by Stage A containment: $commandId"
} elseif ($commandType -eq "enrich_match_discovery") {
$payloadObj = $cmd.request_payload
@@ -19,6 +19,7 @@ $SiteName = [string]$Config.site_name
$Secret = [string]$Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-ID" = $AgentId
"X-MinerOps-Agent-Secret" = $Secret
}
@@ -29,11 +30,57 @@ function Invoke-WhatsMinerApi3Read {
[int] $TimeoutMs = 2500
)
# Exact operation and parameter allowlist at the shared transport boundary.
# No extra keys (including credentials, command aliases, or parameter overrides)
# reach the wire, even when this function is called outside the poller loop.
$readCommands = @("get.device.info", "get.miner.setting", "get.miner.status",
"get.fan.setting", "get.device.custom_data")
$command = $Request["cmd"]
$approvedCommand = $null
if ($command -is [string]) {
foreach ($candidate in $readCommands) {
if ([string]::Equals($candidate, $command, [StringComparison]::Ordinal)) {
$approvedCommand = $candidate
break
}
}
}
if ($null -eq $approvedCommand) {
throw "unsupported_diagnostic_operation_stage_a"
}
$expectedKeys = @("cmd")
$wireRequest = [ordered]@{ cmd = $approvedCommand }
if ([string]::Equals($approvedCommand, "get.miner.status", [StringComparison]::Ordinal)) {
$expectedKeys += "param"
if ($Request["param"] -isnot [string] -or
-not [string]::Equals($Request["param"], "summary+pools+edevs", [StringComparison]::Ordinal)) {
throw "unsupported_diagnostic_operation_stage_a"
}
$wireRequest["param"] = "summary+pools+edevs"
}
if ($Request.Count -ne $expectedKeys.Count) {
throw "unsupported_diagnostic_operation_stage_a"
}
foreach ($key in $Request.Keys) {
$approvedKey = $false
if ($key -is [string]) {
foreach ($expectedKey in $expectedKeys) {
if ([string]::Equals($expectedKey, $key, [StringComparison]::Ordinal)) {
$approvedKey = $true
break
}
}
}
if (-not $approvedKey) {
throw "unsupported_diagnostic_operation_stage_a"
}
}
$client = $null
$stream = $null
try {
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
$json = ($wireRequest | ConvertTo-Json -Depth 20 -Compress)
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
+9 -110
View File
@@ -26,29 +26,8 @@ function Get-MinerOpsControlCredentials {
}
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
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
throw "physical_control_disabled_stage_a"
}
function New-MinerOpsControlResult {
@@ -74,90 +53,11 @@ function New-MinerOpsControlResult {
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"
}
# No current request schema binds authorization to physical identity.
# Do not parse, normalize, reinterpret, or transmit an old queued payload.
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "physical_control_disabled_stage_a" -QueueStatus "failed"
}
function Send-AgentCommandResult {
@@ -185,7 +85,6 @@ function Send-AgentCommandResult {
function Send-AgentCheckin {
$config = Read-AgentConfig
$credentials = Get-MinerOpsControlCredentials
$body = [ordered]@{
agent_id = [string]$config.agent_id
site_id = [string]$config.site_id
@@ -193,8 +92,8 @@ function Send-AgentCheckin {
hostname = $env:COMPUTERNAME
version = Get-MinerOpsAgentVersion
ip_address = ""
control_available = [bool]$credentials.available
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
control_available = $false
control_unavailable_reason = "physical_control_disabled_stage_a"
local_time = (Get-Date).ToString("o")
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
+1
View File
@@ -8,6 +8,7 @@ $AgentId = $Config.agent_id
$AgentSecret = $Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-ID" = $AgentId
"X-MinerOps-Agent-Secret" = $AgentSecret
}
+227 -32
View File
@@ -1,7 +1,44 @@
# MinerOps Windows Agent
## Stage A physical-control containment
Physical miner control is disabled in this version, including suspend, resume,
performance-mode changes, reboot, M30 tests, and miner-side password changes.
No environment flag, configured API3 credential, or Agent capability report can
enable it. This is containment, not completion of Blocker #3 identity binding.
The Hub returns HTTP 410 for retired legacy command, scheduling, and unrestricted
diagnostic endpoints (after authorization where applicable). Typed control POSTs
require `miners.control` and return HTTP 409 `physical_control_disabled_stage_a`.
The unrestricted `whatsminer-read-test` endpoint is not a supported read-only tool.
Authenticated queue polling cancels pending/claimed physical and unknown command
types, records an atomic `agent.command.rejected` audit entry, and only delivers
fixed telemetry/discovery command types. Results cannot promote retired entries.
Original payloads, prior results/errors, timestamps, and terminal history remain.
Cancellation of an already claimed command does **not** prove it was never sent;
the audit explicitly records its previous execution outcome as unknown.
The Windows executor rejects all current physical-control schemas without any
device connection and advertises physical control as unavailable. Existing Agents
must be stopped/drained and upgraded before claiming fleet-wide containment:
server cancellation cannot recall payloads cached by older running Agents.
Do not enable control or deploy this work before separate security review.
The ordered, fail-closed procedure is in
[the Stage A old-Agent cutover runbook](../../docs/operations/STAGE_A_OLD_AGENT_CUTOVER.txt).
Native Windows PowerShell 5.1/SYSTEM validation and verified fleet network fencing
remain production gates; server cancellation alone cannot contain an old executor.
Retained legacy/API3 enrichment helpers validate exact read commands and fixed
parameters before opening a socket. Unknown operations and extra keys fail closed.
Schedule cancel/delete require `miners.control` and retire matching live unsupported
commands for the stored Agent with atomic actor-attributed rejection audits.
Deletion retains schedule history; retries do not duplicate audits. Previously
claimed execution remains unknown. Manual cutover retirement does not claim read
work and must sweep offline/queue-only Agent IDs until the global live count is zero.
The Windows Agent runs at a mining site and performs local discovery, polling,
API3 enrichment, pool polling, and approved queued miner-control operations.
API3 enrichment, and pool polling. Physical miner control is disabled in Stage A.
MinerOps does not send API3 credentials through the hub command queue; those
credentials remain in the Agent's local `agent-config.json`.
@@ -15,8 +52,11 @@ probe output are intentionally excluded.
## Local Files
Create `agent-config.json` from `config.example.json` on the Agent host. Never
commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is
retained only for transitional display compatibility.
commit that file. `site_id` is the durable MinerOps site UUID. `agent_zone_id` is the optional
durable network-reachability Zone UUID; older configurations may omit it only
when the Hub can preserve an established assignment or resolve exactly one
enabled Zone at the Site. `site_name` is retained only for transitional display
compatibility.
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
Installation and upgrades preserve all three paths.
@@ -27,9 +67,12 @@ API3 account and password values are local secrets. Missing credentials make
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
must not be written to normal logs or returned to MinerOps command history.
The Internal Alpha supports one Agent identity protected by the deployment
shared secret. A second active Agent requires per-Agent credentials before it
may be enabled.
New Agent identities use per-Agent credentials created through secure
one-time enrollment. The permanent Agent secret is returned only to the
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
only its hash. The legacy deployment shared secret remains only for explicitly
retained compatibility behavior and must not authenticate a newly enrolled
per-Agent identity.
## Legacy Compatibility
@@ -40,35 +83,187 @@ Agent queue; the modular control backend does not connect directly to miners.
## Deployment
Deploy Agent compatibility before enabling Hub enforcement of the
`X-MinerOps-Agent-ID` header:
### New Agent enrollment
1. Copy this directory to a temporary folder on the Windows Agent host.
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
secrets as installer or scheduled-task arguments.
3. Run `install.ps1` from an elevated Windows PowerShell session.
4. Confirm the four scheduled tasks are registered and that Agent check-in
reports the expected non-secret `VERSION`.
5. Enable the matching Agent ID and shared secret on the Hub.
For a new Agent, use Agent Management in MinerOps:
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
output, and other runtime data. During an upgrade, existing definitions for
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
created for a fresh or incomplete installation without hard-coded user names.
1. Choose the Site and an enabled, unassigned Agent Zone.
2. Enter a descriptive Agent name.
3. Create a Windows enrollment.
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
5. Copy the ZIP to the intended Windows machine and extract it.
6. Open Windows PowerShell as Administrator in the extracted
MinerOpsAgent-Windows directory.
7. Run:
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
.\Enroll-MinerOpsAgent.ps1
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
the account blank if API3 functionality is not being configured yet.
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
saves the resulting permanent Agent identity locally, and hands off to the
safe runtime installer.
10. Verify the Agent appears in Agent Management and checks in successfully.
11. Delete the downloaded ZIP and extracted setup directory.
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
credentials in screenshots, chat, tickets, logs, or source control.
### Existing Agent upgrade
An existing legacy Agent must receive a per-Agent credential before the global
shared-secret compatibility path can be retired for its identity. In Agent
Management, use **Upgrade credential** on that Agent. Download the resulting
Windows package, transfer it only to that Agent computer, and run:
.\Upgrade-MinerOpsAgentCredential.ps1
The authorization is bound server-side to the existing Agent ID and its
current Site and Agent Zone. The script validates the local Agent ID, upgrades
the canonical runtime, stops the main Agent task, generates the permanent
secret locally, and recoverably replaces only the credential and durable routing
fields in `agent-config.json` using protected same-directory pending and prior
names. Existing API3 credentials, polling settings,
network ranges, state, logs, and backups are preserved. Before any secret is
written, the script creates and verifies a protected recovery file containing
the original Scheduled Task state, exact transition token, and exact locally
generated credential. The Hub accepts an ambiguous retry only for that same
token and credential.
The Agent directory is a filesystem security boundary. The transition rejects
junctions/reparse points, verifies trusted ancestors, denies standard users
create/delete/rename rights, and revalidates the boundary before each rename.
If an existing Agent directory is discovered insecure, the script first writes
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
parent, then hardens the directory only to contain the incident. The blocker is
outside the contaminated namespace because changing a DACL does not revoke
file or directory handles opened before repair. The same process never redeems
a token, creates or activates a permanent credential, runs the installer, or
removes that blocker. Ordinary second, third, and later invocations remain
blocked even when every current ACL looks correct.
Recovery, pending, replacement remnants, and late-arriving artifacts are
non-authoritative while the external blocker exists. Known transition state is
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
it on a later run. Invalidate any possibly exposed authorization and retain or
remove evidence only under the approved incident procedure.
### Filesystem trust-epoch recovery
Use this procedure only after confirming the blocker was created by the
containment event and preserving required evidence:
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
and restoring kernel session 0. The script identifies the kernel epoch by
the Event Log record anchor of the newest
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
Provider, event ID, record ID, and supported event version come from typed
EventRecord properties; namespace-aware XML selection reads only the
`BootType` payload. The new full-boot record ID must be strictly greater
than the stored record ID. Equality, an older/restored record, log clearing
or numbering reset, missing history, duplication, malformed XML, and an
unsupported provider schema all fail closed and require safe operator
intervention. There is no timestamp fallback. The anchor is unchanged by
clock or timezone changes, sleep, hibernation, and Fast Startup. See
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
3. For a credential transition, use a fresh transition package if any recovery,
pending, remnant, quarantine, or internal blocker existed. Remove those
artifacts only under the approved incident procedure after invalidating the
old authorization.
4. From the reviewed package, run:
.\Upgrade-MinerOpsAgentCredential.ps1 `
-ApproveFilesystemTrustResetAfterReboot
The script verifies that Windows boot identity changed, rescans the protected
namespace, positively verifies and stops the expected Scheduled Task, and asks the
installer to replace managed runtime without backing up or restoring any
pre-reset runtime. The installer leaves both the Agent task and external
blocker untouched after publication; it has no blocker-deletion path. Before
its first fallible stop/install step, the parent durably records the strictly
validated original task intent as `reset-authorized`; after installer
success it advances the blocker to `runtime-published`. It then verifies the
config against the package/server identity, commits the credential, and
restores the recorded task intent. Only the parent removes the blocker,
after active config and final task state have both been positively verified
and non-recovery transition cleanup has succeeded. Protected recovery stays
until after blocker deletion so an interrupted final cleanup remains safe.
5. For a genuinely new enrollment, preserve evidence and use the approved
protected-recreation incident procedure to remove the contaminated namespace
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
protected directory. Enrollment has no in-script blocker-deletion path; its
explicit compatibility switch cannot override a retained blocker.
The explicit switch without a strictly newer canonical full-kernel-boot anchor
fails.
Any suspicious transition artifact, failed runtime publication, failed
credential convergence, invalid final task, or interrupted cleanup retains the
blocker. Runtime publication alone never authorizes deletion. Protected
directory recreation is the alternative when the retained namespace cannot be
verified.
If the script is interrupted after the Hub commits, rerun the same extracted
package without deleting or editing `agent-credential-transition.recovery.json`
or `agent-config.json.pending`. A protected, matching pending config is resumed;
an insecure, malformed, or mismatched artifact fails closed. The original task
intent is durable: an Agent which was running before the first attempt ends
running, while an intentionally stopped Agent remains stopped. Success is not
reported and transition artifacts are not removed until both the active config
and expected final task state are verified. The verifier requires exactly one
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
the installed Windows PowerShell action and service-script arguments, and an
exact final state of `Running` for running intent or `Ready` for stopped intent.
Missing, query failure, access denied, malformed, multiple, disabled, queued,
unknown, wrong-action, and wrong-principal results all fail closed and retain
recovery.
After a successful transition, the existing Agent ID, Site, and Zone are
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
legacy shared secret is permanently disabled for that identity.
For later code-only upgrades of an Agent that already has a per-Agent
credential, copy the new canonical Agent files to a temporary directory and run
install.ps1 from an elevated Windows PowerShell session.
The installer copies only managed runtime code to C:\MinerOpsAgent and
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
and other runtime data. Existing Scheduled Task definitions are authoritative
and are not rewritten. Missing tasks are created for a fresh or incomplete
installation without hard-coded user names.
The per-user `MinerOpsAgentTray` task is optional UI and is not credential or
core Agent authority. During an explicit filesystem trust reset, runtime
publication may run noninteractively as `NT AUTHORITY\SYSTEM`, where no safe
interactive user principal can be inferred. The installer still publishes
`MinerOpsAgentTray.ps1`, but deliberately defers creation of a missing tray task
until a later interactive maintenance install. It never creates the tray task
as SYSTEM or invents a username. An existing tray task remains authoritative
and is preserved unchanged rather than silently repaired under SYSTEM; inspect
and remediate a suspected malformed definition separately. The core
`MinerOpsAgent` task remains mandatory and strictly verified by credential
recovery. `MinerOpsApi3Enrichment` and `MinerOpsPoolPoller` are non-interactive
background tasks with explicit SYSTEM principals, so missing instances continue
to be created during trust-reset publication.
If MinerOpsAgent was running before an upgrade, the installer stops only that
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
again. A previously stopped Agent remains stopped. The tray and periodic tasks
are not unnecessarily restarted.
again. A previously stopped Agent remains stopped. Before replacing runtime
files, the installer copies them to a timestamped backup under
C:\MinerOpsAgent\backups.
Before replacing existing runtime files, the installer copies them to a
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
automatically restores those managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state. For a manual
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
and restart it only if it was previously running. `uninstall.ps1` unregisters
the four tasks and removes runtime code while preserving local configuration,
state, logs, and backups.
Runtime sources, installed destinations, active config, and rollback copies are
rejected when they are reparse points or have more than one hard link. A normal
trusted upgrade creates a fresh random backup directory and never adopts an
existing backup destination. During an explicit post-reboot trust reset, old
runtime is never copied into backup or restored; fresh package content replaces
it while the external blocker remains, and the Agent task stays stopped for
credential verification.
A failed deployment restores managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state.
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
code while preserving local configuration, state, logs, and backups.
File diff suppressed because it is too large. Load diff
+1
View File
@@ -2,6 +2,7 @@
"hub_url": "https://minerops.example.invalid",
"agent_id": "replace-with-agent-id",
"site_id": "00000000-0000-0000-0000-000000000000",
"agent_zone_id": "00000000-0000-0000-0000-000000000000",
"site_name": "Example Site",
"agent_secret": "replace-with-agent-secret",
"poll_interval_seconds": 120,
+1066 -15
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,247 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Operation,
[Parameter(Mandatory = $true)][string]$InputPath,
[string]$DestinationPath = "",
[ValidateSet("None", "Path")][string]$BackupMode = "None",
[string]$BackupPath = "",
[Parameter(Mandatory = $true)][string]$ResultPath,
[Parameter(Mandatory = $true)][string]$SupportPath
)
$ErrorActionPreference = "Stop"
. $SupportPath
$sourceCreatedByCaller = $false
$sourceReadVerified = $false
$sourceModifyVerified = $false
$sourceRenameVerified = $false
$sourceDeleteAccessVerified = $false
$sourceReplaceAccessVerified = $false
$replaceInvoked = $false
$sourceExistedBeforeAttack = $null
$destinationExistedBeforeAttack = $null
$backupExistedBeforeAttack = $null
$inputFullPath = ""
$destinationFullPath = ""
$backupFullPath = ""
$backupArgumentWasNativeNull = $false
function Get-ProbeMetadata {
param(
[Parameter(Mandatory = $true)][string]$Status,
[Exception]$Exception
)
$inputOwnedByCaller = $false
if ([IO.File]::Exists($InputPath)) {
try {
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
[Security.Principal.SecurityIdentifier]
)
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
}
catch {
$inputOwnedByCaller = $false
}
}
$details = if ($null -ne $Exception) {
Get-MinerOpsNativeExceptionDetails -Exception $Exception
}
else { $null }
return [ordered]@{
status = $Status
operation = $Operation
# Retain these two outer-exception fields for compatibility with
# earlier native-harness result readers.
exception_type = if ($null -ne $Exception) {
$Exception.GetType().FullName
}
else {
""
}
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
outer_exception_type = if ($null -ne $details) {
$details.OuterExceptionType
}
else { "" }
outer_hresult = if ($null -ne $details) {
$details.OuterHResult
}
else { 0 }
leaf_exception_type = if ($null -ne $details) {
$details.LeafExceptionType
}
else { "" }
leaf_hresult = if ($null -ne $details) {
$details.LeafHResult
}
else { 0 }
native_error = if ($null -ne $details) {
$details.NativeError
}
else { $null }
exception_chain_types = if ($null -ne $details) {
@($details.ExceptionChainTypes)
}
else { @() }
exception_chain_truncated = if ($null -ne $details) {
[bool]$details.ExceptionChainTruncated
}
else { $false }
source_exists = [IO.File]::Exists($InputPath)
destination_exists = if (
[string]::IsNullOrWhiteSpace($DestinationPath)
) {
$null
}
else {
[IO.File]::Exists($DestinationPath)
}
input_owned_by_caller = $inputOwnedByCaller
source_created_by_caller = $sourceCreatedByCaller
source_read_verified = $sourceReadVerified
source_modify_verified = $sourceModifyVerified
source_rename_verified = $sourceRenameVerified
source_delete_access_verified = $sourceDeleteAccessVerified
source_replace_access_verified = $sourceReplaceAccessVerified
input_full_path = $inputFullPath
destination_full_path = $destinationFullPath
backup_mode = $BackupMode
backup_full_path = $backupFullPath
backup_argument_was_native_null = $backupArgumentWasNativeNull
source_existed_before_attack = $sourceExistedBeforeAttack
destination_existed_before_attack = $destinationExistedBeforeAttack
backup_existed_before_attack = $backupExistedBeforeAttack
replace_overload = "System.IO.File.Replace(String,String,String)"
replace_invoked = $replaceInvoked
}
}
function Write-ProbeResult {
param([Parameter(Mandatory = $true)]$Value)
$json = $Value | ConvertTo-Json -Compress
[IO.File]::WriteAllText($ResultPath, $json)
}
$exception = $null
$status = "succeeded"
try {
switch ($Operation) {
"read" { [void][IO.File]::ReadAllText($InputPath) }
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
"delete" { [IO.File]::Delete($InputPath) }
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
"prepare_swap" {
if ([IO.File]::Exists($InputPath)) {
throw [ArgumentException]::new(
"swap source unexpectedly existed before caller creation"
)
}
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
[IO.File]::WriteAllText(
$InputPath,
"attacker-controlled-swap-source"
)
$sourceCreatedByCaller = $true
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source"
[IO.File]::AppendAllText($InputPath, "-modified")
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
"attacker-controlled-swap-source-modified"
[IO.File]::Move($InputPath, $renameProof)
[IO.File]::Move($renameProof, $InputPath)
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
-not [IO.File]::Exists($renameProof)
$requiredRights =
[Security.AccessControl.FileSystemRights]::ReadData -bor
[Security.AccessControl.FileSystemRights]::WriteData -bor
[Security.AccessControl.FileSystemRights]::Delete -bor
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[Security.AccessControl.FileSystemRights]::Synchronize
$rightsProbe = [IO.FileStream]::new(
$InputPath,
[IO.FileMode]::Open,
$requiredRights,
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
4096,
[IO.FileOptions]::None
)
try {
$sourceDeleteAccessVerified = $true
$sourceReplaceAccessVerified = $true
}
finally {
$rightsProbe.Dispose()
}
}
"swap" {
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
if ($inputFullPath.Equals(
$destinationFullPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw [ArgumentException]::new(
"swap source and destination are the same path"
)
}
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
$destinationExistedBeforeAttack =
[IO.File]::Exists($destinationFullPath)
if (-not $sourceExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap source does not exist",
$inputFullPath
)
}
if (-not $destinationExistedBeforeAttack) {
throw [IO.FileNotFoundException]::new(
"swap destination does not exist",
$destinationFullPath
)
}
if ($BackupMode -ceq "None") {
if (-not [string]::IsNullOrEmpty($BackupPath)) {
throw [ArgumentException]::new(
"backup path was supplied while backup mode is None"
)
}
# A bare PowerShell $null is coerced to String.Empty for this
# .NET string parameter. NullString is the explicit native
# null required to request no ReplaceFile backup.
$backupArgumentWasNativeNull = $true
}
else {
throw [ArgumentException]::new(
"backup-producing swap fixtures are prohibited because " +
"they can expose protected destination content"
)
}
$replaceInvoked = $true
[IO.File]::Replace(
$inputFullPath,
$destinationFullPath,
[NullString]::Value
)
}
default { throw [ArgumentException]::new("unknown operation") }
}
}
catch {
$exception = $_.Exception
$status = Get-MinerOpsNativeExceptionStatus `
-Operation $Operation -Exception $exception
}
$result = Get-ProbeMetadata -Status $status -Exception $exception
Write-ProbeResult -Value $result
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
@@ -0,0 +1,475 @@
$ErrorActionPreference = "Stop"
function Get-MinerOpsNativeGetItemState {
$effective = Get-Command Get-Item -ErrorAction Stop
$function = if (
$effective.CommandType -eq
[Management.Automation.CommandTypes]::Function
) {
$effective
}
else {
$null
}
return [pscustomobject]@{
FunctionExisted = $null -ne $function
FunctionScriptBlock = if ($null -ne $function) {
$function.ScriptBlock
}
else {
$null
}
EffectiveCommandType = [string]$effective.CommandType
EffectiveName = [string]$effective.Name
EffectiveModuleName = [string]$effective.ModuleName
EffectiveSource = [string]$effective.Source
EffectiveDefinition = [string]$effective.Definition
}
}
function Assert-MinerOpsNativeGetItemState {
param(
[Parameter(Mandatory = $true)]$Expected,
[string]$Context = "Get-Item state check"
)
$actual = Get-MinerOpsNativeGetItemState
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
throw "$Context changed the global Get-Item function's existence."
}
if (
[bool]$Expected.FunctionExisted -and
(
-not [object]::ReferenceEquals(
$actual.FunctionScriptBlock,
$Expected.FunctionScriptBlock
) -or
$actual.FunctionScriptBlock.ToString() -cne
$Expected.FunctionScriptBlock.ToString()
)
) {
throw "$Context changed the global Get-Item function's ScriptBlock."
}
foreach ($property in @(
"EffectiveCommandType",
"EffectiveName",
"EffectiveModuleName",
"EffectiveSource",
"EffectiveDefinition"
)) {
if ([string]$actual.$property -cne [string]$Expected.$property) {
throw "$Context changed effective Get-Item resolution ($property)."
}
}
}
function Invoke-MinerOpsNativeGetItemTrace {
param([Parameter(Mandatory = $true)][scriptblock]$Action)
$before = Get-MinerOpsNativeGetItemState
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
$invokeWithLocalHook = {
param(
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
[Parameter(Mandatory = $true)]$ObservedPaths
)
# Use a child scope because PowerShell can continue resolving a
# removed active function until the scope that defined it exits.
# Exiting this child scope makes cleanup deterministic before the
# caller-state assertion runs.
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
[CmdletBinding(DefaultParameterSetName = "Path")]
param(
[Parameter(Position = 0, ParameterSetName = "Path")]
[string[]]$Path,
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
[Alias("PSPath")]
[string[]]$LiteralPath,
[switch]$Force
)
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
$LiteralPath
}
else {
$Path
}
foreach ($candidate in @($observed)) {
$ObservedPaths.Add([string]$candidate)
}
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
}
try {
& $TraceAction | Out-Null
}
finally {
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
-ErrorAction SilentlyContinue
}
}
try {
& $invokeWithLocalHook $Action $observedGetItemPaths
return @($observedGetItemPaths)
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
}
}
function Get-MinerOpsNativeWin32Error {
param([Parameter(Mandatory = $true)][int]$HResult)
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
$unsignedHResult = [int64]$HResult -band 4294967295L
if (
($unsignedHResult -band 4294901760L) -eq 2147942400L
) {
return [int]($unsignedHResult -band 65535L)
}
return $null
}
function Get-MinerOpsNativeExceptionDetails {
param([Parameter(Mandatory = $true)][Exception]$Exception)
$maximumDepth = 32
$visited = [System.Collections.Generic.List[Exception]]::new()
$chainTypes = [System.Collections.Generic.List[string]]::new()
$current = $Exception
$leaf = $Exception
$truncated = $false
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
if ($null -eq $current) { break }
$alreadyVisited = $false
foreach ($candidate in $visited) {
if ([object]::ReferenceEquals($candidate, $current)) {
$alreadyVisited = $true
break
}
}
if ($alreadyVisited) {
$truncated = $true
break
}
$visited.Add($current)
$chainTypes.Add($current.GetType().FullName)
$leaf = $current
$current = $current.InnerException
}
if ($null -ne $current) {
$truncated = $true
}
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
return [pscustomobject]@{
OuterException = $Exception
OuterExceptionType = $Exception.GetType().FullName
OuterHResult = [int]$Exception.HResult
LeafException = $leaf
LeafExceptionType = $leaf.GetType().FullName
LeafHResult = [int]$leaf.HResult
NativeError = $nativeError
ExceptionChainTypes = @($chainTypes)
ExceptionChainTruncated = $truncated
}
}
function Get-MinerOpsNativeExceptionStatus {
param(
[Parameter(Mandatory = $true)][string]$Operation,
[Parameter(Mandatory = $true)][Exception]$Exception
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
$meaningfulException = $details.LeafException
$nativeError = $details.NativeError
if ($details.ExceptionChainTruncated) {
return "unexpected_failure"
}
if ($meaningfulException -is [UnauthorizedAccessException]) {
return "security_denied"
}
if (
$meaningfulException -is [ArgumentException] -or
$meaningfulException -is [IO.FileNotFoundException] -or
$meaningfulException -is [IO.DirectoryNotFoundException] -or
$meaningfulException -is [IO.DriveNotFoundException] -or
$meaningfulException -is [IO.PathTooLongException] -or
$meaningfulException -is [NotSupportedException]
) {
return "fixture_error"
}
if (
$meaningfulException -is [IO.IOException] -and
$null -ne $nativeError
) {
if ([int]$nativeError -eq 5) {
return "security_denied"
}
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
# retaining both original names. The caller must still prove every
# security and identity postcondition before treating this as a
# passing denial.
return "security_denied_requires_postconditions"
}
if ([int]$nativeError -eq 17) {
# ReplaceFile requires source and destination on the same volume.
# This says nothing about the protected target's access boundary.
return "api_precondition_failure"
}
}
return "unexpected_failure"
}
function Get-MinerOpsNativeStatusExitCode {
param([Parameter(Mandatory = $true)][string]$Status)
switch ($Status) {
"succeeded" { return 0 }
"security_denied" { return 5 }
"fixture_error" { return 6 }
"unexpected_failure" { return 7 }
"security_denied_requires_postconditions" { return 8 }
"api_precondition_failure" { return 9 }
default { throw "Unknown standard-user helper status '$Status'." }
}
}
function Get-MinerOpsNativeSwapFixtureStatus {
param([Parameter(Mandatory = $true)]$Fixture)
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
$requiredFixtureChecks = [ordered]@{
SourceExists = "source is missing"
TargetExists = "target is missing"
PathsDistinct = "source and target are the same path"
SourceCreatedByStandardUser = `
"source was not created by the standard-user process"
SourceOwnedByStandardUser = `
"standard user does not own the source"
SourceReadable = "standard user cannot read the source"
SourceModifiable = "standard user cannot modify the source"
SourceRenameable = "standard user cannot rename the source"
SourceDeleteCapable = `
"standard user does not have required delete access to the source"
SourceReplaceAccessVerified = `
"standard user lacks source access required by ReplaceFile"
TargetAclTrusted = "target ACL was not trusted before attack"
TargetOwnerTrusted = "target owner was not trusted before attack"
TargetIdentityCaptured = "target identity was not captured"
SourceIdentityCaptured = "source identity was not captured"
FileIdentitiesDistinct = "source and target resolve to the same file"
BackupArgumentValid = "backup argument is invalid"
BackupPathsDistinct = `
"backup path is not distinct from source and target"
BackupExpectedStateVerified = `
"backup pre-attack state was not verified"
BackupConfidentialitySafe = `
"backup choice could expose protected target content"
}
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
if (-not [bool]$Fixture.($entry.Key)) {
$fixtureFailures.Add([string]$entry.Value)
}
}
if ($fixtureFailures.Count -gt 0) {
return [pscustomobject]@{
Status = "fixture_error"
Failures = @($fixtureFailures)
}
}
$apiFailures = [System.Collections.Generic.List[string]]::new()
if (-not [bool]$Fixture.SameVolume) {
$apiFailures.Add("source, target, and backup are not on one volume")
}
if (-not [bool]$Fixture.NtfsVolume) {
$apiFailures.Add("attack fixture is not on NTFS")
}
if ($apiFailures.Count -gt 0) {
return [pscustomobject]@{
Status = "api_precondition_failure"
Failures = @($apiFailures)
}
}
return [pscustomobject]@{
Status = "ready"
Failures = @()
}
}
function Assert-MinerOpsNativeSwapFixtureReady {
param([Parameter(Mandatory = $true)]$Fixture)
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
switch ([string]$classification.Status) {
"ready" { return }
"fixture_error" {
throw "Standard-user swap fixture is invalid: " +
"$(@($classification.Failures) -join '; ')."
}
"api_precondition_failure" {
throw "Standard-user swap has an API precondition failure: " +
"$(@($classification.Failures) -join '; ')."
}
default {
throw "Standard-user swap fixture classification failed closed."
}
}
}
function Assert-MinerOpsNativeSwapSecurityOutcome {
param(
[Parameter(Mandatory = $true)]$Result,
[Parameter(Mandatory = $true)]$Postconditions
)
if ([string]$Result.operation -cne "swap") {
throw "Swap result recorded the wrong operation."
}
$detail = "outer=$([string]$Result.outer_exception_type), " +
"outer_hresult=$([string]$Result.outer_hresult), " +
"leaf=$([string]$Result.leaf_exception_type), " +
"leaf_hresult=$([string]$Result.leaf_hresult), " +
"native=$([string]$Result.native_error), " +
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
switch ([string]$Result.status) {
"succeeded" {
if (-not [bool]$Result.replace_invoked) {
throw "Successful swap result did not record File.Replace invocation."
}
throw "Standard user completed the protected replacement swap."
}
"security_denied" { }
"security_denied_requires_postconditions" { }
"fixture_error" {
throw "Standard-user swap fixture was invalid ($detail)."
}
"api_precondition_failure" {
throw "Standard-user swap hit an unrelated API precondition ($detail)."
}
default {
throw "Standard-user swap failed unexpectedly ($detail)."
}
}
if (-not [bool]$Result.replace_invoked) {
throw "Standard-user swap did not reach System.IO.File.Replace."
}
$required = [ordered]@{
TargetExists = "protected target is missing"
TargetIdentityUnchanged = "protected target identity changed"
TargetContentUnchanged = "protected target content changed"
TargetAclTrusted = "protected target ACL is not trusted"
TargetAclUnchanged = "protected target ACL changed"
TargetOwnerTrusted = "protected target owner is not trusted"
TargetOwnerUnchanged = "protected target owner changed"
TargetSingleLink = "protected target is not single-link"
SourceExists = "attacker source is missing"
SourceIdentityUnchanged = "attacker source identity changed"
SourceContentUnchanged = "attacker source content changed"
AttackerSourceNotPromoted = "attacker source was promoted to authority"
TargetDeniedStandardUserRead = `
"protected content became readable by the standard user"
TargetDeniedStandardUserModify = `
"protected target became modifiable by the standard user"
TargetDeniedStandardUserDelete = `
"protected target became deletable by the standard user"
TargetDeniedStandardUserRename = `
"protected target became renameable by the standard user"
NoAttackerReadableBackup = `
"replacement exposed protected content through a backup"
}
$failures = [System.Collections.Generic.List[string]]::new()
foreach ($entry in $required.GetEnumerator()) {
if (-not [bool]$Postconditions.($entry.Key)) {
$failures.Add([string]$entry.Value)
}
}
if ($failures.Count -gt 0) {
throw "Swap security postconditions failed ($detail): " +
"$($failures -join '; ')."
}
}
function Get-MinerOpsNativeFileIdentity {
param([Parameter(Mandatory = $true)]$Stream)
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
public sealed class MinerOpsNativeHarnessFileIdentityResult
{
public string Identity { get; set; }
public uint VolumeSerialNumber { get; set; }
public ulong FileIndex { get; set; }
public uint NumberOfLinks { get; set; }
}
public static class MinerOpsNativeHarnessFileIdentity
{
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public uint FileAttributes;
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file, out ByHandleFileInformation information);
public static MinerOpsNativeHarnessFileIdentityResult Get(
SafeFileHandle file)
{
ByHandleFileInformation information;
if (!GetFileInformationByHandle(file, out information))
throw new Win32Exception(Marshal.GetLastWin32Error());
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
information.FileIndexLow;
return new MinerOpsNativeHarnessFileIdentityResult {
Identity = String.Format(
"{0:X8}:{1:X8}{2:X8}",
information.VolumeSerialNumber,
information.FileIndexHigh,
information.FileIndexLow),
VolumeSerialNumber = information.VolumeSerialNumber,
FileIndex = fileIndex,
NumberOfLinks = information.NumberOfLinks
};
}
}
'@
}
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
}
@@ -0,0 +1,335 @@
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
. $scriptPath
$script:Passed = 0
$script:BootEventRecords = @()
$script:ToXmlCalls = 0
function Assert-True {
param(
[Parameter(Mandatory)][bool]$Condition,
[Parameter(Mandatory)][string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-Throws {
param(
[Parameter(Mandatory)][scriptblock]$Action,
[string]$Pattern = ""
)
try { & $Action | Out-Null }
catch {
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
}
return
}
throw "Expected action to fail."
}
function Invoke-ParserTest {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Action
)
& $Action
$script:Passed++
Write-Host "PASS: $Name"
}
function New-TestKernelBootXml {
param(
[string]$Namespace =
"http://schemas.microsoft.com/win/2004/08/events/event",
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
[switch]$OmitSystem,
[switch]$OmitEventData,
[switch]$DuplicateEventData
)
$system = if ($OmitSystem) { "" } else {
@"
<System>
<Provider Name="Microsoft-Windows-Kernel-Boot" />
<EventID>27</EventID>
<Version>1</Version>
<Level>4</Level>
<Task>33</Task>
<Opcode>0</Opcode>
<EventRecordID>42</EventRecordID>
<Channel>System</Channel>
</System>
"@
}
$eventData = if ($OmitEventData) { "" } else {
" <EventData>$BootTypeContent</EventData>"
}
if ($DuplicateEventData) {
$eventData += "`n <EventData>$BootTypeContent</EventData>"
}
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
}
function New-TestEventRecord {
param(
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
[AllowNull()]$Id = [int]27,
[AllowNull()]$RecordId = [long]42,
[AllowNull()]$Version = [byte]1,
[AllowNull()]$Xml = (New-TestKernelBootXml)
)
$record = [pscustomobject]@{
ProviderName = $ProviderName
Id = $Id
RecordId = $RecordId
Version = $Version
XmlPayload = $Xml
}
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
$script:ToXmlCalls++
return $this.XmlPayload
}
return $record
}
function Get-WinEvent {
param($FilterHashtable, $ErrorAction)
return $script:BootEventRecords
}
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord)
Assert-True ($parsed.ProviderName -ceq
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
"Typed event ID was not retained."
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
"Typed record ID was not retained."
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
"Typed event version was not retained."
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
"BootType payload was not parsed."
}
Invoke-ParserTest "supported event version zero parses" {
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version ([byte]0))
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
}
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
$script:ToXmlCalls = 0
$script:BootEventRecords = @(
New-TestEventRecord -RecordId ([long]43)
)
$identifier = Get-MinerOpsBootIdentifier
Assert-True ($identifier -ceq
"windows-full-boot-event-v1:000000000000002b") `
"Production event path returned the wrong identifier."
Assert-True ($script:ToXmlCalls -eq 1) `
"Production event path did not invoke ToXml exactly once."
}
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
$xml = (New-TestKernelBootXml).Replace(
"<EventID>27</EventID>", "<EventID>999</EventID>"
).Replace(
"<EventRecordID>42</EventRecordID>",
"<EventRecordID>999</EventRecordID>"
)
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Xml $xml)
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
"XML scalar metadata overrode typed EventRecord properties."
}
Invoke-ParserTest "null typed RecordId fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -RecordId $null)
} "metadata"
}
Invoke-ParserTest "wrong typed provider fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -ProviderName "Unexpected-Provider")
} "metadata"
}
Invoke-ParserTest "wrong typed event ID fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Id ([int]28))
} "metadata"
}
Invoke-ParserTest "wrong RecordId type fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
} "metadata"
}
Invoke-ParserTest "null event version fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version $null)
} "metadata"
}
Invoke-ParserTest "unsupported event version fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Version ([byte]2))
} "unsupported"
}
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-Namespace "urn:unexpected"))
} "root"
}
Invoke-ParserTest "malformed Event XML fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord `
-EventRecord (New-TestEventRecord -Xml "<Event>")
} "malformed"
}
Invoke-ParserTest "missing System element fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
} "schema"
}
Invoke-ParserTest "missing EventData fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
} "schema"
}
Invoke-ParserTest "duplicated EventData fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml `
(New-TestKernelBootXml -DuplicateEventData))
} "schema"
}
Invoke-ParserTest "missing BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
} "schema"
}
Invoke-ParserTest "duplicate BootType fails closed" {
$payload = '<Data Name="BootType">0</Data>' +
'<Data Name="BootType">0</Data>'
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent $payload))
} "ambiguous"
}
Invoke-ParserTest "nonnumeric BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">full</Data>'))
} "canonical"
}
Invoke-ParserTest "noncanonical BootType fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">00</Data>'))
} "canonical"
}
Invoke-ParserTest "structured BootType content fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent `
'<Data Name="BootType"><Value>0</Value></Data>'))
} "schema"
}
Invoke-ParserTest "BootType outside UInt32 fails closed" {
Assert-Throws {
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
(New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent `
'<Data Name="BootType">4294967296</Data>'))
} "invalid"
}
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
$script:BootEventRecords = @(
New-TestEventRecord -Xml (New-TestKernelBootXml `
-BootTypeContent '<Data Name="BootType">1</Data>')
)
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "empty event query fails closed" {
$script:BootEventRecords = @()
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
$script:BootEventRecords = @(
New-TestEventRecord -RecordId ([long]42)
New-TestEventRecord -RecordId ([long]43)
New-TestEventRecord -RecordId ([long]42)
)
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
}
Invoke-ParserTest "equal stored and current anchors deny reset" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
} "without a verified full Restart"
}
Invoke-ParserTest "strictly greater current anchor passes ordering" {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
}
Invoke-ParserTest "smaller current anchor fails closed" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
} "older than the"
}
Invoke-ParserTest "malformed stored anchor fails closed" {
Assert-Throws {
Assert-MinerOpsBootEpochChanged `
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
} "canonical"
}
Write-Host "$script:Passed boot-event parser tests passed."
+103 -72
View File
@@ -1,84 +1,115 @@
$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
}
$script:assertions = 0
$script:physicalCalls = 0
$script:readCalls = 0
$script:apiCalls = @()
$script:commands = @()
function Assert-Equal($Actual, $Expected, [string]$Message) {
$script:assertions++
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"
function Assert-Throws($Block, [string]$Message) {
$caught = $false
try { & $Block | Out-Null } catch {
$caught = $true
Assert-Equal $_.Exception.Message "physical_control_disabled_stage_a" $Message
}
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"
Assert-Equal $caught $true "$Message must reject"
}
Reset-Harness @([System.Exception]::new("vendor response included secret"))
$result = Run-MinerControlCommand -Payload @{
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
# Define functions from the real service AST, without executing its service loop,
# config loading, startup, or any scheduled tasks.
$root = Split-Path $PSScriptRoot -Parent
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $root "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$parseErrors)
Assert-Equal $parseErrors.Count 0 "Service parser"
foreach ($function in $ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
}, $true)) {
Invoke-Expression $function.Extent.Text
}
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"
$script:productionApi3Payload = (Get-Command Invoke-MinerOpsApi3Payload).ScriptBlock
$script:productionPlainCommand = (Get-Command Invoke-MinerPlainCommand).ScriptBlock
function Invoke-MinerOpsApi3Payload { $script:physicalCalls++; throw "Unexpected API3 transport" }
function Get-MinerOpsApi3Salt { $script:physicalCalls++; throw "Unexpected salt probe" }
function Invoke-MinerPlainCommand { $script:physicalCalls++; throw "Unexpected legacy transport" }
function Read-AgentConfig { return [pscustomobject]@{
agent_id = "simulated-agent"; site_id = ""; site_name = "Simulation"
api3_account = "operator"; api3_password = "mock-only"; api3_port = 4433
} }
function Write-AgentLog { param([string]$Message) }
function Invoke-AgentApi {
param([string]$Method, [string]$Path, [object]$Body)
$script:apiCalls += @{ method = $Method; path = $Path; body = $Body }
return @{ ok = $true }
}
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
function Get-PendingAgentCommands { return $script:commands }
function Run-KnownMinerPoll { $script:readCalls++; return @{ simulated = $true } }
function Run-NewMinerDiscovery { $script:readCalls++; return @{ simulated = $true } }
Write-Host "MinerOps typed Agent control tests passed."
# The pre-module legacy implementation must also be inert, including helpers
# which used to support direct generic API3 or M30/4028 writes.
Assert-Throws { Run-MinerControlCommand -Payload @{ action = "reboot"; dry_run = $false } } "Legacy executor"
Assert-Throws { Invoke-MinerOpsApi3Service -IpAddress "192.0.2.1" -ServiceAction "stop" } "Legacy service helper"
Assert-Throws { & $script:productionApi3Payload -IpAddress "192.0.2.1" -Payload @{ cmd = "set.miner.service"; param = "stop" } } "Generic API3 payload helper"
Assert-Throws { & $script:productionPlainCommand -IpAddress "192.0.2.1" -Command "reboot" } "Generic legacy command helper"
Assert-Throws { Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Legacy API3"
Assert-Throws { Invoke-MinerOpsLegacy4028Command -IpAddress "192.0.2.1" -CommandJson '{"command":"reboot"}' } "Legacy 4028"
Assert-Throws { Invoke-MinerOpsM30Legacy4028Test -Payload @{ legacy_4028_command = "reboot" } } "M30 helper"
. (Join-Path $root "MinerOpsControl.ps1")
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Typed transport"
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "get.read_then_write" } "Disguised diagnostic"
$payloads = @(
@{ action = "legacy_4028"; legacy_4028_command = "reboot"; dry_run = $false },
@{ action = "reboot"; ip_address = "192.0.2.1" },
@{ schema_version = 1; action = "suspend"; ip_address = "192.0.2.1"; miner_id = "spoofed" },
@{ schema_version = 1; action = "resume"; ip_address = "192.0.2.1" },
@{ schema_version = 1; action = "performance_mode"; requested_value = "high"; ip_address = "192.0.2.1" },
@{ schema_version = 999; action = "suspend" },
@{ schema_version = "1"; action = "performance_mode"; performance_mode = "high" },
@{ command = "summary"; params = @{ cmd = "set.miner.service"; param = "stop" } },
@{ action = "read_reboot"; dry_run = $true },
@{},
'{"schema_version":0,"action":"reboot"}',
"{invalid-json"
)
foreach ($payload in $payloads) {
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.command_accepted $false "Rejected payload acceptance"
Assert-Equal $result.failure_code "physical_control_disabled_stage_a" "Rejected payload reason"
Assert-Equal $result._queue_status "failed" "Rejected payload terminal result"
Assert-Equal $result.verification_attempted $false "No physical verification/probe"
Assert-Equal $result.observed_service_state $null "No invented device state"
Assert-Equal $script:physicalCalls 0 "No physical calls"
$script:commands = @(@{ command_id = "legacy-delivery"; command_type = "miner_control"; request_payload = $payload })
$script:apiCalls = @()
Assert-Equal (Process-PendingCommands) 1 "Delivered legacy command handled"
Assert-Equal $script:apiCalls.Count 1 "One failed result submitted"
Assert-Equal $script:apiCalls[0].body.status "failed" "Legacy delivery failed"
Assert-Equal $script:apiCalls[0].body.result_payload.command_accepted $false "Legacy delivery not accepted"
Assert-Equal $script:physicalCalls 0 "No writes after delivery"
}
foreach ($kind in @("raw_api3", "miner_control_v2", "read_reboot", "")) {
$script:commands = @(@{ command_id = "unsupported"; command_type = $kind; request_payload = @{ action = "reboot" } })
$script:apiCalls = @()
Assert-Equal (Process-PendingCommands) 1 "Unknown command handled"
Assert-Equal $script:apiCalls[0].body.status "failed" "Unknown command fails closed"
}
Assert-Equal $script:readCalls 0 "No physical request reinterpreted as telemetry"
$script:commands = @(@{ command_id = "read-only"; command_type = "poll_known_miners_now"; request_payload = @{} })
$script:apiCalls = @()
Assert-Equal (Process-PendingCommands) 1 "Fixed read-only command available"
Assert-Equal $script:readCalls 1 "Only simulated read performed"
Assert-Equal $script:physicalCalls 0 "No physical calls in entire suite"
$script:apiCalls = @()
Send-AgentCheckin | Out-Null
Assert-Equal $script:apiCalls[0].body.control_available $false "Cannot advertise physical capability"
Assert-Equal $script:apiCalls[0].body.control_unavailable_reason "physical_control_disabled_stage_a" "Containment advertised"
Write-Host "Stage A Windows containment passed: $($script:assertions) assertions; zero physical transport calls."
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,199 @@
$ErrorActionPreference = "Stop"
$script:assertions = 0
$script:connections = 0
$TimeoutMs = 1000
function Assert-True([bool]$Condition, [string]$Message) {
$script:assertions++
if (-not $Condition) { throw $Message }
}
function Assert-Rejected([scriptblock]$Call) {
$before = $script:connections
$caught = $false
try { & $Call | Out-Null } catch {
$caught = $true
Assert-True ($_.Exception.Message -eq "unsupported_diagnostic_operation_stage_a") "Must reject before transport"
}
Assert-True $caught "Unsupported diagnostics must throw"
Assert-True ($script:connections -eq $before) "Rejected request must create zero sockets and write zero bytes"
}
function Import-Definitions([string]$Path) {
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
Assert-True ($errors.Count -eq 0) "Production script must parse"
foreach ($definition in $ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
}, $true)) {
. ([scriptblock]::Create("function global:" + $definition.Name + " " + $definition.Body.Extent.Text))
}
}
# Load only real function definitions: no config, Hub request, or scheduled loop.
$root = Split-Path $PSScriptRoot -Parent
Import-Definitions (Join-Path $root "EnrichOneMiner.ps1")
Import-Definitions (Join-Path $root "MinerOpsApi3EnrichmentPoller.ps1")
Import-Definitions (Join-Path $root "MinerOpsPoolPoller.ps1")
function New-Object {
param([Parameter(Position=0)][string]$TypeName,
[Parameter(Position=1)][object[]]$ArgumentList)
if ($TypeName -eq "System.Net.Sockets.TcpClient") { $script:connections++ }
if ($null -ne $ArgumentList) {
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName -ArgumentList $ArgumentList
}
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName
}
# Auditor reproductions plus write families, alias/parameter overrides, injection,
# malformed structures and operations that merely contain a read-looking substring.
foreach ($command in @("reboot", "set.miner.service", "power_off", "switchpool", "ascset",
"summary|reboot", "summary`nreboot", 'summary","cmd":"reboot', "read_then_reboot",
"SUMMARY", "", "get.device.info", "get_miner_info|all")) {
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command }
}
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command "summary" -Port 4433 }
$badRequests = @(
@{ cmd = "set.miner.service"; param = "stop" },
@{ cmd = "reboot" }, @{ cmd = "set.miner.power_mode"; param = "high" },
@{ cmd = "set.device.password"; param = "changed" }, @{ cmd = "get.read_then_write" },
@{ cmd = "get.device.info"; command = "reboot" },
@{ cmd = "get.device.info"; param = "stop" },
@{ cmd = "get.device.info"; account = "operator"; token = "anything" },
@{ cmd = "get.miner.status" }, @{ cmd = "get.miner.status"; param = "all" },
@{ cmd = "get.miner.status"; param = @("summary+pools+edevs") },
@{ cmd = "get.miner.status"; param = "summary+pools+edevs"; command = "reboot" },
@{ Cmd = "get.device.info" }, @{ cmd = "GET.DEVICE.INFO" },
@{ cmd = "get.device.info`nreboot" }, @{ cmd = @("get.device.info", "reboot") },
@{ cmd = $null }, @{}
)
foreach ($request in $badRequests) {
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request }
}
# Exact re-audit suffixes: U+0000, U+00AD and U+200B; also prefix/embedded
# invisible characters, controls and whitespace. Never trim or normalize inputs.
$originalCulture = [Threading.Thread]::CurrentThread.CurrentCulture
try {
foreach ($cultureName in @("en-US", "tr-TR", "")) {
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo($cultureName)
$variants = @()
foreach ($code in @(0, 0x00ad, 0x200b, 0x200c, 0x200d, 0x2060, 0xfeff,
0x001f, 13, 10, 9, 32, 0x00a0)) {
$character = [string][char]$code
foreach ($position in @("prefix", "embedded", "suffix")) {
$variants += @{ Character = $character; Position = $position }
}
}
foreach ($variant in $variants) {
$legacy = "summary"; $api = "get.device.info"; $key = "cmd"; $param = "summary+pools+edevs"
if ($variant.Position -eq "prefix") {
$legacy = $variant.Character + $legacy; $api = $variant.Character + $api
$key = $variant.Character + $key; $param = $variant.Character + $param
} elseif ($variant.Position -eq "embedded") {
$legacy = "sum" + $variant.Character + "mary"; $api = "get.device." + $variant.Character + "info"
$key = "c" + $variant.Character + "md"; $param = "summary+" + $variant.Character + "pools+edevs"
} else {
$legacy += $variant.Character; $api += $variant.Character
$key += $variant.Character; $param += $variant.Character
}
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = $api } }
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request @{ cmd = $api } }
$request = @{}; $request[$key] = "get.device.info"
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = "get.miner.status"; param = $param } }
$request = @{ cmd = "get.miner.status" }; $request[$key.Replace("cmd", "param")] = "summary+pools+edevs"
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
}
foreach ($legacy in @(("summar" + [char]0x0443), ([char]0xff53 + "ummary"),
("summ" + [char]0x0061 + [char]0x0301 + "ry"),
("summ" + [char]0x00e1 + "ry"), "Summary", "summary/", "summary+")) {
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
}
foreach ($api in @(("get.dev" + [char]0x0456 + "ce.info"), ("get" + [char]0xff0e + "device.info"),
([char]0xff47 + "et.device.info"), "Get.Device.Info", "get.device.info/", "get.device.info+")) {
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd=$api} }
}
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; Param="summary+pools+edevs"} }
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; param="Summary+pools+edevs"} }
}
} finally { [Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture }
Assert-True ($script:connections -eq 0) "All negative cases produced zero transport connections/writes"
# Real TCP compatibility tests: bind loopback only, one request per simulator.
# This suite must be run with physical networks disconnected (Docker --network none).
Add-Type -TypeDefinition @'
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
public class MinerOpsDiagnosticSimulator : IDisposable {
TcpListener listener;
Thread thread;
public string Payload;
public string Error;
public MinerOpsDiagnosticSimulator(int port, bool framed) {
listener = new TcpListener(IPAddress.Loopback, port); listener.Start();
thread = new Thread(() => {
try {
using (var client = listener.AcceptTcpClient()) {
client.ReceiveTimeout = 2000;
var stream = client.GetStream(); var buffer = new byte[65536]; int count;
if (framed) {
var length = new byte[4]; ReadExact(stream, length, 4);
count = BitConverter.ToInt32(length, 0);
if(count < 1 || count > buffer.Length) throw new Exception("Bad request length");
ReadExact(stream, buffer, count);
} else { count = stream.Read(buffer, 0, buffer.Length); }
Payload = Encoding.ASCII.GetString(buffer, 0, count);
var response = Encoding.ASCII.GetBytes("{\"code\":0,\"POOLS\":[],\"msg\":{}}");
if(framed) { var length = BitConverter.GetBytes(response.Length); stream.Write(length,0,4); }
stream.Write(response, 0, response.Length);
}
} catch(Exception e) { Error = e.Message; }
finally { listener.Stop(); }
}); thread.IsBackground = true; thread.Start();
}
static void ReadExact(NetworkStream stream, byte[] bytes, int count) {
int offset=0;
while(offset<count) { int n=stream.Read(bytes,offset,count-offset); if(n==0) throw new Exception("EOF"); offset+=n; }
}
public void Dispose() { listener.Stop(); thread.Join(3000); }
}
'@
foreach ($command in @("summary", "devs", "edevs", "stats", "pools", "devdetails", "version", "get_version", "get_miner_info")) {
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
try {
$null = Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command -TimeoutMs 100
$observed = $miner.Payload | ConvertFrom-Json
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Legacy query must remain compatible"
Assert-True ($observed.PSObject.Properties.Count -eq 1) "Legacy request contains only selected read"
Assert-True (-not $miner.Error) "Simulator received a valid legacy request"
} finally { $miner.Dispose() }
}
foreach ($command in @("get.device.info", "get.miner.setting", "get.miner.status", "get.fan.setting", "get.device.custom_data")) {
$request = @{ cmd = $command }
if ($command -ceq "get.miner.status") { $request.param = "summary+pools+edevs" }
$miner = [MinerOpsDiagnosticSimulator]::new(4433, $true)
try {
$result = Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request
$observed = $miner.Payload | ConvertFrom-Json
Assert-True ($null -ne $result) "Existing API3 reader wrapper remains compatible"
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Exact API3 query transmitted"
Assert-True (@($observed.PSObject.Properties).Count -eq $request.Count) "No additional API3 request keys"
if ($command -ceq "get.miner.status") {
Assert-True ([string]::Equals($observed.param, "summary+pools+edevs", [StringComparison]::Ordinal)) "Only exact read parameter transmitted"
}
Assert-True (-not $miner.Error) "Simulator received a valid framed read"
} finally { $miner.Dispose() }
}
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
try {
$null = Invoke-MinerOps4028PoolsSimple -Ip "127.0.0.1"
Assert-True (($miner.Payload | ConvertFrom-Json).command -ceq "pools") "Pool poller sends a constant read"
} finally { $miner.Dispose() }
Write-Host "Diagnostic containment passed: $($script:assertions) assertions; all rejected operations made zero connections/writes."
@@ -0,0 +1,265 @@
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$bootstrap,
[ref] $tokens,
[ref] $parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
}
$source = Get-Content -LiteralPath $bootstrap -Raw
function Assert-SourceContains {
param(
[Parameter(Mandatory)][string] $Pattern,
[Parameter(Mandatory)][string] $Message
)
if ($source -notmatch $Pattern) {
throw $Message
}
}
Assert-SourceContains 'enrollment\.json' `
"Bootstrap enrollment file is not explicit."
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
"New-Agent bootstrap does not reject transition packages."
Assert-SourceContains '/api/agent/enroll' `
"Bootstrap does not use the secure enrollment endpoint."
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
"API3 password is not prompted as a SecureString."
Assert-SourceContains 'agent-config\.json\.pending' `
"Atomic pending config path is missing."
Assert-SourceContains 'agent-config\.recovery\.json' `
"Post-redemption credential recovery path is missing."
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
"Handle-based credential creation is missing."
Assert-SourceContains 'FileMode\]::CreateNew' `
"Credential files are not created with fail-if-present semantics."
Assert-SourceContains 'FileShare\]::None' `
"Credential files are not held exclusively while being created."
Assert-SourceContains 'Flush\(\$true\)' `
"Credential writes are not durably flushed through the trusted handle."
Assert-SourceContains 'GetNumberOfLinks' `
"Enrollment does not reject hard-linked credential targets."
Assert-SourceContains 'S-1-5-18' `
"SYSTEM ACL entry is missing."
Assert-SourceContains 'S-1-5-32-544' `
"Administrators ACL entry is missing."
Assert-SourceContains 'SetOwner\(\$administrators\)' `
"Stable Administrators ownership is missing from credential ACLs."
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
"Atomic final config move is missing."
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
"Enrollment does not revalidate the directory trust boundary."
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
"Enrollment directory repair lacks an external durable trust blocker."
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
"Enrollment lacks an explicit post-reboot trust-reset gate."
Assert-SourceContains 'windows-full-boot-event-v1:' `
"Enrollment blockers do not use canonical full-kernel-boot records."
if ($source -match 'LastBootUpTime') {
throw "Enrollment must not use a wall-clock boot timestamp."
}
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
throw "Enrollment must not have independent trust-blocker deletion authority."
}
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
"Enrollment does not fail closed on retained filesystem trust blockers."
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
"Enrollment does not validate ancestor namespace protection."
foreach ($functionName in @(
"Assert-MinerOpsEnrollmentNoReparsePath",
"Assert-MinerOpsEnrollmentTrustedAncestors"
)) {
$functionSource = [regex]::Match(
$source,
"(?s)function $functionName \{.*?\n\}"
).Value
if ([string]::IsNullOrWhiteSpace($functionSource)) {
throw "$functionName source was not found."
}
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
throw "$functionName does not preserve the filesystem root."
}
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued full path from trimming."
}
if ($functionSource -match '\$root\.TrimEnd\(') {
throw "$functionName converts a root-qualified path to drive-relative form."
}
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
throw "$functionName trims the filesystem root before identifying it."
}
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued parent from trimming."
}
}
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
"Enrollment does not lock an insecure target during repair."
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
"Enrollment does not securely stage a new directory."
Assert-SourceContains '& \$Installer' `
"Bootstrap does not hand off to the safe installer."
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
"Fresh Agent startup is missing."
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
"Consumed extracted enrollment token is not removed."
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
throw "Enrollment must not truncate or adopt a credential pathname."
}
$createFunction = [regex]::Match(
$source,
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
).Value
$createIndex = $createFunction.IndexOf(
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
)
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
if (
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
$linkIndex -le $verifyIndex -or
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
) {
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
}
$createStreamFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
).Value
foreach ($requiredCreateContract in @(
'\[IO\.FileStream\]::new',
'FileMode\]::CreateNew',
'FileSystemRights\]::WriteData',
'FileSystemRights\]::ReadPermissions',
'FileSystemRights\]::Synchronize',
'FileShare\]::None',
'\$Acl'
)) {
if ($createStreamFunction -notmatch $requiredCreateContract) {
throw "Enrollment protected CreateNew stream contract is incomplete."
}
}
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
throw "Enrollment protected creation requests excess rights or repairs by path."
}
$repairFunction = [regex]::Match(
$source,
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
).Value
foreach ($requiredRepairRight in @(
'ReadPermissions',
'ChangePermissions',
'TakeOwnership',
'Synchronize'
)) {
if ($repairFunction -notmatch $requiredRepairRight) {
throw "Enrollment existing-config repair is missing $requiredRepairRight."
}
}
if ($repairFunction -match 'Set-Acl|FileAccess') {
throw "Enrollment existing-config repair must remain handle-bound."
}
$writeFunction = [regex]::Match(
$source,
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
).Value
if (
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
$writeFunction -notmatch '\$Stream\.Write' -or
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
) {
throw "Enrollment credential content is not written and flushed through the verified handle."
}
$existingConfigGuard = $source.IndexOf(
'Test-Path -LiteralPath $ConfigPath'
)
$redeemCall = $source.IndexOf(
'"$HubUrl/api/agent/enroll"'
)
if (
$existingConfigGuard -lt 0 -or
$redeemCall -lt 0 -or
$existingConfigGuard -gt $redeemCall
) {
throw "Existing Agent identity must be rejected before token redemption."
}
$configMove = $source.IndexOf(
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
)
$installerCall = $source.IndexOf(
'& $Installer'
)
if (
$configMove -lt 0 -or
$installerCall -lt 0 -or
$configMove -gt $installerCall
) {
throw "Permanent Agent credentials must be persisted before installation."
}
foreach ($sensitiveVariable in @(
'EnrollmentToken',
'Api3PasswordPlain'
)) {
if (
$source -match (
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
$sensitiveVariable
)
) {
throw "$sensitiveVariable must not be written to output."
}
}
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
+227 -1
View File
@@ -33,10 +33,14 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
throw "Source preflight must happen before target changes."
}
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
"Existing task preservation guard is missing."
Assert-SourceContains 'Preserving existing Scheduled Task' `
"Existing task preservation is not explicit."
Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' `
"Missing-task trust-reset policy helper is absent."
Assert-SourceContains 'Deferring missing per-user Scheduled Task' `
"Missing per-user task deferral is not explicit."
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
"Fresh enrichment task arguments are missing."
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
@@ -49,6 +53,228 @@ Assert-SourceContains 'Unregister-ScheduledTask' `
"Rollback does not remove tasks created by a failed install."
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
"Rollback restart failure is not surfaced."
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
"Installer does not validate ancestor namespace protection."
$taskDefinitionsStart = $source.IndexOf('$definitions = @(')
$taskLoopStart = $source.IndexOf(
'foreach ($definition in $definitions)', $taskDefinitionsStart
)
if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) {
throw "Scheduled Task definitions or reconciliation loop were not found."
}
$taskDefinitions = $source.Substring(
$taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart
)
foreach ($policy in @(
@{ Name = "MinerOpsAgent"; Expected = '\$true' },
@{ Name = "MinerOpsAgentTray"; Expected = '\$false' },
@{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' },
@{ Name = "MinerOpsPoolPoller"; Expected = '\$true' }
)) {
$definition = [regex]::Match(
$taskDefinitions,
('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f
[regex]::Escape($policy.Name))
).Value
if ([string]::IsNullOrWhiteSpace($definition) -or
$definition -notmatch (
'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f
$policy.Expected
)) {
throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy."
}
}
$taskLoopEnd = $source.IndexOf(
'if ($mainTaskWasRunning', $taskLoopStart
)
if ($taskLoopEnd -le $taskLoopStart) {
throw "Scheduled Task reconciliation loop end was not found."
}
$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart)
$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask')
$deferIndex = $taskLoop.IndexOf(
'Test-MinerOpsInstallerShouldCreateMissingTask'
)
$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction')
if (
$preserveIndex -lt 0 -or
$deferIndex -le $preserveIndex -or
$actionIndex -le $deferIndex
) {
throw "Existing-task preservation and missing-task deferral are ordered unsafely."
}
if ($taskLoop -match '80070534|No mapping between account names') {
throw "Missing per-user task handling must be policy-based, not HRESULT-based."
}
$taskPolicyFunction = [regex]::Match(
$source,
'(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}'
).Value
if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) {
throw "Missing-task trust-reset policy function source was not found."
}
Invoke-Expression $taskPolicyFunction
$trayPolicy = [pscustomobject]@{
CreateWhenMissingDuringTrustReset = $false
}
$systemTaskPolicy = [pscustomobject]@{
CreateWhenMissingDuringTrustReset = $true
}
if (Test-MinerOpsInstallerShouldCreateMissingTask `
-Definition $trayPolicy -TrustResetMode $true) {
throw "Trust reset would create a missing per-user tray task."
}
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
-Definition $trayPolicy -TrustResetMode $false)) {
throw "Normal interactive installation would not repair a missing tray task."
}
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
-Definition $systemTaskPolicy -TrustResetMode $true)) {
throw "Trust reset would skip a missing explicit-SYSTEM task."
}
$missingPolicyRejected = $false
try {
[void](Test-MinerOpsInstallerShouldCreateMissingTask `
-Definition ([pscustomobject]@{}) -TrustResetMode $true)
}
catch {
$missingPolicyRejected = $_.Exception.Message -match
'trust-reset creation policy is missing'
}
if (-not $missingPolicyRejected) {
throw "Trust reset accepted a task definition without explicit policy."
}
foreach ($functionName in @(
"Assert-MinerOpsInstallerNoReparsePath",
"Assert-MinerOpsInstallerTrustedAncestors"
)) {
$functionSource = [regex]::Match(
$source,
"(?s)function $functionName \{.*?\n\}"
).Value
if ([string]::IsNullOrWhiteSpace($functionSource)) {
throw "$functionName source was not found."
}
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
throw "$functionName does not preserve the filesystem root."
}
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued full path from trimming."
}
if ($functionSource -match '\$root\.TrimEnd\(') {
throw "$functionName converts a root-qualified path to drive-relative form."
}
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
throw "$functionName trims the filesystem root before identifying it."
}
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
throw "$functionName does not protect a root-valued parent from trimming."
}
}
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
"Installer does not lock an insecure target against replacement."
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
"Installer directory repair lacks an external durable provenance blocker."
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
"Installer lacks an explicit post-reboot trust-reset gate."
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
"Installer does not stage a protected new directory before publication."
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
"Installer does not read back the final directory security invariant."
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
"Installer does not contain the existing active-config ACL."
Assert-SourceContains 'GetNumberOfLinks' `
"Installer does not reject hard-linked runtime, config, or backup files."
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
"Installer does not verify existing runtime rollback provenance."
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
"Installer does not secure and verify published runtime and rollback copies."
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
"Installer trust reset can adopt pre-reset runtime as rollback authority."
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
"Installer does not explicitly retain the parent-owned trust blocker."
Assert-SourceContains 'windows-full-boot-event-v1:' `
"Installer blockers do not use canonical full-kernel-boot records."
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
throw "Installer must have no filesystem trust-blocker deletion path."
}
if ($source -match 'LastBootUpTime') {
throw "Installer must not create wall-clock boot-epoch records."
}
$createStreamFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
).Value
foreach ($requiredCreateContract in @(
'\[IO\.FileStream\]::new',
'FileMode\]::CreateNew',
'FileSystemRights\]::WriteData',
'FileSystemRights\]::ReadPermissions',
'FileSystemRights\]::Synchronize',
'FileShare\]::None',
'\$Acl'
)) {
if ($createStreamFunction -notmatch $requiredCreateContract) {
throw "Installer protected CreateNew stream contract is incomplete."
}
}
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
throw "Installer protected creation requests excess rights or repairs by path."
}
$trustBlockerFunction = [regex]::Match(
$source,
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
).Value
$trustCreateIndex = $trustBlockerFunction.IndexOf(
'New-MinerOpsInstallerProtectedFileCreateNewStream'
)
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
'Test-MinerOpsInstallerProtectedFileAcl'
)
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
if (
$trustCreateIndex -lt 0 -or
$trustVerifyIndex -le $trustCreateIndex -or
$trustContentIndex -le $trustVerifyIndex
) {
throw "Installer trust blockers must be protected and verified before content."
}
$configRepairFunction = [regex]::Match(
$source,
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
).Value
foreach ($requiredRepairRight in @(
'ReadPermissions',
'ChangePermissions',
'TakeOwnership',
'Synchronize'
)) {
if ($configRepairFunction -notmatch $requiredRepairRight) {
throw "Installer existing-config repair is missing $requiredRepairRight."
}
}
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
throw "Installer existing-config repair must remain handle-bound."
}
$repairFunction = [regex]::Match(
$source,
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
).Value
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
throw "Insecure existing-directory containment must abort before publication."
}
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
throw "Installer must not force-overwrite Scheduled Tasks."
@@ -0,0 +1,582 @@
$ErrorActionPreference = "Stop"
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
. $supportPath
function Assert-True {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-Throws {
param(
[Parameter(Mandatory = $true)][scriptblock]$Action,
[Parameter(Mandatory = $true)][string]$Pattern
)
try {
& $Action | Out-Null
}
catch {
if ($_.Exception.Message -notmatch $Pattern) {
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
}
return
}
throw "Expected action to fail with '$Pattern'."
}
$script:Passed = 0
function Invoke-HarnessTest {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
& $Action
$script:Passed++
Write-Host "PASS: $Name"
}
function New-PassingSwapPostconditions {
return [pscustomobject]@{
TargetExists = $true
TargetIdentityUnchanged = $true
TargetContentUnchanged = $true
TargetAclTrusted = $true
TargetAclUnchanged = $true
TargetOwnerTrusted = $true
TargetOwnerUnchanged = $true
TargetSingleLink = $true
SourceExists = $true
SourceIdentityUnchanged = $true
SourceContentUnchanged = $true
AttackerSourceNotPromoted = $true
TargetDeniedStandardUserRead = $true
TargetDeniedStandardUserModify = $true
TargetDeniedStandardUserDelete = $true
TargetDeniedStandardUserRename = $true
NoAttackerReadableBackup = $true
}
}
function New-ReadySwapFixture {
return [pscustomobject]@{
SourceExists = $true
TargetExists = $true
PathsDistinct = $true
SourceCreatedByStandardUser = $true
SourceOwnedByStandardUser = $true
SourceReadable = $true
SourceModifiable = $true
SourceRenameable = $true
SourceDeleteCapable = $true
SourceReplaceAccessVerified = $true
TargetAclTrusted = $true
TargetOwnerTrusted = $true
TargetIdentityCaptured = $true
SourceIdentityCaptured = $true
FileIdentitiesDistinct = $true
BackupArgumentValid = $true
BackupPathsDistinct = $true
BackupExpectedStateVerified = $true
BackupConfidentialitySafe = $true
SameVolume = $true
NtfsVolume = $true
}
}
function New-SwapResult {
param([Parameter(Mandatory = $true)][string]$Status)
return [pscustomobject]@{
operation = "swap"
status = $Status
exception_type = ""
hresult = 0
outer_exception_type = ""
outer_hresult = 0
leaf_exception_type = ""
leaf_hresult = 0
native_error = $null
exception_chain_types = @()
replace_invoked = $true
}
}
Invoke-HarnessTest "helper classifies success and status exit codes" {
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
"Success exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
"Security-denial exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
"Fixture-error exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
"Unexpected-failure exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "security_denied_requires_postconditions") -eq 8) `
"Postcondition-gated security exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "api_precondition_failure") -eq 9) `
"API-precondition exit code changed."
}
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
$denied = [UnauthorizedAccessException]::new("denied")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $denied) -ceq "security_denied") `
"UnauthorizedAccessException was not a security denial."
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $unableToRemove) -ceq
"security_denied_requires_postconditions") `
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
$missing = [IO.FileNotFoundException]::new("missing")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $missing) -ceq "fixture_error") `
"Missing swap source was not a fixture error."
$invalidArgument = [ArgumentException]::new("illegal backup path")
Assert-True ($invalidArgument.HResult -eq -2147024809) `
"ArgumentException no longer carries 0x80070057."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $crossVolume) -ceq
"api_precondition_failure") `
"Cross-volume swap was not an API-precondition failure."
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
"Partial ReplaceFile failure was accepted."
}
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "security_denied") `
"Wrapped UnauthorizedAccessException was not a security denial."
}
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
$denied = [UnauthorizedAccessException]::new("denied")
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
"runtime failed",
$methodWrapper
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
"Nested PowerShell wrappers were not unwrapped."
}
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $accessDenied) -ceq "security_denied") `
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
Assert-True ($null -eq (
Get-MinerOpsNativeWin32Error -HResult -2146233087
)) "CLR HRESULT 0x80131501 produced a bogus native error."
}
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
$ioFailure = [IO.IOException]::new("unrelated")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$ioFailure
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
"Unrelated wrapped IOException was accepted as security evidence."
}
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
$missing = [IO.FileNotFoundException]::new("missing")
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$missing
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
"Wrapped fixture error changed classification."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
"target invocation failed",
$crossVolume
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedCrossVolume) -ceq
"api_precondition_failure") `
"Wrapped API precondition changed classification."
}
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
Assert-True ($details.OuterExceptionType -ceq
"System.Management.Automation.MethodInvocationException") `
"Exception diagnostics recorded the wrong outer type."
Assert-True ($details.LeafExceptionType -ceq
"System.UnauthorizedAccessException") `
"Exception diagnostics recorded the wrong leaf type."
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
"Exception diagnostics recorded the wrong chain length."
Assert-True (-not $details.ExceptionChainTruncated) `
"A short exception chain was reported as truncated."
}
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
$wrapperOnly = [Management.Automation.RuntimeException]::new(
"wrapper without inner exception"
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
"Null InnerException did not leave the wrapper as the leaf."
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
"Null InnerException produced an invalid chain."
}
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
foreach ($level in 1..40) {
$deep = [Exception]::new("wrapper $level", $deep)
}
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
"Deep exception traversal did not stop at its bound."
Assert-True ([bool]$details.ExceptionChainTruncated) `
"Deep exception traversal did not report truncation."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $deep) -ceq "unexpected_failure") `
"Truncated exception evidence did not fail closed."
}
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
$probePath = Join-Path $PSScriptRoot `
"Invoke-MinerOpsStandardUserProbe.ps1"
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile(
$probePath,
[ref]$tokens,
[ref]$parseErrors
) | Out-Null
Assert-True ($parseErrors.Count -eq 0) `
"Standard-user probe has parser errors."
$probeSource = [IO.File]::ReadAllText($probePath)
Assert-True ($probeSource -match '\[NullString\]::Value') `
"Swap probe does not pass an explicit native null backup argument."
Assert-True ($probeSource -notmatch
'File\]::Replace\([^\)]*\$null') `
"Swap probe regressed to a bare PowerShell null backup argument."
Assert-True ($probeSource -match
'backup-producing swap fixtures are prohibited') `
"Swap probe no longer prohibits confidentiality-unsafe backups."
}
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
Add-Type -TypeDefinition @'
public static class MinerOpsNativeHarnessNullStringProbe
{
public static bool IsNull(string value)
{
return value == null;
}
}
'@
}
Assert-True (-not (
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
)) "A bare PowerShell null was not coerced to String.Empty."
Assert-True (
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
) "NullString did not preserve native null for a .NET string."
}
Invoke-HarnessTest "same source and destination is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.PathsDistinct = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Equal source and destination paths were accepted."
}
Invoke-HarnessTest "missing source is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.SourceExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source was accepted."
}
Invoke-HarnessTest "missing target is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.TargetExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing target was accepted."
}
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
foreach ($property in @(
"BackupArgumentValid",
"BackupPathsDistinct",
"BackupExpectedStateVerified",
"BackupConfidentialitySafe"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Invalid backup property $property was accepted."
}
}
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
$fixture = New-ReadySwapFixture
$fixture.SameVolume = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "api_precondition_failure") `
"Cross-volume fixture was not an API-precondition failure."
}
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
$classification = Get-MinerOpsNativeSwapFixtureStatus `
-Fixture (New-ReadySwapFixture)
Assert-True ($classification.Status -ceq "ready") `
"Valid same-volume fixture was not attack-ready."
}
Invoke-HarnessTest "source ownership and control are mandatory" {
foreach ($property in @(
"SourceCreatedByStandardUser",
"SourceOwnedByStandardUser",
"SourceReadable",
"SourceModifiable",
"SourceRenameable",
"SourceDeleteCapable",
"SourceReplaceAccessVerified"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source-control proof $property was accepted."
}
}
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
Assert-Throws -Pattern "fixture was invalid" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "fixture_error") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
Assert-Throws -Pattern "unrelated API precondition" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "api_precondition_failure") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "successful replacement is a security failure" {
Assert-Throws -Pattern "completed the protected replacement" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "succeeded") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "security denial passes only with all postconditions" {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions (New-PassingSwapPostconditions)
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult `
-Status "security_denied_requires_postconditions") `
-Postconditions (New-PassingSwapPostconditions)
$failed = New-PassingSwapPostconditions
$failed.TargetAclTrusted = $false
Assert-Throws -Pattern "target ACL is not trusted" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target identity is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetIdentityUnchanged = $false
Assert-Throws -Pattern "target identity changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target content is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetContentUnchanged = $false
Assert-Throws -Pattern "target content changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
foreach ($property in @(
"TargetAclTrusted",
"TargetAclUnchanged",
"TargetOwnerTrusted",
"TargetOwnerUnchanged"
)) {
$failed = New-PassingSwapPostconditions
$failed.$property = $false
Assert-Throws -Pattern "target (ACL|owner)" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
}
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.NoAttackerReadableBackup = $false
Assert-Throws -Pattern "through a backup" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial must record that File.Replace was reached" {
$result = New-SwapResult -Status "security_denied"
$result.replace_invoked = $false
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result $result -Postconditions (New-PassingSwapPostconditions)
}
}
$initialState = Get-MinerOpsNativeGetItemState
try {
Invoke-HarnessTest "trace leaves no global function when none existed" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode NoExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"No-existing-function child test failed."
}
Invoke-HarnessTest "trace restores an existing global function exactly" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode ExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"Existing-function child test failed."
}
Invoke-HarnessTest "trace cleanup survives Action failure" {
$before = Get-MinerOpsNativeGetItemState
Assert-Throws -Pattern "injected trace failure" -Action {
Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath | Out-Null
throw "injected trace failure"
}
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "throwing trace action"
}
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
$before = Get-MinerOpsNativeGetItemState
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected post-trace assertion failure"
}
catch {
Assert-True (
$_.Exception.Message -match "post-trace assertion failure"
) "Unexpected post-trace failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "post-trace assertion failure"
}
Invoke-HarnessTest "multiple sequential traces preserve command state" {
$before = Get-MinerOpsNativeGetItemState
foreach ($iteration in 1..3) {
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
Assert-True ($observed.Count -eq 1) `
"Sequential trace $iteration recorded the wrong count."
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "sequential trace $iteration"
}
}
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
$before = Get-MinerOpsNativeGetItemState
try {
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected top-level harness failure"
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "simulated harness final cleanup"
}
}
catch {
Assert-True (
$_.Exception.Message -match "top-level harness failure"
) "Unexpected simulated top-level failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "after simulated top-level failure"
}
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $initialState `
-Context "portable native-harness tests final cleanup"
}
Write-Host "$Passed native-harness regression tests passed."
@@ -0,0 +1,48 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("NoExistingFunction", "ExistingFunction")]
[string]$Mode,
[Parameter(Mandatory = $true)][string]$SupportPath
)
$ErrorActionPreference = "Stop"
. $SupportPath
if ($Mode -ceq "NoExistingFunction") {
$before = Get-MinerOpsNativeGetItemState
if ([bool]$before.FunctionExisted) {
throw "Child process unexpectedly began with a global Get-Item function."
}
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $SupportPath
})
if ($observed -cnotcontains $SupportPath) {
throw "Trace did not observe Get-Item."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "no-preexisting-function child test"
return
}
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
$before = Get-MinerOpsNativeGetItemState
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $SupportPath
})
if ($observed -cnotcontains $SupportPath) {
throw "Trace did not observe Get-Item over an existing global function."
}
$after = Get-MinerOpsNativeGetItemState
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "preexisting-function child test"
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
throw "Existing Get-Item ScriptBlock was not restored exactly."
}
if (-not [object]::ReferenceEquals(
$after.FunctionScriptBlock,
$before.FunctionScriptBlock
)) {
throw "Existing Get-Item ScriptBlock instance changed."
}
@@ -0,0 +1,46 @@
param(
[Parameter(Mandatory=$true)][string]$BaselineRoot,
[Parameter(Mandatory=$true)][string]$CachedPayloadPath,
[Parameter(Mandatory=$true)][string]$MinerIp,
[switch]$ExpectFenced
)
$ErrorActionPreference = "Stop"
if ($env:MINEROPS_STAGE_A_SIMULATION -ne "1") {
throw "Requires an explicitly isolated Stage A simulation; never use physical targets."
}
# Supply a git-archived 2d89e80 Windows source directory, NOT a running installation.
# Only AST function definitions execute; no startup, config loading, or task loops.
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $BaselineRoot "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw "Baseline parser error" }
foreach ($definition in $ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
}, $true)) {
Invoke-Expression $definition.Extent.Text
}
. (Join-Path $BaselineRoot "MinerOpsControl.ps1")
function Read-AgentConfig {
return [pscustomobject]@{
api3_account = "simulation"; api3_password = "simulation-only"; api3_port = 4433
}
}
function Get-MinerOpsApi3Salt { return @{ salt = "simulation-only"; salt_response = @{} } }
$payload = Get-Content -LiteralPath $CachedPayloadPath -Raw | ConvertFrom-Json
if ($payload.action -ne "suspend") { throw "This simulation expects the audited cached suspend command" }
# Remap ONLY the target to the address of the dedicated disposable miner container.
$payload.ip_address = $MinerIp
$result = Run-MinerControlCommand -Payload $payload
if ($ExpectFenced) {
if ($result.command_accepted -ne $false -or $result.failure_code -ne "write_rejected") {
throw "Old executor unexpectedly reached/accepted a simulated miner through the fence"
}
Write-Host "Fenced old cached command failed; independently verify simulator write count stayed zero."
} else {
if ($result.command_accepted -ne $true) { throw "Unfenced control simulation did not reproduce old execution" }
Write-Host "Unfenced old cached command reproduced; simulated miner received service stop."
}
@@ -0,0 +1,305 @@
# Disposable Windows filesystem-trust validation
Native Windows evidence is required before deployment. The cross-platform
behavioral harness proves deterministic state-machine decisions; it cannot
prove NTFS access checks, directory-handle sharing, junction behavior,
same-directory rename semantics, or Scheduled Task behavior.
## Isolation
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
it to a production domain or connect it to a production MinerOps Hub, database,
or miner network. Never use a production package, URL, Agent ID, transition
token, shared secret, or API3 credential. Copy only the candidate
`agents\windows` directory to the VM and take a snapshot first.
Run 64-bit Windows PowerShell 5.1 as Administrator:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
-AcknowledgeDisposableVm
```
Record the exact pass count printed by the candidate harness. Do not claim the
native suite passed unless this command actually completed on Windows. Save the
complete transcript plus:
```powershell
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
$PSVersionTable
```
The native primitive suite verifies:
- protected credential, enrollment, installer, and trust-blocker files are
created with their final `FileSecurity`, held exclusively, and read back
through the same still-empty handle before any content is written;
- the directory and credential-file DACLs deny standard-user read, create,
delete, rename, and replacement-swap operations;
- ACL setup or read-back failure cannot leave secret-bearing content;
- a pre-created pathname remains unchanged when `CreateNew` fails;
- an insecure artifact is repaired through an exclusive handle with explicit
security rights, then rejected; inability to acquire those rights fails
closed without a path-based fallback;
- the durable blocker exists before an ACL-repaired artifact could be
reconsidered, and remains effective on the next check;
- quarantine evidence is unreadable to the standard user;
- the two-rename install leaves the new active file and a non-authoritative
prior remnant, and an active-missing install does not invent prior authority;
- a directory junction is rejected;
- a permissive directory is hardened, gets an external durable blocker, and
cannot continue in that process or an ordinary rerun;
- pre-repair standard-user file and directory handles are exercised after ACL
repair while automatic transition remains blocked regardless of whether the
handle operation succeeds;
- hard-linked enrollment, runtime, and backup targets are rejected; and
- a real harmless expected Scheduled Task is positively verified for exact
`Running` and `Ready`, while missing, query failure, disabled, malformed,
wrong-action, and wrong-principal cases fail closed.
## Full-kernel-boot epoch matrix
The source uses the canonical value
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
Event Log `RecordId` anchor of the newest System-log event from
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
immutable boot GUID. A reset requires a valid current anchor strictly greater
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
anchors fail closed. The implementation deliberately does not use wall-clock
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
restoration and must not authorize reset merely because Windows reports a new
startup sequence.
On a disposable VM, dot-source the transition script and record
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
| Transition | Required canonical identity |
| --- | --- |
| Change system clock within the running session | unchanged |
| Change timezone within the running session | unchanged |
| Sleep and resume | unchanged |
| Hibernate and resume, where supported | unchanged |
| Shutdown and power-on with Fast Startup, where supported | unchanged |
| Restart from the Windows power menu | strictly greater record anchor |
For the Fast Startup row, independently confirm that Windows restored kernel
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
missing System log/provider event, duplicated `BootType`, malformed XML value,
wrong namespace, unsupported/null version, wrong provider/event ID,
zero/malformed record ID, and query access failure; every case must fail
closed.
Use a separate disposable snapshot for log-retention behavior. Preserve the
pre-clear stored anchor and evidence first, then clear only the disposable VM's
System log. Verify that missing history cannot authorize reset. After a full
Restart, if Event Log numbering restarts at or below the stored anchor, verify
that automatic reset remains blocked and requires operator intervention. Also
restore a snapshot exposing only an older valid full-boot event and confirm the
lower record ID is rejected. Do not add a timestamp fallback for any row. A
native result is required because EventLogRecord property types, provider
namespace/version, log-clear numbering, and power-state behavior cannot be
proven by the Linux harness.
## Loopback workflow fixture
Use a fresh snapshot for full workflow testing. All values must be disposable.
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
Site, and Zone UUIDs plus placeholder secrets.
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
`POST /api/agent/credential-transition` handler records only the token hash
and submitted `agent_secret_hash`, returns the fixed disposable
Agent/Site/Zone identity, returns the same response for the same token and
hash, and rejects a changed hash.
4. Build a local package with that loopback origin and a random disposable
43-character token. Never download from or call a real Hub.
5. For each case, restore the snapshot or recreate the fixture. Run once with
the task initially running and once with it intentionally stopped.
The child-process shape for hard termination is:
```powershell
$checkpoint = 'pending-written'
$code = @"
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
Invoke-MinerOpsAgentCredentialTransition `
-TargetRoot 'C:\MinerOpsAgent' `
-PackageRoot 'C:\validation\agents\windows' `
-ScheduledTaskName 'MinerOpsAgent' `
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
Start-Process powershell.exe `
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
```
After termination, rerun normally without deleting, repairing, or editing any
artifact. Every retry request must use the same token and
`agent_secret_hash`; no retry may generate a replacement secret.
## Hard-termination matrix
| Checkpoint | State before rerun | Required rerun result |
| --- | --- | --- |
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
| `after-replace` | same as prior row | active is verified before task restoration |
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
For each row inspect ACLs and task state without displaying secret contents:
```powershell
Get-Acl C:\MinerOpsAgent | Format-List
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
-ErrorAction SilentlyContinue | Format-List
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
-ErrorAction SilentlyContinue | Format-List
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
```
## Provenance and quarantine cases
Run these as separate restored-snapshot cases:
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
a Users read/write ACE to recovery, replace its secret and
`original_task_running`, and run twice. The first run must create a blocker,
quarantine without parsing, and send no request. The second must remain
blocked, send no request, keep the real original task state, and omit the
attacker secret from all output. A third ordinary run must remain blocked.
2. **ACL-repair laundering attempt.** Terminate at
`quarantine-blocker-written`, then repair the still-authoritative recovery
file ACL by hand without deleting the blocker. The next run must quarantine
it without reading or further ACL mutation and must not redeem it. This
proves ACL correctness cannot override the durable provenance decision.
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
Users ACE and alter the credential/Agent data. All runs must send no new
request and must never promote pending.
4. **Permissive directory with attacker state.** Add Users Modify to the target
directory, place recovery and pending before repair, and run. Both artifacts
must be quarantined, a blocker retained, and no request sent. A hard stop at
`directory-trust-established` must have the same second-run result.
5. **Quarantine preservation.** A later successful, separately authorized
transition must not silently delete unresolved blocker or evidence files.
Operator review must remove them only after invalidating the exposed token
and obtaining a new package.
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
handle opened before repair to attempt creation/rename of recovery and
pending names, including a file whose ACL now looks correct. The current,
second, and third ordinary runs must send no request and perform no
activation regardless of the handle operation's Windows result.
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
event identity changed, clear suspicious transition state only under the
approved procedure, and run with
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
occurs while the external blocker exists, old runtime is never a rollback
source, the task remains stopped until transition verification, and the
blocker remains through credential/config convergence and final strict task
verification. It may disappear only after all of those parent checks pass.
The same switch before Restart must fail. Repeat after clock/timezone change,
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
8. **Direct installer reset attempts.** With a real external blocker present,
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
successful publication and an injected publication failure. Repeat with the
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
blocker must remain in every case. Then run the complete parent workflow and
prove deletion occurs only after its final convergence checks.
In every quarantine case, test standard-user reads against every
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
denied, and neither console nor error transcripts may contain its contents.
## Hard-link cases
On NTFS, create a second name for each candidate with `New-Item -ItemType
HardLink`. Test attacker-created enrollment pending, recovery, and final config
paths; installed runtime destinations; and rollback sources/destinations. Every
case must fail before secret write, redemption, publication, or restore. Verify
that no attacker-readable alias contains a permanent credential and that a
failed `CreateNew` does not delete or adopt the pre-existing file.
## Strict Scheduled Task matrix
Use the exact installer-controlled action and principal as the valid fixture.
Run both durable intents through `Running`, `Ready`, missing, provider/query
exception, access denied where practical, null/empty state, `Disabled`,
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
executable/script arguments, and wrong principal. Only running plus `Running`
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
then positively verify `Ready`.
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
string substitutions must fail; provider enum normalization is allowed only for
`State`, `LogonType`, and `RunLevel`.
After the server-commit checkpoint, inject final task verification failure.
Recovery must remain and no success output or cleanup may occur. Repair the task
and rerun; the recorded token and `agent_secret_hash` must be identical and the
retry must converge without generating a new secret.
## Active-missing and replacement cases
Run all of these with a cryptographically matching protected recovery record:
- active missing, protected matching pending present, no prior (legacy
`ReplaceFile` partial state): converges with the same token and secret;
- active missing, matching pending and valid legacy prior present (new first
rename completed): converges through pending; prior is never authority;
- committed active and valid prior present, pending absent (new second rename
completed): verifies active and cleans prior last;
- failure before the first rename: active and pending remain, rerun converges;
- failure between the two renames: active is absent, pending and prior remain,
rerun converges;
- failure immediately after pending becomes active: active and prior remain,
rerun verifies active before cleanup;
- active missing with no recovery or with unknown pending: fails closed;
- active missing with malformed pending: fails before redemption;
- active missing with mismatched Agent, secret, or secret hash: fails before
redemption; and
- active missing with only a stale prior: fails closed and never promotes it.
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
identity, the same token and secret hash on every request, active-file DACL,
and final Scheduled Task intent. Initially running must end running;
intentionally stopped must remain stopped.
## Completion and cleanup
At successful termination, recovery, pending, prior, and consumed enrollment
files are absent; unresolved quarantine/blocker evidence is never silently
removed. Console/error transcripts contain neither token nor generated secret.
Record any disposable account, Scheduled Task, junction, directory, or mock
service left by an interrupted validation, remove only those validation
resources, then restore the snapshot or destroy the VM.
+8 -5
View File
@@ -5,15 +5,18 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt /app/requirements.txt
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic
COPY backend/app /app/app
COPY backend/alembic.ini /app/alembic.ini
COPY backend/alembic /app/alembic
# Canonical Agent source used to construct short-lived enrollment packages.
COPY agents/windows /app/agents/windows
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,365 @@
"""add agent zones
Revision ID: 0016_agent_zones
Revises: 0015_miner_control_foundation
Create Date: 2026-08-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0016_agent_zones"
down_revision: Union[str, None] = "0015_miner_control_foundation"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEFAULT_ZONE_NAME = "Default Zone"
def _format_rows(rows: list[tuple]) -> str:
return ", ".join("/".join(str(value) for value in row) for row in rows)
def upgrade() -> None:
op.create_table(
"agent_zones",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"site_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
name="ck_agent_zones_name",
),
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
)
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
op.create_index(
"uq_agent_zones_active_site_name",
"agent_zones",
["site_id", sa.text("lower(name)")],
unique=True,
postgresql_where=sa.text("is_enabled"),
)
op.create_table(
"agent_zone_network_ranges",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"agent_zone_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("network", postgresql.CIDR(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint(
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
),
)
op.create_index(
"idx_agent_zone_network_ranges_zone_id",
"agent_zone_network_ranges",
["agent_zone_id"],
)
op.add_column(
"miners",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_check_constraint(
"ck_miners_agent_zone_requires_site",
"miners",
"agent_zone_id IS NULL OR site_id IS NOT NULL",
)
op.create_foreign_key(
"fk_miners_agent_zone_site",
"miners",
"agent_zones",
["agent_zone_id", "site_id"],
["id", "site_id"],
ondelete="RESTRICT",
)
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
op.add_column(
"minerops_site_agents",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_check_constraint(
"ck_minerops_site_agents_zone_requires_site",
"minerops_site_agents",
"agent_zone_id IS NULL OR site_id IS NOT NULL",
)
op.create_foreign_key(
"fk_minerops_site_agents_zone_site",
"minerops_site_agents",
"agent_zones",
["agent_zone_id", "site_id"],
["id", "site_id"],
ondelete="RESTRICT",
)
op.add_column(
"minerops_agent_snapshots",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
"agent_zones",
["agent_zone_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
["agent_zone_id"],
)
op.add_column(
"minerops_agent_discoveries",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
"agent_zones",
["agent_zone_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
["agent_zone_id"],
)
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO agent_zones (site_id, name)
SELECT sites.id, :default_name
FROM sites
ORDER BY sites.id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE miners AS miner
SET agent_zone_id = zone.id
FROM agent_zones AS zone
WHERE miner.site_id = zone.site_id
AND zone.name = :default_name
AND miner.agent_zone_id IS NULL
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE minerops_site_agents AS agent
SET agent_zone_id = zone.id
FROM agent_zones AS zone
WHERE agent.site_id = zone.site_id
AND zone.name = :default_name
AND agent.agent_zone_id IS NULL
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_snapshots AS snapshot
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE snapshot.agent_id = agent.agent_id
AND agent.agent_zone_id IS NOT NULL
AND snapshot.agent_zone_id IS NULL
"""
)
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_discoveries AS discovery
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE discovery.agent_id = agent.agent_id
AND agent.agent_zone_id IS NOT NULL
AND discovery.agent_zone_id IS NULL
"""
)
)
op.create_index(
"uq_minerops_site_agents_agent_zone_id",
"minerops_site_agents",
["agent_zone_id"],
unique=True,
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
)
op.drop_index(
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
)
def downgrade() -> None:
connection = op.get_bind()
duplicate_site_agents = connection.execute(
sa.text(
"""
SELECT site_id::text, count(*)
FROM minerops_site_agents
WHERE site_id IS NOT NULL
GROUP BY site_id
HAVING count(*) > 1
ORDER BY site_id
"""
)
).all()
if duplicate_site_agents:
raise RuntimeError(
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
"assigned within Site(s): "
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
"until each Site has at most one Agent before retrying; no Agent Zone "
"data was changed."
)
non_reducible_zones = connection.execute(
sa.text(
"""
SELECT site_id::text, count(*)
FROM agent_zones
GROUP BY site_id
HAVING count(*) > 1
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
ORDER BY site_id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
).all()
range_count = connection.execute(
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
).scalar_one()
if non_reducible_zones or range_count:
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
raise RuntimeError(
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
f"represented by the one-Agent-per-Site model ({detail}). Remove "
"additional/custom Zones and Zone network ranges before retrying; no "
"Agent Zone data was changed."
)
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.drop_index(
"uq_minerops_site_agents_agent_zone_id",
table_name="minerops_site_agents",
)
op.drop_index(
"idx_minerops_agent_discoveries_agent_zone_id",
table_name="minerops_agent_discoveries",
)
op.drop_constraint(
"fk_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
type_="foreignkey",
)
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
op.drop_index(
"idx_minerops_agent_snapshots_agent_zone_id",
table_name="minerops_agent_snapshots",
)
op.drop_constraint(
"fk_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
type_="foreignkey",
)
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
op.drop_constraint(
"fk_minerops_site_agents_zone_site",
"minerops_site_agents",
type_="foreignkey",
)
op.drop_constraint(
"ck_minerops_site_agents_zone_requires_site",
"minerops_site_agents",
type_="check",
)
op.drop_column("minerops_site_agents", "agent_zone_id")
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
op.drop_constraint(
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
)
op.drop_constraint(
"ck_miners_agent_zone_requires_site", "miners", type_="check"
)
op.drop_column("miners", "agent_zone_id")
op.drop_index(
"idx_agent_zone_network_ranges_zone_id",
table_name="agent_zone_network_ranges",
)
op.drop_table("agent_zone_network_ranges")
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
op.drop_table("agent_zones")
@@ -0,0 +1,333 @@
"""add secure Agent enrollment and per-Agent credential storage
Revision ID: 0017_agent_enrollment
Revises: 0016_agent_zones
Create Date: 2026-08-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0017_agent_enrollment"
down_revision: Union[str, None] = "0016_agent_zones"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Per-Agent credentials are additive. Existing Agents retain NULL credential
# fields and therefore remain eligible for the temporary legacy shared-secret
# authentication path until explicitly migrated.
op.add_column(
"minerops_site_agents",
sa.Column("display_name", sa.Text(), nullable=True),
)
op.add_column(
"minerops_site_agents",
sa.Column("agent_secret_hash", sa.Text(), nullable=True),
)
op.add_column(
"minerops_site_agents",
sa.Column(
"credential_created_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"minerops_site_agents",
sa.Column(
"credential_revoked_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.create_check_constraint(
"ck_minerops_site_agents_display_name",
"minerops_site_agents",
"""
display_name IS NULL
OR (
btrim(display_name) <> ''
AND char_length(display_name) <= 120
)
""",
)
op.create_check_constraint(
"ck_minerops_site_agents_secret_hash",
"minerops_site_agents",
"""
agent_secret_hash IS NULL
OR agent_secret_hash ~ '^[0-9a-f]{64}$'
""",
)
op.create_check_constraint(
"ck_minerops_site_agents_credential_pair",
"minerops_site_agents",
"""
(
agent_secret_hash IS NULL
AND credential_created_at IS NULL
)
OR (
agent_secret_hash IS NOT NULL
AND credential_created_at IS NOT NULL
)
""",
)
op.create_check_constraint(
"ck_minerops_site_agents_credential_revoked_time",
"minerops_site_agents",
"""
credential_revoked_at IS NULL
OR (
credential_created_at IS NOT NULL
AND credential_revoked_at >= credential_created_at
)
""",
)
op.create_index(
"uq_minerops_site_agents_agent_secret_hash",
"minerops_site_agents",
["agent_secret_hash"],
unique=True,
postgresql_where=sa.text("agent_secret_hash IS NOT NULL"),
)
op.create_table(
"agent_enrollments",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column(
"site_id",
postgresql.UUID(as_uuid=True),
nullable=False,
),
sa.Column(
"agent_zone_id",
postgresql.UUID(as_uuid=True),
nullable=False,
),
sa.Column("display_name", sa.Text(), nullable=False),
sa.Column(
"created_by_user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"expires_at",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column(
"used_at",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column(
"revoked_at",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column("enrolled_agent_id", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["agent_zone_id", "site_id"],
["agent_zones.id", "agent_zones.site_id"],
name="fk_agent_enrollments_zone_site",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["enrolled_agent_id"],
["minerops_site_agents.agent_id"],
name="fk_agent_enrollments_agent",
ondelete="RESTRICT",
),
sa.CheckConstraint(
"token_hash ~ '^[0-9a-f]{64}$'",
name="ck_agent_enrollments_token_hash",
),
sa.CheckConstraint(
"btrim(display_name) <> '' AND char_length(display_name) <= 120",
name="ck_agent_enrollments_display_name",
),
sa.CheckConstraint(
"expires_at > created_at",
name="ck_agent_enrollments_expiry",
),
sa.CheckConstraint(
"used_at IS NULL OR used_at >= created_at",
name="ck_agent_enrollments_used_time",
),
sa.CheckConstraint(
"used_at IS NULL OR used_at <= expires_at",
name="ck_agent_enrollments_used_before_expiry",
),
sa.CheckConstraint(
"revoked_at IS NULL OR revoked_at >= created_at",
name="ck_agent_enrollments_revoked_time",
),
sa.CheckConstraint(
"NOT (used_at IS NOT NULL AND revoked_at IS NOT NULL)",
name="ck_agent_enrollments_terminal_state",
),
sa.CheckConstraint(
"""
(
used_at IS NULL
AND enrolled_agent_id IS NULL
)
OR (
used_at IS NOT NULL
AND enrolled_agent_id IS NOT NULL
)
""",
name="ck_agent_enrollments_agent_when_used",
),
)
op.create_index(
"uq_agent_enrollments_token_hash",
"agent_enrollments",
["token_hash"],
unique=True,
)
# PostgreSQL cannot use now() in a partial-index predicate because now() is
# not immutable. Therefore "open" means unused and unrevoked. Creation code
# will revoke an old expired/open enrollment before inserting a replacement.
op.create_index(
"uq_agent_enrollments_open_zone",
"agent_enrollments",
["agent_zone_id"],
unique=True,
postgresql_where=sa.text(
"used_at IS NULL AND revoked_at IS NULL"
),
)
op.create_index(
"uq_agent_enrollments_enrolled_agent",
"agent_enrollments",
["enrolled_agent_id"],
unique=True,
postgresql_where=sa.text("enrolled_agent_id IS NOT NULL"),
)
op.create_index(
"idx_agent_enrollments_site_id",
"agent_enrollments",
["site_id"],
)
op.create_index(
"idx_agent_enrollments_created_at",
"agent_enrollments",
["created_at"],
)
def downgrade() -> None:
# 0016 has nowhere to represent enrollment history or per-Agent
# credentials. Refuse before destructive DDL if either has been used.
connection = op.get_bind()
enrollment_count = connection.execute(
sa.text("SELECT COUNT(*) FROM agent_enrollments")
).scalar_one()
credential_count = connection.execute(
sa.text(
"""
SELECT COUNT(*)
FROM minerops_site_agents
WHERE display_name IS NOT NULL
OR agent_secret_hash IS NOT NULL
OR credential_created_at IS NOT NULL
OR credential_revoked_at IS NOT NULL
"""
)
).scalar_one()
if enrollment_count:
raise RuntimeError(
"Cannot downgrade 0017: Agent enrollment history exists"
)
if credential_count:
raise RuntimeError(
"Cannot downgrade 0017: per-Agent credentials exist"
)
op.drop_index(
"idx_agent_enrollments_created_at",
table_name="agent_enrollments",
)
op.drop_index(
"idx_agent_enrollments_site_id",
table_name="agent_enrollments",
)
op.drop_index(
"uq_agent_enrollments_enrolled_agent",
table_name="agent_enrollments",
)
op.drop_index(
"uq_agent_enrollments_open_zone",
table_name="agent_enrollments",
)
op.drop_index(
"uq_agent_enrollments_token_hash",
table_name="agent_enrollments",
)
op.drop_table("agent_enrollments")
op.drop_index(
"uq_minerops_site_agents_agent_secret_hash",
table_name="minerops_site_agents",
)
op.drop_constraint(
"ck_minerops_site_agents_credential_revoked_time",
"minerops_site_agents",
type_="check",
)
op.drop_constraint(
"ck_minerops_site_agents_credential_pair",
"minerops_site_agents",
type_="check",
)
op.drop_constraint(
"ck_minerops_site_agents_secret_hash",
"minerops_site_agents",
type_="check",
)
op.drop_constraint(
"ck_minerops_site_agents_display_name",
"minerops_site_agents",
type_="check",
)
op.drop_column("minerops_site_agents", "credential_revoked_at")
op.drop_column("minerops_site_agents", "credential_created_at")
op.drop_column("minerops_site_agents", "agent_secret_hash")
op.drop_column("minerops_site_agents", "display_name")
@@ -0,0 +1,259 @@
"""route pre-Zone Agent installations without rewriting ownership history
Revision ID: 0018_legacy_agent_routing
Revises: 0017_agent_enrollment
Create Date: 2026-08-28
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018_legacy_agent_routing"
down_revision: Union[str, None] = "0017_agent_enrollment"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEFAULT_ZONE_NAME = "Default Zone"
# Every preflight and the eventual Site UPDATE use this exact resolver. An
# exact trim/case-folded name match wins. The sole-Site fallback applies only
# when there are zero exact matches and exactly one durable Site in the whole
# database. Ambiguous exact matches and unresolved multi-Site installations
# deliberately produce NULL resolved_site_id and fail before any UPDATE.
RESOLVED_AGENT_SITES_CTE = """
site_catalog AS (
SELECT count(*) AS site_count,
(array_agg(id ORDER BY id))[1] AS sole_site_id
FROM sites
),
unassigned_exact_matches AS (
SELECT agent.agent_id,
agent.site_name,
count(site.id) AS exact_match_count,
(
array_agg(site.id ORDER BY site.id)
FILTER (WHERE site.id IS NOT NULL)
)[1] AS exact_site_id
FROM minerops_site_agents AS agent
LEFT JOIN sites AS site
ON lower(btrim(site.name)) = lower(btrim(agent.site_name))
WHERE agent.site_id IS NULL
GROUP BY agent.agent_id, agent.site_name
),
resolved_unassigned_agents AS (
SELECT match.agent_id,
match.site_name,
match.exact_match_count,
catalog.site_count,
CASE
WHEN match.exact_match_count = 1
THEN match.exact_site_id
WHEN match.exact_match_count = 0
AND catalog.site_count = 1
THEN catalog.sole_site_id
ELSE NULL
END AS resolved_site_id
FROM unassigned_exact_matches AS match
CROSS JOIN site_catalog AS catalog
)
"""
def _format_rows(rows: list[tuple]) -> str:
return ", ".join("/".join(repr(str(value)) for value in row) for row in rows)
def upgrade() -> None:
connection = op.get_bind()
# Keep every preflight result stable until the corresponding routing and
# history updates commit. Production must still take the Hub and all queue
# producers offline before migration; these locks are the database-level
# backstop against an accidental concurrent Site, Zone, Agent, snapshot, or
# discovery write changing an already-checked assumption.
connection.execute(
sa.text(
"""
LOCK TABLE
agent_zones,
minerops_agent_discoveries,
minerops_agent_snapshots,
minerops_site_agents,
sites
IN SHARE ROW EXCLUSIVE MODE
"""
)
)
# Run every fail-closed preflight before the first UPDATE. Site resolution
# is intentionally limited to legacy rows without an explicit Site
# assignment; explicit site_id values are never candidates for replacement.
site_match_failures = connection.execute(
sa.text(
f"""
WITH {RESOLVED_AGENT_SITES_CTE}
SELECT agent_id, site_name, exact_match_count, site_count
FROM resolved_unassigned_agents
WHERE resolved_site_id IS NULL
ORDER BY agent_id
"""
)
).all()
if site_match_failures:
raise RuntimeError(
"Cannot route legacy Agent installations: each unassigned Agent "
"must have exactly one Site name match after trim/case folding, "
"or zero exact matches while the database contains exactly one "
"Site. Ambiguous exact matches and unmatched multi-Site "
"installations fail closed. Failing "
"agent/site/exact-match-count/site-count rows: "
f"{_format_rows(list(site_match_failures))}. Assign site_id "
"explicitly, make Site names unambiguous, or identify the correct "
"Site before retrying; no "
"Agent, miner, MAC, queue, snapshot, or discovery data was changed."
)
default_zone_failures = connection.execute(
sa.text(
f"""
WITH {RESOLVED_AGENT_SITES_CTE},
effective_agents AS (
SELECT agent.agent_id,
coalesce(
agent.site_id,
resolved.resolved_site_id
) AS site_id
FROM minerops_site_agents AS agent
LEFT JOIN resolved_unassigned_agents AS resolved
ON resolved.agent_id = agent.agent_id
WHERE agent.agent_zone_id IS NULL
)
SELECT agent.agent_id, agent.site_id::text, count(zone.id)
FROM effective_agents AS agent
LEFT JOIN agent_zones AS zone
ON zone.site_id = agent.site_id
AND zone.is_enabled
AND lower(btrim(zone.name)) = lower(:default_name)
GROUP BY agent.agent_id, agent.site_id
HAVING count(zone.id) <> 1
ORDER BY agent.agent_id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
).all()
if default_zone_failures:
raise RuntimeError(
"Cannot route legacy Agent installations: each Agent without an "
"explicit Zone must have exactly one enabled Default Zone in its "
"effective Site. Failing agent/site/count rows: "
f"{_format_rows(list(default_zone_failures))}. Repair the Site's "
"Default Zone or assign agent_zone_id explicitly before retrying; "
"no data was changed."
)
zone_conflicts = connection.execute(
sa.text(
f"""
WITH {RESOLVED_AGENT_SITES_CTE},
targets AS (
SELECT agent.agent_id, zone.id AS agent_zone_id
FROM minerops_site_agents AS agent
LEFT JOIN resolved_unassigned_agents AS resolved
ON resolved.agent_id = agent.agent_id
JOIN agent_zones AS zone
ON zone.site_id = coalesce(
agent.site_id,
resolved.resolved_site_id
)
AND zone.is_enabled
AND lower(btrim(zone.name)) = lower(:default_name)
WHERE agent.agent_zone_id IS NULL
)
SELECT target.agent_zone_id::text,
string_agg(target.agent_id, ',' ORDER BY target.agent_id),
coalesce(string_agg(occupant.agent_id, ','
ORDER BY occupant.agent_id), '')
FROM targets AS target
LEFT JOIN minerops_site_agents AS occupant
ON occupant.agent_zone_id = target.agent_zone_id
GROUP BY target.agent_zone_id
HAVING count(DISTINCT target.agent_id) > 1
OR count(occupant.agent_id) > 0
ORDER BY target.agent_zone_id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
).all()
if zone_conflicts:
raise RuntimeError(
"Cannot route legacy Agent installations: one-Agent-per-Zone would "
"be violated. Failing zone/target Agents/current Agent rows: "
f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled "
"Zones explicitly before retrying; no data was changed."
)
connection.execute(
sa.text(
f"""
WITH {RESOLVED_AGENT_SITES_CTE}
UPDATE minerops_site_agents AS agent
SET site_id = resolved.resolved_site_id
FROM resolved_unassigned_agents AS resolved
WHERE agent.agent_id = resolved.agent_id
AND agent.site_id IS NULL
AND resolved.resolved_site_id IS NOT NULL
"""
)
)
connection.execute(
sa.text(
"""
UPDATE minerops_site_agents AS agent
SET agent_zone_id = zone.id
FROM agent_zones AS zone
WHERE agent.site_id = zone.site_id
AND zone.is_enabled
AND lower(btrim(zone.name)) = lower(:default_name)
AND agent.agent_zone_id IS NULL
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_snapshots AS snapshot
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE snapshot.agent_id = agent.agent_id
AND snapshot.agent_zone_id IS NULL
AND agent.agent_zone_id IS NOT NULL
"""
)
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_discoveries AS discovery
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE discovery.agent_id = agent.agent_id
AND discovery.agent_zone_id IS NULL
AND agent.agent_zone_id IS NOT NULL
"""
)
)
def downgrade() -> None:
# There is no durable, trustworthy way to distinguish values assigned here
# from assignments made by an operator after upgrade. Refuse instead of
# erasing potentially explicit production routing or historical attribution.
raise RuntimeError(
"Cannot downgrade 0018 automatically: legacy Site/Zone routing and "
"snapshot/discovery attribution may now be production data. Restore the "
"pre-upgrade database backup for rollback; no data was changed."
)
@@ -0,0 +1,308 @@
"""make legacy Agent authentication retirement durable and one-way
Revision ID: 0019_agent_legacy_auth_retire
Revises: 0018_legacy_agent_routing
Create Date: 2026-08-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019_agent_legacy_auth_retire"
down_revision: Union[str, None] = "0018_legacy_agent_routing"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Keep the row-level value as a projection for operators and defense in
# depth. The independent identity table below is the authoritative record:
# it deliberately has no foreign key to the deletable current Agent row.
op.add_column(
"minerops_site_agents",
sa.Column(
"legacy_auth_disabled_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.create_table(
"agent_legacy_auth_retirements",
sa.Column("agent_id", sa.Text(), primary_key=True),
sa.Column(
"retired_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("clock_timestamp()"),
),
sa.CheckConstraint(
"btrim(agent_id) <> ''",
name="ck_agent_legacy_auth_retirements_agent_id",
),
)
connection = op.get_bind()
# Stabilize both credential sources until backfill and all enforcement
# triggers are installed. This preserves a single atomic migration view.
connection.execute(
sa.text(
"""
LOCK TABLE minerops_site_agents, agent_enrollments
IN SHARE ROW EXCLUSIVE MODE
"""
)
)
# Backfill every reliable indication that a per-Agent secret was issued.
# A used enrollment remains evidence even if credential columns were later
# cleared. Merely existing as an uncredentialed Agent is not evidence, so
# the production-shaped legacy Plexus identity remains eligible.
op.execute(
"""
WITH credential_evidence AS (
SELECT
agent_id,
coalesce(
credential_created_at,
credential_revoked_at,
clock_timestamp()
) AS retired_at
FROM minerops_site_agents
WHERE agent_secret_hash IS NOT NULL
OR credential_created_at IS NOT NULL
OR credential_revoked_at IS NOT NULL
UNION ALL
SELECT enrolled_agent_id AS agent_id, used_at AS retired_at
FROM agent_enrollments
WHERE enrolled_agent_id IS NOT NULL
AND used_at IS NOT NULL
)
INSERT INTO agent_legacy_auth_retirements (agent_id, retired_at)
SELECT agent_id, min(retired_at)
FROM credential_evidence
GROUP BY agent_id
"""
)
op.execute(
"""
UPDATE minerops_site_agents AS agent
SET legacy_auth_disabled_at = retirement.retired_at
FROM agent_legacy_auth_retirements AS retirement
WHERE retirement.agent_id = agent.agent_id
"""
)
op.create_check_constraint(
"ck_minerops_site_agents_credential_retires_legacy_auth",
"minerops_site_agents",
"""
legacy_auth_disabled_at IS NOT NULL
OR (
agent_secret_hash IS NULL
AND credential_created_at IS NULL
AND credential_revoked_at IS NULL
)
""",
)
# The retirement ledger is append-only for ordinary database operations.
# Migration rollback can still DROP the empty table, and a DBA retains the
# usual PostgreSQL superuser disaster-recovery capabilities.
op.execute(
"""
CREATE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'legacy Agent authentication retirement history is append-only'
USING ERRCODE = '23514';
END;
$$
"""
)
op.execute(
"""
CREATE TRIGGER trg_agent_legacy_auth_retirements_immutable
BEFORE UPDATE OR DELETE
ON agent_legacy_auth_retirements
FOR EACH ROW
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
"""
)
op.execute(
"""
CREATE TRIGGER trg_agent_legacy_auth_retirements_no_truncate
BEFORE TRUNCATE
ON agent_legacy_auth_retirements
FOR EACH STATEMENT
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
"""
)
# Any database path that stores credential evidence also creates the
# identity tombstone in the same transaction. The row marker is always
# synchronized from that tombstone, including on recreation of an Agent
# row whose identity was previously retired.
op.execute(
"""
CREATE FUNCTION minerops_sync_agent_legacy_auth_retirement()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
identity_retired_at timestamptz;
BEGIN
IF NEW.agent_secret_hash IS NOT NULL
OR NEW.credential_created_at IS NOT NULL
OR NEW.credential_revoked_at IS NOT NULL
OR NEW.legacy_auth_disabled_at IS NOT NULL THEN
INSERT INTO agent_legacy_auth_retirements (
agent_id,
retired_at
)
VALUES (
NEW.agent_id,
coalesce(
NEW.credential_created_at,
NEW.legacy_auth_disabled_at,
NEW.credential_revoked_at,
clock_timestamp()
)
)
ON CONFLICT (agent_id) DO NOTHING;
END IF;
SELECT retired_at
INTO identity_retired_at
FROM agent_legacy_auth_retirements
WHERE agent_id = NEW.agent_id;
IF identity_retired_at IS NOT NULL THEN
IF TG_OP = 'UPDATE'
AND NEW.agent_id = OLD.agent_id
AND NEW.legacy_auth_disabled_at
IS DISTINCT FROM OLD.legacy_auth_disabled_at
AND NEW.legacy_auth_disabled_at
IS DISTINCT FROM identity_retired_at THEN
RAISE EXCEPTION
'legacy Agent authentication retirement is permanent'
USING ERRCODE = '23514';
END IF;
NEW.legacy_auth_disabled_at := identity_retired_at;
ELSE
NEW.legacy_auth_disabled_at := NULL;
END IF;
RETURN NEW;
END;
$$
"""
)
op.execute(
"""
CREATE TRIGGER trg_minerops_site_agents_legacy_auth_retirement
BEFORE INSERT OR UPDATE OF
agent_id,
agent_secret_hash,
credential_created_at,
credential_revoked_at,
legacy_auth_disabled_at
ON minerops_site_agents
FOR EACH ROW
EXECUTE FUNCTION minerops_sync_agent_legacy_auth_retirement()
"""
)
# Direct, one-way ledger inserts also refresh the optional row projection.
# Skip the nested case where the Agent trigger itself inserted the ledger
# row; that outer trigger already has NEW and will set the projection.
op.execute(
"""
CREATE FUNCTION minerops_project_agent_legacy_auth_retirement()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF pg_trigger_depth() = 1 THEN
UPDATE minerops_site_agents
SET legacy_auth_disabled_at = NEW.retired_at
WHERE agent_id = NEW.agent_id;
END IF;
RETURN NEW;
END;
$$
"""
)
op.execute(
"""
CREATE TRIGGER trg_agent_legacy_auth_retirements_project
AFTER INSERT
ON agent_legacy_auth_retirements
FOR EACH ROW
EXECUTE FUNCTION minerops_project_agent_legacy_auth_retirement()
"""
)
def downgrade() -> None:
connection = op.get_bind()
retired_count = connection.execute(
sa.text(
"""
SELECT count(*)
FROM agent_legacy_auth_retirements
"""
)
).scalar_one()
if retired_count:
raise RuntimeError(
"Cannot downgrade 0019: durable identity-level legacy-auth "
"retirement history exists. Restore the pre-upgrade database "
"backup rather than making retired Agent identities eligible for "
"legacy auth again."
)
op.execute(
"""
DROP TRIGGER trg_agent_legacy_auth_retirements_project
ON agent_legacy_auth_retirements
"""
)
op.execute("DROP FUNCTION minerops_project_agent_legacy_auth_retirement()")
op.execute(
"""
DROP TRIGGER trg_minerops_site_agents_legacy_auth_retirement
ON minerops_site_agents
"""
)
op.execute("DROP FUNCTION minerops_sync_agent_legacy_auth_retirement()")
op.execute(
"""
DROP TRIGGER trg_agent_legacy_auth_retirements_no_truncate
ON agent_legacy_auth_retirements
"""
)
op.execute(
"""
DROP TRIGGER trg_agent_legacy_auth_retirements_immutable
ON agent_legacy_auth_retirements
"""
)
op.execute(
"DROP FUNCTION minerops_prevent_legacy_auth_retirement_mutation()"
)
op.drop_constraint(
"ck_minerops_site_agents_credential_retires_legacy_auth",
"minerops_site_agents",
type_="check",
)
op.drop_column("minerops_site_agents", "legacy_auth_disabled_at")
op.drop_table("agent_legacy_auth_retirements")
@@ -0,0 +1,100 @@
"""bind one-time credential transitions to existing Agent identities
Revision ID: 0020_existing_agent_transition
Revises: 0019_agent_legacy_auth_retire
Create Date: 2026-09-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0020_existing_agent_transition"
down_revision: Union[str, None] = "0019_agent_legacy_auth_retire"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# NULL continues to mean a normal new-Agent enrollment. A non-NULL value
# is server-derived from the current Agent row and binds the one-time token
# to that exact durable identity without accepting browser routing input.
op.add_column(
"agent_enrollments",
sa.Column("target_agent_id", sa.Text(), nullable=True),
)
op.create_foreign_key(
"fk_agent_enrollments_target_agent",
"agent_enrollments",
"minerops_site_agents",
["target_agent_id"],
["agent_id"],
ondelete="RESTRICT",
)
op.create_check_constraint(
"ck_agent_enrollments_target_agent",
"agent_enrollments",
"target_agent_id IS NULL OR btrim(target_agent_id) <> ''",
)
op.create_check_constraint(
"ck_agent_enrollments_target_result",
"agent_enrollments",
"""
target_agent_id IS NULL
OR enrolled_agent_id IS NULL
OR enrolled_agent_id = target_agent_id
""",
)
op.create_index(
"uq_agent_enrollments_open_target_agent",
"agent_enrollments",
["target_agent_id"],
unique=True,
postgresql_where=sa.text(
"target_agent_id IS NOT NULL "
"AND used_at IS NULL AND revoked_at IS NULL"
),
)
def downgrade() -> None:
connection = op.get_bind()
transition_count = connection.execute(
sa.text(
"""
SELECT count(*)
FROM agent_enrollments
WHERE target_agent_id IS NOT NULL
"""
)
).scalar_one()
if transition_count:
raise RuntimeError(
"Cannot downgrade 0020: existing-Agent credential transition "
"history exists. Restore the pre-upgrade database backup rather "
"than discarding the identity binding."
)
op.drop_index(
"uq_agent_enrollments_open_target_agent",
table_name="agent_enrollments",
)
op.drop_constraint(
"ck_agent_enrollments_target_result",
"agent_enrollments",
type_="check",
)
op.drop_constraint(
"ck_agent_enrollments_target_agent",
"agent_enrollments",
type_="check",
)
op.drop_constraint(
"fk_agent_enrollments_target_agent",
"agent_enrollments",
type_="foreignkey",
)
op.drop_column("agent_enrollments", "target_agent_id")
+1
View File
@@ -0,0 +1 @@
"""Per-Agent authentication support."""
+21
View File
@@ -0,0 +1,21 @@
"""Domain errors for Agent authentication."""
class AgentAuthenticationError(Exception):
"""Base Agent authentication error."""
class AgentAuthenticationUnavailable(AgentAuthenticationError):
"""Agent authentication cannot currently be performed."""
class AgentAuthenticationFailed(AgentAuthenticationError):
"""The supplied Agent secret is invalid."""
class AgentIdentityUnauthorized(AgentAuthenticationError):
"""The supplied Agent identity is not authorized."""
class AgentCredentialRevoked(AgentAuthenticationError):
"""The Agent's permanent credential has been revoked."""
+53
View File
@@ -0,0 +1,53 @@
"""PostgreSQL persistence for Agent authentication."""
from collections.abc import Callable
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
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 AgentAuthRepository:
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_credential(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT identity.agent_id,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
retirement.retired_at AS legacy_auth_disabled_at
FROM (VALUES (%s::text)) AS identity(agent_id)
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_id = identity.agent_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = identity.agent_id
WHERE agent.agent_id IS NOT NULL
OR retirement.agent_id IS NOT NULL
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
+102
View File
@@ -0,0 +1,102 @@
"""Per-Agent authentication with bounded legacy compatibility."""
import hashlib
import hmac
from app.agent_auth.exceptions import (
AgentAuthenticationFailed,
AgentAuthenticationUnavailable,
AgentCredentialRevoked,
AgentIdentityUnauthorized,
)
from app.config import settings
def hash_agent_secret(secret: str) -> str:
"""Hash a high-entropy machine-generated Agent secret for persistence."""
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
class AgentAuthService:
def __init__(self, repository):
self.repository = repository
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
identity = (agent_id or "").strip()
secret = (provided_secret or "").strip()
if not secret:
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
# The production Alpha Agent predates the identity header. Missing-ID
# compatibility is therefore bound to exactly the configured legacy
# identity; no request value is allowed to select another Agent.
legacy_id = settings.minerops_agent_id.strip()
legacy_secret = settings.minerops_agent_shared_secret.strip()
missing_identity = not identity
if missing_identity:
if not legacy_id:
raise AgentAuthenticationUnavailable(
"Legacy Agent authentication is not configured"
)
identity = legacy_id
credential = self.repository.get_credential(identity)
# Revocation is authoritative regardless of which credential path
# would otherwise apply. Never allow malformed or historical
# credential state to fall through to the legacy shared secret.
if credential and credential.get("credential_revoked_at") is not None:
raise AgentCredentialRevoked("Agent credential has been revoked")
# This value comes from the identity-level retirement ledger, not the
# deletable current Agent row. It is therefore authoritative even when
# the Agent was deleted and has not yet been recreated.
legacy_auth_retired = bool(
credential
and credential.get("legacy_auth_disabled_at") is not None
)
# Any Agent with a per-Agent credential is permanently on the new
# authentication path. The legacy shared secret must never authenticate
# that identity, even if the Agent later becomes revoked.
if credential and credential.get("agent_secret_hash"):
# Once the configured legacy identity has ever received a
# per-Agent credential, secret-only fallback is permanently
# disabled. The legacy global secret cannot authenticate it and a
# per-Agent secret cannot be used without an explicit identity.
if missing_identity:
raise AgentAuthenticationFailed(
"Explicit Agent identity is required for enrolled Agents"
)
supplied_hash = hash_agent_secret(secret)
if not hmac.compare_digest(
supplied_hash,
str(credential["agent_secret_hash"]),
):
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
return identity
# The durable identity retirement survives Agent-row deletion,
# credential-field corruption, administrative mistakes, and future
# credential rotation workflows.
if legacy_auth_retired:
raise AgentAuthenticationFailed(
"Legacy Agent authentication has been permanently disabled"
)
# Temporary compatibility for the single pre-enrollment Agent.
if not legacy_id or not legacy_secret:
raise AgentAuthenticationUnavailable(
"Legacy Agent authentication is not configured"
)
if not hmac.compare_digest(identity, legacy_id):
raise AgentIdentityUnauthorized("Agent identity is not authorized")
if not hmac.compare_digest(secret, legacy_secret):
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
return identity
+27 -18
View File
@@ -1,37 +1,46 @@
"""Dependencies for the authenticated Agent queue protocol."""
import hmac
from typing import Annotated
from fastapi import Header, HTTPException
from fastapi import Depends, Header, HTTPException
from app.agent_auth.exceptions import (
AgentAuthenticationFailed,
AgentAuthenticationUnavailable,
AgentCredentialRevoked,
AgentIdentityUnauthorized,
)
from app.agent_auth.repository import AgentAuthRepository
from app.agent_auth.service import AgentAuthService
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 get_agent_auth_service() -> AgentAuthService:
return AgentAuthService(AgentAuthRepository())
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,
service: AgentAuthService = Depends(get_agent_auth_service),
) -> 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
try:
return service.authenticate(agent_id, provided_secret)
except AgentAuthenticationUnavailable as exc:
raise HTTPException(
status_code=503,
detail="Agent authentication is not configured",
) from exc
except AgentAuthenticationFailed as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
except AgentCredentialRevoked as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except AgentIdentityUnauthorized as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
+35
View File
@@ -0,0 +1,35 @@
"""Stage A containment: no current payload authorizes a physical write.
There is deliberately no environment-variable override. Re-enabling physical
control requires a separately reviewed identity-binding protocol.
"""
PHYSICAL_CONTROL_DISABLED = "physical_control_disabled_stage_a"
UNSUPPORTED_COMMAND = "unsupported_command_schema_stage_a"
READ_ONLY_COMMAND_TYPES = (
"poll_known_miners_now",
"discover_new_miners",
"enrich_match_discovery",
)
def rejection_reason(command_type: str) -> str | None:
if command_type == "miner_control":
return PHYSICAL_CONTROL_DISABLED
if command_type not in READ_ONLY_COMMAND_TYPES:
return UNSUPPORTED_COMMAND
return None
def rejection_execution_outcome(row: dict) -> str:
"""A current pending state alone says nothing about earlier delivery.
The repository supplies an explicit pre-transition evidence check while
holding the queue row lock. Missing/ambiguous checks and result submissions
are unknown. This describes queue delivery, never proof of physical outcome.
"""
if (row.get("previous_status") == "pending"
and row.get("no_delivery_evidence") is True
and row.get("delivery_reported", False) is False):
return "not_dispatched"
return "unknown"
+193 -1
View File
@@ -8,6 +8,7 @@ from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
from app.config import settings
@@ -19,6 +20,7 @@ WITH candidates AS (
FROM minerops_agent_command_queue
WHERE agent_id = %s
AND command_status = 'pending'
AND command_type = ANY(%s)
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT %s
@@ -32,6 +34,70 @@ RETURNING queue.id AS command_id, queue.command_type,
queue.request_payload, queue.created_at
"""
# Inspect existing durable history before changing status/completed_at/error.
# A linked non-creation audit is conservatively ambiguous, even if timestamps
# were lost. Legacy results need not agree on Agent ID to prevent a false claim
# of non-delivery. Missing tables/permissions fail the transaction closed.
NO_DELIVERY_EVIDENCE_SQL = """
q.created_at IS NOT NULL
AND q.claimed_at IS NULL AND q.completed_at IS NULL
AND q.result_payload IS NULL AND COALESCE(q.error_message, '') = ''
AND NOT EXISTS (
SELECT 1 FROM minerops_agent_command_results r
WHERE r.command_id = q.id
OR (r.agent_id = q.agent_id AND NULLIF(btrim(r.command_id), '') IS NULL)
)
AND NOT EXISTS (
SELECT 1 FROM audit_logs a
WHERE a.action <> 'miner.control.queued'
AND (a.old_value_json ->> 'command_id' = q.id
OR a.new_value_json ->> 'command_id' = q.id
OR a.old_value_json ->> 'operation_id' = q.id
OR a.new_value_json ->> 'operation_id' = q.id
OR (a.entity_type = 'agent_command' AND a.entity_id::text = q.id))
)
"""
RETIRE_COMMANDS_SQL = f"""
WITH candidates AS (
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q
WHERE agent_id = %s AND command_status IN ('pending', 'claimed')
AND NOT (command_type = ANY(%s))
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1000
)
UPDATE minerops_agent_command_queue AS queue
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
THEN 'physical_control_disabled_stage_a'
ELSE 'unsupported_command_schema_stage_a' END)
FROM candidates WHERE queue.id = candidates.id
RETURNING queue.id, queue.agent_id, queue.command_type,
candidates.command_status AS previous_status, candidates.no_delivery_evidence, queue.error_message
"""
RETIRE_SCHEDULE_COMMANDS_SQL = f"""
WITH candidates AS (
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q
WHERE agent_id = %s AND request_payload ->> 'scheduled_action_id' = %s
AND command_status IN ('pending', 'claimed')
AND NOT (command_type = ANY(%s))
ORDER BY id
FOR UPDATE
)
UPDATE minerops_agent_command_queue AS queue
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
THEN 'physical_control_disabled_stage_a'
ELSE 'unsupported_command_schema_stage_a' END)
FROM candidates WHERE queue.id = candidates.id
RETURNING queue.id, queue.agent_id, queue.command_type,
candidates.command_status AS previous_status, candidates.no_delivery_evidence
"""
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
@@ -54,7 +120,8 @@ class AgentCommandRepository:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
self._retire_unsupported(cur, agent_id)
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
rows = [dict(row) for row in cur.fetchall()]
connection.commit()
return rows
@@ -64,6 +131,105 @@ class AgentCommandRepository:
finally:
connection.close()
@staticmethod
def _audit_rejection(
cur, row: dict, *, actor_user_id: str | None = None,
source: str = "agent_poll",
) -> None:
# Queue IDs can be legacy text, not UUIDs. Preserve them in JSON rather
# than coercing them into audit_logs.entity_id. Never log raw payloads.
cur.execute(
"""INSERT INTO audit_logs
(actor_user_id, action, entity_type, old_value_json, new_value_json)
VALUES (%s, 'agent.command.rejected', 'agent_command', %s, %s)""",
(actor_user_id, Jsonb({"command_id": row["id"], "agent_id": row["agent_id"],
"status": row["previous_status"]}),
Jsonb({"status": "cancelled", "reason": rejection_reason(row["command_type"]),
"source": source,
"execution_outcome": rejection_execution_outcome(row)})),
)
@classmethod
def _retire_unsupported(
cls, cur, agent_id: str, *, source: str = "agent_poll",
actor_user_id: str | None = None,
) -> int:
cur.execute(RETIRE_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES)))
rows = list(cur.fetchall())
for row in rows:
cls._audit_rejection(cur, row, source=source, actor_user_id=actor_user_id)
return len(rows)
def retire_unsupported(self, agent_id: str, *, actor_user_id: str) -> int:
"""Audited cutover batch, including offline Agents; never claim any work.
A zero batch can mean locked rows were skipped. Operators must separately
verify no unsupported live rows remain before declaring a drain complete.
"""
if not actor_user_id:
raise ValueError("Cutover retirement requires an accountable operator actor")
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
count = self._retire_unsupported(cur, agent_id, source="stage_a_cutover",
actor_user_id=actor_user_id)
connection.commit()
return count
except Exception:
connection.rollback()
raise
finally:
connection.close()
def retire_schedule(self, schedule_id: str, *, actor_user_id: str, operation: str) -> int:
"""Cancel live unsupported work and retain schedule history atomically.
The stored schedule determines Agent scope. Delete retires the schedule;
neither operation erases evidence or changes terminal command history.
"""
if not actor_user_id or operation not in {"cancel", "delete"}:
raise ValueError("An authenticated actor and cancel/delete operation are required")
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
# Serialize retries and cancel/delete workers for this schedule.
cur.execute(
"""SELECT id, agent_id, status FROM minerops_scheduled_bulk_actions
WHERE id = %s FOR UPDATE""", (schedule_id,),
)
schedule = cur.fetchone()
if not schedule:
raise AgentCommandNotFound("Scheduled action not found")
cur.execute(RETIRE_SCHEDULE_COMMANDS_SQL,
(schedule["agent_id"], schedule_id, list(READ_ONLY_COMMAND_TYPES)))
rows = list(cur.fetchall())
for row in rows:
self._audit_rejection(cur, row, actor_user_id=actor_user_id,
source=f"schedule_{operation}")
if schedule["status"] in {"pending", "queued", "running"}:
cur.execute(
"""UPDATE minerops_scheduled_bulk_actions
SET status = 'cancelled', updated_at = now(),
last_error = concat_ws(chr(10), NULLIF(last_error, ''),
'physical_control_disabled_stage_a')
WHERE id = %s""", (schedule_id,),
)
cur.execute(
"""INSERT INTO audit_logs
(actor_user_id, action, entity_type, old_value_json, new_value_json)
VALUES (%s, 'scheduled_action.retired', 'scheduled_action', %s, %s)""",
(actor_user_id, Jsonb(dict(schedule)),
Jsonb({"status": "cancelled", "operation": operation,
"reason": "physical_control_disabled_stage_a"})),
)
connection.commit()
return len(rows)
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_command(self, command_id: str) -> dict | None:
connection = self._connection_factory()
try:
@@ -91,6 +257,32 @@ class AgentCommandRepository:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
# Enforce the same boundary on results: an old claimed entry
# cannot be promoted by an old Agent, retry, or direct caller.
cur.execute(
f"""SELECT q.id, q.agent_id, q.command_type, q.command_status AS previous_status,
({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q
WHERE q.id = %s AND q.agent_id = %s FOR UPDATE""",
(command_id, agent_id),
)
existing = cur.fetchone()
if existing and rejection_reason(existing["command_type"]):
reason = rejection_reason(existing["command_type"])
if existing["previous_status"] in {"pending", "claimed"}:
cur.execute(
"""UPDATE minerops_agent_command_queue
SET command_status = 'cancelled',
error_message = concat_ws(chr(10), NULLIF(error_message, ''), %s::text),
completed_at = COALESCE(completed_at, now()) WHERE id = %s""",
(reason, command_id),
)
# A result submission itself reports possible delivery, even
# when earlier durable claim metadata is absent.
existing["delivery_reported"] = True
self._audit_rejection(cur, existing, source="agent_result")
connection.commit()
raise AgentCommandConflict(reason)
cur.execute(
"""
UPDATE minerops_agent_command_queue
+1 -12
View File
@@ -1,6 +1,4 @@
"""HTTP boundary for authenticated Agent command transport."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -18,8 +16,6 @@ from app.agent_commands.models import (
)
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)]
@@ -29,14 +25,7 @@ AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)
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")
del request
return AgentCommandListResponse(commands=service.claim(agent_id))
+10 -32
View File
@@ -2,19 +2,16 @@
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
from app.agent_commands.policy import rejection_reason
class AgentCommandService:
@@ -25,6 +22,7 @@ class AgentCommandService:
return [
AgentCommand.model_validate(row)
for row in self.repository.claim_pending(agent_id)
if rejection_reason(row["command_type"]) is None
]
@staticmethod
@@ -42,6 +40,14 @@ class AgentCommandService:
raise AgentCommandNotFound("Agent command not found")
if command["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
reason = rejection_reason(command["command_type"])
if reason:
# Repository repeats this check under a row lock and records the
# cancellation atomically; never accept an old physical result.
self.repository.finish_command(
request.command_id, agent_id, "cancelled", {}, reason
)
raise AgentCommandConflict(reason)
if command["command_status"] != "claimed":
raise AgentCommandConflict(
f"Agent command is {command['command_status']}, not claimed"
@@ -49,34 +55,6 @@ class AgentCommandService:
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,
+1
View File
@@ -0,0 +1 @@
"""Secure one-time Agent enrollment."""
@@ -0,0 +1,8 @@
"""Dependency wiring for secure Agent enrollment."""
from app.agent_enrollment.repository import AgentEnrollmentRepository
from app.agent_enrollment.service import AgentEnrollmentService
def get_agent_enrollment_service() -> AgentEnrollmentService:
return AgentEnrollmentService(AgentEnrollmentRepository())
@@ -0,0 +1,21 @@
"""Domain exceptions for secure Agent enrollment."""
class AgentEnrollmentError(Exception):
"""Base Agent enrollment error."""
class AgentEnrollmentNotFound(AgentEnrollmentError):
"""The requested Site or Agent Zone does not exist."""
class AgentEnrollmentConflict(AgentEnrollmentError):
"""The requested enrollment conflicts with current Agent routing state."""
class AgentEnrollmentTokenInvalid(AgentEnrollmentError):
"""The supplied bootstrap token cannot be redeemed."""
class AgentEnrollmentCredentialCollision(AgentEnrollmentError):
"""A generated machine credential collided with an existing credential."""
+153
View File
@@ -0,0 +1,153 @@
"""Typed contracts for secure Agent enrollment."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AgentEnrollmentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
site_id: UUID
agent_zone_id: UUID
display_name: str = Field(min_length=1, max_length=120)
@field_validator("display_name")
@classmethod
def normalize_display_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("display_name cannot be blank")
return normalized
class AgentEnrollmentCreated(BaseModel):
id: UUID
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
created_at: datetime
expires_at: datetime
enrollment_token: str
class AgentEnrollmentCreateResponse(BaseModel):
data: AgentEnrollmentCreated
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentEnrollmentSummary(BaseModel):
id: UUID
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
created_by_user_id: UUID | None = None
created_at: datetime
expires_at: datetime
used_at: datetime | None = None
revoked_at: datetime | None = None
target_agent_id: str | None = None
enrolled_agent_id: str | None = None
purpose: Literal["new_agent", "existing_agent_credential_transition"]
status: Literal["waiting", "used", "revoked", "expired"]
class AgentEnrollmentListResponse(BaseModel):
data: list[AgentEnrollmentSummary]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentEnrollRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
token: str = Field(min_length=32, max_length=256)
@field_validator("token")
@classmethod
def normalize_token(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("token cannot be blank")
return normalized
class AgentEnrollCredentials(BaseModel):
agent_id: str
agent_secret: str
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
ip_ranges: list[str] = Field(default_factory=list)
class AgentEnrollResponse(BaseModel):
data: AgentEnrollCredentials
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentWindowsPackageRequest(AgentEnrollRequest):
"""Short-lived token used to construct a Windows bootstrap package."""
class AgentCredentialTransitionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
agent_id: str = Field(min_length=1, max_length=255)
@field_validator("agent_id")
@classmethod
def normalize_agent_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("agent_id cannot be blank")
return normalized
class AgentCredentialTransitionCreated(BaseModel):
id: UUID
target_agent_id: str
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
created_at: datetime
expires_at: datetime
transition_token: str
class AgentCredentialTransitionCreateResponse(BaseModel):
data: AgentCredentialTransitionCreated
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
class AgentCredentialTransitionCredentials(BaseModel):
agent_id: str
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
ip_ranges: list[str] = Field(default_factory=list)
class AgentCredentialTransitionResponse(BaseModel):
data: AgentCredentialTransitionCredentials
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+968
View File
@@ -0,0 +1,968 @@
"""PostgreSQL persistence for secure Agent enrollment."""
import hmac
from collections.abc import Callable
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid,
)
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
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 AgentEnrollmentRepository:
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def create_enrollment(
self,
*,
token_hash: str,
site_id: UUID,
agent_zone_id: UUID,
display_name: str,
actor_user_id: UUID,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT zone.id,
zone.site_id,
zone.name AS agent_zone_name,
zone.is_enabled,
site.name AS site_name,
agent.agent_id AS assigned_agent_id
FROM agent_zones AS zone
JOIN sites AS site ON site.id = zone.site_id
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_zone_id = zone.id
WHERE zone.id = %s
AND zone.site_id = %s
FOR UPDATE OF zone
""",
(agent_zone_id, site_id),
)
zone = cur.fetchone()
if not zone:
raise AgentEnrollmentNotFound(
"Agent Zone was not found for the selected Site"
)
if zone["is_enabled"] is not True:
raise AgentEnrollmentConflict(
"Agent Zone is disabled"
)
if zone["assigned_agent_id"]:
raise AgentEnrollmentConflict(
"Agent Zone already has an assigned Agent"
)
cur.execute(
"SELECT clock_timestamp() AS operation_now"
)
operation_now = cur.fetchone()["operation_now"]
cur.execute(
"""
UPDATE agent_enrollments
SET revoked_at = %s
WHERE agent_zone_id = %s
AND used_at IS NULL
AND revoked_at IS NULL
RETURNING id
""",
(operation_now, agent_zone_id),
)
replaced_ids = [
str(row["id"]) for row in cur.fetchall()
]
cur.execute(
"""
INSERT INTO agent_enrollments (
token_hash,
site_id,
agent_zone_id,
display_name,
created_by_user_id,
created_at,
expires_at
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s + interval '30 minutes'
)
RETURNING
id,
site_id,
agent_zone_id,
display_name,
created_at,
expires_at
""",
(
token_hash,
site_id,
agent_zone_id,
display_name,
actor_user_id,
operation_now,
operation_now,
),
)
enrollment = dict(cur.fetchone())
enrollment["site_name"] = zone["site_name"]
enrollment["agent_zone_name"] = zone["agent_zone_name"]
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
%s,
'agent.enrollment.created',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
actor_user_id,
enrollment["id"],
Jsonb(
{
"site_id": str(site_id),
"agent_zone_id": str(agent_zone_id),
"display_name": display_name,
"expires_at": enrollment[
"expires_at"
].isoformat(),
"replaced_enrollment_ids": replaced_ids,
}
),
operation_now,
),
)
connection.commit()
return enrollment
except UniqueViolation as exc:
connection.rollback()
constraint = exc.diag.constraint_name
if constraint == TOKEN_HASH_CONSTRAINT:
raise AgentEnrollmentCredentialCollision(
"Generated enrollment token collided"
) from exc
if constraint == OPEN_ZONE_CONSTRAINT:
raise AgentEnrollmentConflict(
"An active enrollment already exists for this Agent Zone"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
def create_credential_transition(
self,
*,
token_hash: str,
target_agent_id: str,
actor_user_id: UUID,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
agent.display_name,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
agent.legacy_auth_disabled_at,
site.name AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
retirement.retired_at
FROM minerops_site_agents AS agent
JOIN sites AS site ON site.id = agent.site_id
JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
AND zone.site_id = agent.site_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
WHERE agent.agent_id = %s
FOR UPDATE OF agent, zone
""",
(target_agent_id,),
)
agent = cur.fetchone()
if not agent:
raise AgentEnrollmentNotFound(
"Existing Agent with durable Site and Zone routing was not found"
)
if agent["agent_zone_enabled"] is not True:
raise AgentEnrollmentConflict("Agent Zone is disabled")
if any(
agent[field] is not None
for field in (
"agent_secret_hash",
"credential_created_at",
"credential_revoked_at",
"legacy_auth_disabled_at",
"retired_at",
)
):
raise AgentEnrollmentConflict(
"Existing Agent is not eligible for legacy credential transition"
)
cur.execute("SELECT clock_timestamp() AS operation_now")
operation_now = cur.fetchone()["operation_now"]
cur.execute(
"""
UPDATE agent_enrollments
SET revoked_at = %s
WHERE agent_zone_id = %s
AND used_at IS NULL
AND revoked_at IS NULL
RETURNING id
""",
(operation_now, agent["agent_zone_id"]),
)
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
display_name = (
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
)
cur.execute(
"""
INSERT INTO agent_enrollments (
token_hash,
site_id,
agent_zone_id,
display_name,
created_by_user_id,
created_at,
expires_at,
target_agent_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
RETURNING
id,
site_id,
agent_zone_id,
display_name,
created_at,
expires_at,
target_agent_id
""",
(
token_hash,
agent["site_id"],
agent["agent_zone_id"],
display_name,
actor_user_id,
operation_now,
operation_now,
agent["agent_id"],
),
)
transition = dict(cur.fetchone())
transition["site_name"] = agent["site_name"]
transition["agent_zone_name"] = agent["agent_zone_name"]
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
%s,
'agent.credential_transition.created',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
actor_user_id,
transition["id"],
Jsonb(
{
"target_agent_id": agent["agent_id"],
"site_id": str(agent["site_id"]),
"agent_zone_id": str(agent["agent_zone_id"]),
"expires_at": transition["expires_at"].isoformat(),
"replaced_enrollment_ids": replaced_ids,
}
),
operation_now,
),
)
connection.commit()
return transition
except UniqueViolation as exc:
connection.rollback()
constraint = exc.diag.constraint_name
if constraint == TOKEN_HASH_CONSTRAINT:
raise AgentEnrollmentCredentialCollision(
"Generated transition token collided"
) from exc
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
raise AgentEnrollmentConflict(
"An active credential transition already exists for this Agent"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
def list_enrollments(
self,
*,
site_id: UUID | None = None,
agent_zone_id: UUID | None = None,
limit: int = 100,
) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
enrollment.id,
enrollment.site_id,
site.name AS site_name,
enrollment.agent_zone_id,
zone.name AS agent_zone_name,
enrollment.display_name,
enrollment.created_by_user_id,
enrollment.created_at,
enrollment.expires_at,
enrollment.used_at,
enrollment.revoked_at,
enrollment.target_agent_id,
enrollment.enrolled_agent_id,
CASE
WHEN enrollment.target_agent_id IS NULL
THEN 'new_agent'
ELSE 'existing_agent_credential_transition'
END AS purpose,
CASE
WHEN enrollment.used_at IS NOT NULL
THEN 'used'
WHEN enrollment.revoked_at IS NOT NULL
THEN 'revoked'
WHEN enrollment.expires_at <= now()
THEN 'expired'
ELSE 'waiting'
END AS status
FROM agent_enrollments AS enrollment
JOIN sites AS site
ON site.id = enrollment.site_id
JOIN agent_zones AS zone
ON zone.id = enrollment.agent_zone_id
WHERE (%s::uuid IS NULL OR enrollment.site_id = %s::uuid)
AND (
%s::uuid IS NULL
OR enrollment.agent_zone_id = %s::uuid
)
ORDER BY enrollment.created_at DESC, enrollment.id DESC
LIMIT %s
""",
(
site_id,
site_id,
agent_zone_id,
agent_zone_id,
limit,
),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def redeem_enrollment(
self,
*,
token_hash: str,
agent_id: str,
agent_secret_hash: str,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
site_id,
agent_zone_id,
display_name,
expires_at,
used_at,
revoked_at,
target_agent_id
FROM agent_enrollments
WHERE token_hash = %s
FOR UPDATE
""",
(token_hash,),
)
enrollment = cur.fetchone()
if not enrollment:
raise AgentEnrollmentTokenInvalid(
"Invalid or expired enrollment token"
)
if (
enrollment["used_at"] is not None
or enrollment["revoked_at"] is not None
or enrollment["target_agent_id"] is not None
):
raise AgentEnrollmentTokenInvalid(
"Invalid or expired enrollment token"
)
cur.execute(
"""
SELECT
zone.id,
zone.site_id,
zone.name AS agent_zone_name,
zone.is_enabled,
site.name AS site_name
FROM agent_zones AS zone
JOIN sites AS site ON site.id = zone.site_id
WHERE zone.id = %s
AND zone.site_id = %s
FOR UPDATE OF zone
""",
(
enrollment["agent_zone_id"],
enrollment["site_id"],
),
)
zone = cur.fetchone()
if not zone:
raise AgentEnrollmentConflict(
"Enrollment Agent Zone no longer exists"
)
if zone["is_enabled"] is not True:
raise AgentEnrollmentConflict(
"Agent Zone is disabled"
)
cur.execute(
"""
SELECT agent_id
FROM minerops_site_agents
WHERE agent_zone_id = %s
FOR UPDATE
""",
(enrollment["agent_zone_id"],),
)
existing_agent = cur.fetchone()
if existing_agent:
raise AgentEnrollmentConflict(
"Agent Zone already has an assigned Agent"
)
cur.execute(
"SELECT clock_timestamp() AS operation_now"
)
operation_now = cur.fetchone()["operation_now"]
if enrollment["expires_at"] <= operation_now:
raise AgentEnrollmentTokenInvalid(
"Invalid or expired enrollment token"
)
# Retire the identity before storing its credential. Both
# writes use this transaction, so a failed issuance cannot
# leave a new misleading tombstone. ON CONFLICT preserves an
# earlier retirement if this identity has credential history.
cur.execute(
"""
INSERT INTO agent_legacy_auth_retirements (
agent_id,
retired_at
)
VALUES (%s, %s)
ON CONFLICT (agent_id) DO NOTHING
""",
(agent_id, operation_now),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name,
display_name,
agent_secret_hash,
credential_created_at,
legacy_auth_disabled_at
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s
)
""",
(
agent_id,
enrollment["site_id"],
enrollment["agent_zone_id"],
zone["site_name"],
enrollment["display_name"],
agent_secret_hash,
operation_now,
operation_now,
),
)
cur.execute(
"""
UPDATE agent_enrollments
SET used_at = %s,
enrolled_agent_id = %s
WHERE id = %s
RETURNING used_at
""",
(operation_now, agent_id, enrollment["id"]),
)
used_at = cur.fetchone()["used_at"]
cur.execute(
"""
SELECT network::text AS network
FROM agent_zone_network_ranges
WHERE agent_zone_id = %s
ORDER BY network, id
""",
(enrollment["agent_zone_id"],),
)
networks = [
row["network"] for row in cur.fetchall()
]
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
NULL,
'agent.enrollment.redeemed',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
enrollment["id"],
Jsonb(
{
"agent_id": agent_id,
"site_id": str(enrollment["site_id"]),
"agent_zone_id": str(
enrollment["agent_zone_id"]
),
"display_name": enrollment[
"display_name"
],
"used_at": used_at.isoformat(),
}
),
operation_now,
),
)
result = {
"agent_id": agent_id,
"site_id": enrollment["site_id"],
"site_name": zone["site_name"],
"agent_zone_id": enrollment["agent_zone_id"],
"agent_zone_name": zone["agent_zone_name"],
"display_name": enrollment["display_name"],
"ip_ranges": networks,
}
connection.commit()
return result
except UniqueViolation as exc:
connection.rollback()
constraint = exc.diag.constraint_name
if constraint in {
AGENT_ID_CONSTRAINT,
AGENT_SECRET_HASH_CONSTRAINT,
}:
raise AgentEnrollmentCredentialCollision(
"Generated Agent credential collided"
) from exc
if constraint == AGENT_ZONE_CONSTRAINT:
raise AgentEnrollmentConflict(
"Agent Zone already has an assigned Agent"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
def redeem_credential_transition(
self,
*,
token_hash: str,
agent_secret_hash: str,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
site_id,
agent_zone_id,
display_name,
expires_at,
used_at,
revoked_at,
target_agent_id,
enrolled_agent_id
FROM agent_enrollments
WHERE token_hash = %s
FOR UPDATE
""",
(token_hash,),
)
enrollment = cur.fetchone()
if (
not enrollment
or enrollment["target_agent_id"] is None
or enrollment["revoked_at"] is not None
):
raise AgentEnrollmentTokenInvalid(
"Invalid or expired credential transition token"
)
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
agent.display_name,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
agent.legacy_auth_disabled_at,
site.name AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
retirement.retired_at
FROM minerops_site_agents AS agent
JOIN sites AS site ON site.id = agent.site_id
JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
AND zone.site_id = agent.site_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
WHERE agent.agent_id = %s
FOR UPDATE OF agent, zone
""",
(enrollment["target_agent_id"],),
)
agent = cur.fetchone()
if not agent:
raise AgentEnrollmentConflict(
"Credential transition target no longer exists with durable routing"
)
if (
agent["site_id"] != enrollment["site_id"]
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
):
raise AgentEnrollmentConflict(
"Existing Agent routing changed after transition authorization"
)
if agent["agent_zone_enabled"] is not True:
raise AgentEnrollmentConflict("Agent Zone is disabled")
def transition_result() -> dict:
cur.execute(
"""
SELECT network::text AS network
FROM agent_zone_network_ranges
WHERE agent_zone_id = %s
ORDER BY network, id
""",
(enrollment["agent_zone_id"],),
)
return {
"agent_id": agent["agent_id"],
"site_id": agent["site_id"],
"site_name": agent["site_name"],
"agent_zone_id": agent["agent_zone_id"],
"agent_zone_name": agent["agent_zone_name"],
"display_name": str(
agent["display_name"] or enrollment["display_name"]
),
"ip_ranges": [row["network"] for row in cur.fetchall()],
}
# A retry after an ambiguous network response is successful only
# for the exact locally generated credential that won the first
# transaction. It never rotates or reveals a credential.
if enrollment["used_at"] is not None:
stored_hash = str(agent["agent_secret_hash"] or "")
if (
enrollment["enrolled_agent_id"]
== enrollment["target_agent_id"]
and stored_hash
and hmac.compare_digest(stored_hash, agent_secret_hash)
and agent["credential_created_at"] is not None
and agent["credential_revoked_at"] is None
and agent["legacy_auth_disabled_at"] is not None
and agent["retired_at"] is not None
):
result = transition_result()
connection.commit()
return result
raise AgentEnrollmentTokenInvalid(
"Credential transition token has already been consumed"
)
cur.execute("SELECT clock_timestamp() AS operation_now")
operation_now = cur.fetchone()["operation_now"]
if enrollment["expires_at"] <= operation_now:
raise AgentEnrollmentTokenInvalid(
"Invalid or expired credential transition token"
)
if any(
agent[field] is not None
for field in (
"agent_secret_hash",
"credential_created_at",
"credential_revoked_at",
"legacy_auth_disabled_at",
"retired_at",
)
):
raise AgentEnrollmentConflict(
"Existing Agent is no longer eligible for credential transition"
)
# The durable retirement, Agent credential, enrollment terminal
# state, and audit record commit together. Any later failure
# rolls all of them back.
cur.execute(
"""
INSERT INTO agent_legacy_auth_retirements (
agent_id,
retired_at
)
VALUES (%s, %s)
""",
(agent["agent_id"], operation_now),
)
cur.execute(
"""
UPDATE minerops_site_agents
SET agent_secret_hash = %s,
credential_created_at = %s,
legacy_auth_disabled_at = %s
WHERE agent_id = %s
RETURNING agent_id
""",
(
agent_secret_hash,
operation_now,
operation_now,
agent["agent_id"],
),
)
if not cur.fetchone():
raise AgentEnrollmentConflict(
"Credential transition target disappeared"
)
cur.execute(
"""
UPDATE agent_enrollments
SET used_at = %s,
enrolled_agent_id = target_agent_id
WHERE id = %s
AND used_at IS NULL
AND revoked_at IS NULL
RETURNING used_at, enrolled_agent_id
""",
(operation_now, enrollment["id"]),
)
completed = cur.fetchone()
if not completed:
raise AgentEnrollmentConflict(
"Credential transition state changed concurrently"
)
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
NULL,
'agent.credential_transition.redeemed',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
enrollment["id"],
Jsonb(
{
"agent_id": agent["agent_id"],
"site_id": str(agent["site_id"]),
"agent_zone_id": str(agent["agent_zone_id"]),
"used_at": completed["used_at"].isoformat(),
}
),
operation_now,
),
)
result = transition_result()
connection.commit()
return result
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
raise AgentEnrollmentCredentialCollision(
"Agent credential is already in use"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
+258
View File
@@ -0,0 +1,258 @@
"""HTTP routes for Agent enrollment and bootstrap."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from app.agent_enrollment.dependencies import get_agent_enrollment_service
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid,
)
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionCreateResponse,
AgentCredentialTransitionRedeemRequest,
AgentCredentialTransitionResponse,
AgentEnrollmentCreateRequest,
AgentEnrollmentCreateResponse,
AgentEnrollmentListResponse,
AgentEnrollRequest,
AgentEnrollResponse,
AgentWindowsPackageRequest,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
build_windows_agent_package,
normalize_hub_url,
)
from app.identity import require_permission
router = APIRouter(tags=["agent-enrollment"])
EnrollmentService = Annotated[
AgentEnrollmentService,
Depends(get_agent_enrollment_service),
]
EnrollmentViewer = Annotated[
dict,
Depends(require_permission("agents.view")),
]
EnrollmentManager = Annotated[
dict,
Depends(require_permission("agents.manage")),
]
@router.post(
"/api/agent-enrollments",
response_model=AgentEnrollmentCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_enrollment(
request: AgentEnrollmentCreateRequest,
response: Response,
current_user: EnrollmentManager,
service: EnrollmentService,
) -> AgentEnrollmentCreateResponse:
response.headers["Cache-Control"] = "no-store"
try:
enrollment = service.create_enrollment(
request,
actor_user_id=UUID(str(current_user["id"])),
)
return AgentEnrollmentCreateResponse(data=enrollment)
except AgentEnrollmentNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post(
"/api/agent-credential-transitions",
response_model=AgentCredentialTransitionCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_credential_transition(
request: AgentCredentialTransitionCreateRequest,
response: Response,
current_user: EnrollmentManager,
service: EnrollmentService,
) -> AgentCredentialTransitionCreateResponse:
response.headers["Cache-Control"] = "no-store"
try:
transition = service.create_credential_transition(
request,
actor_user_id=UUID(str(current_user["id"])),
)
return AgentCredentialTransitionCreateResponse(data=transition)
except AgentEnrollmentNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get(
"/api/agent-enrollments",
response_model=AgentEnrollmentListResponse,
)
def list_agent_enrollments(
current_user: EnrollmentViewer,
service: EnrollmentService,
site_id: UUID | None = Query(default=None),
agent_zone_id: UUID | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
) -> AgentEnrollmentListResponse:
del current_user
return AgentEnrollmentListResponse(
data=service.list_enrollments(
site_id=site_id,
agent_zone_id=agent_zone_id,
limit=limit,
)
)
@router.post("/api/agent-enrollments/windows-package")
def download_windows_agent_package(
package_request: AgentWindowsPackageRequest,
http_request: Request,
current_user: EnrollmentManager,
) -> Response:
del current_user
browser_origin = http_request.headers.get("origin", "").strip()
try:
hub_url = normalize_hub_url(browser_origin)
except AgentWindowsPackageError as exc:
raise HTTPException(
status_code=400,
detail=(
"Windows Agent package downloads require "
"a valid browser Origin"
),
) from exc
try:
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
workflow="new_agent",
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except AgentWindowsPackageError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
content=package,
media_type="application/zip",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Content-Disposition": (
'attachment; filename="MinerOpsAgent-Windows.zip"'
),
},
)
@router.post("/api/agent-credential-transitions/windows-package")
def download_windows_agent_credential_transition_package(
package_request: AgentWindowsPackageRequest,
http_request: Request,
current_user: EnrollmentManager,
) -> Response:
del current_user
browser_origin = http_request.headers.get("origin", "").strip()
try:
hub_url = normalize_hub_url(browser_origin)
except AgentWindowsPackageError as exc:
raise HTTPException(
status_code=400,
detail=(
"Windows Agent package downloads require "
"a valid browser Origin"
),
) from exc
try:
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
workflow="existing_agent_credential_transition",
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except AgentWindowsPackageError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
content=package,
media_type="application/zip",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Content-Disposition": (
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
),
},
)
@router.post(
"/api/agent/enroll",
response_model=AgentEnrollResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_agent(
request: AgentEnrollRequest,
response: Response,
service: EnrollmentService,
) -> AgentEnrollResponse:
response.headers["Cache-Control"] = "no-store"
try:
return AgentEnrollResponse(data=service.redeem(request))
except AgentEnrollmentTokenInvalid as exc:
raise HTTPException(
status_code=401,
detail="Invalid or expired enrollment token",
) from exc
except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post(
"/api/agent/credential-transition",
response_model=AgentCredentialTransitionResponse,
)
def transition_agent_credential(
request: AgentCredentialTransitionRedeemRequest,
response: Response,
service: EnrollmentService,
) -> AgentCredentialTransitionResponse:
response.headers["Cache-Control"] = "no-store"
try:
return AgentCredentialTransitionResponse(
data=service.redeem_credential_transition(request)
)
except AgentEnrollmentTokenInvalid as exc:
raise HTTPException(
status_code=401,
detail="Invalid or expired credential transition token",
) from exc
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
+134
View File
@@ -0,0 +1,134 @@
"""Application service for secure one-time Agent enrollment."""
import secrets
from uuid import UUID, uuid4
from app.agent_auth.service import hash_agent_secret
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
)
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
MAX_GENERATION_ATTEMPTS = 3
def generate_enrollment_token() -> str:
return secrets.token_urlsafe(32)
def generate_agent_secret() -> str:
return secrets.token_urlsafe(32)
def generate_agent_id() -> str:
return f"agent-{uuid4().hex}"
class AgentEnrollmentService:
def __init__(self, repository):
self.repository = repository
def create_enrollment(
self,
request: AgentEnrollmentCreateRequest,
*,
actor_user_id: UUID,
) -> dict:
for _ in range(MAX_GENERATION_ATTEMPTS):
token = generate_enrollment_token()
try:
enrollment = self.repository.create_enrollment(
token_hash=hash_agent_secret(token),
site_id=request.site_id,
agent_zone_id=request.agent_zone_id,
display_name=request.display_name,
actor_user_id=actor_user_id,
)
return {
**enrollment,
"enrollment_token": token,
}
except AgentEnrollmentCredentialCollision:
continue
raise AgentEnrollmentConflict(
"Unable to generate a unique enrollment token"
)
def list_enrollments(
self,
*,
site_id: UUID | None = None,
agent_zone_id: UUID | None = None,
limit: int = 100,
) -> list[dict]:
return self.repository.list_enrollments(
site_id=site_id,
agent_zone_id=agent_zone_id,
limit=limit,
)
def create_credential_transition(
self,
request: AgentCredentialTransitionCreateRequest,
*,
actor_user_id: UUID,
) -> dict:
for _ in range(MAX_GENERATION_ATTEMPTS):
token = generate_enrollment_token()
try:
transition = self.repository.create_credential_transition(
token_hash=hash_agent_secret(token),
target_agent_id=request.agent_id,
actor_user_id=actor_user_id,
)
return {
**transition,
"transition_token": token,
}
except AgentEnrollmentCredentialCollision:
continue
raise AgentEnrollmentConflict(
"Unable to generate a unique credential transition token"
)
def redeem(self, request: AgentEnrollRequest) -> dict:
token_hash = hash_agent_secret(request.token)
for _ in range(MAX_GENERATION_ATTEMPTS):
agent_id = generate_agent_id()
agent_secret = generate_agent_secret()
try:
result = self.repository.redeem_enrollment(
token_hash=token_hash,
agent_id=agent_id,
agent_secret_hash=hash_agent_secret(agent_secret),
)
return {
**result,
"agent_secret": agent_secret,
}
except AgentEnrollmentCredentialCollision:
continue
raise AgentEnrollmentConflict(
"Unable to generate unique Agent credentials"
)
def redeem_credential_transition(
self,
request: AgentCredentialTransitionRedeemRequest,
) -> dict:
return self.repository.redeem_credential_transition(
token_hash=hash_agent_secret(request.token),
agent_secret_hash=request.agent_secret_hash,
)
+1
View File
@@ -0,0 +1 @@
"""Downloadable MinerOps Agent deployment packages."""
+181
View File
@@ -0,0 +1,181 @@
"""Build short-lived Windows Agent enrollment packages in memory."""
from __future__ import annotations
import io
import json
import os
import zipfile
from pathlib import Path
from urllib.parse import urlsplit
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
WINDOWS_PACKAGE_FILES = (
"Enroll-MinerOpsAgent.ps1",
"Upgrade-MinerOpsAgentCredential.ps1",
"install.ps1",
"uninstall.ps1",
"MinerOpsAgentService.ps1",
"MinerOpsAgentTray.ps1",
"MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1",
"MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1",
"EnrichOneMiner.ps1",
"MinerOpsControl.ps1",
"VERSION",
"README.md",
)
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
os.environ.get(
"MINEROPS_WINDOWS_AGENT_SOURCE",
"/app/agents/windows",
)
)
class AgentWindowsPackageError(RuntimeError):
"""Base Windows package construction error."""
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
"""Canonical Windows Agent source is unavailable or incomplete."""
def normalize_hub_url(value: str) -> str:
candidate = value.strip()
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must use http or https"
)
if not parsed.netloc:
raise AgentWindowsPackageError(
"MinerOps Hub URL must include a host"
)
if parsed.username or parsed.password:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain credentials"
)
if parsed.query or parsed.fragment:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain query or fragment data"
)
if parsed.path not in {"", "/"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must be an origin without a path"
)
return f"{parsed.scheme}://{parsed.netloc}"
def build_windows_agent_package(
*,
enrollment_token: str,
hub_url: str,
workflow: str = "new_agent",
source_root: Path | None = None,
) -> bytes:
token = enrollment_token.strip()
if not 32 <= len(token) <= 256:
raise AgentWindowsPackageError(
"Enrollment token has an invalid length"
)
normalized_hub_url = normalize_hub_url(hub_url)
if workflow not in {
"new_agent",
"existing_agent_credential_transition",
}:
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
root = (
Path(source_root)
if source_root is not None
else DEFAULT_WINDOWS_AGENT_SOURCE
)
if not root.is_dir():
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is unavailable"
)
missing = [
name
for name in WINDOWS_PACKAGE_FILES
if not (root / name).is_file()
]
if missing:
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is incomplete: "
+ ", ".join(missing)
)
enrollment = {
"hub_url": normalized_hub_url,
"token": token,
"workflow": workflow,
}
if workflow == "new_agent":
command = ".\\Enroll-MinerOpsAgent.ps1"
description = "enrollment"
else:
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
description = "existing-Agent credential transition"
instructions = f"""MinerOps Windows Agent
1. Extract this ZIP on the target Windows machine.
2. Open Windows PowerShell as Administrator.
3. Change to the extracted MinerOpsAgent-Windows folder.
4. Run:
{command}
This package is for the {description} workflow. The credential is short-lived
and single-use. Do not use the other bootstrap script with this package.
Do not post enrollment.json in chat, tickets, screenshots, or logs.
Delete the ZIP and extracted setup folder after successful installation.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for name in WINDOWS_PACKAGE_FILES:
archive.write(
root / name,
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
json.dumps(
enrollment,
indent=2,
sort_keys=True,
)
+ "\n",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
instructions,
)
return buffer.getvalue()
+1
View File
@@ -0,0 +1 @@
"""Read-only Agent registry projection."""
@@ -0,0 +1,8 @@
"""Dependencies for the Agent registry read model."""
from app.agent_registry.repository import AgentRegistryRepository
from app.agent_registry.service import AgentRegistryService
def get_agent_registry_service() -> AgentRegistryService:
return AgentRegistryService(AgentRegistryRepository())
+62
View File
@@ -0,0 +1,62 @@
"""Typed read models for Agent Management."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
CredentialState = Literal["legacy", "active", "revoked", "retired"]
RoutingState = Literal["assigned", "unassigned", "disabled"]
class AgentRegistrySite(BaseModel):
id: UUID
name: str
class AgentRegistryZone(BaseModel):
id: UUID
site_id: UUID
site_name: str
name: str
description: str | None = None
is_enabled: bool
assigned_agent_id: str | None = None
networks: list[str] = Field(default_factory=list)
class AgentRegistryAgent(BaseModel):
agent_id: str
display_name: str | None = None
site_id: UUID | None = None
site_name: str
agent_zone_id: UUID | None = None
agent_zone_name: str | None = None
agent_zone_enabled: bool | None = None
hostname: str | None = None
version: str | None = None
ip_address: str | None = None
last_checkin_at: datetime
credential_state: CredentialState
credential_created_at: datetime | None = None
credential_revoked_at: datetime | None = None
routing_state: RoutingState
class AgentRegistryData(BaseModel):
agents: list[AgentRegistryAgent] = Field(default_factory=list)
sites: list[AgentRegistrySite] = Field(default_factory=list)
zones: list[AgentRegistryZone] = Field(default_factory=list)
class AgentRegistryResponse(BaseModel):
data: AgentRegistryData
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+131
View File
@@ -0,0 +1,131 @@
"""PostgreSQL reads for the durable Agent registry."""
import psycopg
from psycopg.rows import dict_row
from app.config import settings
def default_connection_factory():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
row_factory=dict_row,
)
class AgentRegistryRepository:
def __init__(self, connection_factory=default_connection_factory):
self.connection_factory = connection_factory
def list_registry(self) -> dict[str, list[dict]]:
connection = self.connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT
site.id,
site.name
FROM sites AS site
WHERE EXISTS (
SELECT 1
FROM agent_zones AS zone
WHERE zone.site_id = site.id
)
ORDER BY lower(site.name), site.id
"""
)
sites = cur.fetchall()
cur.execute(
"""
SELECT
zone.id,
zone.site_id,
site.name AS site_name,
zone.name,
zone.description,
zone.is_enabled,
assigned.agent_id AS assigned_agent_id,
COALESCE(
array_agg(
network.network::text
ORDER BY network.network::text
) FILTER (WHERE network.id IS NOT NULL),
ARRAY[]::text[]
) AS networks
FROM agent_zones AS zone
JOIN sites AS site
ON site.id = zone.site_id
LEFT JOIN minerops_site_agents AS assigned
ON assigned.agent_zone_id = zone.id
LEFT JOIN agent_zone_network_ranges AS network
ON network.agent_zone_id = zone.id
GROUP BY
zone.id,
zone.site_id,
site.name,
zone.name,
zone.description,
zone.is_enabled,
assigned.agent_id
ORDER BY
lower(site.name),
lower(zone.name),
zone.id
"""
)
zones = cur.fetchall()
cur.execute(
"""
SELECT
agent.agent_id,
agent.display_name,
agent.site_id,
COALESCE(site.name, agent.site_name) AS site_name,
agent.agent_zone_id,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
agent.hostname,
agent.version,
agent.ip_address,
agent.last_checkin_at,
agent.credential_created_at,
agent.credential_revoked_at,
CASE
WHEN agent.credential_revoked_at IS NOT NULL
THEN 'revoked'
WHEN agent.agent_secret_hash IS NOT NULL
THEN 'active'
WHEN retirement.retired_at IS NOT NULL
THEN 'retired'
ELSE 'legacy'
END AS credential_state
FROM minerops_site_agents AS agent
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
LEFT JOIN sites AS site
ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
ORDER BY
lower(COALESCE(site.name, agent.site_name)),
lower(COALESCE(agent.display_name, agent.agent_id)),
agent.agent_id
"""
)
agents = cur.fetchall()
return {
"sites": sites,
"zones": zones,
"agents": agents,
}
finally:
connection.close()
+48
View File
@@ -0,0 +1,48 @@
"""Read-only Agent Management API."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.agent_registry.dependencies import get_agent_registry_service
from app.agent_registry.models import AgentRegistryResponse
from app.agent_registry.service import AgentRegistryService
from app.identity import require_permission
router = APIRouter(tags=["agents"])
RegistryService = Annotated[
AgentRegistryService,
Depends(get_agent_registry_service),
]
AgentViewer = Annotated[
dict,
Depends(require_permission("agents.view")),
]
@router.get(
"/api/agents",
response_model=AgentRegistryResponse,
)
def list_agents(
response: Response,
current_user: AgentViewer,
service: RegistryService,
) -> AgentRegistryResponse:
del current_user
response.headers["Cache-Control"] = "no-store"
data = service.list_registry()
return AgentRegistryResponse(
data=data,
meta={
"agent_count": len(data.agents),
"site_count": len(data.sites),
"zone_count": len(data.zones),
},
)
+52
View File
@@ -0,0 +1,52 @@
"""Application service for the Agent Management read model."""
from app.agent_registry.models import (
AgentRegistryAgent,
AgentRegistryData,
AgentRegistrySite,
AgentRegistryZone,
)
class AgentRegistryService:
def __init__(self, repository):
self.repository = repository
def list_registry(self) -> AgentRegistryData:
rows = self.repository.list_registry()
sites = [
AgentRegistrySite(**row)
for row in rows["sites"]
]
zones = [
AgentRegistryZone(**row)
for row in rows["zones"]
]
agents: list[AgentRegistryAgent] = []
for row in rows["agents"]:
zone_id = row.get("agent_zone_id")
zone_enabled = row.get("agent_zone_enabled")
if zone_id is None:
routing_state = "unassigned"
elif zone_enabled is False:
routing_state = "disabled"
else:
routing_state = "assigned"
agents.append(
AgentRegistryAgent(
**row,
routing_state=routing_state,
)
)
return AgentRegistryData(
agents=agents,
sites=sites,
zones=zones,
)
+1
View File
@@ -0,0 +1 @@
"""Agent Zone routing domain."""
+8
View File
@@ -0,0 +1,8 @@
"""Agent Zone dependency wiring."""
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
def get_agent_zone_service() -> AgentZoneService:
return AgentZoneService(AgentZoneRepository())
+17
View File
@@ -0,0 +1,17 @@
"""Agent Zone domain exceptions."""
class AgentZoneError(Exception):
"""Base Agent Zone error."""
class AgentZoneNotFound(AgentZoneError):
"""A Site or Agent Zone does not exist."""
class AgentZoneValidationError(AgentZoneError):
"""Agent Zone input or assignment is invalid."""
class AgentZoneConflict(AgentZoneError):
"""An Agent Zone uniqueness or assignment invariant conflicts."""
+114
View File
@@ -0,0 +1,114 @@
"""Typed Agent Zone and check-in contracts."""
from ipaddress import ip_network
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class AgentZoneCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
site_id: UUID
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
networks: list[str] = Field(default_factory=list, max_length=64)
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name cannot be blank")
return normalized
@field_validator("description")
@classmethod
def normalize_description(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
@field_validator("networks")
@classmethod
def normalize_networks(cls, values: list[str]) -> list[str]:
normalized: list[str] = []
for value in values:
try:
network = str(ip_network(value.strip(), strict=True))
except ValueError as exc:
raise ValueError(f"invalid CIDR network: {value}") from exc
if network in normalized:
raise ValueError(f"duplicate CIDR network: {network}")
normalized.append(network)
return normalized
class AgentZoneNetwork(BaseModel):
id: UUID
network: str
class AgentZoneSummary(BaseModel):
id: UUID
site_id: UUID
name: str
description: str | None = None
is_enabled: bool
assigned_agent_id: str | None = None
networks: list[AgentZoneNetwork] = Field(default_factory=list)
class AgentZoneListResponse(BaseModel):
data: list[AgentZoneSummary]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentZoneCreateResponse(BaseModel):
data: AgentZoneSummary
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentCheckinRequest(BaseModel):
model_config = ConfigDict(extra="allow")
agent_id: str | None = Field(default=None, max_length=255)
site_id: UUID | None = None
agent_zone_id: UUID | None = None
site_name: str | None = Field(default=None, max_length=255)
hostname: str | None = Field(default=None, max_length=255)
version: str | None = Field(default=None, max_length=120)
ip_address: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
if self.agent_zone_id is not None and self.site_id is None:
raise ValueError("site_id is required when agent_zone_id is supplied")
return self
class AgentCheckinResponse(BaseModel):
ok: bool = True
hub_mode: bool = True
agent_id: str
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_available: bool
routing_reason: str | None = None
site_name: str
message: str = "Agent check-in received."
class AgentZoneRoutingContext(BaseModel):
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_reason: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
outcomes: dict[UUID, str] = Field(default_factory=dict)
+344
View File
@@ -0,0 +1,344 @@
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
from collections.abc import Callable, Iterable
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
)
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
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 AgentZoneRepository:
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_agent(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def get_zone(self, zone_id: UUID) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled,
created_at, updated_at
FROM agent_zones
WHERE id = %s
""",
(zone_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def list_usable_zones(self, site_id: UUID) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled
FROM agent_zones
WHERE site_id = %s AND is_enabled
ORDER BY lower(name), id
""",
(site_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT zone.id, zone.site_id, zone.name, zone.description,
zone.is_enabled, agent.agent_id AS assigned_agent_id,
range.id AS range_id, range.network::text AS network
FROM agent_zones AS zone
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_zone_id = zone.id
LEFT JOIN agent_zone_network_ranges AS range
ON range.agent_zone_id = zone.id
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
ORDER BY lower(zone.name), zone.id, range.network, range.id
""",
(site_id, site_id),
)
rows = cur.fetchall()
finally:
connection.close()
zones: dict[UUID, dict] = {}
for row in rows:
zone = zones.setdefault(
row["id"],
{
"id": row["id"],
"site_id": row["site_id"],
"name": row["name"],
"description": row["description"],
"is_enabled": row["is_enabled"],
"assigned_agent_id": row["assigned_agent_id"],
"networks": [],
},
)
if row["range_id"] is not None:
zone["networks"].append(
{"id": row["range_id"], "network": row["network"]}
)
return list(zones.values())
def create_zone(
self,
*,
site_id: UUID,
name: str,
description: str | None,
networks: list[str],
) -> UUID:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO agent_zones (site_id, name, description)
VALUES (%s, %s, %s)
RETURNING id
""",
(site_id, name, description),
)
zone_id = cur.fetchone()["id"]
for network in networks:
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, %s::cidr)
""",
(zone_id, network),
)
connection.commit()
return zone_id
except ForeignKeyViolation as exc:
connection.rollback()
raise AgentZoneNotFound("Site not found") from exc
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"An enabled Agent Zone with this name already exists at the Site"
) from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def save_checkin(
self,
*,
agent_id: str,
site_id: UUID | None,
agent_zone_id: UUID | None,
site_name: str,
hostname: str | None,
version: str | None,
ip_address: str | None,
payload: dict[str, Any],
) -> None:
connection = self._connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
ON CONFLICT (agent_id) DO UPDATE SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
(
agent_id,
site_id,
agent_zone_id,
site_name,
hostname,
version,
ip_address,
Jsonb(payload),
),
)
connection.commit()
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"The Agent Zone already has an assigned Agent"
) from exc
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
connection.rollback()
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_agent_assignment(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
zone.is_enabled AS zone_enabled
FROM minerops_site_agents AS agent
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
WHERE agent.agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def get_agent_configuration(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
coalesce(site.name, agent.site_name) AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
coalesce(
array_agg(
network.network::text
ORDER BY network.network::text, network.id
) FILTER (WHERE network.id IS NOT NULL),
ARRAY[]::text[]
) AS networks
FROM minerops_site_agents AS agent
LEFT JOIN sites AS site
ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
LEFT JOIN agent_zone_network_ranges AS network
ON network.agent_zone_id = zone.id
WHERE agent.agent_id = %s
GROUP BY
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
site.name,
agent.site_name,
zone.name,
zone.is_enabled
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
unique_ids = list(dict.fromkeys(miner_ids))
if not unique_ids:
return {}
connection = self._connection_factory()
outcomes: dict[UUID, str] = {}
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT id, site_id FROM agent_zones WHERE id = %s",
(agent_zone_id,),
)
zone = cur.fetchone()
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
for miner_id in unique_ids:
cur.execute(
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
outcomes[miner_id] = "miner_not_found"
elif miner["agent_zone_id"] == agent_zone_id:
outcomes[miner_id] = "preserved"
elif miner["agent_zone_id"] is not None:
outcomes[miner_id] = "agent_zone_conflict"
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
connection.commit()
return outcomes
except Exception:
connection.rollback()
raise
finally:
connection.close()
+75
View File
@@ -0,0 +1,75 @@
"""HTTP mapping for Agent Zones and Agent check-in."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneCreateRequest,
AgentZoneCreateResponse,
AgentZoneListResponse,
)
from app.agent_zones.service import AgentZoneService
from app.identity import require_permission
router = APIRouter(tags=["agent-zones"])
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
def list_agent_zones(
current_user: ZoneViewer,
service: ZoneService,
site_id: UUID | None = Query(default=None),
) -> AgentZoneListResponse:
del current_user
return AgentZoneListResponse(data=service.list_zones(site_id))
@router.post(
"/api/agent-zones",
response_model=AgentZoneCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_zone(
request: AgentZoneCreateRequest,
current_user: ZoneManager,
service: ZoneService,
) -> AgentZoneCreateResponse:
del current_user
try:
return AgentZoneCreateResponse(data=service.create_zone(request))
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
def agent_checkin(
request: AgentCheckinRequest,
agent_id: AgentIdentity,
service: ZoneService,
) -> AgentCheckinResponse:
try:
return service.checkin(agent_id, request)
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentZoneValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+216
View File
@@ -0,0 +1,216 @@
"""Business rules for Agent Zone routing and transitional check-in."""
from typing import Any
from uuid import UUID
from app.agent_zones.exceptions import (
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
MinerZoneAssociationResult,
)
from app.agent_zones.repository import AgentZoneRepository
SENSITIVE_KEYS = {
"agent_secret",
"api3_account",
"api3_password",
"password",
"salt",
"token",
}
def _sanitize(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _sanitize(item)
for key, item in value.items()
if key.lower() not in SENSITIVE_KEYS
and not any(
marker in key.lower()
for marker in ("secret", "password", "token", "salt")
)
}
if isinstance(value, list):
return [_sanitize(item) for item in value]
return value
class AgentZoneService:
@staticmethod
def validate_agent_identity(
authenticated_agent_id: str, claimed_agent_id: str | None
) -> None:
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
raise AgentZoneValidationError(
"Payload agent_id does not match authenticated Agent identity"
)
def __init__(self, repository: AgentZoneRepository):
self.repository = repository
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
return [
AgentZoneSummary.model_validate(row)
for row in self.repository.list_zones(site_id)
]
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
zone_id = self.repository.create_zone(
site_id=request.site_id,
name=request.name,
description=request.description,
networks=request.networks,
)
for zone in self.list_zones(request.site_id):
if zone.id == zone_id:
return zone
raise AgentZoneNotFound("Created Agent Zone was not found")
def checkin(
self, authenticated_agent_id: str, request: AgentCheckinRequest
) -> AgentCheckinResponse:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
final_zone: UUID | None = None
reason: str | None = None
established_zone = None
established_zone_id = (existing or {}).get("agent_zone_id")
if established_zone_id is not None:
established_zone = self.repository.get_zone(established_zone_id)
if not established_zone:
raise AgentZoneValidationError(
"Existing Agent Zone assignment is invalid"
)
if (
requested_site is not None
and established_zone["site_id"] != requested_site
):
raise AgentZoneValidationError(
"Agent cannot change Sites while assigned to an Agent Zone"
)
if requested_zone is not None:
zone = self.repository.get_zone(requested_zone)
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
if zone["site_id"] != requested_site:
raise AgentZoneValidationError(
"Agent Zone does not belong to the supplied Site"
)
if not zone["is_enabled"]:
raise AgentZoneValidationError("Agent Zone is disabled")
if established_zone_id is not None and established_zone_id != requested_zone:
raise AgentZoneValidationError(
"Agent is already assigned to a different Agent Zone"
)
final_zone = requested_zone
elif established_zone is not None:
final_site = established_zone["site_id"]
final_zone = established_zone["id"]
if not established_zone["is_enabled"]:
reason = "agent_zone_disabled"
elif final_site is not None:
usable = self.repository.list_usable_zones(final_site)
if len(usable) == 1:
final_zone = usable[0]["id"]
elif not usable:
reason = "agent_zone_unavailable"
else:
reason = "agent_zone_ambiguous"
else:
reason = "site_unassigned"
site_name = (request.site_name or "").strip()
if not site_name:
site_name = str((existing or {}).get("site_name") or "Unassigned")
payload = _sanitize(request.model_dump(mode="json"))
self.repository.save_checkin(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
site_name=site_name,
hostname=(request.hostname or "").strip() or None,
version=(request.version or "").strip() or None,
ip_address=(request.ip_address or "").strip() or None,
payload=payload,
)
routing_available = final_zone is not None and reason is None
return AgentCheckinResponse(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
routing_available=routing_available,
routing_reason=reason,
site_name=site_name,
)
def routing_context(
self, agent_id: str, recorded_zone_id: UUID | None = None
) -> AgentZoneRoutingContext:
assignment = self.repository.get_agent_assignment(agent_id)
if not assignment or assignment.get("agent_zone_id") is None:
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
zone_id = assignment["agent_zone_id"]
if recorded_zone_id is not None and recorded_zone_id != zone_id:
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
if assignment.get("zone_enabled") is not True:
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"),
agent_zone_id=zone_id,
routing_reason="agent_zone_disabled",
)
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"), agent_zone_id=zone_id
)
def configuration(self, agent_id: str) -> dict[str, Any] | None:
row = self.repository.get_agent_configuration(agent_id)
if not row:
return None
return {
"agent_id": row["agent_id"],
"site_id": row["site_id"],
"site_name": row["site_name"],
"agent_zone_id": row["agent_zone_id"],
"agent_zone_name": row["agent_zone_name"],
"ip_ranges": [
{
"name": row["agent_zone_name"],
"cidr": network,
"enabled": row["agent_zone_enabled"] is True,
"api_port": 4028,
"api3_port": 4433,
}
for network in row.get("networks") or []
],
}
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
routing = self.routing_context(agent_id)
if routing.routing_reason:
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
routing_reason=routing.routing_reason,
)
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
outcomes=self.repository.assign_miners_to_zone(
miner_ids, routing.agent_zone_id
),
)
@@ -0,0 +1,277 @@
"""Semantic HTML for the Internal Alpha Agent Management page."""
def agent_management_content(can_manage_agents: bool) -> str:
management = """
<section class="panel agent-enrollment-panel"
aria-labelledby="agentEnrollmentCreateTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Agent deployment</p>
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
<p class="panel-copy">
Create a short-lived one-time enrollment for an Agent Zone.
Windows is the currently supported deployment platform.
</p>
</div>
</div>
<form id="agentCreateForm" class="agent-create-form">
<div class="field">
<label for="agentSite">Site</label>
<select id="agentSite" required>
<option value="">Choose a site</option>
</select>
</div>
<div class="field">
<label for="agentZone">Agent Zone</label>
<select id="agentZone" required disabled>
<option value="">Choose a site first</option>
</select>
</div>
<div class="field">
<label for="agentDisplayName">Agent name</label>
<input id="agentDisplayName"
type="text"
maxlength="120"
autocomplete="off"
placeholder="Example: Plexus Unit 4 Agent"
required>
</div>
<div class="field">
<label for="agentPlatform">Platform</label>
<select id="agentPlatform">
<option value="windows">Windows</option>
<option value="linux-x64" disabled>Linux x64 — coming later</option>
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
</select>
</div>
<div class="agent-form-actions">
<button id="agentCreateSubmit"
class="command-button"
type="submit">
Create Windows enrollment
</button>
</div>
<p id="agentCreateStatus"
class="freshness"
aria-live="polite"></p>
</form>
<section id="agentEnrollmentResult"
class="agent-enrollment-result"
aria-labelledby="agentEnrollmentResultTitle"
hidden>
<div>
<p class="eyebrow">One-time credential</p>
<h3 id="agentEnrollmentResultTitle">
Enrollment created
</h3>
</div>
<p id="agentEnrollmentResultCopy">
This token is displayed only for this enrollment step.
Keep it private. MinerOps does not store the plaintext token.
</p>
<div class="agent-token-box">
<code id="agentEnrollmentToken"></code>
</div>
<p id="agentEnrollmentExpires" class="freshness"></p>
<div class="agent-form-actions">
<button id="agentEnrollmentDownload"
class="command-button"
type="button">
Download Windows package
</button>
<button id="agentEnrollmentCopy"
class="quiet-button"
type="button">
Copy token
</button>
<button id="agentEnrollmentDismiss"
class="quiet-button"
type="button">
Clear token from screen
</button>
</div>
<p id="agentEnrollmentCopyStatus"
class="freshness"
aria-live="polite"></p>
<p class="panel-copy">
The Windows package contains the short-lived enrollment
credential and generic MinerOps Agent runtime. Transfer it
only to the intended Agent computer and delete the ZIP after
successful installation.
</p>
</section>
</section>
""" if can_manage_agents else """
<section class="panel">
<div class="panel-head">
<div>
<p class="eyebrow">Agent deployment</p>
<h2>Agent enrollment</h2>
</div>
</div>
<p class="permission-note">
Your account can view Agent status but cannot create Agent
enrollments.
</p>
</section>
"""
return f"""
<div id="agentManagementLoading" class="state-message" role="status">
<strong>Loading Agents</strong>
<span>Requesting Agent registry and enrollment information.</span>
</div>
<div id="agentManagementError"
class="state-message"
data-kind="error"
role="alert"
hidden>
<strong>Agent Management unavailable</strong>
<span id="agentManagementErrorText">
Agent information could not be loaded.
</span>
<button id="agentManagementRetry"
class="command-button"
type="button">
Retry
</button>
</div>
<div id="agentManagementContent"
data-can-manage-agents="{str(can_manage_agents).lower()}"
hidden>
<section class="agent-summary-grid" aria-label="Agent Management summary">
<div class="panel agent-summary-card">
<span>Registered Agents</span>
<strong id="agentCount">-</strong>
</div>
<div class="panel agent-summary-card">
<span>Agent Zones</span>
<strong id="agentZoneCount">-</strong>
</div>
<div class="panel agent-summary-card">
<span>Waiting enrollments</span>
<strong id="agentWaitingEnrollmentCount">-</strong>
</div>
</section>
<div class="agent-management-columns">
<section class="panel" aria-labelledby="registeredAgentsTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Durable identities</p>
<h2 id="registeredAgentsTitle">Agents</h2>
<p class="panel-copy">
Registered Agent identities and their latest factual
check-in information.
</p>
</div>
<button id="agentManagementRefresh"
class="quiet-button"
type="button">
Refresh
</button>
</div>
<div class="table-wrap">
<table class="agent-table">
<thead>
<tr>
<th>Agent</th>
<th>Site / Zone</th>
<th>Host</th>
<th>Version</th>
<th>Last check-in</th>
<th>Credential</th>
<th>Routing</th>
<th>Action</th>
</tr>
</thead>
<tbody id="agentRows"></tbody>
</table>
</div>
<p id="agentEmpty" class="panel-copy" hidden>
No registered Agents.
</p>
</section>
<section class="panel" aria-labelledby="agentZonesTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Network routing boundaries</p>
<h2 id="agentZonesTitle">Agent Zones</h2>
</div>
</div>
<div class="table-wrap">
<table class="agent-zone-table">
<thead>
<tr>
<th>Site</th>
<th>Zone</th>
<th>Networks</th>
<th>Assigned Agent</th>
<th>State</th>
</tr>
</thead>
<tbody id="agentZoneRows"></tbody>
</table>
</div>
<p id="agentZoneEmpty" class="panel-copy" hidden>
No Agent Zones.
</p>
</section>
</div>
{management}
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Bootstrap history</p>
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
<p class="panel-copy">
Metadata only. Plaintext enrollment tokens are never
returned by this history view.
</p>
</div>
</div>
<div class="table-wrap">
<table class="agent-enrollment-table">
<thead>
<tr>
<th>Agent name</th>
<th>Site / Zone</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Enrolled Agent</th>
</tr>
</thead>
<tbody id="agentEnrollmentRows"></tbody>
</table>
</div>
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
No Agent enrollment records.
</p>
</section>
</div>
<script src="/assets/minerops-agent-management.js" defer></script>
"""
+22
View File
@@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
def render_page(view: str, current_user: dict, **context) -> str:
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
headers={"Cache-Control": "no-store"},
)
@router.get(
"/assets/minerops-agent-management.js",
include_in_schema=False,
)
def agent_management_script() -> Response:
return Response(
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get(
"/agent/management",
response_class=HTMLResponse,
include_in_schema=False,
)
def agent_management_page(current_user: AgentUser) -> str:
return render_page("agent_management", current_user)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard_page(current_user: DashboardUser) -> str:
return render_page("dashboard", current_user)
@@ -0,0 +1,567 @@
(function () {
"use strict";
if (document.body.dataset.view !== "agent_management") return;
const ids = [
"agentManagementLoading",
"agentManagementError",
"agentManagementErrorText",
"agentManagementRetry",
"agentManagementContent",
"agentManagementRefresh",
"agentCount",
"agentZoneCount",
"agentWaitingEnrollmentCount",
"agentRows",
"agentEmpty",
"agentZoneRows",
"agentZoneEmpty",
"agentEnrollmentRows",
"agentEnrollmentEmpty",
"agentCreateForm",
"agentSite",
"agentZone",
"agentDisplayName",
"agentPlatform",
"agentCreateSubmit",
"agentCreateStatus",
"agentEnrollmentResult",
"agentEnrollmentResultTitle",
"agentEnrollmentResultCopy",
"agentEnrollmentToken",
"agentEnrollmentExpires",
"agentEnrollmentDownload",
"agentEnrollmentCopy",
"agentEnrollmentDismiss",
"agentEnrollmentCopyStatus"
];
const el = {};
ids.forEach(function (id) {
el[id] = document.getElementById(id);
});
let registry = {
agents: [],
sites: [],
zones: []
};
let enrollments = [];
let packageEndpoint = "/api/agent-enrollments/windows-package";
let packageFilename = "MinerOpsAgent-Windows.zip";
function text(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback || "Unavailable";
}
return String(value);
}
function dateTime(value) {
if (!value) return "Never";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
return parsed.toLocaleString();
}
function words(value) {
return text(value, "Unknown")
.replaceAll("_", " ")
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function cell(value, className) {
const node = document.createElement("td");
if (className) node.className = className;
node.textContent = value;
return node;
}
function setState(name) {
el.agentManagementLoading.hidden = name !== "loading";
el.agentManagementError.hidden = name !== "error";
el.agentManagementContent.hidden = name !== "ready";
}
async function api(path, options) {
const response = await fetch(path, Object.assign({
credentials: "same-origin",
cache: "no-store",
headers: {
"Accept": "application/json"
}
}, options || {}));
const payload = await response.json().catch(function () {
return {};
});
if (!response.ok) {
const detail = payload && payload.detail
? String(payload.detail)
: "Request failed with status " + response.status + ".";
throw new Error(detail);
}
return payload;
}
function renderAgents() {
el.agentRows.replaceChildren();
registry.agents.forEach(function (agent) {
const row = document.createElement("tr");
const identity = document.createElement("td");
const name = document.createElement("strong");
const id = document.createElement("span");
name.textContent = text(agent.display_name, "Unnamed Agent");
id.className = "secondary";
id.textContent = agent.agent_id;
identity.append(name, id);
const location = [
text(agent.site_name, "Unknown site"),
text(agent.agent_zone_name, "No Agent Zone")
].join(" / ");
const host = [
text(agent.hostname, "Unknown host"),
text(agent.ip_address, "No IP")
].join(" / ");
row.append(
identity,
cell(location),
cell(host),
cell(text(agent.version, "Unknown")),
cell(dateTime(agent.last_checkin_at)),
cell(words(agent.credential_state)),
cell(words(agent.routing_state))
);
const action = document.createElement("td");
const canManage =
el.agentManagementContent.dataset.canManageAgents === "true";
if (
canManage &&
agent.credential_state === "legacy" &&
agent.routing_state === "assigned"
) {
const button = document.createElement("button");
button.type = "button";
button.className = "quiet-button";
button.textContent = "Upgrade credential";
button.addEventListener("click", function () {
createCredentialTransition(agent);
});
action.appendChild(button);
} else {
action.textContent = "—";
}
row.appendChild(action);
el.agentRows.appendChild(row);
});
el.agentEmpty.hidden = registry.agents.length !== 0;
}
function renderZones() {
el.agentZoneRows.replaceChildren();
registry.zones.forEach(function (zone) {
const row = document.createElement("tr");
const networks = Array.isArray(zone.networks) && zone.networks.length
? zone.networks.join(", ")
: "No networks recorded";
row.append(
cell(zone.site_name),
cell(zone.name),
cell(networks),
cell(text(zone.assigned_agent_id, "Unassigned")),
cell(zone.is_enabled ? "Enabled" : "Disabled")
);
el.agentZoneRows.appendChild(row);
});
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
}
function renderEnrollments() {
el.agentEnrollmentRows.replaceChildren();
enrollments.forEach(function (enrollment) {
const row = document.createElement("tr");
row.append(
cell(enrollment.display_name),
cell(
enrollment.site_name + " / " +
enrollment.agent_zone_name
),
cell(words(enrollment.status)),
cell(dateTime(enrollment.created_at)),
cell(dateTime(enrollment.expires_at)),
cell(text(
enrollment.enrolled_agent_id || enrollment.target_agent_id,
"Not enrolled"
))
);
el.agentEnrollmentRows.appendChild(row);
});
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
}
function populateSites() {
if (!el.agentSite) return;
const selected = el.agentSite.value;
el.agentSite.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Choose a site";
el.agentSite.appendChild(placeholder);
registry.sites.forEach(function (site) {
const option = document.createElement("option");
option.value = site.id;
option.textContent = site.name;
el.agentSite.appendChild(option);
});
if ([...el.agentSite.options].some(function (option) {
return option.value === selected;
})) {
el.agentSite.value = selected;
}
populateZones();
}
function populateZones() {
if (!el.agentZone || !el.agentSite) return;
const siteId = el.agentSite.value;
const selected = el.agentZone.value;
el.agentZone.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
if (!siteId) {
placeholder.textContent = "Choose a site first";
el.agentZone.disabled = true;
el.agentZone.appendChild(placeholder);
return;
}
placeholder.textContent = "Choose an Agent Zone";
el.agentZone.appendChild(placeholder);
const available = registry.zones.filter(function (zone) {
return (
zone.site_id === siteId &&
zone.is_enabled === true &&
!zone.assigned_agent_id
);
});
available.forEach(function (zone) {
const option = document.createElement("option");
option.value = zone.id;
option.textContent = zone.name;
el.agentZone.appendChild(option);
});
el.agentZone.disabled = available.length === 0;
if ([...el.agentZone.options].some(function (option) {
return option.value === selected;
})) {
el.agentZone.value = selected;
}
}
function renderSummary() {
el.agentCount.textContent = String(registry.agents.length);
el.agentZoneCount.textContent = String(registry.zones.length);
const waiting = enrollments.filter(function (item) {
return item.status === "waiting";
}).length;
el.agentWaitingEnrollmentCount.textContent = String(waiting);
}
function render() {
renderSummary();
renderAgents();
renderZones();
renderEnrollments();
populateSites();
}
async function refreshData(showLoading) {
if (showLoading !== false) setState("loading");
try {
const results = await Promise.all([
api("/api/agents"),
api("/api/agent-enrollments")
]);
registry = results[0] && results[0].data
? results[0].data
: { agents: [], sites: [], zones: [] };
enrollments = results[1] && Array.isArray(results[1].data)
? results[1].data
: [];
render();
setState("ready");
} catch (error) {
el.agentManagementErrorText.textContent =
error.message || "Agent information could not be loaded.";
setState("error");
}
}
async function createEnrollment(event) {
event.preventDefault();
if (!el.agentCreateForm) return;
el.agentCreateSubmit.disabled = true;
el.agentCreateStatus.textContent = "Creating enrollment...";
try {
const payload = await api("/api/agent-enrollments", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({
site_id: el.agentSite.value,
agent_zone_id: el.agentZone.value,
display_name: el.agentDisplayName.value.trim()
})
});
const enrollment = payload.data;
showOneTimeToken({
token: enrollment.enrollment_token,
expiresAt: enrollment.expires_at,
title: "Enrollment created",
copy: "This token creates a new Agent identity. Keep it private. " +
"MinerOps does not store the plaintext token.",
endpoint: "/api/agent-enrollments/windows-package",
filename: "MinerOpsAgent-Windows.zip"
});
el.agentCreateStatus.textContent = "Enrollment created.";
el.agentDisplayName.value = "";
await refreshData(false);
} catch (error) {
el.agentCreateStatus.textContent =
error.message || "Enrollment could not be created.";
} finally {
el.agentCreateSubmit.disabled = false;
}
}
function showOneTimeToken(options) {
el.agentEnrollmentToken.textContent = options.token;
el.agentEnrollmentExpires.textContent =
"Expires " + dateTime(options.expiresAt);
el.agentEnrollmentResultTitle.textContent = options.title;
el.agentEnrollmentResultCopy.textContent = options.copy;
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = false;
packageEndpoint = options.endpoint;
packageFilename = options.filename;
}
async function createCredentialTransition(agent) {
const confirmed = window.confirm(
"Create a one-time credential upgrade for " + agent.agent_id +
"? Its Agent identity, Site, and Zone will remain unchanged."
);
if (!confirmed) return;
try {
const payload = await api("/api/agent-credential-transitions", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({agent_id: agent.agent_id})
});
const transition = payload.data;
showOneTimeToken({
token: transition.transition_token,
expiresAt: transition.expires_at,
title: "Existing Agent credential upgrade created",
copy: "This token is bound to the existing Agent identity " +
transition.target_agent_id + ". It cannot enroll a new " +
"Agent or transfer its Zone.",
endpoint: "/api/agent-credential-transitions/windows-package",
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
});
el.agentCreateStatus.textContent =
"Credential upgrade created for " + transition.target_agent_id + ".";
await refreshData(false);
} catch (error) {
el.agentCreateStatus.textContent =
error.message || "Credential upgrade could not be created.";
}
}
async function downloadWindowsPackage() {
const token = el.agentEnrollmentToken.textContent;
if (!token) {
el.agentEnrollmentCopyStatus.textContent =
"No enrollment token is available to download.";
return;
}
el.agentEnrollmentDownload.disabled = true;
el.agentEnrollmentCopyStatus.textContent =
"Building Windows Agent package...";
try {
const response = await fetch(
packageEndpoint,
{
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
"Accept": "application/zip",
"Content-Type": "application/json"
},
body: JSON.stringify({
token: token
})
}
);
if (!response.ok) {
const payload = await response.json().catch(function () {
return {};
});
throw new Error(
payload && payload.detail
? String(payload.detail)
: "Package request failed with status " +
response.status + "."
);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = packageFilename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
dismissToken();
el.agentCreateStatus.textContent =
"Windows Agent package downloaded. " +
"The on-screen token has been cleared.";
} catch (error) {
el.agentEnrollmentCopyStatus.textContent =
error.message || "Windows package could not be downloaded.";
} finally {
el.agentEnrollmentDownload.disabled = false;
}
}
async function copyToken() {
const token = el.agentEnrollmentToken.textContent;
if (!token) return;
try {
await navigator.clipboard.writeText(token);
el.agentEnrollmentCopyStatus.textContent =
"Enrollment token copied.";
} catch (error) {
el.agentEnrollmentCopyStatus.textContent =
"Copy was unavailable. Select and copy the token manually.";
}
}
function dismissToken() {
el.agentEnrollmentToken.textContent = "";
el.agentEnrollmentExpires.textContent = "";
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = true;
}
el.agentManagementRetry.addEventListener("click", function () {
refreshData(true);
});
if (el.agentManagementRefresh) {
el.agentManagementRefresh.addEventListener("click", function () {
refreshData(true);
});
}
if (el.agentSite) {
el.agentSite.addEventListener("change", populateZones);
}
if (el.agentCreateForm) {
el.agentCreateForm.addEventListener("submit", createEnrollment);
}
if (el.agentEnrollmentDownload) {
el.agentEnrollmentDownload.addEventListener(
"click",
downloadWindowsPackage
);
}
if (el.agentEnrollmentCopy) {
el.agentEnrollmentCopy.addEventListener("click", copyToken);
}
if (el.agentEnrollmentDismiss) {
el.agentEnrollmentDismiss.addEventListener(
"click",
dismissToken
);
}
refreshData(true);
}());
+83
View File
@@ -1771,3 +1771,86 @@ button.map-position:hover {
flex-direction: column;
}
}
/* Agent Management v1 */
.agent-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.agent-summary-card {
display: grid;
gap: 6px;
}
.agent-summary-card span {
font-size: 0.85rem;
}
.agent-summary-card strong {
font-size: 2rem;
}
.agent-management-columns {
display: grid;
gap: 20px;
margin-bottom: 20px;
}
.agent-table td:first-child {
display: grid;
gap: 4px;
}
.agent-create-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.agent-create-form .agent-form-actions,
.agent-create-form #agentCreateStatus {
grid-column: 1 / -1;
}
.agent-form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.agent-enrollment-result {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.agent-token-box {
overflow-wrap: anywhere;
padding: 14px;
margin: 12px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-muted);
}
.agent-token-box code {
user-select: all;
}
@media (max-width: 760px) {
.agent-summary-grid,
.agent-create-form {
grid-template-columns: 1fr;
}
}
/* Agent Management action visibility */
.agent-management-columns .quiet-button,
.agent-enrollment-panel .quiet-button {
color: var(--ink);
}
+10 -1
View File
@@ -6,6 +6,7 @@ from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
from app.frontend.monitoring_trends_view import monitoring_trends_content
from app.frontend.system_health_view import system_health_content
from app.frontend.agent_management_view import agent_management_content
NAVIGATION = (
@@ -15,6 +16,7 @@ NAVIGATION = (
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"),
("system_health", "System Health", "/system-health", "system_health.view"),
("agent_management", "Agents", "/agent/management", "agents.view"),
)
PAGE_DETAILS = {
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
"System Health",
"Inspect application services, operational data, and maintenance facts.",
),
"agent_management": (
"Administration / Agents",
"Agent Management",
"Inspect Agent identities, routing boundaries, and secure enrollment.",
),
}
@@ -462,7 +469,9 @@ def render_app_page(
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = (
system_health_content()
agent_management_content(has_permission("agents.manage"))
if view == "agent_management"
else system_health_content()
if view == "system_health"
else miner_detail_content()
if view == "miner_detail"
+30
View File
@@ -699,12 +699,17 @@ def required_page_permission(path: str) -> str:
def machine_agent_request(path: str) -> bool:
exact = {
"/api/agent/enroll",
"/api/agent/credential-transition",
"/api/agent/checkin",
"/api/agent/config",
"/api/agent/known-miners",
"/api/agent/miner-snapshots",
"/api/agent/pool-snapshots",
"/api/agent/commands/pending",
"/api/agent/commands/result",
"/api/agent/commands/pending-disabled-old",
"/api/agent/commands/result-disabled-old",
"/api/agent/discoveries",
"/api/agent/miner-enrichment",
"/api/agent/miner-enrichment-v2",
@@ -757,6 +762,19 @@ def component_request(path: str) -> bool:
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if method == "POST" and path == "/api/dashboard/m30-command-test-lab/run":
return "miners.control"
if method == "POST" and path == "/api/tools/whatsminer-read-test":
return "miners.control"
if method == "POST" and path in {
"/api/dashboard/scheduled-bulk-actions",
"/api/dashboard/scheduled-bulk-actions/process-due",
}:
return "miners.control"
if (method == "POST" and len(parts) == 6
and parts[:4] == ["", "api", "dashboard", "scheduled-bulk-actions"]
and parts[5] in {"cancel", "delete"}):
return "miners.control"
if (
len(parts) >= 5
and parts[:3] == ["", "api", "miners"]
@@ -777,6 +795,18 @@ def required_api_permission(method: str, path: str) -> str | None:
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if path == "/api/agents":
return "agents.view"
if path == "/api/agent-zones":
return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments":
return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments/windows-package":
return "agents.manage"
if path == "/api/agent-credential-transitions":
return "agents.manage"
if path == "/api/agent-credential-transitions/windows-package":
return "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
+477 -1906
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -40,15 +40,20 @@ class MinerControlRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
SELECT miners.id, miners.site_id, miners.agent_zone_id,
zones.id IS NOT NULL AS agent_zone_exists,
zones.is_enabled AS agent_zone_enabled,
host(miners.current_ip) AS current_ip,
miners.status AS runtime_state,
miners.configured_performance_mode,
miners.api3_supported, miners.api3_api_switch,
agents.agent_id, agents.last_checkin_at,
agents.version AS agent_version, agents.last_payload
FROM miners
LEFT JOIN agent_zones AS zones
ON zones.id = miners.agent_zone_id
LEFT JOIN minerops_site_agents AS agents
ON agents.site_id = miners.site_id
ON agents.agent_zone_id = miners.agent_zone_id
WHERE miners.id = %s
""",
(miner_id,),
+5 -72
View File
@@ -1,7 +1,6 @@
"""Business rules for typed, Agent-routed miner controls."""
from typing import Any
from uuid import UUID, uuid4
from uuid import UUID
from app.miner_control.exceptions import (
ControlledMinerNotFound,
@@ -16,6 +15,7 @@ from app.miner_control.models import (
MinerControlProjection,
)
from app.miner_control.repository import MinerControlRepository
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
KNOWN_MODES = {"low", "normal", "high"}
@@ -29,29 +29,8 @@ class MinerControlService:
@staticmethod
def _capability(context: dict) -> ControlCapability:
if not context.get("site_id"):
return ControlCapability(state="unavailable", reason="site_unassigned")
if not context.get("agent_id"):
return ControlCapability(state="unavailable", reason="agent_unassigned")
if not context.get("current_ip"):
return ControlCapability(state="unavailable", reason="current_ip_missing")
if context.get("api3_supported") is False:
return ControlCapability(state="unavailable", reason="api3_unsupported")
if context.get("api3_supported") is not True:
return ControlCapability(state="unknown", reason="api3_support_unknown")
payload = context.get("last_payload") or {}
if payload.get("control_available") is False:
reason = payload.get("control_unavailable_reason")
return ControlCapability(
state="unavailable",
reason=reason if reason == "credentials_missing" else "agent_unavailable",
)
if payload.get("control_available") is not True:
return ControlCapability(
state="unknown", reason="agent_capability_unknown"
)
return ControlCapability(state="supported")
# Credentials and Zone routing are not physical identity authorization.
return ControlCapability(state="unavailable", reason=PHYSICAL_CONTROL_DISABLED)
@staticmethod
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
@@ -116,50 +95,4 @@ class MinerControlService:
) -> MinerControlAccepted:
if not actor_user_id:
raise MinerControlActorRequired("Authenticated actor UUID is required")
context = self.repository.get_control_context(miner_id)
if not context:
raise ControlledMinerNotFound("Miner not found")
capability = self._capability(context)
if capability.state != "supported":
raise MinerControlUnavailable(
capability.reason or "control_capability_not_supported"
)
runtime = str(context.get("runtime_state") or "").strip().lower()
if action == "suspend" and runtime not in RUNNING_STATES:
raise MinerControlUnavailable("miner_not_factually_running")
if action == "resume" and runtime not in STOPPED_STATES:
raise MinerControlUnavailable("miner_not_factually_stopped")
if action == "performance_mode" and requested_value not in KNOWN_MODES:
raise MinerControlUnavailable("invalid_performance_mode")
operation_id = str(uuid4())
payload: dict[str, Any] = {
"schema_version": 1,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
"ip_address": context["current_ip"],
}
operation = self.repository.enqueue_with_audit(
operation_id=operation_id,
miner_id=miner_id,
agent_id=context["agent_id"],
actor_user_id=actor_user_id,
actor_username=actor_username,
request_payload=payload,
audit_payload={
"operation_id": operation_id,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
},
)
return MinerControlAccepted(
operation_id=operation["operation_id"],
miner_id=miner_id,
action=action,
requested_value=requested_value,
status=operation["status"],
created_at=operation["created_at"],
)
raise MinerControlUnavailable(PHYSICAL_CONTROL_DISABLED)
+219
View File
@@ -0,0 +1,219 @@
"""Focused tests for per-Agent authentication and legacy compatibility."""
import unittest
from datetime import datetime, timezone
from app.agent_auth.exceptions import (
AgentAuthenticationFailed,
AgentAuthenticationUnavailable,
AgentCredentialRevoked,
AgentIdentityUnauthorized,
)
from app.agent_auth.service import AgentAuthService, hash_agent_secret
from app.config import settings
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
class FakeAgentAuthRepository:
def __init__(self, rows=None):
self.rows = rows or {}
def get_credential(self, agent_id):
return self.rows.get(agent_id)
class AgentAuthServiceTests(unittest.TestCase):
def setUp(self):
self.old_id = settings.minerops_agent_id
self.old_secret = settings.minerops_agent_shared_secret
settings.minerops_agent_id = "legacy-agent"
settings.minerops_agent_shared_secret = "legacy-secret"
def tearDown(self):
settings.minerops_agent_id = self.old_id
settings.minerops_agent_shared_secret = self.old_secret
def service(self, rows=None):
return AgentAuthService(FakeAgentAuthRepository(rows))
def test_high_entropy_secret_hash_is_sha256_hex(self):
digest = hash_agent_secret("example-secret")
self.assertEqual(len(digest), 64)
self.assertRegex(digest, r"^[0-9a-f]{64}$")
def test_legacy_agent_without_registry_row_still_authenticates(self):
self.assertEqual(
self.service().authenticate("legacy-agent", "legacy-secret"),
"legacy-agent",
)
def test_secret_only_legacy_agent_maps_to_configured_identity(self):
self.assertEqual(
self.service().authenticate(None, "legacy-secret"),
"legacy-agent",
)
with self.assertRaises(AgentAuthenticationFailed):
self.service().authenticate(None, "wrong-secret")
def test_legacy_agent_with_null_credential_still_authenticates(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": None,
"credential_created_at": None,
"credential_revoked_at": None,
}
}
self.assertEqual(
self.service(rows).authenticate("legacy-agent", "legacy-secret"),
"legacy-agent",
)
def test_per_agent_credential_authenticates_independently(self):
rows = {
"agent-new": {
"agent_id": "agent-new",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": None,
}
}
self.assertEqual(
self.service(rows).authenticate("agent-new", "unique-secret"),
"agent-new",
)
def test_legacy_secret_never_authenticates_per_agent_identity(self):
settings.minerops_agent_id = "agent-new"
rows = {
"agent-new": {
"agent_id": "agent-new",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": None,
}
}
with self.assertRaises(AgentAuthenticationFailed):
self.service(rows).authenticate("agent-new", "legacy-secret")
def test_wrong_per_agent_secret_is_rejected(self):
rows = {
"agent-new": {
"agent_id": "agent-new",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": None,
}
}
with self.assertRaises(AgentAuthenticationFailed):
self.service(rows).authenticate("agent-new", "wrong-secret")
def test_revoked_per_agent_credential_never_falls_back(self):
settings.minerops_agent_id = "agent-new"
rows = {
"agent-new": {
"agent_id": "agent-new",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": NOW,
}
}
for secret in ("unique-secret", "legacy-secret"):
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
self.service(rows).authenticate("agent-new", secret)
def test_revoked_registry_row_never_falls_back_even_without_hash(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": None,
"credential_created_at": None,
"credential_revoked_at": NOW,
}
}
for agent_id in ("legacy-agent", None):
with self.subTest(agent_id=agent_id), self.assertRaises(
AgentCredentialRevoked
):
self.service(rows).authenticate(agent_id, "legacy-secret")
def test_missing_identity_never_authenticates_enrolled_legacy_agent(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": None,
}
}
for secret in ("unique-secret", "legacy-secret"):
with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed):
self.service(rows).authenticate(None, secret)
def test_missing_identity_reports_revoked_legacy_credential(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": hash_agent_secret("unique-secret"),
"credential_created_at": NOW,
"credential_revoked_at": NOW,
}
}
with self.assertRaises(AgentCredentialRevoked):
self.service(rows).authenticate(None, "legacy-secret")
def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": None,
"credential_created_at": None,
"credential_revoked_at": None,
"legacy_auth_disabled_at": NOW,
}
}
for agent_id in ("legacy-agent", None):
with self.subTest(agent_id=agent_id), self.assertRaises(
AgentAuthenticationFailed
):
self.service(rows).authenticate(agent_id, "legacy-secret")
def test_identity_retirement_rejects_fallback_without_current_agent_row(self):
rows = {
"legacy-agent": {
"agent_id": "legacy-agent",
"agent_secret_hash": None,
"credential_created_at": None,
"credential_revoked_at": None,
"legacy_auth_disabled_at": NOW,
}
}
for agent_id in ("legacy-agent", None):
with self.subTest(agent_id=agent_id), self.assertRaises(
AgentAuthenticationFailed
):
self.service(rows).authenticate(agent_id, "legacy-secret")
def test_unknown_identity_does_not_use_shared_secret(self):
with self.assertRaises(AgentIdentityUnauthorized):
self.service().authenticate("unknown-agent", "legacy-secret")
def test_missing_secret_is_rejected(self):
with self.assertRaises(AgentAuthenticationFailed):
self.service().authenticate("legacy-agent", None)
def test_legacy_path_reports_unavailable_if_not_configured(self):
settings.minerops_agent_shared_secret = ""
with self.assertRaises(AgentAuthenticationUnavailable):
self.service().authenticate("legacy-agent", "anything")
def test_secret_only_path_requires_configured_legacy_identity(self):
settings.minerops_agent_id = ""
with self.assertRaises(AgentAuthenticationUnavailable):
self.service().authenticate(None, "legacy-secret")
if __name__ == "__main__":
unittest.main()
+175 -14
View File
@@ -7,12 +7,16 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_commands.dependencies import get_agent_command_service
from app.agent_auth.service import AgentAuthService
from app.agent_commands.dependencies import (
get_agent_auth_service,
get_agent_command_service,
)
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentResultValidationError,
)
from app.agent_commands.models import AgentCommandResultRequest
from app.agent_commands.models import AgentCommandResultRequest, MinerControlResultPayload
from app.agent_commands.repository import CLAIM_COMMANDS_SQL
from app.agent_commands.router import router
from app.agent_commands.service import AgentCommandService
@@ -34,14 +38,14 @@ class FakeRepository:
def __init__(self, row=None):
self.row = row or {
"id": "command-1", "agent_id": "test-agent",
"command_type": "miner_control", "command_status": "claimed",
"command_type": "poll_known_miners_now", "command_status": "claimed",
"request_payload": {"action": "suspend"},
}
self.finished = []
def claim_pending(self, agent_id, limit=5):
return [{
"command_id": "command-1", "command_type": "miner_control",
"command_id": "command-1", "command_type": "poll_known_miners_now",
"request_payload": {"action": "suspend"}, "created_at": NOW,
}]
@@ -50,6 +54,8 @@ class FakeRepository:
def finish_command(self, *args):
self.finished.append(args)
if args[2] == "cancelled":
raise AgentCommandConflict(args[4])
def typed_result(**overrides):
@@ -80,7 +86,7 @@ class AgentCommandServiceTests(unittest.TestCase):
)
AgentCommandService(repository).submit_result("test-agent", request)
finished = repository.finished[0]
self.assertIsNone(finished[4])
self.assertIn("[address]", finished[4])
self.assertNotIn("192.0.2.10", str(finished))
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
@@ -107,8 +113,8 @@ class AgentCommandServiceTests(unittest.TestCase):
command_id="command-1", status="completed",
result_payload={**typed_result(), forbidden: "sensitive"},
)
with self.assertRaises(AgentResultValidationError):
AgentCommandService(repository).submit_result("test-agent", request)
with self.assertRaises(ValueError):
MinerControlResultPayload.model_validate(request.result_payload)
def test_suspend_resume_cannot_claim_confirmation(self):
for status in ("confirmed", "verification_failed"):
@@ -119,8 +125,15 @@ class AgentCommandServiceTests(unittest.TestCase):
verification_attempted=True, verification_status=status
),
)
with self.assertRaises(AgentResultValidationError):
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
repository = FakeRepository()
repository.row["command_type"] = "miner_control"
with self.assertRaisesRegex(AgentCommandConflict, "physical_control_disabled_stage_a"):
AgentCommandService(repository).submit_result("test-agent", request)
class FakeAgentAuthRepository:
def get_credential(self, agent_id):
return None
class AgentCommandRouterTests(unittest.TestCase):
@@ -131,6 +144,9 @@ class AgentCommandRouterTests(unittest.TestCase):
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
self.repository
)
self.app.dependency_overrides[get_agent_auth_service] = lambda: AgentAuthService(
FakeAgentAuthRepository()
)
self.client = TestClient(self.app)
self.old_id = settings.minerops_agent_id
self.old_secret = settings.minerops_agent_shared_secret
@@ -148,17 +164,26 @@ class AgentCommandRouterTests(unittest.TestCase):
"X-MinerOps-Agent-Secret": "test-secret",
}
def test_poll_requires_header_identity_and_ignores_query_spoofing(self):
def test_poll_accepts_secret_only_legacy_agent_and_ignores_query_spoofing(self):
before_claim_calls = []
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401)
self.assertEqual(
self.client.get(
"/api/agent/commands/pending",
headers={"X-MinerOps-Agent-Secret": "test-secret"},
).status_code,
200,
)
self.assertEqual(
self.client.get(
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
).status_code,
200,
)
self.assertEqual(before_claim_calls, [True])
# Agent polling may claim already queued commands, but must never invoke
# a legacy scheduler/materializer as a side effect.
self.assertEqual(before_claim_calls, [])
self.assertEqual(
self.client.get(
"/api/agent/commands/pending", headers=self.headers("other")
@@ -199,10 +224,12 @@ class AgentSourceContractTests(unittest.TestCase):
self.assertNotIn('DefaultValue "super"', service)
self.assertNotIn('DefaultValue "super"', powershell)
self.assertIn('"X-MinerOps-Agent-ID"', service)
self.assertIn('.agent_zone_id', service)
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
self.assertNotIn("?agent_id=", service)
self.assertIn('"get.miner.setting"', control)
self.assertIn("$attempt -le 3", control)
self.assertIn("$Payload.performance_mode", control)
self.assertIn("physical_control_disabled_stage_a", control)
self.assertNotIn('"set.miner.', control)
self.assertNotIn("$Payload.performance_mode", control)
self.assertNotIn("api3_account =", control)
self.assertNotIn("api3_password =", control)
for forbidden in ("raw_response", "raw_command", "raw_exception"):
@@ -233,3 +260,137 @@ class AgentSourceContractTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class AgentTransportIsolationSourceTests(unittest.TestCase):
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
known_start = main.index('@app.get("/api/agent/known-miners")')
known_end = main.index("# Phase 5J:", known_start)
known = main[known_start:known_end]
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
pool_end = main.index("\n@app.", pool_start + 1)
pool = main[pool_start:pool_end]
self.assertIn('"/api/agent/known-miners"', identity)
self.assertIn("Depends(require_agent_identity)", known)
self.assertIn("zone_service.routing_context", known)
self.assertIn("miners.agent_zone_id = $1::uuid", known)
self.assertNotIn("minerops_require_agent_secret", known)
self.assertIn("Depends(require_agent_identity)", pool)
self.assertIn("zone_service.routing_context", pool)
self.assertIn("agent_id = authenticated_agent_id", pool)
self.assertIn("agent_zone_id = %s", pool)
self.assertNotIn('body.get("agent_id")', pool)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
def test_windows_machine_pollers_send_agent_identity(self):
root = agent_source_root()
if root is None:
self.skipTest("Windows Agent source is not mounted")
for filename in (
"MinerOpsPoolPoller.ps1",
"MinerOpsApi3EnrichmentPoller.ps1",
"EnrichOneMiner.ps1",
):
with self.subTest(filename=filename):
source = (root / filename).read_text()
self.assertIn("X-MinerOps-Agent-ID", source)
self.assertIn("X-MinerOps-Agent-Secret", source)
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
decorators = (
'@app.post("/api/agent/miner-enrichment")',
'@app.post("/api/agent/miner-enrichment-v2")',
'@app.post("/api/agent/miner-enrichment-v3")',
)
for decorator in decorators:
with self.subTest(decorator=decorator):
start = main.index(decorator)
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_zone_id", route)
self.assertNotIn("minerops_require_agent_secret", route)
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
v2_end = main.index("\n@app.", v2_start + 1)
v2 = main[v2_start:v2_end]
self.assertIn(
"AND agent_zone_id = $2::uuid",
v2,
)
self.assertIn(
"AND agent_zone_id = $3::uuid",
v2,
)
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
v3_end = main.index("\n@app.", v3_start + 1)
v3 = main[v3_start:v3_end]
self.assertIn(
"AND agent_zone_id = $8::uuid",
v3,
)
self.assertIn(
"AND agent_zone_id = $7::uuid",
v3,
)
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
start = main.index('@app.post("/api/agent/api3-enrichment")')
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_id = authenticated_agent_id", route)
self.assertIn("agent_zone_id = %s", route)
self.assertIn("if miner_id is None:", route)
self.assertIn("continue", route)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
self.assertNotIn('body.get("agent_id")', route)
self.assertNotIn('body.get("site_name")', route)
self.assertNotIn('request.headers.get("x-agent-secret")', route)
def test_legacy_config_remains_explicitly_legacy_only(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
start = main.index('@app.get("/api/agent/config")')
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("minerops_require_agent_secret(request)", route)
self.assertIn('"site_name": "Plexus"', route)
self.assertNotIn("Depends(require_agent_identity)", route)
def test_disabled_old_command_routes_are_fixed_retirement_responses(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
for decorator in (
'@app.get("/api/agent/commands/pending-disabled-old")',
'@app.post("/api/agent/commands/result-disabled-old")',
):
with self.subTest(decorator=decorator):
start = main.index(decorator)
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("status_code=410", route)
self.assertNotIn("minerops_require_agent_secret", route)
self.assertNotIn("request.json", route)
self.assertNotIn("minerops_agent_command_queue", route)
@@ -0,0 +1,584 @@
"""PostgreSQL security contract for existing-Agent credential transition."""
from concurrent.futures import ThreadPoolExecutor
import os
import unittest
from uuid import UUID, uuid4
from pydantic import ValidationError
from psycopg.rows import dict_row
from app.agent_auth.exceptions import AgentAuthenticationFailed
from app.agent_auth.repository import AgentAuthRepository
from app.agent_auth.service import AgentAuthService, hash_agent_secret
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentTokenInvalid,
)
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
from app.agent_enrollment.repository import (
AgentEnrollmentRepository,
default_connection_factory,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_registry.repository import AgentRegistryRepository
from app.config import settings
PRODUCTION_AGENT_ID = "plexus-windows-agent"
PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001")
PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002")
PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003")
def connect():
return default_connection_factory()
class AgentCredentialTransitionPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1":
raise unittest.SkipTest(
"credential transition requires its disposable PostgreSQL runner"
)
if settings.minerops_agent_id != PRODUCTION_AGENT_ID:
raise AssertionError(
"runner must bind the production-shaped legacy Agent identity"
)
cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect))
cls.auth = AgentAuthService(AgentAuthRepository(connect))
def create_legacy_agent(
self,
agent_id,
*,
site_id=None,
zone_id=None,
enabled=True,
secret_hash=None,
revoked=False,
):
site_id = site_id or uuid4()
zone_id = zone_id or uuid4()
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s)",
(site_id, f"Transition Site {site_id.hex[:8]}"),
)
cur.execute(
"""
INSERT INTO agent_zones (id, site_id, name, is_enabled)
VALUES (%s, %s, %s, %s)
""",
(zone_id, site_id, "Default Zone", enabled),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, '10.220.0.0/24'::cidr)
""",
(zone_id,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name,
display_name, agent_secret_hash,
credential_created_at, credential_revoked_at
)
VALUES (
%s, %s, %s, %s, %s, %s,
CASE WHEN %s::boolean THEN now() ELSE NULL END,
CASE WHEN %s::boolean THEN now() ELSE NULL END
)
""",
(
agent_id,
site_id,
zone_id,
f"Transition Site {site_id.hex[:8]}",
f"Agent {agent_id}"[:120],
secret_hash,
secret_hash is not None,
revoked,
),
)
connection.commit()
finally:
connection.close()
return site_id, zone_id
def create_transition(self, agent_id):
return self.service.create_credential_transition(
AgentCredentialTransitionCreateRequest(agent_id=agent_id),
actor_user_id=None,
)
def redeem_transition(self, token, secret):
return AgentEnrollmentService(
AgentEnrollmentRepository(connect)
).redeem_credential_transition(
AgentCredentialTransitionRedeemRequest(
token=token,
agent_secret_hash=hash_agent_secret(secret),
)
)
def agent_row(self, agent_id):
connection = connect()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent_id, site_id, agent_zone_id, agent_secret_hash,
credential_created_at, credential_revoked_at,
legacy_auth_disabled_at
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
return cur.fetchone()
finally:
connection.close()
def test_01_production_identity_transitions_without_routing_change(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES (%s, 'Plexus Mining Site')
""",
(PRODUCTION_SITE_ID,),
)
cur.execute(
"""
INSERT INTO agent_zones (id, site_id, name, is_enabled)
VALUES (%s, %s, 'Default Zone', true)
""",
(PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, '192.0.2.0/24'::cidr)
""",
(PRODUCTION_ZONE_ID,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name, display_name
)
VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent')
""",
(
PRODUCTION_AGENT_ID,
PRODUCTION_SITE_ID,
PRODUCTION_ZONE_ID,
),
)
cur.execute(
"""
INSERT INTO miners (
id, site_id, agent_zone_id, asset_tag,
current_ip, current_mac, status
)
VALUES (
%s, %s, %s, 'TRANSITION-PRODUCTION-MINER',
'192.0.2.20', '02:00:00:00:20:03', 'online'
)
""",
(
PRODUCTION_MINER_ID,
PRODUCTION_SITE_ID,
PRODUCTION_ZONE_ID,
),
)
connection.commit()
finally:
connection.close()
legacy_secret = settings.minerops_agent_shared_secret
self.assertEqual(
self.auth.authenticate(None, legacy_secret),
PRODUCTION_AGENT_ID,
)
transition = self.create_transition(PRODUCTION_AGENT_ID)
token = transition["transition_token"]
self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID)
self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID)
self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID)
connection = connect()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT token_hash, target_agent_id, used_at,
enrolled_agent_id, site_id, agent_zone_id
FROM agent_enrollments WHERE id = %s
""",
(transition["id"],),
)
authorization = cur.fetchone()
finally:
connection.close()
self.assertEqual(authorization["token_hash"], hash_agent_secret(token))
self.assertNotEqual(authorization["token_hash"], token)
self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID)
self.assertIsNone(authorization["used_at"])
new_secret = "production-shaped-per-agent-secret"
result = self.redeem_transition(token, new_secret)
self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID)
self.assertNotIn("agent_secret", result)
self.assertEqual(result["site_id"], PRODUCTION_SITE_ID)
self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID)
# An ambiguous response is safely retryable only with the same locally
# generated credential; no second transition or audit event is created.
retried = self.redeem_transition(token, new_secret)
self.assertEqual(retried, result)
with self.assertRaises(AgentEnrollmentTokenInvalid):
self.redeem_transition(token, "different-secret-cannot-rotate")
self.assertEqual(
self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret),
PRODUCTION_AGENT_ID,
)
for supplied_id in (None, PRODUCTION_AGENT_ID):
with self.subTest(supplied_id=supplied_id), self.assertRaises(
AgentAuthenticationFailed
):
self.auth.authenticate(supplied_id, legacy_secret)
connection = connect()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT site_id, agent_zone_id, agent_secret_hash,
credential_created_at, legacy_auth_disabled_at
FROM minerops_site_agents WHERE agent_id = %s
""",
(PRODUCTION_AGENT_ID,),
)
agent = cur.fetchone()
cur.execute(
"""
SELECT site_id, agent_zone_id, current_mac::text,
host(current_ip) AS current_ip
FROM miners WHERE id = %s
""",
(PRODUCTION_MINER_ID,),
)
miner = cur.fetchone()
cur.execute(
"""
SELECT count(*) AS retirement_count
FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
(PRODUCTION_AGENT_ID,),
)
retirement_count = cur.fetchone()["retirement_count"]
cur.execute(
"""
SELECT count(*) AS redeemed_audits
FROM audit_logs
WHERE action = 'agent.credential_transition.redeemed'
AND entity_id = %s
""",
(transition["id"],),
)
redeemed_audits = cur.fetchone()["redeemed_audits"]
finally:
connection.close()
self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID)
self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID)
self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret))
self.assertIsNotNone(agent["credential_created_at"])
self.assertIsNotNone(agent["legacy_auth_disabled_at"])
self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID)
self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID)
self.assertEqual(miner["current_mac"], "02:00:00:00:20:03")
self.assertEqual(miner["current_ip"], "192.0.2.20")
self.assertEqual(retirement_count, 1)
self.assertEqual(redeemed_audits, 1)
# Remove deletable enrollment/current state to prove that the separate
# append-only retirement identity still defeats fallback on recreation.
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM agent_enrollments WHERE id = %s",
(transition["id"],),
)
cur.execute(
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
(PRODUCTION_AGENT_ID,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name
) VALUES (%s, %s, %s, 'Recreated Plexus')
""",
(
PRODUCTION_AGENT_ID,
PRODUCTION_SITE_ID,
PRODUCTION_ZONE_ID,
),
)
connection.commit()
finally:
connection.close()
recreated = self.agent_row(PRODUCTION_AGENT_ID)
self.assertIsNone(recreated["agent_secret_hash"])
self.assertIsNotNone(recreated["legacy_auth_disabled_at"])
with self.assertRaises(AgentAuthenticationFailed):
self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret)
def test_02_concurrent_redemption_has_one_credential_winner(self):
agent_id = "transition-concurrent-agent"
self.create_legacy_agent(agent_id)
token = self.create_transition(agent_id)["transition_token"]
secrets = ("concurrent-secret-a", "concurrent-secret-b")
def redeem(secret):
try:
return ("success", secret, self.redeem_transition(token, secret))
except AgentEnrollmentTokenInvalid:
return ("invalid", secret, None)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(redeem, secrets))
winners = [item for item in results if item[0] == "success"]
losers = [item for item in results if item[0] == "invalid"]
self.assertEqual(len(winners), 1)
self.assertEqual(len(losers), 1)
row = self.agent_row(agent_id)
self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1]))
def test_03_authorization_cannot_substitute_identity_or_steal_zone(self):
agent_a = "transition-bound-agent-a"
agent_b = "transition-bound-agent-b"
site_a, zone_a = self.create_legacy_agent(agent_a)
site_b, zone_b = self.create_legacy_agent(agent_b)
token = self.create_transition(agent_a)["transition_token"]
with self.assertRaises(ValidationError):
AgentCredentialTransitionRedeemRequest.model_validate(
{
"token": token,
"agent_secret_hash": hash_agent_secret("bound-secret"),
"agent_id": agent_b,
}
)
result = self.redeem_transition(token, "bound-secret")
self.assertEqual(result["agent_id"], agent_a)
self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a))
other = self.agent_row(agent_b)
self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b))
self.assertIsNone(other["agent_secret_hash"])
def test_04_revoked_retired_and_disabled_targets_fail_closed(self):
revoked = "transition-revoked-agent"
self.create_legacy_agent(
revoked,
secret_hash=hash_agent_secret("revoked-secret"),
revoked=True,
)
with self.assertRaises(AgentEnrollmentConflict):
self.create_transition(revoked)
retired = "transition-retired-agent"
self.create_legacy_agent(retired)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)",
(retired,),
)
connection.commit()
finally:
connection.close()
with self.assertRaises(AgentEnrollmentConflict):
self.create_transition(retired)
disabled = "transition-disabled-agent"
self.create_legacy_agent(disabled, enabled=False)
with self.assertRaises(AgentEnrollmentConflict):
self.create_transition(disabled)
changed = "transition-zone-disabled-after-authorization"
_, changed_zone = self.create_legacy_agent(changed)
authorization = self.create_transition(changed)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE agent_zones SET is_enabled = false WHERE id = %s",
(changed_zone,),
)
connection.commit()
finally:
connection.close()
with self.assertRaises(AgentEnrollmentConflict):
self.redeem_transition(
authorization["transition_token"], "disabled-zone-secret"
)
self.assertIsNone(self.agent_row(changed)["agent_secret_hash"])
def test_05_credential_collision_rolls_back_retirement_and_token_use(self):
collision_hash = hash_agent_secret("already-issued-secret")
self.create_legacy_agent(
"transition-existing-credential-holder",
secret_hash=collision_hash,
)
target = "transition-rollback-target"
self.create_legacy_agent(target)
transition = self.create_transition(target)
with self.assertRaises(AgentEnrollmentCredentialCollision):
self.service.redeem_credential_transition(
AgentCredentialTransitionRedeemRequest(
token=transition["transition_token"],
agent_secret_hash=collision_hash,
)
)
row = self.agent_row(target)
self.assertIsNone(row["agent_secret_hash"])
self.assertIsNone(row["credential_created_at"])
self.assertIsNone(row["legacy_auth_disabled_at"])
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT count(*) FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
(target,),
)
self.assertEqual(cur.fetchone()[0], 0)
cur.execute(
"""
SELECT used_at, enrolled_agent_id
FROM agent_enrollments WHERE id = %s
""",
(transition["id"],),
)
self.assertEqual(cur.fetchone(), (None, None))
finally:
connection.close()
def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self):
site_id, occupied_zone = self.create_legacy_agent(
"transition-ordinary-occupied"
)
with self.assertRaises(AgentEnrollmentConflict):
self.service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=site_id,
agent_zone_id=occupied_zone,
display_name="Must Not Steal Occupied Zone",
),
actor_user_id=None,
)
free_zone = uuid4()
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO agent_zones (id, site_id, name, is_enabled)
VALUES (%s, %s, 'Free Enrollment Zone', true)
""",
(free_zone, site_id),
)
connection.commit()
finally:
connection.close()
enrollment = self.service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=site_id,
agent_zone_id=free_zone,
display_name="Ordinary New Agent",
),
actor_user_id=None,
)
enrolled = self.service.redeem(
AgentEnrollRequest(token=enrollment["enrollment_token"])
)
self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$")
self.assertEqual(enrolled["agent_zone_id"], free_zone)
def test_07_registry_history_and_audit_never_expose_plaintext(self):
agent_id = "transition-privacy-agent"
self.create_legacy_agent(agent_id)
transition = self.create_transition(agent_id)
token = transition["transition_token"]
listed = self.service.list_enrollments()
item = next(row for row in listed if row["id"] == transition["id"])
self.assertEqual(item["target_agent_id"], agent_id)
self.assertEqual(item["purpose"], "existing_agent_credential_transition")
serialized_item = repr(item)
self.assertNotIn(token, serialized_item)
for forbidden in (
"token_hash",
"transition_token",
"agent_secret",
"agent_secret_hash",
):
self.assertNotIn(forbidden, item)
registry = AgentRegistryRepository().list_registry()
registry_agent = next(
row for row in registry["agents"] if row["agent_id"] == agent_id
)
for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"):
self.assertNotIn(forbidden, registry_agent)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT new_value_json::text
FROM audit_logs
WHERE entity_id = %s
""",
(transition["id"],),
)
audit_text = "\n".join(row[0] for row in cur.fetchall())
finally:
connection.close()
self.assertNotIn(token, audit_text)
if __name__ == "__main__":
unittest.main()
+305
View File
@@ -0,0 +1,305 @@
"""Focused tests for secure Agent enrollment."""
import re
import unittest
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_enrollment.dependencies import get_agent_enrollment_service
from app.agent_auth.service import hash_agent_secret
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_enrollment.router import router
from app.identity import (
get_current_user,
machine_agent_request,
required_api_permission,
)
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001701")
USER_ID = UUID("00000000-0000-4000-8000-000000001702")
ENROLLMENT_ID = UUID("00000000-0000-4000-8000-000000001703")
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
class FakeAgentEnrollmentRepository:
def __init__(self):
self.created = None
self.redeemed = None
self.transition_created = None
self.transition_redeemed = None
def create_enrollment(self, **kwargs):
self.created = kwargs
return {
"id": ENROLLMENT_ID,
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Test Zone",
"display_name": kwargs["display_name"],
"created_at": NOW,
"expires_at": NOW + timedelta(minutes=30),
}
def list_enrollments(self, **kwargs):
return []
def create_credential_transition(self, **kwargs):
self.transition_created = kwargs
return {
"id": ENROLLMENT_ID,
"target_agent_id": kwargs["target_agent_id"],
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Test Zone",
"display_name": "Existing Agent",
"created_at": NOW,
"expires_at": NOW + timedelta(minutes=30),
}
def redeem_enrollment(self, **kwargs):
self.redeemed = kwargs
return {
"agent_id": kwargs["agent_id"],
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Test Zone",
"display_name": "Test Agent",
"ip_ranges": ["10.30.0.0/22"],
}
def redeem_credential_transition(self, **kwargs):
self.transition_redeemed = kwargs
return {
"agent_id": "existing-agent",
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Test Zone",
"display_name": "Existing Agent",
"ip_ranges": ["10.30.0.0/22"],
}
class AgentEnrollmentServiceTests(unittest.TestCase):
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
repository = FakeAgentEnrollmentRepository()
service = AgentEnrollmentService(repository)
result = service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=SITE_ID,
agent_zone_id=ZONE_ID,
display_name=" Test Agent ",
),
actor_user_id=USER_ID,
)
token = result["enrollment_token"]
self.assertGreaterEqual(len(token), 32)
self.assertNotEqual(repository.created["token_hash"], token)
self.assertEqual(
repository.created["token_hash"],
hash_agent_secret(token),
)
self.assertRegex(
repository.created["token_hash"],
r"^[0-9a-f]{64}$",
)
self.assertEqual(
repository.created["display_name"],
"Test Agent",
)
serialized = repr(repository.created)
self.assertNotIn(token, serialized)
def test_redeem_returns_plaintext_secret_but_repository_gets_only_hash(self):
repository = FakeAgentEnrollmentRepository()
service = AgentEnrollmentService(repository)
result = service.redeem(
AgentEnrollRequest(
token="A" * 43,
)
)
secret = result["agent_secret"]
self.assertGreaterEqual(len(secret), 32)
self.assertNotEqual(
repository.redeemed["agent_secret_hash"],
secret,
)
self.assertEqual(
repository.redeemed["agent_secret_hash"],
hash_agent_secret(secret),
)
self.assertRegex(
repository.redeemed["agent_secret_hash"],
r"^[0-9a-f]{64}$",
)
self.assertRegex(
result["agent_id"],
r"^agent-[0-9a-f]{32}$",
)
serialized = repr(repository.redeemed)
self.assertNotIn(secret, serialized)
def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self):
repository = FakeAgentEnrollmentRepository()
service = AgentEnrollmentService(repository)
created = service.create_credential_transition(
AgentCredentialTransitionCreateRequest(
agent_id=" existing-agent "
),
actor_user_id=USER_ID,
)
token = created["transition_token"]
self.assertEqual(
repository.transition_created["target_agent_id"],
"existing-agent",
)
self.assertEqual(
repository.transition_created["token_hash"],
hash_agent_secret(token),
)
self.assertNotIn(token, repr(repository.transition_created))
credential_hash = hash_agent_secret("locally-generated-secret")
result = service.redeem_credential_transition(
AgentCredentialTransitionRedeemRequest(
token=token,
agent_secret_hash=credential_hash,
)
)
self.assertEqual(result["agent_id"], "existing-agent")
self.assertEqual(
repository.transition_redeemed,
{
"token_hash": hash_agent_secret(token),
"agent_secret_hash": credential_hash,
},
)
self.assertNotIn("agent_secret", result)
def test_enrollment_admin_permission_mapping_is_explicit(self):
self.assertEqual(
required_api_permission("GET", "/api/agent-enrollments"),
"agents.view",
)
self.assertEqual(
required_api_permission("POST", "/api/agent-enrollments"),
"agents.manage",
)
self.assertEqual(
required_api_permission(
"POST", "/api/agent-credential-transitions"
),
"agents.manage",
)
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
self.assertTrue(machine_agent_request("/api/agent/enroll"))
self.assertTrue(
machine_agent_request("/api/agent/credential-transition")
)
self.assertFalse(
machine_agent_request("/api/agent-enrollments")
)
def test_generated_identifiers_are_bounded(self):
repository = FakeAgentEnrollmentRepository()
service = AgentEnrollmentService(repository)
result = service.redeem(
AgentEnrollRequest(token="B" * 43)
)
self.assertLessEqual(len(result["agent_id"]), 255)
self.assertTrue(
re.fullmatch(r"agent-[0-9a-f]{32}", result["agent_id"])
)
class AgentCredentialTransitionRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeAgentEnrollmentRepository()
self.service = AgentEnrollmentService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_agent_enrollment_service] = (
lambda: self.service
)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(USER_ID),
"role": "Test",
"permissions": ("agents.view", "agents.manage"),
}
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def test_admin_creation_is_distinct_no_store_and_identity_only(self):
response = self.client.post(
"/api/agent-credential-transitions",
json={"agent_id": "existing-agent"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()["data"]
self.assertEqual(payload["target_agent_id"], "existing-agent")
self.assertIn("transition_token", payload)
self.assertNotIn("site_id", self.repository.transition_created)
self.assertNotIn("agent_zone_id", self.repository.transition_created)
def test_machine_redemption_returns_no_permanent_plaintext_secret(self):
response = self.client.post(
"/api/agent/credential-transition",
json={
"token": "T" * 43,
"agent_secret_hash": hash_agent_secret(
"locally-generated-router-secret"
),
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()["data"]
self.assertEqual(payload["agent_id"], "existing-agent")
self.assertNotIn("agent_secret", payload)
def test_view_only_admin_cannot_create_transition(self):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(USER_ID),
"role": "Test",
"permissions": ("agents.view",),
}
response = self.client.post(
"/api/agent-credential-transitions",
json={"agent_id": "existing-agent"},
)
self.assertEqual(response.status_code, 403)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,804 @@
"""PostgreSQL integration tests for secure Agent enrollment."""
import unittest
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
from psycopg.errors import ForeignKeyViolation
from psycopg.rows import dict_row
from app.agent_auth.exceptions import AgentAuthenticationFailed
from app.agent_auth.repository import AgentAuthRepository
from app.agent_auth.service import AgentAuthService, hash_agent_secret
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid,
)
from app.agent_enrollment.models import (
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
from app.agent_enrollment.repository import (
AgentEnrollmentRepository,
default_connection_factory,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_zones.models import AgentCheckinRequest
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
from app.config import settings
class AgentEnrollmentPostgresTests(unittest.TestCase):
def setUp(self):
self.site_id = uuid4()
self.zone_id = uuid4()
self.site_name = f"Enrollment Test Site {self.site_id.hex[:8]}"
self.zone_name = f"Enrollment Test Zone {self.zone_id.hex[:8]}"
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES (%s, %s)
""",
(self.site_id, self.site_name),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name
)
VALUES (%s, %s, %s)
""",
(
self.zone_id,
self.site_id,
self.zone_name,
),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (
agent_zone_id,
network
)
VALUES (%s, '10.250.0.0/24'::cidr)
""",
(self.zone_id,),
)
connection.commit()
finally:
connection.close()
self.service = AgentEnrollmentService(
AgentEnrollmentRepository()
)
def tearDown(self):
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
DELETE FROM audit_logs
WHERE entity_type = 'agent_enrollment'
AND (
new_value_json ->> 'site_id' = %s
OR entity_id IN (
SELECT id
FROM agent_enrollments
WHERE site_id = %s
)
)
""",
(str(self.site_id), self.site_id),
)
cur.execute(
"""
DELETE FROM agent_enrollments
WHERE site_id = %s
""",
(self.site_id,),
)
cur.execute(
"""
DELETE FROM minerops_site_agents
WHERE site_id = %s
""",
(self.site_id,),
)
cur.execute(
"""
DELETE FROM agent_zone_network_ranges
WHERE agent_zone_id = %s
""",
(self.zone_id,),
)
cur.execute(
"""
DELETE FROM agent_zones
WHERE id = %s
""",
(self.zone_id,),
)
cur.execute(
"""
DELETE FROM sites
WHERE id = %s
""",
(self.site_id,),
)
connection.commit()
finally:
connection.close()
def create_enrollment(self, display_name="Integration Agent"):
# created_by_user_id is nullable at the persistence layer.
# Router-level tests separately prove that the real admin path supplies
# the authenticated user's UUID.
return self.service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=self.site_id,
agent_zone_id=self.zone_id,
display_name=display_name,
),
actor_user_id=None,
)
def test_replacement_revokes_previous_open_enrollment(self):
first = self.create_enrollment("First Agent")
second = self.create_enrollment("Replacement Agent")
self.assertNotEqual(
first["enrollment_token"],
second["enrollment_token"],
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
token_hash,
used_at,
revoked_at
FROM agent_enrollments
WHERE site_id = %s
AND agent_zone_id = %s
""",
(self.site_id, self.zone_id),
)
rows = cur.fetchall()
finally:
connection.close()
self.assertEqual(len(rows), 2)
by_hash = {
row["token_hash"]: row
for row in rows
}
first_hash = hash_agent_secret(
first["enrollment_token"]
)
second_hash = hash_agent_secret(
second["enrollment_token"]
)
self.assertIsNotNone(by_hash[first_hash]["revoked_at"])
self.assertIsNone(by_hash[first_hash]["used_at"])
self.assertIsNone(by_hash[second_hash]["revoked_at"])
self.assertIsNone(by_hash[second_hash]["used_at"])
with self.assertRaises(AgentEnrollmentTokenInvalid):
self.service.redeem(
AgentEnrollRequest(
token=first["enrollment_token"]
)
)
listed = self.service.list_enrollments(
site_id=self.site_id,
agent_zone_id=self.zone_id,
)
self.assertEqual(len(listed), 2)
for item in listed:
self.assertNotIn("token_hash", item)
self.assertNotIn("agent_secret_hash", item)
self.assertNotIn("enrollment_token", item)
self.assertNotIn("agent_secret", item)
def test_concurrent_creation_leaves_exactly_one_open_enrollment(self):
def create_one(number):
service = AgentEnrollmentService(
AgentEnrollmentRepository()
)
return service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=self.site_id,
agent_zone_id=self.zone_id,
display_name=f"Concurrent Agent {number}",
),
actor_user_id=None,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(create_one, (1, 2))
)
self.assertEqual(len(results), 2)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
count(*) AS total,
count(*) FILTER (
WHERE used_at IS NULL
AND revoked_at IS NULL
) AS open_count,
count(*) FILTER (
WHERE revoked_at IS NOT NULL
) AS revoked_count,
count(*) FILTER (
WHERE revoked_at IS NOT NULL
AND revoked_at < created_at
) AS invalid_revocation_count
FROM agent_enrollments
WHERE site_id = %s
AND agent_zone_id = %s
""",
(self.site_id, self.zone_id),
)
counts = cur.fetchone()
finally:
connection.close()
self.assertEqual(counts["total"], 2)
self.assertEqual(counts["open_count"], 1)
self.assertEqual(counts["revoked_count"], 1)
self.assertEqual(counts["invalid_revocation_count"], 0)
def test_concurrent_redemption_has_exactly_one_winner(self):
enrollment = self.create_enrollment()
token = enrollment["enrollment_token"]
def redeem_once():
service = AgentEnrollmentService(
AgentEnrollmentRepository()
)
try:
return (
"success",
service.redeem(
AgentEnrollRequest(token=token)
),
)
except AgentEnrollmentTokenInvalid:
return ("invalid", None)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(lambda _: redeem_once(), range(2))
)
successes = [
result
for status, result in results
if status == "success"
]
failures = [
status
for status, _ in results
if status == "invalid"
]
self.assertEqual(len(successes), 1)
self.assertEqual(len(failures), 1)
credentials = successes[0]
agent_id = credentials["agent_id"]
agent_secret = credentials["agent_secret"]
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent_id,
agent_secret_hash,
credential_created_at,
credential_revoked_at,
legacy_auth_disabled_at,
last_payload::text AS last_payload
FROM minerops_site_agents
WHERE site_id = %s
AND agent_zone_id = %s
""",
(self.site_id, self.zone_id),
)
agents = cur.fetchall()
cur.execute(
"""
SELECT
used_at,
revoked_at,
enrolled_agent_id,
token_hash
FROM agent_enrollments
WHERE id = %s
""",
(enrollment["id"],),
)
enrollment_row = cur.fetchone()
cur.execute(
"""
SELECT retired_at
FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
(agent_id,),
)
retirement_row = cur.fetchone()
cur.execute(
"""
SELECT new_value_json::text AS payload
FROM audit_logs
WHERE entity_type = 'agent_enrollment'
AND entity_id = %s
ORDER BY created_at
""",
(enrollment["id"],),
)
audit_rows = cur.fetchall()
finally:
connection.close()
self.assertEqual(len(agents), 1)
agent = agents[0]
self.assertEqual(agent["agent_id"], agent_id)
self.assertEqual(
agent["agent_secret_hash"],
hash_agent_secret(agent_secret),
)
self.assertIsNotNone(agent["credential_created_at"])
self.assertIsNone(agent["credential_revoked_at"])
self.assertEqual(
agent["legacy_auth_disabled_at"],
agent["credential_created_at"],
)
self.assertIsNotNone(retirement_row)
self.assertEqual(
retirement_row["retired_at"],
agent["credential_created_at"],
)
self.assertIsNotNone(enrollment_row["used_at"])
self.assertIsNone(enrollment_row["revoked_at"])
self.assertEqual(
enrollment_row["enrolled_agent_id"],
agent_id,
)
# Neither plaintext credential may persist in operational state.
persisted_text = "\n".join(
[
str(enrollment_row),
str(agent),
*(row["payload"] for row in audit_rows),
]
)
self.assertNotIn(token, persisted_text)
self.assertNotIn(agent_secret, persisted_text)
self.assertNotIn(agent_secret, agent["last_payload"])
# The newly issued per-Agent credential must immediately authenticate.
auth_service = AgentAuthService(
AgentAuthRepository()
)
self.assertEqual(
auth_service.authenticate(
agent_id,
agent_secret,
),
agent_id,
)
with self.assertRaises(AgentAuthenticationFailed):
auth_service.authenticate(
agent_id,
"definitely-not-the-issued-secret",
)
original_legacy_id = settings.minerops_agent_id
original_legacy_secret = settings.minerops_agent_shared_secret
settings.minerops_agent_id = agent_id
settings.minerops_agent_shared_secret = "global-secret-must-not-work"
try:
with self.assertRaises(AgentAuthenticationFailed):
auth_service.authenticate(None, "global-secret-must-not-work")
finally:
settings.minerops_agent_id = original_legacy_id
settings.minerops_agent_shared_secret = original_legacy_secret
# Normal check-in must never mutate the credential.
secret_hash_before = agent["agent_secret_hash"]
AgentZoneService(
AgentZoneRepository()
).checkin(
agent_id,
AgentCheckinRequest(
site_id=self.site_id,
agent_zone_id=self.zone_id,
site_name=self.site_name,
hostname="integration-test-agent",
version="integration-test",
),
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent_secret_hash,
credential_created_at,
credential_revoked_at,
legacy_auth_disabled_at
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
after_checkin = cur.fetchone()
finally:
connection.close()
self.assertEqual(
after_checkin["agent_secret_hash"],
secret_hash_before,
)
self.assertIsNotNone(
after_checkin["credential_created_at"]
)
self.assertIsNone(
after_checkin["credential_revoked_at"]
)
self.assertEqual(
after_checkin["legacy_auth_disabled_at"],
agent["legacy_auth_disabled_at"],
)
# 0017 enrollment integrity remains RESTRICT: the current Agent row
# cannot be deleted while its enrollment relationship exists.
connection = default_connection_factory()
try:
with self.assertRaises(ForeignKeyViolation):
with connection.cursor() as cur:
cur.execute(
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
(agent_id,),
)
connection.rollback()
finally:
connection.close()
# After an operator deliberately removes the enrollment history, the
# independent retirement ledger must outlive deletion and recreation
# of the current Agent row with the identical identity.
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM agent_enrollments WHERE id = %s",
(enrollment["id"],),
)
cur.execute(
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
(agent_id,),
)
connection.commit()
finally:
connection.close()
original_legacy_id = settings.minerops_agent_id
original_legacy_secret = settings.minerops_agent_shared_secret
settings.minerops_agent_id = agent_id
settings.minerops_agent_shared_secret = "recreation-global-secret"
try:
with self.assertRaises(AgentAuthenticationFailed):
auth_service.authenticate(None, "recreation-global-secret")
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name
)
VALUES (%s, %s, %s, %s)
RETURNING legacy_auth_disabled_at
""",
(
agent_id,
self.site_id,
self.zone_id,
self.site_name,
),
)
recreated = cur.fetchone()
connection.commit()
finally:
connection.close()
self.assertEqual(
recreated["legacy_auth_disabled_at"],
retirement_row["retired_at"],
)
with self.assertRaises(AgentAuthenticationFailed):
auth_service.authenticate(agent_id, "recreation-global-secret")
finally:
settings.minerops_agent_id = original_legacy_id
settings.minerops_agent_shared_secret = original_legacy_secret
def test_failed_credential_issuance_rolls_back_new_retirement(self):
enrollment = self.create_enrollment("Collision Agent")
collision_id = f"agent-collision-{uuid4().hex}"
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (agent_id, site_name)
VALUES (%s, 'Existing uncredentialed identity')
""",
(collision_id,),
)
connection.commit()
finally:
connection.close()
try:
with self.assertRaises(AgentEnrollmentCredentialCollision):
AgentEnrollmentRepository().redeem_enrollment(
token_hash=hash_agent_secret(enrollment["enrollment_token"]),
agent_id=collision_id,
agent_secret_hash=hash_agent_secret("never-issued-secret"),
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT count(*) AS count
FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
(collision_id,),
)
self.assertEqual(cur.fetchone()["count"], 0)
cur.execute(
"""
SELECT used_at, enrolled_agent_id
FROM agent_enrollments
WHERE id = %s
""",
(enrollment["id"],),
)
enrollment_row = cur.fetchone()
finally:
connection.close()
self.assertIsNone(enrollment_row["used_at"])
self.assertIsNone(enrollment_row["enrolled_agent_id"])
finally:
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
(collision_id,),
)
connection.commit()
finally:
connection.close()
def test_expired_token_is_rejected_without_side_effects(self):
enrollment = self.create_enrollment()
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE agent_enrollments
SET created_at = now() - interval '2 hours',
expires_at = now() - interval '1 hour'
WHERE id = %s
""",
(enrollment["id"],),
)
connection.commit()
finally:
connection.close()
with self.assertRaises(AgentEnrollmentTokenInvalid):
self.service.redeem(
AgentEnrollRequest(
token=enrollment["enrollment_token"]
)
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT used_at, revoked_at, enrolled_agent_id
FROM agent_enrollments
WHERE id = %s
""",
(enrollment["id"],),
)
row = cur.fetchone()
cur.execute(
"""
SELECT count(*) AS count
FROM minerops_site_agents
WHERE agent_zone_id = %s
""",
(self.zone_id,),
)
agent_count = cur.fetchone()["count"]
finally:
connection.close()
self.assertIsNone(row["used_at"])
self.assertIsNone(row["revoked_at"])
self.assertIsNone(row["enrolled_agent_id"])
self.assertEqual(agent_count, 0)
def test_disabled_and_occupied_zone_do_not_consume_token(self):
enrollment = self.create_enrollment()
token = enrollment["enrollment_token"]
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE agent_zones
SET is_enabled = false
WHERE id = %s
""",
(self.zone_id,),
)
connection.commit()
finally:
connection.close()
with self.assertRaises(AgentEnrollmentConflict):
self.service.redeem(
AgentEnrollRequest(token=token)
)
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE agent_zones
SET is_enabled = true
WHERE id = %s
""",
(self.zone_id,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name
)
VALUES (%s, %s, %s, %s)
""",
(
f"legacy-{uuid4().hex}",
self.site_id,
self.zone_id,
self.site_name,
),
)
connection.commit()
finally:
connection.close()
with self.assertRaises(AgentEnrollmentConflict):
self.service.redeem(
AgentEnrollRequest(token=token)
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT used_at, revoked_at, enrolled_agent_id
FROM agent_enrollments
WHERE id = %s
""",
(enrollment["id"],),
)
row = cur.fetchone()
finally:
connection.close()
self.assertIsNone(row["used_at"])
self.assertIsNone(row["revoked_at"])
self.assertIsNone(row["enrolled_agent_id"])
def test_site_zone_mismatch_is_rejected_before_creation(self):
wrong_site_id = uuid4()
with self.assertRaises(AgentEnrollmentNotFound):
self.service.create_enrollment(
AgentEnrollmentCreateRequest(
site_id=wrong_site_id,
agent_zone_id=self.zone_id,
display_name="Wrong Site Agent",
),
actor_user_id=None,
)
connection = default_connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT count(*) AS count
FROM agent_enrollments
WHERE agent_zone_id = %s
""",
(self.zone_id,),
)
count = cur.fetchone()["count"]
finally:
connection.close()
self.assertEqual(count, 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,245 @@
"""Disposable PostgreSQL contract for durable legacy-auth retirement."""
import os
import unittest
from psycopg.errors import CheckViolation
from app.agent_auth.exceptions import (
AgentAuthenticationFailed,
AgentCredentialRevoked,
)
from app.agent_auth.repository import AgentAuthRepository, default_connection_factory
from app.agent_auth.service import AgentAuthService
from app.config import settings
class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1":
raise unittest.SkipTest(
"legacy-auth retirement requires its disposable PostgreSQL runner"
)
cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"]
cls.agent_id = settings.minerops_agent_id
cls.auth = AgentAuthService(AgentAuthRepository())
def row(self):
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT agent_secret_hash, credential_created_at,
credential_revoked_at, legacy_auth_disabled_at
FROM minerops_site_agents
WHERE agent_id = %s
""",
(self.agent_id,),
)
return cur.fetchone()
finally:
connection.close()
def retirement_row(self, agent_id=None):
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT agent_id, retired_at
FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
(agent_id or self.agent_id,),
)
return cur.fetchone()
finally:
connection.close()
def test_01_migration_backfill_is_selective(self):
row = self.row()
self.assertIsNotNone(row)
if self.case == "legacy":
self.assertEqual(row, (None, None, None, None))
self.assertIsNone(self.retirement_row())
else:
self.assertIsNotNone(row[3])
retirement = self.retirement_row()
self.assertIsNotNone(retirement)
self.assertEqual(retirement[1], row[3])
def test_02_real_repository_authentication_obeys_retirement(self):
secret = settings.minerops_agent_shared_secret
if self.case == "legacy":
self.assertEqual(self.auth.authenticate(None, secret), self.agent_id)
self.assertEqual(
self.auth.authenticate(self.agent_id, secret), self.agent_id
)
return
for agent_id in (None, self.agent_id):
with self.subTest(agent_id=agent_id), self.assertRaises(
AgentCredentialRevoked
):
self.auth.authenticate(agent_id, secret)
def test_03_clearing_credentials_cannot_restore_global_fallback(self):
if self.case == "legacy":
self.skipTest("uncredentialed legacy case must remain eligible")
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE minerops_site_agents
SET agent_secret_hash = NULL,
credential_created_at = NULL,
credential_revoked_at = NULL
WHERE agent_id = %s
""",
(self.agent_id,),
)
connection.commit()
finally:
connection.close()
row = self.row()
self.assertEqual(row[:3], (None, None, None))
self.assertIsNotNone(row[3])
for agent_id in (None, self.agent_id):
with self.subTest(agent_id=agent_id), self.assertRaises(
AgentAuthenticationFailed
):
self.auth.authenticate(
agent_id, settings.minerops_agent_shared_secret
)
def test_04_deletion_and_recreation_cannot_restore_fallback(self):
if self.case == "legacy":
self.skipTest("uncredentialed legacy case has no retirement history")
retirement = self.retirement_row()
self.assertIsNotNone(retirement)
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
(self.agent_id,),
)
connection.commit()
finally:
connection.close()
self.assertIsNone(self.row())
self.assertEqual(self.retirement_row(), retirement)
for agent_id in (None, self.agent_id):
with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises(
AgentAuthenticationFailed
):
self.auth.authenticate(
agent_id, settings.minerops_agent_shared_secret
)
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (agent_id, site_name)
VALUES (%s, 'Recreated')
""",
(self.agent_id,),
)
connection.commit()
finally:
connection.close()
recreated = self.row()
self.assertEqual(recreated[:3], (None, None, None))
self.assertEqual(recreated[3], retirement[1])
for agent_id in (None, self.agent_id):
with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises(
AgentAuthenticationFailed
):
self.auth.authenticate(
agent_id, settings.minerops_agent_shared_secret
)
def test_05_retirement_ledger_and_projection_are_one_way(self):
if self.case == "legacy":
self.skipTest("uncredentialed legacy case has no retirement history")
statements = (
"""
UPDATE agent_legacy_auth_retirements
SET retired_at = retired_at + interval '1 second'
WHERE agent_id = %s
""",
"""
DELETE FROM agent_legacy_auth_retirements
WHERE agent_id = %s
""",
"TRUNCATE agent_legacy_auth_retirements",
"""
UPDATE minerops_site_agents
SET legacy_auth_disabled_at = NULL
WHERE agent_id = %s
""",
)
for statement in statements:
connection = default_connection_factory()
try:
with self.subTest(statement=" ".join(statement.split())):
with self.assertRaises(CheckViolation):
with connection.cursor() as cur:
if "%s" in statement:
cur.execute(statement, (self.agent_id,))
else:
cur.execute(statement)
connection.rollback()
finally:
connection.close()
self.assertIsNotNone(self.retirement_row())
self.assertIsNotNone(self.row()[3])
def test_06_other_legacy_identity_is_not_accidentally_retired(self):
if self.case == "legacy":
self.skipTest("cross-identity check runs in the retired case")
other_id = self.agent_id + "-untouched"
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (agent_id, site_name)
VALUES (%s, 'Untouched')
""",
(other_id,),
)
connection.commit()
finally:
connection.close()
self.assertIsNone(self.retirement_row(other_id))
original_legacy_id = settings.minerops_agent_id
settings.minerops_agent_id = other_id
try:
self.assertEqual(
self.auth.authenticate(
other_id, settings.minerops_agent_shared_secret
),
other_id,
)
finally:
settings.minerops_agent_id = original_legacy_id
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,182 @@
"""Frontend contract tests for Agent Management."""
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.frontend.router import router
from app.identity import get_current_user, required_page_permission
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
class AgentManagementFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-4000-8000-000000009001",
"username": "agent-test-user",
"display_name": "Agent Test User",
"role": "Test Role",
"permissions": tuple(permissions),
}
def test_page_requires_agents_view(self):
self.authenticate(("unrelated.permission",))
self.assertEqual(
self.client.get("/agent/management").status_code,
403,
)
self.authenticate(("agents.view",))
response = self.client.get("/agent/management")
self.assertEqual(response.status_code, 200)
self.assertIn("Agent Management", response.text)
self.assertIn('href="/agent/management"', response.text)
def test_manage_controls_follow_agents_manage_permission(self):
self.authenticate(("agents.view",))
restricted = self.client.get("/agent/management").text
self.assertNotIn('id="agentCreateForm"', restricted)
restricted_text = " ".join(restricted.split())
self.assertIn(
"cannot create Agent enrollments.",
restricted_text,
)
self.authenticate(("agents.view", "agents.manage"))
managed = self.client.get("/agent/management").text
self.assertIn('id="agentCreateForm"', managed)
self.assertIn('id="agentSite"', managed)
self.assertIn('id="agentZone"', managed)
self.assertIn('id="agentDisplayName"', managed)
self.assertIn('data-can-manage-agents="true"', managed)
self.assertIn("Linux x64 — coming later", managed)
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
def test_route_and_asset_contract(self):
self.assertEqual(
required_page_permission("/agent/management"),
"agents.view",
)
self.authenticate(("agents.view",))
page = self.client.get("/agent/management")
self.assertIn(
"/assets/minerops-agent-management.js",
page.text,
)
asset = self.client.get(
"/assets/minerops-agent-management.js"
)
self.assertEqual(asset.status_code, 200)
self.assertEqual(
asset.headers.get("cache-control"),
"no-store",
)
def test_javascript_is_secret_conscious_and_safe_dom_only(self):
source = (
STATIC_ROOT / "agent-management.js"
).read_text(encoding="utf-8")
self.assertIn('api("/api/agents")', source)
self.assertIn('api("/api/agent-enrollments")', source)
self.assertIn(
'api("/api/agent-enrollments", {',
source,
)
self.assertIn(
'api("/api/agent-credential-transitions", {',
source,
)
self.assertIn("Upgrade credential", source)
for unsafe in (
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"localStorage",
"sessionStorage",
"console.log",
):
self.assertNotIn(unsafe, source)
self.assertIn(
"el.agentEnrollmentToken.textContent",
source,
)
self.assertIn(
"el.agentEnrollmentToken.textContent = \"\"",
source,
)
def test_agent_management_quiet_actions_have_visible_text(self):
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(
".agent-management-columns .quiet-button,",
css,
)
self.assertIn(
".agent-enrollment-panel .quiet-button {",
css,
)
self.assertIn(
"color: var(--ink);",
css,
)
def test_windows_package_download_is_ephemeral(self):
self.authenticate(("agents.view", "agents.manage"))
page = self.client.get("/agent/management").text
self.assertIn('id="agentEnrollmentDownload"', page)
self.assertIn("Download Windows package", page)
source = (
STATIC_ROOT / "agent-management.js"
).read_text(encoding="utf-8")
self.assertIn(
'"/api/agent-enrollments/windows-package"',
source,
)
self.assertIn(
'packageFilename = "MinerOpsAgent-Windows.zip"',
source,
)
self.assertIn("link.download = packageFilename", source)
self.assertIn(
"window.URL.revokeObjectURL(downloadUrl)",
source,
)
self.assertIn("dismissToken();", source)
for forbidden in (
"localStorage",
"sessionStorage",
"agent_secret",
"api3_password",
):
self.assertNotIn(forbidden, source)
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Focused tests for the Agent Management read model."""
import unittest
from uuid import UUID
from app.agent_registry.service import AgentRegistryService
from app.identity import required_api_permission
SITE_ID = UUID("00000000-0000-4000-8000-000000001800")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001801")
class FakeAgentRegistryRepository:
def list_registry(self):
return {
"sites": [
{
"id": SITE_ID,
"name": "Test Site",
}
],
"zones": [
{
"id": ZONE_ID,
"site_id": SITE_ID,
"site_name": "Test Site",
"name": "Network A",
"description": None,
"is_enabled": True,
"assigned_agent_id": "agent-test",
"networks": ["10.50.0.0/24"],
}
],
"agents": [
{
"agent_id": "agent-test",
"display_name": "Test Agent",
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Network A",
"agent_zone_enabled": True,
"hostname": "test-agent",
"version": "1.0.0-alpha",
"ip_address": "10.50.0.10",
"last_checkin_at": "2026-08-26T20:00:00+00:00",
"credential_state": "active",
"credential_created_at": "2026-08-26T19:00:00+00:00",
"credential_revoked_at": None,
}
],
}
class AgentRegistryServiceTests(unittest.TestCase):
def test_projection_is_factual_and_zone_aware(self):
data = AgentRegistryService(
FakeAgentRegistryRepository()
).list_registry()
self.assertEqual(len(data.agents), 1)
self.assertEqual(len(data.sites), 1)
self.assertEqual(len(data.zones), 1)
agent = data.agents[0]
self.assertEqual(agent.agent_id, "agent-test")
self.assertEqual(agent.credential_state, "active")
self.assertEqual(agent.routing_state, "assigned")
self.assertEqual(
data.zones[0].networks,
["10.50.0.0/24"],
)
def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self):
repository = FakeAgentRegistryRepository()
repository.list_registry = lambda: {
"sites": [],
"zones": [],
"agents": [
{
"agent_id": "legacy",
"display_name": None,
"site_id": None,
"site_name": "Legacy Site",
"agent_zone_id": None,
"agent_zone_name": None,
"agent_zone_enabled": None,
"hostname": None,
"version": None,
"ip_address": None,
"last_checkin_at": "2026-08-26T20:00:00+00:00",
"credential_state": "legacy",
"credential_created_at": None,
"credential_revoked_at": None,
}
],
}
data = AgentRegistryService(repository).list_registry()
self.assertEqual(
data.agents[0].routing_state,
"unassigned",
)
self.assertEqual(
data.agents[0].credential_state,
"legacy",
)
def test_api_permission_mapping_is_explicit(self):
self.assertEqual(
required_api_permission("GET", "/api/agents"),
"agents.view",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,196 @@
"""PostgreSQL integration test for the Agent registry projection."""
import unittest
from uuid import uuid4
from app.agent_registry.repository import (
AgentRegistryRepository,
default_connection_factory,
)
from app.agent_registry.service import AgentRegistryService
class AgentRegistryPostgresTests(unittest.TestCase):
def setUp(self):
self.site_id = uuid4()
self.zone_id = uuid4()
self.agent_id = f"agent-registry-{uuid4().hex}"
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES (%s, %s)
""",
(
self.site_id,
"Registry Integration Site",
),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name,
description,
is_enabled
)
VALUES (%s, %s, %s, %s, true)
""",
(
self.zone_id,
self.site_id,
"Registry Network",
"Integration-test network",
),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (
agent_zone_id,
network
)
VALUES (%s, '10.251.0.0/24'::cidr)
""",
(self.zone_id,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_name,
site_id,
agent_zone_id,
display_name,
hostname,
version,
ip_address,
agent_secret_hash,
credential_created_at,
legacy_auth_disabled_at
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
now(),
now()
)
""",
(
self.agent_id,
"Registry Integration Site",
self.site_id,
self.zone_id,
"Registry Test Agent",
"registry-agent",
"1.0.0-alpha",
"10.251.0.10",
"a" * 64,
),
)
connection.commit()
finally:
connection.close()
def tearDown(self):
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
DELETE FROM minerops_site_agents
WHERE agent_id = %s
""",
(self.agent_id,),
)
cur.execute(
"""
DELETE FROM agent_zone_network_ranges
WHERE agent_zone_id = %s
""",
(self.zone_id,),
)
cur.execute(
"""
DELETE FROM agent_zones
WHERE id = %s
""",
(self.zone_id,),
)
cur.execute(
"""
DELETE FROM sites
WHERE id = %s
""",
(self.site_id,),
)
connection.commit()
finally:
connection.close()
def test_registry_joins_site_zone_network_and_redacts_secret_hash(self):
data = AgentRegistryService(
AgentRegistryRepository()
).list_registry()
agents = [
item
for item in data.agents
if item.agent_id == self.agent_id
]
self.assertEqual(len(agents), 1)
agent = agents[0]
self.assertEqual(
agent.site_name,
"Registry Integration Site",
)
self.assertEqual(
agent.agent_zone_name,
"Registry Network",
)
self.assertEqual(agent.credential_state, "active")
self.assertEqual(agent.routing_state, "assigned")
zones = [
zone
for zone in data.zones
if zone.id == self.zone_id
]
self.assertEqual(len(zones), 1)
self.assertEqual(
zones[0].networks,
["10.251.0.0/24"],
)
serialized = data.model_dump_json()
self.assertNotIn("agent_secret_hash", serialized)
self.assertNotIn("a" * 64, serialized)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,633 @@
"""Runtime PostgreSQL isolation tests for Agent-owned machine transport."""
import asyncio
import json
import os
import unittest
from uuid import UUID
import psycopg
from fastapi import HTTPException
from starlette.requests import Request
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
from app.config import settings
from app.main import (
minerops_agent_api3_enrichment,
minerops_agent_miner_enrichment_v2,
minerops_agent_miner_enrichment_v3,
)
SITE_A = UUID("00000000-0000-4000-8000-000000001710")
SITE_B = UUID("00000000-0000-4000-8000-000000001711")
ZONE_A = UUID("00000000-0000-4000-8000-000000001712")
ZONE_B = UUID("00000000-0000-4000-8000-000000001713")
MINER_A = UUID("00000000-0000-4000-8000-000000001714")
MINER_B = UUID("00000000-0000-4000-8000-000000001715")
AGENT_A = "agent-transport-isolation-a"
AGENT_B = "agent-transport-isolation-b"
IP_A = "198.51.100.171"
IP_B = "198.51.100.172"
MAC_A = "02:00:00:00:17:A1"
MAC_B = "02:00:00:00:17:B1"
def connect():
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,
)
def json_request(path, payload):
body = json.dumps(payload).encode("utf-8")
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {
"type": "http.request",
"body": b"",
"more_body": False,
}
delivered = True
return {
"type": "http.request",
"body": body,
"more_body": False,
}
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": [(b"content-type", b"application/json")],
"client": ("testclient", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
class AgentTransportIsolationPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get(
"MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE"
) != "1":
raise unittest.SkipTest(
"Runtime Agent transport isolation is executed "
"by its dedicated disposable PostgreSQL runner."
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES
(%s, 'Transport Isolation Site A'),
(%s, 'Transport Isolation Site B')
ON CONFLICT (id) DO NOTHING
""",
(SITE_A, SITE_B),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name,
is_enabled
)
VALUES
(
%s,
%s,
'Transport Isolation Zone A',
true
),
(
%s,
%s,
'Transport Isolation Zone B',
true
)
ON CONFLICT (id) DO UPDATE
SET is_enabled = true
""",
(ZONE_A, SITE_A, ZONE_B, SITE_B),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name,
last_payload
)
VALUES
(
%s,
%s,
%s,
'Transport Isolation Site A',
'{}'::jsonb
),
(
%s,
%s,
%s,
'Transport Isolation Site B',
'{}'::jsonb
)
ON CONFLICT (agent_id) DO UPDATE
SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
last_payload = EXCLUDED.last_payload
""",
(
AGENT_A,
SITE_A,
ZONE_A,
AGENT_B,
SITE_B,
ZONE_B,
),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
# Immutable identity/history rows intentionally are not deleted.
# The disposable Compose runner destroys the database volume.
pass
@staticmethod
def _delete_optional_rows(cur, table, predicate, params):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
if cur.fetchone()[0] is not None:
cur.execute(
f"DELETE FROM {table} WHERE {predicate}",
params,
)
def setUp(self):
self.zone_service = AgentZoneService(
AgentZoneRepository(connect)
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miners (
id,
site_id,
agent_zone_id,
asset_tag,
current_mac,
current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
notes,
api3_supported
)
VALUES
(
%s,
%s,
%s,
'TRANSPORT-ZONE-A',
%s,
%s::inet,
'zone-a-before',
'ZONE-A-SERIAL',
'Zone A Model',
'Zone A Model',
'1.0-a',
'fw-a',
'platform-a',
'online',
'Zone A baseline',
false
),
(
%s,
%s,
%s,
'TRANSPORT-ZONE-B',
%s,
%s::inet,
'zone-b-before',
'ZONE-B-SERIAL',
'Zone B Model',
'Zone B Model',
'1.0-b',
'fw-b',
'platform-b',
'online',
'Zone B baseline',
false
)
ON CONFLICT (id) DO NOTHING
""",
(
MINER_A,
SITE_A,
ZONE_A,
MAC_A,
IP_A,
MINER_B,
SITE_B,
ZONE_B,
MAC_B,
IP_B,
),
)
connection.commit()
finally:
connection.close()
def _table_exists(self, cur, table):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
return cur.fetchone()[0] is not None
def _snapshot_rows(self, cur, table):
cur.execute(
f"""
SELECT to_jsonb(t)
FROM {table} AS t
WHERE miner_id IN (%s, %s)
ORDER BY to_jsonb(t)::text
""",
(MINER_A, MINER_B),
)
return [row[0] for row in cur.fetchall()]
def _count_optional_miner_rows(self, cur, table, miner_id):
if not self._table_exists(cur, table):
return 0
cur.execute(
f"""
SELECT COUNT(*)
FROM {table}
WHERE miner_id = %s
""",
(miner_id,),
)
return cur.fetchone()[0]
def _capture_inventory_state(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT id::text, to_jsonb(m)
FROM miners AS m
WHERE id IN (%s, %s)
ORDER BY id
""",
(MINER_A, MINER_B),
)
miners = {
row[0]: row[1]
for row in cur.fetchall()
}
ownerships = self._snapshot_rows(
cur,
"miner_mac_ownerships",
)
identities = self._snapshot_rows(
cur,
"miner_network_identities",
)
counts = {}
for table in (
"miner_status_snapshots",
"miner_api3_enrichment_snapshots",
"miner_api3_hashboard_snapshots",
"miner_pool_snapshots",
):
counts[(table, "A")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_A,
)
)
counts[(table, "B")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_B,
)
)
return {
"miners": miners,
"mac_ownerships": ownerships,
"network_identities": identities,
"snapshot_counts": counts,
}
finally:
connection.close()
def _cross_zone_api3_probe_count(self):
connection = connect()
try:
with connection.cursor() as cur:
if not self._table_exists(
cur,
"miner_api3_enrichment_snapshots",
):
return 0
cur.execute(
"""
SELECT COUNT(*)
FROM miner_api3_enrichment_snapshots
WHERE agent_id = %s
AND (
host(ip_address) = %s
OR upper(mac_address) = upper(%s)
)
""",
(AGENT_A, IP_B, MAC_B),
)
return cur.fetchone()[0]
finally:
connection.close()
def test_v2_cannot_address_other_zone_miner_directly(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_B),
"mac_address": MAC_B,
"hostname": "zone-a-must-not-write-this",
"model": "cross-zone-v2-probe",
},
)
with self.assertRaises(HTTPException) as context:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
self.assertEqual(context.exception.status_code, 404)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_A),
"ip_address": IP_A,
"mac_address": MAC_B,
"hostname": "cross-zone-merge-probe",
"miner_sn": "MUST-NOT-CROSS-ZONES",
"model": "Cross Zone Merge Probe",
},
)
error = None
try:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
except Exception as exc:
error = exc
self.assertIsNotNone(
error,
"A Zone A miner must not successfully claim a Zone B MAC",
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v3_cannot_match_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v3",
{
"miner_id": str(MINER_B),
"ip_address": IP_B,
"mac_address": MAC_B,
"hostname": "zone-a-v3-cross-zone-probe",
"miner_sn": "V3-MUST-NOT-MUTATE",
"model": "V3 Cross Zone Probe",
"api_version": "99.99",
"firmware_version": "cross-zone",
"platform": "cross-zone",
"raw_version": {"cross_zone": True},
"raw_miner_info": {"cross_zone": True},
},
)
result = asyncio.run(
minerops_agent_miner_enrichment_v3(
request,
AGENT_A,
self.zone_service,
)
)
self.assertFalse(result["matched"])
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
)
request = json_request(
"/api/agent/api3-enrichment",
{
"agent_id": AGENT_B,
"site_name": "spoofed-zone-b",
"items": [
{
"ip": IP_B,
"mac": MAC_B,
"api3_supported": True,
"api3_error": None,
"device_info": {
"msg": {
"network": {
"ip": IP_B,
"mac": MAC_B,
},
"miner": {
"type": "Cross Zone API3 Probe",
"miner-sn": "API3-MUST-NOT-MUTATE",
},
"system": {
"api": "99.99",
"apiswitch": "on",
"fwversion": "cross-zone-fw",
"platform": "cross-zone-platform",
},
"power": {
"sn": "cross-zone-power",
"model": "cross-zone-psu",
},
}
},
"miner_setting": {
"msg": {
"power-mode": "high",
}
},
"miner_status": {
"msg": {
"summary": {
"hash-average": 999,
"power-realtime": 9999,
"power-rate": 99,
"chip-temp-avg": 99,
"chip-temp-max": 100,
},
"pools": [
{
"id": 0,
"status": "Alive",
"account": "cross-zone-worker",
"url": "stratum+tcp://invalid.example:3333",
}
],
"edevs": [
{
"slot": 0,
"hash-average": 999,
"factory-hash": 999,
"freq": 999,
"effective-chips": 999,
}
],
}
},
"fan_setting": {
"msg": {
"mode": "cross-zone",
}
},
"custom_data": {
"msg": {
"CustomerSn": "CROSS-ZONE-CUSTOMER",
}
},
}
],
},
)
asyncio.run(
minerops_agent_api3_enrichment(
request,
AGENT_A,
self.zone_service,
)
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
(
"Agent A must not persist API3 enrichment associated "
"with Zone B identity data"
),
)
if __name__ == "__main__":
unittest.main()
+279
View File
@@ -0,0 +1,279 @@
"""Tests for downloadable Windows Agent enrollment packages."""
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_enrollment.router import router
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
WINDOWS_PACKAGE_FILES,
WINDOWS_PACKAGE_ROOT,
build_windows_agent_package,
normalize_hub_url,
)
from app.identity import get_current_user, required_api_permission
TOKEN = "t" * 43
class WindowsPackageBuilderTests(unittest.TestCase):
def source_tree(self, root: Path):
for name in WINDOWS_PACKAGE_FILES:
(root / name).write_text(f"source:{name}\n")
def test_package_contains_only_generic_source_plus_bootstrap_material(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
package = build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
source_root=root,
)
with zipfile.ZipFile(io.BytesIO(package)) as archive:
names = set(archive.namelist())
expected = {
f"{WINDOWS_PACKAGE_ROOT}/{name}"
for name in WINDOWS_PACKAGE_FILES
}
expected.update(
{
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
}
)
self.assertEqual(names, expected)
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(enrollment["workflow"], "new_agent")
self.assertEqual(
enrollment["hub_url"],
"https://minerops.example.test",
)
serialized = json.dumps(enrollment)
self.assertNotIn("agent_secret", serialized)
self.assertNotIn("api3_password", serialized)
self.assertNotIn("api3_account", serialized)
self.assertNotIn("agent-config.json", "\n".join(names))
def test_existing_agent_transition_package_is_generic_and_distinct(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
package = build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
workflow="existing_agent_credential_transition",
source_root=root,
)
with zipfile.ZipFile(io.BytesIO(package)) as archive:
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
instructions = archive.read(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
).decode("utf-8")
names = set(archive.namelist())
self.assertEqual(
enrollment,
{
"hub_url": "https://minerops.example.test",
"token": TOKEN,
"workflow": "existing_agent_credential_transition",
},
)
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
self.assertIn(
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
names,
)
serialized = json.dumps(enrollment)
for forbidden in (
"plexus-windows-agent",
"site_id",
"agent_zone_id",
"agent_secret",
):
self.assertNotIn(forbidden, serialized)
def test_missing_canonical_source_fails_closed(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
(root / WINDOWS_PACKAGE_FILES[0]).unlink()
with self.assertRaises(AgentWindowsPackageSourceError):
build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
source_root=root,
)
def test_hub_url_is_origin_only(self):
self.assertEqual(
normalize_hub_url("https://minerops.example.test/"),
"https://minerops.example.test",
)
for invalid in (
"ftp://minerops.example.test",
"https://user:pass@minerops.example.test",
"https://minerops.example.test/path",
"https://minerops.example.test/?token=x",
):
with self.subTest(value=invalid):
with self.assertRaises(AgentWindowsPackageError):
normalize_hub_url(invalid)
class WindowsPackageRouteTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-4000-8000-000000009100",
"username": "package-test",
"display_name": "Package Test",
"role": "Test",
"permissions": tuple(permissions),
}
def test_permission_mapping_is_manage_only(self):
self.assertEqual(
required_api_permission(
"POST",
"/api/agent-enrollments/windows-package",
),
"agents.manage",
)
self.assertEqual(
required_api_permission(
"POST",
"/api/agent-credential-transitions/windows-package",
),
"agents.manage",
)
def test_view_only_user_cannot_download_package(self):
self.authenticate(("agents.view",))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 403)
def test_manager_download_is_same_origin_and_not_cacheable(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers["content-type"],
"application/zip",
)
self.assertEqual(
response.headers["cache-control"],
"no-store",
)
self.assertIn(
'filename="MinerOpsAgent-Windows.zip"',
response.headers["content-disposition"],
)
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(enrollment["workflow"], "new_agent")
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-credential-transitions/windows-package",
headers={"Origin": "http://testserver"},
json={"token": TOKEN},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertIn(
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
response.headers["content-disposition"],
)
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
enrollment = json.loads(
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
)
instructions = archive.read(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
).decode("utf-8")
self.assertEqual(
enrollment["workflow"],
"existing_agent_credential_transition",
)
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
def test_package_requires_browser_origin(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()
+319
View File
@@ -0,0 +1,319 @@
"""Focused model, service, router, and architecture tests for Agent Zones v1."""
import unittest
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import AgentZoneValidationError
from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest
from app.agent_zones.router import router
from app.agent_zones.service import AgentZoneService
from app.identity import get_current_user, required_api_permission
SITE_A = UUID("00000000-0000-4000-8000-000000001601")
SITE_B = UUID("00000000-0000-4000-8000-000000001602")
ZONE_A = UUID("00000000-0000-4000-8000-000000001611")
ZONE_B = UUID("00000000-0000-4000-8000-000000001612")
MINER_A = UUID("00000000-0000-4000-8000-000000001621")
APP_ROOT = Path(__file__).parents[1] / "app"
def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"):
return {
"id": zone_id,
"site_id": site_id,
"name": name,
"description": None,
"is_enabled": enabled,
"assigned_agent_id": None,
"networks": [],
}
class FakeRepository:
def __init__(self):
self.zones = {ZONE_A: zone()}
self.agent = None
self.saved = []
self.assign_result = {MINER_A: "assigned"}
def get_agent(self, agent_id):
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
def get_zone(self, zone_id):
return self.zones.get(zone_id)
def list_usable_zones(self, site_id):
return [
value for value in self.zones.values()
if value["site_id"] == site_id and value["is_enabled"]
]
def list_zones(self, site_id=None):
return [
value for value in self.zones.values()
if site_id is None or value["site_id"] == site_id
]
def create_zone(self, *, site_id, name, description, networks):
created_id = uuid4()
self.zones[created_id] = {
**zone(created_id, site_id, name=name),
"description": description,
"networks": [
{"id": uuid4(), "network": network} for network in networks
],
}
return created_id
def save_checkin(self, **kwargs):
self.saved.append(kwargs)
self.agent = {
"agent_id": kwargs["agent_id"],
"site_id": kwargs["site_id"],
"agent_zone_id": kwargs["agent_zone_id"],
"site_name": kwargs["site_name"],
}
def get_agent_assignment(self, agent_id):
if not self.agent or self.agent["agent_id"] != agent_id:
return None
zone_row = self.zones.get(self.agent.get("agent_zone_id"))
return {
**self.agent,
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
}
def get_agent_configuration(self, agent_id):
assignment = self.get_agent_assignment(agent_id)
if not assignment:
return None
zone_row = self.zones.get(assignment.get("agent_zone_id"))
return {
"agent_id": agent_id,
"site_id": assignment.get("site_id"),
"agent_zone_id": assignment.get("agent_zone_id"),
"site_name": assignment.get("site_name"),
"agent_zone_name": zone_row["name"] if zone_row else None,
"agent_zone_enabled": zone_row["is_enabled"] if zone_row else None,
"networks": [
item["network"] for item in (zone_row or {}).get("networks", [])
],
}
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(miner_ids), agent_zone_id)
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
for miner_id in miner_ids}
class AgentZoneModelTests(unittest.TestCase):
def test_names_and_networks_are_normalized(self):
request = AgentZoneCreateRequest(
site_id=SITE_A,
name=" Main Room ",
description=" Local VLAN ",
networks=["10.30.0.0/24", "2001:db8::/64"],
)
self.assertEqual(request.name, "Main Room")
self.assertEqual(request.description, "Local VLAN")
self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"])
def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self):
for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]):
with self.subTest(networks=networks), self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks)
with self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True)
class AgentZoneServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
def checkin(self, **kwargs):
return self.service.checkin(
"agent-a",
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
)
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.assertTrue(first.routing_available)
self.assertEqual(second.agent_zone_id, ZONE_A)
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A)
)
def test_established_agent_cannot_change_zone_within_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2")
with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_established_agent_site_conflict_precedes_zone_conflict(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_omitted_zone_preserves_established_assignment_and_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North")
)
self.assertEqual(result.site_id, SITE_A)
self.assertEqual(result.agent_zone_id, ZONE_A)
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
single = self.checkin(site_id=SITE_A)
self.assertEqual(single.agent_zone_id, ZONE_A)
self.repository.agent = None
self.repository.zones = {}
zero = self.checkin(site_id=SITE_A)
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
self.repository.agent = None
self.repository.zones = {
ZONE_A: zone(),
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
}
many = self.checkin(site_id=SITE_A)
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
self.assertIsNone(many.agent_zone_id)
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
self.repository.zones[ZONE_A]["is_enabled"] = False
unassigned = self.checkin(site_id=SITE_A)
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
self.repository.agent = {
"agent_id": "agent-a", "site_id": SITE_A,
"agent_zone_id": ZONE_A, "site_name": "North",
}
existing = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a")
)
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
self.assertEqual(existing.agent_zone_id, ZONE_A)
def test_payload_is_sanitized_before_persistence(self):
request = AgentCheckinRequest.model_validate({
"agent_id": "agent-a", "site_id": str(SITE_A),
"api3_password": "secret", "nested": {"access_token": "secret"},
"control_available": True,
})
self.service.checkin("agent-a", request)
payload = self.repository.saved[-1]["payload"]
self.assertNotIn("password", str(payload).lower())
self.assertNotIn("token", str(payload).lower())
self.assertTrue(payload["control_available"])
def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.assign_result[MINER_A] = "agent_zone_conflict"
result = self.service.associate_miners_from_agent("agent-a", [MINER_A])
self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict")
self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A))
def test_routing_context_rejects_stale_recorded_zone(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.routing_context("agent-a", ZONE_B)
self.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertIsNone(result.site_id)
def test_configuration_uses_only_authenticated_agent_zone_networks(self):
self.repository.zones[ZONE_A]["networks"] = [
{"id": uuid4(), "network": "10.30.0.0/24"}
]
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
self.repository.zones[ZONE_B]["networks"] = [
{"id": uuid4(), "network": "10.80.0.0/24"}
]
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
configuration = self.service.configuration("agent-a")
self.assertEqual(configuration["agent_zone_id"], ZONE_A)
self.assertEqual(
[item["cidr"] for item in configuration["ip_ranges"]],
["10.30.0.0/24"],
)
self.assertNotIn("10.80.0.0/24", str(configuration))
class AgentZoneRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service
self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a"
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "username": "operator", "role": "Custom",
"permissions": list(permissions),
}
def test_list_and_create_use_existing_agent_permissions(self):
self.assertEqual(self.client.get("/api/agent-zones").status_code, 401)
self.authenticate(("agents.view",))
self.assertEqual(self.client.get("/api/agent-zones").status_code, 200)
self.assertEqual(
self.client.post(
"/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"}
).status_code,
403,
)
self.authenticate(("agents.manage",))
response = self.client.post(
"/api/agent-zones",
json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["name"], "Room 2")
def test_checkin_uses_authenticated_agent_identity(self):
response = self.client.post(
"/api/agent/checkin",
json={"agent_id": "agent-a", "site_id": str(SITE_A),
"agent_zone_id": str(ZONE_A)},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["agent_id"], "agent-a")
self.assertNotIn("secret", response.text.lower())
def test_permission_and_architecture_audits(self):
self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view")
self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage")
router_source = (APP_ROOT / "agent_zones" / "router.py").read_text()
service_source = (APP_ROOT / "agent_zones" / "service.py").read_text()
repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text()
self.assertNotIn("psycopg", router_source)
self.assertNotIn("SELECT ", router_source)
self.assertNotIn("fastapi", service_source.lower())
self.assertNotIn("fastapi", repository_source.lower())
self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text())
if __name__ == "__main__":
unittest.main()
+262
View File
@@ -0,0 +1,262 @@
"""Real PostgreSQL integrity and routing tests for Agent Zones v1."""
import unittest
from uuid import UUID
from unittest.mock import patch
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from app.agent_zones.repository import AgentZoneRepository
from app.config import settings
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
from app.miner_control.exceptions import MinerControlUnavailable
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
SITE_B = UUID("00000000-0000-4000-8000-000000001631")
ZONE_A = UUID("00000000-0000-4000-8000-000000001632")
ZONE_B = UUID("00000000-0000-4000-8000-000000001633")
ZONE_C = UUID("00000000-0000-4000-8000-000000001636")
MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634")
MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635")
AGENT_A = "agent-zone-postgres-a"
def connect():
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 AgentZonePostgresTests(unittest.TestCase):
def assert_stage_a_unavailable(self, expected_zone, expected_agent):
repository = MinerControlRepository(connect)
context = repository.get_control_context(MINER_UNASSIGNED)
# Check actual routing facts independently of the unconditional Stage A
# reason. A changed assertion must not hide a Site fallback regression.
self.assertEqual(context["agent_zone_id"], expected_zone)
self.assertEqual(context["agent_id"], expected_agent)
projection = MinerControlService(repository).get_controls(MINER_UNASSIGNED)
for capability in (projection.service_control, projection.performance_mode_control):
self.assertEqual(capability.state, "unavailable")
self.assertEqual(capability.reason, PHYSICAL_CONTROL_DISABLED)
self.assertIsNone(projection.available_service_action)
with patch.object(repository, "enqueue_with_audit") as enqueue:
for action, value in (("suspend", None), ("resume", None), ("performance_mode", "high")):
with self.assertRaisesRegex(MinerControlUnavailable, PHYSICAL_CONTROL_DISABLED):
MinerControlService(repository).queue_control(
MINER_UNASSIGNED, action, requested_value=value,
actor_user_id=UUID("00000000-0000-4000-8000-000000001639"),
actor_username="zone-containment-test",
)
enqueue.assert_not_called()
return context
@classmethod
def setUpClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), "
"(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING",
(SITE_A, SITE_B),
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) VALUES "
"(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), "
"(%s, %s, 'Unstaffed Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A),
)
cur.execute(
"INSERT INTO miners (id, site_id, asset_tag, current_ip, status, "
"api3_supported, first_seen_at, last_seen_at) VALUES "
"(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), "
"(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) "
"ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL",
(MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B),
)
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_B, MINER_OTHER),
)
cur.execute(
"INSERT INTO minerops_site_agents ("
"agent_id, site_id, agent_zone_id, site_name, last_payload) "
"VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') "
"ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, "
"agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload",
(AGENT_A, SITE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'")
cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER))
cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B))
connection.commit()
finally:
connection.close()
def setUp(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE agent_zones SET is_enabled = true "
"WHERE id IN (%s, %s, %s)",
(ZONE_A, ZONE_B, ZONE_C),
)
cur.execute(
"UPDATE miners SET agent_zone_id = NULL WHERE id = %s",
(MINER_UNASSIGNED,),
)
cur.execute(
"UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s",
(SITE_B, ZONE_B, MINER_OTHER),
)
cur.execute(
"UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s "
"WHERE agent_id = %s",
(SITE_A, ZONE_A, AGENT_A),
)
connection.commit()
finally:
connection.close()
def test_repository_lists_multiple_canonical_networks(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zone_network_ranges (agent_zone_id, network) "
"VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') "
"ON CONFLICT DO NOTHING",
(ZONE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
listed = AgentZoneRepository(connect).list_zones(SITE_A)
self.assertEqual([item["network"] for item in listed[0]["networks"]],
["10.30.0.0/24", "10.31.0.0/24"])
def test_active_names_are_case_insensitively_unique_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')",
(SITE_A,),
)
finally:
connection.rollback()
connection.close()
def test_one_agent_per_zone_but_multiple_zones_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO minerops_site_agents "
"(agent_id, site_id, agent_zone_id, site_name) "
"VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')",
(SITE_A, ZONE_A),
)
finally:
connection.rollback()
connection.close()
def test_composite_foreign_keys_reject_cross_site_assignment(self):
connection = connect()
try:
with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_OTHER),
)
finally:
connection.rollback()
connection.close()
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
repository = AgentZoneRepository(connect)
outcomes = repository.assign_miners_to_zone(
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
)
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)",
(MINER_UNASSIGNED, MINER_OTHER),
)
assignments = dict(cur.fetchall())
finally:
connection.close()
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
def test_disabled_zone_blocks_control_without_site_fallback(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_UNASSIGNED),
)
cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
try:
context = self.assert_stage_a_unavailable(ZONE_A, AGENT_A)
self.assertFalse(context["agent_zone_enabled"])
finally:
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
def test_enabled_zone_without_agent_does_not_fall_back_within_site(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_C, MINER_UNASSIGNED),
)
connection.commit()
finally:
connection.close()
self.assert_stage_a_unavailable(ZONE_C, None)
def test_null_zone_does_not_fall_back_to_site_agent(self):
self.assert_stage_a_unavailable(None, None)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,275 @@
"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent."""
import os
import unittest
from uuid import UUID
from fastapi.testclient import TestClient
from app.agent_auth.service import hash_agent_secret
from app.config import settings
from app.main import app
SITE_A = UUID("00000000-0000-4000-8000-000000001a01")
SITE_B = UUID("00000000-0000-4000-8000-000000001a02")
ZONE_C = UUID("00000000-0000-4000-8000-000000001a03")
MINER_A = UUID("00000000-0000-4000-8000-000000001a11")
MINER_B = UUID("00000000-0000-4000-8000-000000001a12")
LEGACY_AGENT = "legacy-plexus-http"
ENROLLED_AGENT = "enrolled-http-agent"
REVOKED_AGENT = "revoked-http-agent"
ENROLLED_SECRET = "enrolled-http-agent-secret"
REVOKED_SECRET = "revoked-http-agent-secret"
ZONE_A_NETWORK = "10.31.0.0/24"
ZONE_B_NETWORK = "10.81.0.0/24"
ZONE_C_NETWORK = "10.82.0.0/24"
def connect():
from app.agent_auth.repository import default_connection_factory
return default_connection_factory()
class LegacyAgentHttpPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1":
raise unittest.SkipTest(
"full-stack legacy contract requires its disposable runner"
)
if settings.minerops_agent_id != LEGACY_AGENT:
raise AssertionError("runner did not bind the configured legacy identity")
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT agent_zone_id
FROM minerops_site_agents
WHERE agent_id = %s
""",
(LEGACY_AGENT,),
)
cls.zone_a = cur.fetchone()[0]
cur.execute(
"""
SELECT id FROM agent_zones
WHERE site_id = %s AND name = 'Default Zone'
""",
(SITE_B,),
)
cls.zone_b = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES
(%s, %s::cidr),
(%s, %s::cidr)
""",
(cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK),
)
cur.execute(
"""
INSERT INTO agent_zones (id, site_id, name)
VALUES (%s, %s, 'Revoked Contract Zone')
""",
(ZONE_C, SITE_B),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, %s::cidr)
""",
(ZONE_C, ZONE_C_NETWORK),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name, display_name,
agent_secret_hash, credential_created_at,
credential_revoked_at, legacy_auth_disabled_at
) VALUES
(%s, %s, %s, 'Contract Site B', 'Enrolled Agent',
%s, now(), NULL, now()),
(%s, %s, %s, 'Contract Site B', 'Revoked Agent',
%s, now() - interval '1 hour', now(),
now() - interval '1 hour')
""",
(
ENROLLED_AGENT,
SITE_B,
cls.zone_b,
hash_agent_secret(ENROLLED_SECRET),
REVOKED_AGENT,
SITE_B,
ZONE_C,
hash_agent_secret(REVOKED_SECRET),
),
)
connection.commit()
finally:
connection.close()
# Deliberately do not enter TestClient as a context manager. That keeps
# unrelated application startup jobs out of this focused HTTP contract
# while exercising the real ASGI middleware, dependencies, and routes.
cls.client = TestClient(app)
@classmethod
def tearDownClass(cls):
cls.client.close()
@staticmethod
def legacy_headers():
return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret}
@staticmethod
def agent_headers(agent_id, secret):
return {
"X-MinerOps-Agent-ID": agent_id,
"X-MinerOps-Agent-Secret": secret,
}
def assert_no_credentials(self, response):
lowered = response.text.lower()
for forbidden in (
"agent_secret_hash", "credential_created_at", "token_hash",
settings.minerops_agent_shared_secret.lower(),
ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(),
):
self.assertNotIn(forbidden, lowered)
def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self):
response = self.client.get(
"/api/agent/config?agent_id=" + ENROLLED_AGENT
+ "&agent_zone_id=" + str(self.zone_b),
headers=self.legacy_headers(),
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual(payload["agent_id"], LEGACY_AGENT)
self.assertEqual(payload["agent_zone_id"], str(self.zone_a))
self.assertEqual(
[item["cidr"] for item in payload["ip_ranges"]],
[ZONE_A_NETWORK],
)
self.assertNotIn(ZONE_B_NETWORK, response.text)
self.assertNotIn(ZONE_C_NETWORK, response.text)
self.assert_no_credentials(response)
def test_20_known_miners_returns_only_migrated_legacy_zone(self):
response = self.client.get(
"/api/agent/known-miners", headers=self.legacy_headers()
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(
[item["miner_id"] for item in response.json()["miners"]],
[str(MINER_A)],
)
self.assertNotIn(str(MINER_B), response.text)
self.assert_no_credentials(response)
def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self):
old_body = {
"agent_id": LEGACY_AGENT,
"site_name": "Plexus",
"hostname": "legacy-contract-host",
"version": "0.4-command-queue",
"ip_address": "",
"local_time": "2026-08-31T00:00:00Z",
}
response = self.client.post(
"/api/agent/checkin", headers=self.legacy_headers(), json=old_body
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["agent_id"], LEGACY_AGENT)
self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a))
self.assert_no_credentials(response)
spoofed = self.client.post(
"/api/agent/checkin?agent_id=" + ENROLLED_AGENT,
headers=self.legacy_headers(),
json={**old_body, "agent_id": "body-spoof-agent"},
)
self.assertEqual(spoofed.status_code, 422, spoofed.text)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s",
("body-spoof-agent",),
)
self.assertEqual(cur.fetchone()[0], 0)
finally:
connection.close()
def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self):
for agent_id, expected_status in (
("unknown-agent", 403),
(ENROLLED_AGENT, 401),
):
with self.subTest(agent_id=agent_id):
response = self.client.get(
"/api/agent/config",
headers=self.agent_headers(
agent_id, settings.minerops_agent_shared_secret
),
)
self.assertEqual(response.status_code, expected_status, response.text)
self.assert_no_credentials(response)
def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self):
response = self.client.get(
"/api/agent/config",
headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET),
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual(payload["agent_id"], ENROLLED_AGENT)
self.assertEqual(payload["agent_zone_id"], str(self.zone_b))
self.assertEqual(
[item["cidr"] for item in payload["ip_ranges"]],
[ZONE_B_NETWORK],
)
self.assertNotIn(ZONE_A_NETWORK, response.text)
self.assertNotIn(ZONE_C_NETWORK, response.text)
self.assert_no_credentials(response)
def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self):
for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret):
with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"):
response = self.client.get(
"/api/agent/config",
headers=self.agent_headers(REVOKED_AGENT, secret),
)
self.assertEqual(response.status_code, 403, response.text)
self.assert_no_credentials(response)
def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE minerops_site_agents
SET legacy_auth_disabled_at = now()
WHERE agent_id = %s
""",
(LEGACY_AGENT,),
)
connection.commit()
finally:
connection.close()
response = self.client.get(
"/api/agent/config", headers=self.legacy_headers()
)
self.assertEqual(response.status_code, 401, response.text)
self.assert_no_credentials(response)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""Focused source-level contracts for legacy Agent migrations."""
import importlib.util
import unittest
from pathlib import Path
from unittest.mock import patch
VERSIONS = Path(__file__).parents[1] / "alembic" / "versions"
def load_migration(filename):
path = VERSIONS / filename
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class EmptyResult:
def all(self):
return []
class RecordingConnection:
def __init__(self):
self.statements = []
def execute(self, statement, parameters=None):
del parameters
self.statements.append(str(statement))
return EmptyResult()
class LegacyRoutingMigrationTests(unittest.TestCase):
def test_0018_locks_every_preflight_and_update_table_before_reads(self):
migration = load_migration("0018_legacy_agent_routing.py")
connection = RecordingConnection()
with patch.object(migration.op, "get_bind", return_value=connection):
migration.upgrade()
first = " ".join(connection.statements[0].split()).lower()
self.assertTrue(first.startswith("lock table"))
self.assertIn("in share row exclusive mode", first)
for table in (
"agent_zones", "minerops_agent_discoveries",
"minerops_agent_snapshots", "minerops_site_agents", "sites",
):
with self.subTest(table=table):
self.assertIn(table, first)
first_update = next(
index for index, statement in enumerate(connection.statements)
if "UPDATE " in statement
)
self.assertEqual(first_update, 4)
def test_0019_is_the_direct_forward_only_successor(self):
migration = load_migration("0019_agent_legacy_auth_retire.py")
self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire")
self.assertEqual(migration.down_revision, "0018_legacy_agent_routing")
def test_0020_adds_only_existing_identity_transition_binding(self):
migration = load_migration(
"0020_existing_agent_credential_transition.py"
)
self.assertEqual(migration.revision, "0020_existing_agent_transition")
self.assertEqual(
migration.down_revision,
"0019_agent_legacy_auth_retire",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,223 @@
"""Fresh-chain production compatibility contract for the legacy Plexus Agent."""
import asyncio
import json
import os
import unittest
from uuid import UUID
import psycopg
from starlette.requests import Request
from app.agent_auth.exceptions import (
AgentAuthenticationFailed,
AgentIdentityUnauthorized,
)
from app.agent_auth.repository import AgentAuthRepository
from app.agent_auth.service import AgentAuthService, hash_agent_secret
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
from app.config import settings
from app.main import (
minerops_agent_known_miners,
minerops_agent_miner_snapshots,
)
SITE_A = UUID("00000000-0000-4000-8000-000000001901")
SITE_B = UUID("00000000-0000-4000-8000-000000001902")
MINER_A = UUID("00000000-0000-4000-8000-000000001911")
MINER_B = UUID("00000000-0000-4000-8000-000000001912")
LEGACY_AGENT = "legacy-plexus-production"
NEW_AGENT = "enrolled-agent-production-contract"
NEW_SECRET = "new-agent-production-contract-secret"
def connect():
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,
)
def json_request(payload):
body = json.dumps(payload).encode("utf-8")
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {"type": "http.request", "body": b"", "more_body": False}
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return Request(
{
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/api/agent/miner-snapshots",
"raw_path": b"/api/agent/miner-snapshots",
"query_string": b"",
"headers": [(b"content-type", b"application/json")],
"client": ("testclient", 12345),
"server": ("testserver", 80),
},
receive,
)
class LegacyAgentProductionPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1":
raise unittest.SkipTest(
"Legacy production contract requires its disposable PostgreSQL runner"
)
if settings.minerops_agent_id != LEGACY_AGENT:
raise AssertionError("Runner did not bind the configured legacy identity")
cls.auth = AgentAuthService(AgentAuthRepository(connect))
cls.zones = AgentZoneService(AgentZoneRepository(connect))
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT agent_zone_id
FROM minerops_site_agents
WHERE agent_id = %s
""",
(LEGACY_AGENT,),
)
cls.zone_a = cur.fetchone()[0]
cur.execute(
"""
SELECT id
FROM agent_zones
WHERE site_id = %s AND name = 'Default Zone'
""",
(SITE_B,),
)
cls.zone_b = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name,
agent_secret_hash, credential_created_at,
legacy_auth_disabled_at
)
VALUES (
%s, %s, %s, 'Enrolled Contract Agent', %s, now(), now()
)
""",
(
NEW_AGENT,
SITE_B,
cls.zone_b,
hash_agent_secret(NEW_SECRET),
),
)
connection.commit()
finally:
connection.close()
def test_secret_only_auth_is_bounded_to_legacy_identity(self):
self.assertEqual(
self.auth.authenticate(None, settings.minerops_agent_shared_secret),
LEGACY_AGENT,
)
with self.assertRaises(AgentIdentityUnauthorized):
self.auth.authenticate(
"unknown-agent", settings.minerops_agent_shared_secret
)
with self.assertRaises(AgentAuthenticationFailed):
self.auth.authenticate(
NEW_AGENT, settings.minerops_agent_shared_secret
)
self.assertEqual(
self.auth.authenticate(NEW_AGENT, NEW_SECRET),
NEW_AGENT,
)
def test_migrated_legacy_agent_has_correct_site_and_zone(self):
routing = self.zones.routing_context(LEGACY_AGENT)
self.assertIsNone(routing.routing_reason)
self.assertEqual(routing.site_id, SITE_A)
self.assertEqual(routing.agent_zone_id, self.zone_a)
def test_known_miners_and_telemetry_remain_zone_scoped(self):
known = asyncio.run(
minerops_agent_known_miners(LEGACY_AGENT, self.zones)
)
self.assertEqual(
[row["miner_id"] for row in known["miners"]],
[str(MINER_A)],
)
result = asyncio.run(
minerops_agent_miner_snapshots(
json_request(
{
"agent_id": LEGACY_AGENT,
"site_name": "Plexus",
"miners": [
{
"miner_id": str(MINER_A),
"ip_address": "192.0.2.191",
"online": False,
},
{
"miner_id": str(MINER_B),
"ip_address": "192.0.2.192",
"online": False,
},
],
}
),
LEGACY_AGENT,
self.zones,
)
)
self.assertEqual(result["agent_zone_id"], str(self.zone_a))
self.assertEqual(result["apply_result"]["applied"], 1)
self.assertIn(
{"miner_id": str(MINER_B), "error": "agent_zone_conflict"},
result["apply_result"]["errors"],
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT status FROM miners WHERE id = %s",
(MINER_A,),
)
self.assertEqual(cur.fetchone()[0], "offline")
cur.execute(
"SELECT status FROM miners WHERE id = %s",
(MINER_B,),
)
self.assertEqual(cur.fetchone()[0], "online")
cur.execute(
"""
SELECT count(*)
FROM minerops_agent_snapshots
WHERE agent_id = %s AND agent_zone_id = %s
""",
(LEGACY_AGENT, self.zone_a),
)
self.assertGreaterEqual(cur.fetchone()[0], 1)
finally:
connection.close()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,243 @@
"""Focused regression tests for retired legacy miner-write surfaces."""
import asyncio
import inspect
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app import main
from app.identity import get_current_user, require_permission, required_api_permission
APP_ROOT = Path(__file__).parents[1] / "app"
class LegacyControlRetirementTests(unittest.TestCase):
@staticmethod
def assert_retired(callable_, *args, **kwargs):
try:
result = callable_(*args, **kwargs)
if inspect.isawaitable(result):
asyncio.run(result)
except HTTPException as exc:
if exc.status_code != 410:
raise AssertionError(f"Expected HTTP 410, got {exc.status_code}") from exc
return
raise AssertionError("Expected a fixed HTTP 410 retirement response")
def test_all_legacy_http_write_routes_are_fixed_inert_responses(self):
routes = (
(main.run_dashboard_m30_command_test_lab, {"role": "Admin"}),
(main.retired_direct_reboot_miner_command, "miner-1", {"role": "Admin"}),
(main.real_power_off_miner_command, "miner-1", {"role": "Admin"}),
(main.real_power_on_miner_command, "miner-1", {"role": "Admin"}),
(main.real_suspend_miner_command, "miner-1", {"role": "Admin"}),
(main.real_resume_miner_command, "miner-1", {"role": "Admin"}),
(main.retired_direct_api3_password_change, "miner-1", {"role": "Admin"}),
(main.retired_legacy_agent_miner_control, "miner-1", {"role": "Admin"}),
(main.process_due_dashboard_scheduled_bulk_actions, {"role": "Admin"}),
(main.create_dashboard_scheduled_bulk_action, {"role": "Admin"}),
(main.whatsminer_read_test, {"role": "Admin"}),
)
with (
patch.object(main, "get_db_connection") as sync_database,
patch.object(main, "minerops_command_db_connect") as async_database,
patch.object(main, "insert_miner_command_event") as audit_write,
patch.object(main, "minerops_send_whatsminer_reboot") as reboot_write,
patch.object(main, "minerops_power_command") as power_write,
patch.object(main, "minerops_api3_change_password") as password_write,
):
for route in routes:
with self.subTest(route=route[0].__name__):
self.assert_retired(route[0], *route[1:])
for forbidden_call in (
sync_database,
async_database,
audit_write,
reboot_write,
power_write,
password_write,
):
self.assertFalse(forbidden_call.called)
def test_m30_requires_control_permission_before_inert_response(self):
self.assertEqual(
required_api_permission(
"POST", "/api/dashboard/m30-command-test-lab/run"
),
"miners.control",
)
dependency = require_permission("miners.control")
with self.assertRaises(HTTPException) as denied:
dependency({"role": "Read Only", "permissions": []})
self.assertEqual(denied.exception.status_code, 403)
route_source = inspect.getsource(main.run_dashboard_m30_command_test_lab)
self.assertIn('Depends(require_permission("miners.control"))', route_source)
self.assertIn("status_code=410", route_source)
self.assertNotIn("minerops_agent_command_queue", route_source)
self.assertNotIn("plexus-windows-agent", route_source)
self.assertNotIn("dry_run", route_source)
app = FastAPI()
app.add_api_route(
"/api/dashboard/m30-command-test-lab/run",
main.run_dashboard_m30_command_test_lab,
methods=["POST"],
)
with TestClient(app) as client:
app.dependency_overrides[get_current_user] = lambda: {
"role": "Read Only",
"permissions": [],
}
self.assertEqual(
client.post("/api/dashboard/m30-command-test-lab/run").status_code,
403,
)
app.dependency_overrides[get_current_user] = lambda: {
"role": "Custom",
"permissions": ["miners.control"],
}
self.assertEqual(
client.post("/api/dashboard/m30-command-test-lab/run").status_code,
410,
)
def test_registered_retired_routes_have_no_direct_write_implementation(self):
retired_paths = {
"/api/dashboard/m30-command-test-lab/run",
"/api/dashboard/scheduled-bulk-actions/process-due",
"/api/dashboard/scheduled-bulk-actions",
"/api/tools/whatsminer-read-test",
"/api/miners/{miner_id}/commands/reboot",
"/api/miners/{miner_id}/commands/power-off",
"/api/miners/{miner_id}/commands/power-on",
"/api/miners/{miner_id}/commands/suspend",
"/api/miners/{miner_id}/commands/resume",
"/api/miners/{miner_id}/api3/change-password",
"/api/miners/{miner_id}/agent-control",
}
registered = [
(route.path, route.endpoint)
for route in main.app.routes
if getattr(route, "path", None) in retired_paths
and "POST" in getattr(route, "methods", set())
]
self.assertEqual({path for path, _ in registered}, retired_paths)
self.assertEqual(len(registered), len(retired_paths))
forbidden = (
"socket",
"minerops_send_",
"minerops_power_command",
"minerops_api3_change_password",
"insert_miner_command_event",
"minerops_agent_command_queue",
"minerops_command_db_connect",
"get_db_connection",
"request.json",
)
for path, endpoint in registered:
source = inspect.getsource(endpoint)
with self.subTest(path=path):
self.assertIn("status_code=410", source)
for unsafe_fragment in forbidden:
self.assertNotIn(unsafe_fragment, source)
def test_legacy_scheduler_is_noop_and_not_registered_before_claim(self):
with patch.object(main, "get_db_connection") as database:
self.assertEqual(main.minerops_process_due_scheduled_bulk_actions(), [])
self.assertFalse(database.called)
self.assertFalse(hasattr(main.app.state, "agent_command_before_claim"))
source = inspect.getsource(main.minerops_process_due_scheduled_bulk_actions)
self.assertNotIn("minerops_agent_command_queue", source)
self.assertNotIn("get_db_connection", source)
def test_every_retired_http_route_denies_unprivileged_users_and_never_writes(self):
paths = {
"/api/dashboard/m30-command-test-lab/run",
"/api/dashboard/scheduled-bulk-actions",
"/api/dashboard/scheduled-bulk-actions/process-due",
"/api/tools/whatsminer-read-test",
"/api/miners/{miner_id}/commands/reboot",
"/api/miners/{miner_id}/commands/power-off",
"/api/miners/{miner_id}/commands/power-on",
"/api/miners/{miner_id}/commands/suspend",
"/api/miners/{miner_id}/commands/resume",
"/api/miners/{miner_id}/api3/change-password",
"/api/miners/{miner_id}/agent-control",
}
app = FastAPI()
for route in main.app.routes:
if getattr(route, "path", None) in paths and "POST" in route.methods:
app.add_api_route(route.path, route.endpoint, methods=["POST"])
with TestClient(app) as client, patch.object(main.socket, "create_connection") as transport, patch.object(main, "get_db_connection") as database:
for permissions, expected in (([], 403), (["miners.control", "credentials.manage"], 410)):
app.dependency_overrides[get_current_user] = lambda: {
"role": "Custom", "permissions": permissions,
}
for path in paths:
concrete = path.replace("{miner_id}", "miner-1")
self.assertEqual(client.post(concrete, json={"dry_run": False}).status_code, expected, concrete)
transport.assert_not_called()
database.assert_not_called()
def test_diagnostic_write_disguises_overrides_and_api3_are_retired(self):
app = FastAPI()
app.add_api_route("/api/tools/whatsminer-read-test", main.whatsminer_read_test, methods=["POST"])
app.dependency_overrides[get_current_user] = lambda: {"role": "Custom", "permissions": ["miners.control"]}
attacks = (
{"command": "reboot"}, {"command": "read_then_reboot"},
{"command": "summary", "params": {"command": "reboot"}},
{"command": "summary", "params": {"cmd": "set.miner.service", "param": "stop"}},
{"port": 4433, "command": "set.miner.power_mode", "params": {"param": "high"}},
{"command": "summary\n{\"command\":\"reboot\"}"},
{"command": "summary"},
)
with TestClient(app) as client, patch.object(main.socket, "create_connection") as transport, patch.object(main, "send_whatsminer_read_command") as sender:
for attack in attacks:
self.assertEqual(client.post("/api/tools/whatsminer-read-test", json={"ip_address": "192.0.2.1", **attack}).status_code, 410)
transport.assert_not_called()
sender.assert_not_called()
def test_internal_discovery_helper_cannot_become_a_generic_command_gateway(self):
with patch.object(main.socket, "create_connection") as transport:
for command, port, params in (
("reboot", 4028, None), ("read_reboot", 4028, None),
("summary", 4433, None), ("set.miner.service", 4433, {"param": "stop"}),
("summary", 4028, {"command": "reboot"}),
("summary", 4028, {"cmd": "set.miner.service"}),
("get_miner_info", 4028, {"info": "arbitrary"}),
):
self.assert_retired(main.send_whatsminer_read_command, "192.0.2.1", port, command, 500, params)
transport.assert_not_called()
def test_old_agent_aliases_are_inert_even_without_authentication(self):
app = FastAPI()
app.add_api_route("/api/agent/commands/pending-disabled-old", main.minerops_agent_commands_pending_retired, methods=["GET"])
app.add_api_route("/api/agent/commands/result-disabled-old", main.minerops_agent_commands_result_retired, methods=["POST"])
with TestClient(app) as client, patch.object(main, "minerops_command_db_connect") as database:
self.assertEqual(client.get("/api/agent/commands/pending-disabled-old").status_code, 410)
self.assertEqual(client.post("/api/agent/commands/result-disabled-old", json={"status": "completed"}).status_code, 410)
database.assert_not_called()
def test_canonical_control_still_derives_agent_from_miner_zone(self):
repository_source = (
APP_ROOT / "miner_control" / "repository.py"
).read_text()
self.assertIn("miners.agent_zone_id", repository_source)
self.assertIn(
"agents.agent_zone_id = miners.agent_zone_id",
repository_source,
)
self.assertNotIn("agents.site_id = miners.site_id", repository_source)
if __name__ == "__main__":
unittest.main()
+47 -61
View File
@@ -26,6 +26,9 @@ def context(**overrides):
result = {
"id": MINER_ID,
"site_id": uuid4(),
"agent_zone_id": uuid4(),
"agent_zone_exists": True,
"agent_zone_enabled": True,
"current_ip": "192.0.2.10",
"runtime_state": "online",
"configured_performance_mode": "normal",
@@ -68,62 +71,47 @@ class FakeRepository:
class MinerControlServiceTests(unittest.TestCase):
def test_supported_projection_is_factual_and_bounded(self):
def test_disabled_projection_preserves_facts_and_bounded_history(self):
repository = FakeRepository()
result = MinerControlService(repository).get_controls(MINER_ID)
self.assertEqual(result.service_control.state, "supported")
self.assertEqual(result.service_control.state, "unavailable")
self.assertEqual(result.service_control.reason, "physical_control_disabled_stage_a")
self.assertEqual(result.performance_mode, "normal")
self.assertEqual(result.available_service_action, "suspend")
self.assertIsNone(result.available_service_action)
self.assertEqual(repository.limits, [5])
def test_capability_missing_facts_is_not_optimistic(self):
cases = (
({"site_id": None}, "unavailable", "site_unassigned"),
({"agent_id": None}, "unavailable", "agent_unassigned"),
({"current_ip": None}, "unavailable", "current_ip_missing"),
({"api3_supported": False}, "unavailable", "api3_unsupported"),
({"api3_supported": None}, "unknown", "api3_support_unknown"),
({"last_payload": {}}, "unknown", "agent_capability_unknown"),
(
{"last_payload": {"control_available": False,
"control_unavailable_reason": "credentials_missing"}},
"unavailable", "credentials_missing",
),
)
for changes, state, reason in cases:
with self.subTest(reason=reason):
result = MinerControlService(FakeRepository(context(**changes))).get_controls(MINER_ID)
self.assertEqual(result.service_control.state, state)
self.assertEqual(result.service_control.reason, reason)
def test_capability_cannot_be_enabled_by_agent_claims(self):
for facts in ({}, {"control_available": True}, {"control_available": False}):
repository = FakeRepository(context(last_payload=facts))
result = MinerControlService(repository).get_controls(MINER_ID)
self.assertEqual(result.service_control.state, "unavailable")
self.assertEqual(result.service_control.reason, "physical_control_disabled_stage_a")
def test_resume_requires_known_stopped_state(self):
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
service.queue_control(
MINER_ID, "resume", requested_value=None,
actor_user_id=USER_ID, actor_username="operator",
)
def test_resume_is_disabled_even_for_known_stopped_state(self):
service = MinerControlService(FakeRepository(context(runtime_state="stopped")))
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
service.queue_control(MINER_ID, "resume", requested_value=None,
actor_user_id=USER_ID, actor_username="operator")
def test_queue_contains_routing_but_audit_and_response_do_not(self):
def test_all_current_actions_are_rejected_before_routing_or_queue_write(self):
for action in ("suspend", "resume", "performance_mode"):
repository = FakeRepository()
with self.assertRaisesRegex(MinerControlUnavailable, "physical_control_disabled_stage_a"):
MinerControlService(repository).queue_control(
MINER_ID, action, requested_value="high",
actor_user_id=USER_ID, actor_username="operator")
self.assertEqual(repository.enqueue_calls, [])
self.assertFalse(hasattr(repository, "requested_miner"))
def test_rejected_request_leaves_context_unchanged(self):
repository = FakeRepository()
result = MinerControlService(repository).queue_control(
MINER_ID, "performance_mode", requested_value="high",
actor_user_id=USER_ID, actor_username="operator",
)
call = repository.enqueue_calls[0]
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
self.assertNotIn("ip_address", call["audit_payload"])
self.assertNotIn("agent_id", call["audit_payload"])
self.assertNotIn("password", str(call).lower())
self.assertNotIn("192.0.2.10", result.model_dump_json())
def test_no_projection_changes_when_work_is_queued(self):
repository = FakeRepository()
MinerControlService(repository).queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="operator",
)
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
before = repository.miner_context.copy()
with self.assertRaises(MinerControlUnavailable):
MinerControlService(repository).queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="operator")
self.assertEqual(repository.miner_context, before)
self.assertEqual(repository.enqueue_calls, [])
class MinerControlRouterTests(unittest.TestCase):
@@ -154,18 +142,16 @@ class MinerControlRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
def test_posts_require_control_and_return_202_bounded_envelope(self):
path = f"/api/miners/{MINER_ID}/controls/suspend"
self.authenticate(("miners.view",))
self.assertEqual(self.client.post(path, json={}).status_code, 403)
self.authenticate(("miners.control",))
response = self.client.post(path, json={})
self.assertEqual(response.status_code, 202)
self.assertEqual(set(response.json()["data"]), {
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
})
self.assertNotIn("ip", response.text.lower())
self.assertNotIn("agent", response.text.lower())
def test_posts_require_control_and_then_fail_closed(self):
for action, body in (("suspend", {}), ("resume", {}), ("performance-mode", {"mode": "high"})):
path = f"/api/miners/{MINER_ID}/controls/{action}"
self.authenticate(("miners.view",))
self.assertEqual(self.client.post(path, json=body).status_code, 403)
self.authenticate(("miners.control",))
response = self.client.post(path, json=body)
self.assertEqual(response.status_code, 409)
self.assertIn("physical_control_disabled_stage_a", response.text)
self.assertEqual(self.repository.enqueue_calls, [])
def test_strict_bodies_reject_routing_commands_and_credentials(self):
self.authenticate(("miners.control",))
@@ -190,7 +176,7 @@ class MinerControlRouterTests(unittest.TestCase):
)
self.assertEqual(
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
202,
409,
)
def test_identity_permission_mapping_is_specific(self):
+41 -13
View File
@@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001514")
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
@@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase):
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) "
"VALUES (%s, %s, 'Control Test Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_ID, SITE_ID),
)
cur.execute(
"""
INSERT INTO users (id, username, display_name, password_hash, role_id)
@@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute(
"""
INSERT INTO miners (
id, site_id, asset_tag, current_ip, status, api3_supported,
id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported,
configured_performance_mode, first_seen_at, last_seen_at
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
'normal', NULL, NULL)
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
api3_supported = true
""", (MINER_ID, SITE_ID)
""", (MINER_ID, SITE_ID, ZONE_ID)
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, site_name, last_payload
) VALUES (%s, %s, 'Control Test Site', %s)
agent_id, site_id, agent_zone_id, site_name, last_payload
) VALUES (%s, %s, %s, 'Control Test Site', %s)
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
last_payload = EXCLUDED.last_payload
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
""", (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True}))
)
connection.commit()
finally:
@@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,))
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
connection.commit()
finally:
@@ -127,7 +137,7 @@ class MinerControlPostgresTests(unittest.TestCase):
"""
INSERT INTO minerops_agent_command_queue (
id, agent_id, command_type, command_status, request_payload, created_at
) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now()))
) VALUES (%s, %s, 'poll_known_miners_now', %s, %s, COALESCE(%s, now()))
""",
(command_id, AGENT_ID, status,
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
@@ -189,13 +199,22 @@ class MinerControlPostgresTests(unittest.TestCase):
service.submit_result(AGENT_ID, pending)
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
service = MinerControlService(MinerControlRepository(connect))
first = service.queue_control(
repository = MinerControlRepository(connect)
def queue_control(miner_id, action, *, requested_value, actor_user_id, actor_username):
from types import SimpleNamespace
from uuid import uuid4
return SimpleNamespace(**repository.enqueue_with_audit(
operation_id=str(uuid4()), miner_id=miner_id, agent_id=AGENT_ID,
actor_user_id=actor_user_id, actor_username=actor_username,
request_payload={"schema_version": 1, "miner_id": str(miner_id),
"action": action, "requested_value": requested_value},
audit_payload={"miner_id": str(miner_id), "action": action}))
first = queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
with self.assertRaises(MinerControlConflict):
service.queue_control(
queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
@@ -213,17 +232,26 @@ class MinerControlPostgresTests(unittest.TestCase):
connection.commit()
finally:
connection.close()
second = service.queue_control(
second = queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
self.assertNotEqual(first.operation_id, second.operation_id)
def test_audit_failure_rolls_back_queue_row(self):
service = MinerControlService(MinerControlRepository(connect))
repository = MinerControlRepository(connect)
def queue_control(miner_id, action, *, requested_value, actor_user_id, actor_username):
from types import SimpleNamespace
from uuid import uuid4
return SimpleNamespace(**repository.enqueue_with_audit(
operation_id=str(uuid4()), miner_id=miner_id, agent_id=AGENT_ID,
actor_user_id=actor_user_id, actor_username=actor_username,
request_payload={"schema_version": 1, "miner_id": str(miner_id),
"action": action, "requested_value": requested_value},
audit_payload={"miner_id": str(miner_id), "action": action}))
operation_count_before = self._operation_count()
with self.assertRaises(Exception):
service.queue_control(
queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
actor_username="missing-user",
+89
View File
@@ -0,0 +1,89 @@
"""Defense-in-depth unit tests; no physical miner or external service is used."""
import asyncio
import inspect
import unittest
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from fastapi import HTTPException
from app import main
from app.agent_commands.exceptions import AgentCommandConflict
from app.agent_commands.models import AgentCommandResultRequest
from app.agent_commands.policy import rejection_reason
from app.agent_commands.service import AgentCommandService
class StageAContainmentTests(unittest.TestCase):
def test_service_dispatch_rejects_physical_and_unknown_rows_even_if_repository_regresses(self):
repository = Mock()
repository.claim_pending.return_value = [
{"command_id": str(index), "command_type": kind, "request_payload": payload,
"created_at": datetime.now(timezone.utc)}
for index, (kind, payload) in enumerate((
("miner_control", {"schema_version": 1, "action": "suspend"}),
("miner_control", {"schema_version": 999}),
("miner_control_v2", {}), ("raw_api3", {}),
("poll_known_miners_now", {}),
))
]
result = AgentCommandService(repository).claim("simulated-agent")
self.assertEqual([row.command_type for row in result], ["poll_known_miners_now"])
def test_service_result_rejection_does_not_depend_on_payload_validation(self):
for kind in ("miner_control", "raw_api3", "miner_control_v2"):
repository = Mock()
repository.get_command.return_value = {"id": "old-command", "agent_id": "simulated-agent",
"command_type": kind, "command_status": "claimed", "request_payload": {}}
with self.assertRaises(AgentCommandConflict):
AgentCommandService(repository).submit_result("simulated-agent",
AgentCommandResultRequest(command_id="old-command", status="completed",
result_payload={"schema_version": 999, "command_accepted": True}))
args = repository.finish_command.call_args.args
self.assertEqual(args[2:4], ("cancelled", {}))
self.assertEqual(args[4], rejection_reason(kind))
def test_dormant_legacy_helpers_cannot_transmit_or_queue_if_called_internally(self):
names = (
"dry_run_reboot_miner_command", "change_miner_api3_password",
"minerops_queue_agent_miner_control", "minerops_power_command",
"minerops_send_whatsminer_reboot_sync", "minerops_send_whatsminer_reboot",
"minerops_send_whatsminer_command_sync", "minerops_api3_send_payload_sync",
"minerops_api3_service_sync", "minerops_api3_service",
"minerops_send_control_command_item", "minerops_send_whatsminer_command_sequence",
"minerops_send_whatsminer_command", "minerops_api3_service_sync_with_credentials",
"minerops_api3_service_with_credentials", "minerops_api3_change_password_sync",
"minerops_api3_change_password",
)
with patch.object(main.socket, "create_connection") as transport, patch.object(main, "get_db_connection") as database, patch.object(main, "minerops_command_db_connect") as async_database:
for name in names:
function = getattr(main, name)
required = ["simulated" for parameter in inspect.signature(function).parameters.values()
if parameter.default is inspect.Parameter.empty]
with self.subTest(name=name), self.assertRaises(HTTPException) as rejected:
result = function(*required)
if inspect.isawaitable(result):
asyncio.run(result)
self.assertEqual(rejected.exception.status_code, 410)
transport.assert_not_called()
database.assert_not_called()
async_database.assert_not_called()
def test_fixed_internal_discovery_queries_still_use_only_allowlisted_selection(self):
parameters = {"get_miner_info": {"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"},
"get_version": {}, "summary": {}, "edevs": {}, "pools": {}}
for command, params in parameters.items():
with patch.object(main.socket, "create_connection") as transport:
socket = transport.return_value.__enter__.return_value
socket.recv.side_effect = [b'{"STATUS":[]}', b'']
result = main.send_whatsminer_read_command("192.0.2.1", 4028, command, 500, params)
self.assertTrue(result["success"])
self.assertEqual(socket.sendall.call_count, 1)
import json
transmitted = json.loads(socket.sendall.call_args.args[0])
self.assertEqual(transmitted, {"command": command, **params})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,191 @@
"""Stage A queue containment; requires an explicitly disposable PostgreSQL DB."""
import os
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict
from app.agent_commands.models import AgentCommandResultRequest
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.service import AgentCommandService
from app.config import settings
AGENT = "stage-a-simulated-agent"
OTHER_AGENT = "stage-a-other-agent"
def connect():
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 StageAQueueContainmentTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
raise RuntimeError("Refusing queue tests outside an explicitly disposable Stage A database")
def setUp(self):
self.transport_patch = patch("socket.create_connection", side_effect=AssertionError("No miner transport allowed"))
self.transport = self.transport_patch.start()
self.addCleanup(self.transport_patch.stop)
self.addCleanup(self.transport.assert_not_called)
with connect() as connection, connection.cursor() as cur:
cur.execute("DELETE FROM audit_logs WHERE entity_type = 'agent_command' AND old_value_json ->> 'agent_id' IN (%s, %s)", (AGENT, OTHER_AGENT))
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id IN (%s, %s)", (AGENT, OTHER_AGENT))
self.repository = AgentCommandRepository(connect)
self.service = AgentCommandService(self.repository)
@staticmethod
def seed(command_id, status="pending", command_type="miner_control", payload=None, agent=AGENT):
if payload is None:
payload = {"action": "legacy_4028", "ip_address": "192.0.2.1", "legacy_4028_command": "reboot", "dry_run": False}
with connect() as connection, connection.cursor() as cur:
cur.execute("""INSERT INTO minerops_agent_command_queue
(id, agent_id, command_type, command_status, request_payload,
result_payload, error_message, claimed_at)
VALUES (%s, %s, %s, %s, %s, %s, 'prior evidence',
CASE WHEN %s = 'claimed' THEN now() ELSE NULL END)""",
(command_id, agent, command_type, status, Jsonb(payload),
Jsonb({"historical_evidence": True}), status))
@staticmethod
def row(command_id):
with connect() as connection, connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id = %s", (command_id,))
return dict(cur.fetchone())
@staticmethod
def audits(command_id):
with connect() as connection, connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM audit_logs WHERE action = 'agent.command.rejected' AND old_value_json ->> 'command_id' = %s", (command_id,))
return [dict(row) for row in cur.fetchall()]
def assert_cancelled_preserving_history(self, command_id, before, outcome):
after = self.row(command_id)
self.assertEqual(after["command_status"], "cancelled")
for field in ("id", "agent_id", "command_type", "request_payload", "result_payload", "created_at", "claimed_at"):
self.assertEqual(after[field], before[field], field)
self.assertTrue(after["error_message"].startswith("prior evidence\n"))
self.assertIsNotNone(after["completed_at"])
audits = self.audits(command_id)
self.assertEqual(len(audits), 1)
self.assertEqual(audits[0]["old_value_json"]["status"], before["command_status"])
self.assertEqual(audits[0]["new_value_json"]["execution_outcome"], outcome)
self.assertNotIn("192.0.2.1", str(audits))
def test_seeded_legacy_pending_never_dispatched(self):
self.seed("stage-a-pending")
before = self.row("stage-a-pending")
self.assertEqual(self.service.claim(AGENT), [])
self.assert_cancelled_preserving_history("stage-a-pending", before, "unknown")
def test_seeded_legacy_claimed_cancelled_with_unknown_prior_execution(self):
self.seed("stage-a-claimed", status="claimed")
before = self.row("stage-a-claimed")
self.assertEqual(self.service.claim(AGENT), [])
self.assert_cancelled_preserving_history("stage-a-claimed", before, "unknown")
def test_all_physical_schemas_and_unknown_types_are_rejected(self):
payloads = ({}, {"schema_version": 1, "action": "suspend"},
{"schema_version": 2, "action": "resume"},
{"schema_version": "1", "action": "performance_mode"},
{"schema_version": 999, "action": "reboot"})
for index, payload in enumerate(payloads):
self.seed(f"stage-a-schema-{index}", payload=payload)
self.seed("stage-a-unknown", command_type="raw_api3")
self.assertEqual(self.service.claim(AGENT), [])
for index in range(len(payloads)):
self.assertIn("physical_control_disabled_stage_a", self.row(f"stage-a-schema-{index}")["error_message"])
self.assertIn("unsupported_command_schema_stage_a", self.row("stage-a-unknown")["error_message"])
def test_late_results_and_replay_cannot_promote_old_claimed_commands(self):
self.seed("stage-a-late", status="claimed")
before = self.row("stage-a-late")
for status in ("completed", "failed", "completed"):
request = AgentCommandResultRequest(command_id="stage-a-late", status=status,
result_payload={"command_accepted": True})
with self.assertRaisesRegex(AgentCommandConflict, "physical_control_disabled_stage_a"):
self.service.submit_result(AGENT, request)
self.assert_cancelled_preserving_history("stage-a-late", before, "unknown")
def test_repository_cannot_be_bypassed_by_direct_finish_call(self):
self.seed("stage-a-direct", status="pending")
before = self.row("stage-a-direct")
with self.assertRaises(AgentCommandConflict):
self.repository.finish_command("stage-a-direct", AGENT, "completed", {"accepted": True}, None)
self.assert_cancelled_preserving_history("stage-a-direct", before, "unknown")
def test_wrong_agent_cannot_cancel_or_promote_another_agents_entry(self):
self.seed("stage-a-foreign", status="claimed", agent=OTHER_AGENT)
before = self.row("stage-a-foreign")
self.assertEqual(self.service.claim(AGENT), [])
with self.assertRaises(AgentCommandConflict):
self.service.submit_result(AGENT, AgentCommandResultRequest(command_id="stage-a-foreign", status="completed"))
with self.assertRaises(AgentCommandConflict):
self.repository.finish_command("stage-a-foreign", AGENT, "completed", {}, None)
self.assertEqual(self.row("stage-a-foreign"), before)
self.assertEqual(self.audits("stage-a-foreign"), [])
def test_terminal_history_is_immutable_and_not_reaudited(self):
for status in ("completed", "failed", "cancelled"):
command_id = f"stage-a-terminal-{status}"
self.seed(command_id, status=status)
before = self.row(command_id)
self.assertEqual(self.service.claim(AGENT), [])
with self.assertRaises(AgentCommandConflict):
self.repository.finish_command(command_id, AGENT, "completed", {}, None)
self.assertEqual(self.row(command_id), before)
self.assertEqual(self.audits(command_id), [])
def test_read_only_commands_still_claim_and_complete(self):
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
commands = self.service.claim(AGENT)
self.assertEqual(len(commands), 3)
for command in commands:
self.service.submit_result(AGENT, AgentCommandResultRequest(command_id=command.command_id, status="completed", result_payload={"simulated": True}))
self.assertEqual(self.row(command.command_id)["command_status"], "completed")
def test_concurrent_pollers_cancel_once_and_never_deliver_physical_commands(self):
self.seed("stage-a-concurrent")
before = self.row("stage-a-concurrent")
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: AgentCommandRepository(connect).claim_pending(AGENT), range(2)))
self.assertEqual(results, [[], []])
self.assert_cancelled_preserving_history("stage-a-concurrent", before, "unknown")
def test_locked_legacy_entry_cannot_leak_into_claim_query(self):
self.seed("stage-a-locked")
locker = connect()
try:
with locker.cursor() as cur:
cur.execute("SELECT id FROM minerops_agent_command_queue WHERE id = 'stage-a-locked' FOR UPDATE")
self.assertEqual(self.service.claim(AGENT), [])
finally:
locker.rollback()
locker.close()
self.assertEqual(self.service.claim(AGENT), [])
self.assertEqual(self.row("stage-a-locked")["command_status"], "cancelled")
def test_audit_failure_rolls_back_cancellation_and_prevents_dispatch(self):
self.seed("stage-a-audit-failure")
before = self.row("stage-a-audit-failure")
def fail_audit(cur, row, **kwargs):
cur.execute("SELECT 1 / 0")
with patch.object(AgentCommandRepository, "_audit_rejection", side_effect=fail_audit):
with self.assertRaises(psycopg.errors.DivisionByZero):
self.service.claim(AGENT)
self.assertEqual(self.row("stage-a-audit-failure"), before)
self.assertEqual(self.audits("stage-a-audit-failure"), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,21 @@
"""Conservative delivery outcome decisions, independent of SQL row shape."""
import unittest
from app.agent_commands.policy import rejection_execution_outcome
class DeliveryOutcomeTests(unittest.TestCase):
def test_only_explicit_clean_pending_evidence_allows_not_dispatched(self):
self.assertEqual(rejection_execution_outcome({"previous_status": "pending", "no_delivery_evidence": True}), "not_dispatched")
def test_missing_ambiguous_or_reported_delivery_is_unknown(self):
for row in ({}, {"previous_status": "pending"},
{"previous_status": "pending", "no_delivery_evidence": False},
{"previous_status": "pending", "no_delivery_evidence": None},
{"previous_status": "pending", "no_delivery_evidence": 1},
{"previous_status": "pending", "no_delivery_evidence": "true"},
{"previous_status": "claimed", "no_delivery_evidence": True},
{"previous_status": "cancelled", "no_delivery_evidence": True},
{"previous_status": "pending", "no_delivery_evidence": True, "delivery_reported": True},
{"previous_status": "pending", "no_delivery_evidence": True, "delivery_reported": None}):
with self.subTest(row=row):
self.assertEqual(rejection_execution_outcome(row), "unknown")
@@ -0,0 +1,230 @@
"""Delivery evidence across every Stage A retirement entry point; no miner IO."""
import os
import unittest
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict
from app.agent_commands.repository import AgentCommandRepository
from app.config import settings
from tests.test_stage_a_containment_postgres import connect
AGENT = "stage-a-final-evidence-agent"
ACTOR = "00000000-0000-4000-8000-000000003302"
SCHEDULE = "stage-a-final-evidence-schedule"
PATHS = ("poll", "result", "cutover", "cancel", "delete")
# Verbatim CLAIM_COMMANDS_SQL from committed 2d89e80. Test-only legacy delivery;
# never use this query in the Stage A production repository.
BASELINE_CLAIM_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
"""
class DeliveryEvidencePostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
raise RuntimeError("Delivery evidence tests require a disposable Stage A database")
def setUp(self):
self.repo = AgentCommandRepository(connect)
self.clean()
self.addCleanup(self.clean)
with connect() as conn, conn.cursor() as cur:
cur.execute("""INSERT INTO users (id,username,display_name,password_hash,role_id)
SELECT %s,'stage-a-final-evidence','Delivery Evidence','unused',id
FROM roles WHERE name='Admin' ON CONFLICT (id) DO NOTHING""", (ACTOR,))
self.no_transport = patch("socket.create_connection", side_effect=AssertionError("No miner IO"))
mocked = self.no_transport.start()
self.addCleanup(mocked.assert_not_called)
self.addCleanup(self.no_transport.stop)
def clean(self):
with connect() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM audit_logs WHERE actor_user_id=%s OR old_value_json->>'agent_id'=%s", (ACTOR, AGENT))
cur.execute("DELETE FROM minerops_agent_command_results WHERE agent_id=%s", (AGENT,))
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id=%s", (AGENT,))
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id=%s", (SCHEDULE,))
def seed(self, command_id="stage-a-final-evidence-command", kind="miner_control"):
with connect() as conn, conn.cursor() as cur:
cur.execute("""INSERT INTO minerops_scheduled_bulk_actions
(id,agent_id,target_type,status,scheduled_for) VALUES (%s,%s,'all','running',now())
ON CONFLICT (id) DO UPDATE SET status='running'""", (SCHEDULE,AGENT))
cur.execute("""INSERT INTO minerops_agent_command_queue (id,agent_id,command_type,request_payload)
VALUES (%s,%s,%s,%s)""",
(command_id,AGENT,kind,Jsonb({"scheduled_action_id": SCHEDULE,"action":"suspend","ip_address":"127.0.0.1"})))
return command_id
def row(self, cid):
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id=%s", (cid,))
return dict(cur.fetchone())
def audits(self, cid):
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute("""SELECT * FROM audit_logs WHERE action='agent.command.rejected'
AND old_value_json->>'command_id'=%s""", (cid,))
return list(cur.fetchall())
def retire(self, path, cid):
if path == "poll": self.assertEqual(self.repo.claim_pending(AGENT), [])
elif path == "result":
with self.assertRaises(AgentCommandConflict): self.repo.finish_command(cid, AGENT, "completed", {}, None)
elif path == "cutover": return self.repo.retire_unsupported(AGENT, actor_user_id=ACTOR)
else: return self.repo.retire_schedule(SCHEDULE, actor_user_id=ACTOR, operation=path)
def assert_retired(self, cid, before, outcome):
after = self.row(cid)
self.assertEqual(after["command_status"], "cancelled")
self.assertIsNotNone(after["completed_at"])
for key in ("id","agent_id","command_type","claimed_at","created_at","request_payload","result_payload"):
self.assertEqual(after[key], before[key], key)
if before["error_message"]: self.assertTrue(after["error_message"].startswith(before["error_message"]+"\n"))
audits = self.audits(cid)
self.assertEqual(len(audits), 1)
self.assertEqual(audits[0]["old_value_json"]["status"], before["command_status"])
self.assertEqual(audits[0]["new_value_json"]["execution_outcome"], outcome)
def test_genuine_baseline_claim_then_explicit_repair_requeue_all_paths(self):
for path in PATHS:
with self.subTest(path=path):
self.clean(); cid=self.seed()
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute(BASELINE_CLAIM_SQL,(AGENT,5)); delivered=list(cur.fetchall())
self.assertEqual([r["command_id"] for r in delivered], [cid])
claimed=self.row(cid)
self.assertEqual(claimed["command_status"],"claimed"); self.assertIsNotNone(claimed["claimed_at"])
# Explicitly simulate a legacy repair. No production requeue path
# is asserted or added; retain the actual baseline claim evidence.
with connect() as conn, conn.cursor() as cur:
cur.execute("UPDATE minerops_agent_command_queue SET command_status='pending' WHERE id=%s",(cid,))
before=self.row(cid); self.assertEqual(before["claimed_at"],claimed["claimed_at"])
self.retire(path,cid); self.assert_retired(cid,before,"unknown")
audit_before=self.audits(cid); row_before=self.row(cid)
for retry in PATHS: self.retire(retry,cid)
self.assertEqual(self.audits(cid),audit_before); self.assertEqual(self.row(cid),row_before)
def test_pristine_pending_all_paths_and_result_itself_is_delivery_evidence(self):
for path in PATHS:
with self.subTest(path=path):
self.clean(); cid=self.seed(); before=self.row(cid)
self.retire(path,cid)
self.assert_retired(cid,before,"unknown" if path=="result" else "not_dispatched")
def test_each_durable_evidence_source_is_unknown_on_every_path(self):
sources=("claim","completion","result","error","legacy_result","unlinked_legacy_result","audit_old","audit_new","prior_unknown")
for source in sources:
for path in PATHS:
with self.subTest(source=source,path=path):
self.clean(); cid=self.seed()
with connect() as conn, conn.cursor() as cur:
if source in ("claim","completion","result","error"):
assignments={"claim":"claimed_at=now()", "completion":"completed_at=now()",
"result":"result_payload='{}'::jsonb", "error":"error_message='ambiguous legacy error'"}
cur.execute("UPDATE minerops_agent_command_queue SET "+assignments[source]+" WHERE id=%s",(cid,))
elif source in ("legacy_result", "unlinked_legacy_result"):
cur.execute("""INSERT INTO minerops_agent_command_results (agent_id,site_name,command_id,status,payload)
VALUES (%s,'simulated',%s,'completed','{}'::jsonb)""",(AGENT,cid if source=="legacy_result" else None))
else:
# Durable audit evidence with all queue timestamps missing.
old={"command_id":cid,"agent_id":AGENT} if source!="audit_new" else {"agent_id":AGENT}
new={"operation_id":cid} if source=="audit_new" else {"execution_outcome":"unknown"}
action="agent.command.rejected" if source=="prior_unknown" else "legacy.command.delivered"
cur.execute("""INSERT INTO audit_logs (actor_user_id,action,entity_type,old_value_json,new_value_json)
VALUES (%s,%s,'agent_command',%s,%s)""",(ACTOR,action,Jsonb(old),Jsonb(new)))
prior_audits=self.audits(cid); before=self.row(cid); self.retire(path,cid)
after=self.row(cid)
self.assertEqual(after["command_status"],"cancelled")
self.assertEqual(after["claimed_at"],before["claimed_at"])
self.assertEqual(after["result_payload"],before["result_payload"])
if before["completed_at"]: self.assertEqual(after["completed_at"],before["completed_at"])
audits=self.audits(cid); self.assertEqual(len(audits),len(prior_audits)+1)
self.assertTrue(all(a["new_value_json"].get("execution_outcome")=="unknown" for a in audits))
def test_creation_audit_alone_does_not_report_delivery(self):
cid=self.seed()
with connect() as conn, conn.cursor() as cur:
cur.execute("""INSERT INTO audit_logs (actor_user_id,action,entity_type,new_value_json)
VALUES (%s,'miner.control.queued','miner',%s)""",(ACTOR,Jsonb({"operation_id":cid})))
before=self.row(cid); self.retire("cutover",cid); self.assert_retired(cid,before,"not_dispatched")
def test_currently_claimed_without_timestamp_remains_unknown(self):
for path in PATHS:
self.clean(); cid=self.seed()
with connect() as conn, conn.cursor() as cur:
cur.execute("UPDATE minerops_agent_command_queue SET command_status='claimed' WHERE id=%s",(cid,))
before=self.row(cid); self.retire(path,cid); self.assert_retired(cid,before,"unknown")
def test_audit_failure_rolls_back_every_cancellation_path(self):
for path in PATHS:
with self.subTest(path=path):
self.clean(); cid=self.seed(); before=self.row(cid)
def fail(cur,row,**kwargs): cur.execute("SELECT 1/0")
with patch.object(AgentCommandRepository,"_audit_rejection",side_effect=fail):
with self.assertRaises(Exception): self.retire(path,cid)
self.assertEqual(self.row(cid),before); self.assertEqual(self.audits(cid),[])
def test_concurrent_baseline_claim_blocks_result_then_unknown(self):
cid=self.seed(); locker=connect()
try:
with locker.cursor() as cur:
cur.execute(BASELINE_CLAIM_SQL,(AGENT,1)); self.assertEqual(len(cur.fetchall()),1)
with ThreadPoolExecutor(max_workers=1) as pool:
def cancellation():
def named_connection():
conn=connect()
conn.execute("SET application_name='stage-a-final-result-race'")
return conn
with self.assertRaises(AgentCommandConflict):
AgentCommandRepository(named_connection).finish_command(cid,AGENT,"completed",{},None)
future=pool.submit(cancellation)
try:
# Observe an actual database lock wait before committing the
# claim, rather than relying on thread scheduling or sleeps.
blocked=False; deadline=time.monotonic()+5
while time.monotonic()<deadline:
with connect() as observer, observer.cursor() as cur:
cur.execute("""SELECT EXISTS (SELECT 1 FROM pg_stat_activity
WHERE application_name='stage-a-final-result-race'
AND wait_event_type='Lock')""")
blocked=cur.fetchone()[0]
if blocked: break
time.sleep(.01)
self.assertTrue(blocked,"Cancellation must wait for the genuine legacy claim lock")
finally:
locker.commit()
future.result(timeout=10)
self.assertEqual(self.row(cid)["command_status"],"cancelled")
self.assertIsNotNone(self.row(cid)["claimed_at"])
self.assertEqual(self.audits(cid)[0]["new_value_json"]["execution_outcome"],"unknown")
finally: locker.close()
def test_mixed_legacy_backlog_classifies_each_row_without_delivering(self):
for n in range(12):
cid=self.seed("stage-a-final-evidence-"+str(n),"miner_control" if n%2 else "unknown_legacy")
if n%3:
with connect() as conn,conn.cursor() as cur:
cur.execute("UPDATE minerops_agent_command_queue SET claimed_at=now() WHERE id=%s",(cid,))
with ThreadPoolExecutor(max_workers=2) as pool:
self.assertEqual(sum(pool.map(lambda _: self.repo.retire_unsupported(AGENT,actor_user_id=ACTOR), range(2))),12)
for n in range(12):
audit=self.audits("stage-a-final-evidence-"+str(n)); self.assertEqual(len(audit),1)
self.assertEqual(audit[0]["new_value_json"]["execution_outcome"],"unknown" if n%3 else "not_dispatched")
@@ -0,0 +1,309 @@
"""Audited schedule retirement on an explicitly disposable PostgreSQL database."""
import os
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from uuid import UUID
from fastapi import FastAPI
from fastapi.testclient import TestClient
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app import main
from app.agent_commands.exceptions import AgentCommandConflict
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.policy import PHYSICAL_CONTROL_DISABLED
from app.config import settings
from app.identity import get_current_user, required_api_permission
from tests.test_stage_a_containment_postgres import connect
AGENT = "stage-a-schedule-agent"
OTHER_AGENT = "stage-a-schedule-other-agent"
ACTOR = UUID("00000000-0000-4000-8000-000000003301")
SCHEDULE = "stage-a-schedule-history"
class StageAScheduleCancellationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get("MINEROPS_STAGE_A_DISPOSABLE") != "1" or not settings.postgres_db.startswith("minerops_stage_a_"):
raise RuntimeError("Refusing schedule tests outside an explicitly disposable Stage A database")
def setUp(self):
self.no_transport = patch("socket.create_connection", side_effect=AssertionError("No miner connection"))
self.transport = self.no_transport.start()
self.addCleanup(self.no_transport.stop)
self.addCleanup(self.transport.assert_not_called)
with connect() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM audit_logs WHERE actor_user_id = %s OR old_value_json ->> 'agent_id' IN (%s, %s)",
(ACTOR, AGENT, OTHER_AGENT))
cur.execute("DELETE FROM minerops_agent_command_queue WHERE agent_id IN (%s, %s)", (AGENT, OTHER_AGENT))
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
cur.execute("""INSERT INTO users (id, username, display_name, password_hash, role_id)
SELECT %s, 'stage-a-retirement-auditor', 'Retirement Auditor', 'unused', id
FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING""", (ACTOR,))
cur.execute("""INSERT INTO minerops_scheduled_bulk_actions
(id, agent_id, name, target_type, scheduled_for, status, last_error)
VALUES (%s, %s, 'Historical schedule', 'all', now(), 'running', 'prior evidence')""",
(SCHEDULE, AGENT))
self.repository = AgentCommandRepository(connect)
def seed(self, command_id, status="pending", agent=AGENT, kind="miner_control", schedule=SCHEDULE):
with connect() as conn, conn.cursor() as cur:
cur.execute("""INSERT INTO minerops_agent_command_queue
(id, agent_id, command_type, command_status, request_payload, result_payload,
error_message, claimed_at)
VALUES (%s, %s, %s, %s, %s, %s, 'prior evidence',
CASE WHEN %s = 'claimed' THEN now() ELSE NULL END)""",
(command_id, agent, kind, status,
Jsonb({"scheduled_action_id": schedule, "ip_address": "192.0.2.1", "action": "suspend"}),
Jsonb({"historical_result": True}), status))
@staticmethod
def row(command_id):
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM minerops_agent_command_queue WHERE id = %s", (command_id,))
return dict(cur.fetchone())
@staticmethod
def schedule():
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
return dict(cur.fetchone())
@staticmethod
def audits():
with connect() as conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute("""SELECT * FROM audit_logs WHERE actor_user_id = %s
OR old_value_json ->> 'agent_id' IN (%s, %s)""", (ACTOR, AGENT, OTHER_AGENT))
return [dict(row) for row in cur.fetchall()]
def retire(self, operation="cancel"):
with patch.object(main, "get_db_connection", side_effect=connect):
return getattr(main, f"{operation}_dashboard_scheduled_bulk_action")(
SCHEDULE, {"id": str(ACTOR), "username": "retirement-auditor"},
)
def assert_retired(self, command_id, before, operation):
after = self.row(command_id)
self.assertEqual(after["command_status"], "cancelled")
for field in ("id", "agent_id", "command_type", "request_payload", "result_payload", "created_at", "claimed_at"):
self.assertEqual(after[field], before[field], field)
self.assertEqual(after["error_message"], "prior evidence\n" + PHYSICAL_CONTROL_DISABLED)
self.assertIsNotNone(after["completed_at"])
audits = [r for r in self.audits() if r["action"] == "agent.command.rejected"
and r["old_value_json"]["command_id"] == command_id]
self.assertEqual(len(audits), 1)
audit = audits[0]
self.assertEqual(audit["actor_user_id"], ACTOR)
self.assertEqual(audit["old_value_json"]["agent_id"], AGENT)
self.assertEqual(audit["old_value_json"]["status"], before["command_status"])
self.assertEqual(audit["new_value_json"]["source"], "schedule_" + operation)
self.assertEqual(audit["new_value_json"]["reason"], PHYSICAL_CONTROL_DISABLED)
self.assertEqual(audit["new_value_json"]["execution_outcome"],
"unknown")
self.assertNotIn("192.0.2.1", str(audit))
def test_schedule_cancel_retires_pending_and_claimed_with_attributed_audits(self):
for status in ("pending", "claimed"):
command_id = "stage-a-schedule-cancel-" + status
self.seed(command_id, status)
before = {s: self.row("stage-a-schedule-cancel-" + s) for s in ("pending", "claimed")}
self.retire()
for status in before:
self.assert_retired("stage-a-schedule-cancel-" + status, before[status], "cancel")
self.assertEqual(self.schedule()["status"], "cancelled")
self.assertEqual(len(self.audits()), 3)
def test_schedule_delete_is_audited_retirement_and_preserves_history(self):
self.seed("stage-a-schedule-delete", "claimed")
before, schedule_before = self.row("stage-a-schedule-delete"), self.schedule()
self.retire("delete")
self.assert_retired("stage-a-schedule-delete", before, "delete")
after = self.schedule()
for field in schedule_before:
if field not in {"status", "last_error", "updated_at"}:
self.assertEqual(after[field], schedule_before[field], field)
parent_audits = [r for r in self.audits() if r["action"] == "scheduled_action.retired"]
self.assertEqual(len(parent_audits), 1)
self.assertEqual(parent_audits[0]["new_value_json"]["operation"], "delete")
def test_repeated_cancel_and_delete_are_idempotent(self):
self.seed("stage-a-schedule-repeat")
self.retire()
before, schedule_before, audits_before = self.row("stage-a-schedule-repeat"), self.schedule(), self.audits()
for operation in ("cancel", "delete", "delete"):
self.retire(operation)
self.assertEqual(self.row("stage-a-schedule-repeat"), before)
self.assertEqual(self.schedule(), schedule_before)
self.assertEqual(self.audits(), audits_before)
def test_terminal_queue_and_schedule_history_remains_immutable(self):
for status in ("completed", "failed", "cancelled"):
self.seed("stage-a-schedule-terminal-" + status, status)
before = {s: self.row("stage-a-schedule-terminal-" + s) for s in ("completed", "failed", "cancelled")}
with connect() as conn, conn.cursor() as cur:
cur.execute("UPDATE minerops_scheduled_bulk_actions SET status = 'completed' WHERE id = %s", (SCHEDULE,))
schedule_before = self.schedule()
self.retire("delete")
self.assertEqual(self.schedule(), schedule_before)
for status, row in before.items():
self.assertEqual(self.row("stage-a-schedule-terminal-" + status), row)
self.assertEqual(self.audits(), [])
def test_stored_agent_and_schedule_scope_cannot_be_overridden(self):
self.seed("stage-a-schedule-own")
self.seed("stage-a-schedule-foreign", agent=OTHER_AGENT)
self.seed("stage-a-schedule-unrelated", schedule="another-schedule")
self.seed("stage-a-schedule-telemetry", kind="poll_known_miners_now")
protected = {c: self.row(c) for c in ("stage-a-schedule-foreign", "stage-a-schedule-unrelated", "stage-a-schedule-telemetry")}
self.retire()
self.assertEqual(self.row("stage-a-schedule-own")["command_status"], "cancelled")
for command_id, before in protected.items():
self.assertEqual(self.row(command_id), before)
def test_schedule_unknown_type_is_rejected_with_its_actual_reason(self):
self.seed("stage-a-schedule-unknown", kind="raw_api3")
self.retire()
rejection = next(a for a in self.audits() if a["action"] == "agent.command.rejected")
self.assertEqual(rejection["new_value_json"]["reason"], "unsupported_command_schema_stage_a")
def test_concurrent_cancel_delete_workers_audit_each_transition_once(self):
self.seed("stage-a-schedule-concurrent", "claimed")
def worker(operation):
return AgentCommandRepository(connect).retire_schedule(SCHEDULE, actor_user_id=str(ACTOR), operation=operation)
with ThreadPoolExecutor(max_workers=2) as pool:
counts = list(pool.map(worker, ("cancel", "delete")))
self.assertEqual(sorted(counts), [0, 1])
self.assertEqual(self.row("stage-a-schedule-concurrent")["command_status"], "cancelled")
self.assertEqual(len(self.audits()), 2)
def test_poll_finish_and_schedule_cancel_race_has_one_queue_audit(self):
self.seed("stage-a-schedule-race", "claimed")
def worker(i):
repo = AgentCommandRepository(connect)
try:
if i == 0:
return repo.retire_schedule(SCHEDULE, actor_user_id=str(ACTOR), operation="cancel")
if i == 1:
return repo.claim_pending(AGENT)
return repo.finish_command("stage-a-schedule-race", AGENT, "completed", {}, None)
except AgentCommandConflict:
return None
with ThreadPoolExecutor(max_workers=3) as pool:
list(pool.map(worker, range(3)))
rejections = [a for a in self.audits() if a["action"] == "agent.command.rejected"]
self.assertEqual(len(rejections), 1)
self.assertEqual(rejections[0]["new_value_json"]["execution_outcome"], "unknown")
self.assertEqual(self.row("stage-a-schedule-race")["command_status"], "cancelled")
def test_audit_failure_after_one_insert_rolls_back_all_cancellations(self):
for command_id in ("stage-a-schedule-rollback-a", "stage-a-schedule-rollback-b"):
self.seed(command_id, "claimed")
before = {c: self.row(c) for c in ("stage-a-schedule-rollback-a", "stage-a-schedule-rollback-b")}
schedule_before = self.schedule()
original = AgentCommandRepository._audit_rejection
calls = []
def fail_second(cur, row, **kwargs):
calls.append(row["id"])
if len(calls) == 2:
raise RuntimeError("simulated audit failure")
original(cur, row, **kwargs)
with patch.object(AgentCommandRepository, "_audit_rejection", side_effect=fail_second):
with self.assertRaisesRegex(RuntimeError, "simulated audit failure"):
self.retire("delete")
self.assertEqual(len(calls), 2)
self.assertEqual(self.schedule(), schedule_before)
for command_id, row in before.items():
self.assertEqual(self.row(command_id), row)
self.assertEqual(self.audits(), [])
def test_schedule_parent_audit_failure_rolls_back_queue_and_parent(self):
self.seed("stage-a-schedule-parent-rollback")
before, parent_before = self.row("stage-a-schedule-parent-rollback"), self.schedule()
# A database constraint failure after child audits exercises the final
# schedule/audit portion, rather than only an early application exception.
def connection_with_failure():
conn = connect()
with conn.cursor() as cur:
cur.execute("""CREATE OR REPLACE FUNCTION pg_temp.reject_schedule_audit() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action = 'scheduled_action.retired' THEN RAISE EXCEPTION 'simulated parent audit failure'; END IF;
RETURN NEW; END $$""")
cur.execute("CREATE TRIGGER stage_a_parent_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION pg_temp.reject_schedule_audit()")
# Trigger creation is in the very transaction the repository must roll back.
return conn
with self.assertRaisesRegex(Exception, "simulated parent audit failure"):
AgentCommandRepository(connection_with_failure).retire_schedule(
SCHEDULE, actor_user_id=str(ACTOR), operation="cancel")
self.assertEqual(self.row("stage-a-schedule-parent-rollback"), before)
self.assertEqual(self.schedule(), parent_before)
self.assertEqual(self.audits(), [])
def test_unknown_schedule_has_no_queue_or_audit_side_effects(self):
self.seed("stage-a-schedule-orphan")
before = self.row("stage-a-schedule-orphan")
with connect() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM minerops_scheduled_bulk_actions WHERE id = %s", (SCHEDULE,))
from fastapi import HTTPException
with self.assertRaises(HTTPException) as error:
self.retire()
self.assertEqual(error.exception.status_code, 404)
self.assertEqual(self.row("stage-a-schedule-orphan"), before)
self.assertEqual(self.audits(), [])
def test_http_cancel_delete_require_control_permission(self):
app = FastAPI()
for operation in ("cancel", "delete"):
path = f"/api/dashboard/scheduled-bulk-actions/{{action_id}}/{operation}"
app.add_api_route(path, getattr(main, f"{operation}_dashboard_scheduled_bulk_action"), methods=["POST"])
self.assertEqual(required_api_permission("POST", path), "miners.control")
app.dependency_overrides[get_current_user] = lambda: {"id": str(ACTOR), "role": "Custom", "permissions": ["agents.manage"]}
with TestClient(app) as client, patch.object(main, "get_db_connection") as db:
for operation in ("cancel", "delete"):
self.assertEqual(client.post(f"/api/dashboard/scheduled-bulk-actions/{SCHEDULE}/{operation}").status_code, 403)
db.assert_not_called()
def test_offline_cutover_batch_is_audited_and_never_claims_readers(self):
with connect() as conn, conn.cursor() as cur:
cur.execute("""INSERT INTO minerops_agent_command_queue
(id, agent_id, command_type, command_status, request_payload, claimed_at)
SELECT 'stage-a-schedule-backlog-' || n, %s,
CASE WHEN n %% 2 = 0 THEN 'miner_control' ELSE 'raw_api3' END,
CASE WHEN n %% 3 = 0 THEN 'claimed' ELSE 'pending' END,
'{}'::jsonb, CASE WHEN n %% 5 = 0 THEN now() ELSE NULL END
FROM generate_series(1,1003) n""", (AGENT,))
self.seed("stage-a-schedule-offline-read", kind="poll_known_miners_now")
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 1000)
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 3)
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 0)
self.assertEqual(self.row("stage-a-schedule-offline-read")["command_status"], "pending")
rejections = self.audits()
self.assertEqual(len(rejections), 1003)
for audit in rejections:
self.assertEqual(audit["actor_user_id"], ACTOR)
self.assertEqual(audit["new_value_json"]["source"], "stage_a_cutover")
n = int(audit["old_value_json"]["command_id"].rsplit("-", 1)[1])
self.assertEqual(audit["new_value_json"]["execution_outcome"],
"unknown" if n % 3 == 0 or n % 5 == 0 else "not_dispatched")
def test_cutover_zero_batch_does_not_claim_a_locked_drain_is_complete(self):
self.seed("stage-a-schedule-cutover-locked", "claimed")
locker = connect()
try:
with locker.cursor() as cur:
cur.execute("SELECT id FROM minerops_agent_command_queue WHERE id = %s FOR UPDATE", ("stage-a-schedule-cutover-locked",))
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 0)
self.assertEqual(self.row("stage-a-schedule-cutover-locked")["command_status"], "claimed")
finally:
locker.rollback()
locker.close()
self.assertEqual(self.repository.retire_unsupported(AGENT, actor_user_id=str(ACTOR)), 1)
self.assertEqual(self.row("stage-a-schedule-cutover-locked")["command_status"], "cancelled")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
APP_ROOT = Path(__file__).parents[1] / "app"
EXPECTED_HEAD = "0015_miner_control_foundation"
EXPECTED_HEAD = "0017_agent_enrollment"
def database_payload(revision=EXPECTED_HEAD):
+2 -1
View File
@@ -32,7 +32,8 @@ services:
backend:
build:
context: ./backend
context: .
dockerfile: backend/Dockerfile
env_file:
- .env.dev
command:
+3 -2
View File
@@ -34,7 +34,8 @@
backend:
build:
context: ./backend
context: .
dockerfile: backend/Dockerfile
container_name: minerops-backend
restart: unless-stopped
env_file:
@@ -51,4 +52,4 @@
networks:
minerops-net:
driver: bridge
driver: bridge
+132
View File
@@ -0,0 +1,132 @@
# Agent Zones
## Purpose
An Agent Zone is MinerOps' durable network-reachability boundary. A Site is an
operational location and may contain networks that cannot be reached by one
Agent computer. Agent Zones let those networks remain within one Site without
making Site identity determine control routing.
The implemented relationship is:
```text
Site
-> Agent Zone
-> Agent installation
-> miners reachable through that Zone
```
`agent_id` remains the durable Agent-installation identity. Agent Zone identity
is a UUID independent of the Agent computer assigned to it.
## Site, Zone, And Agent
A Site groups rooms and operational records. An Agent Zone groups one or more
CIDR network ranges that are reachable from an Agent installation. The ranges
are factual Zone configuration for future enrollment and do not implement an
arbitrary routing table.
A Site can contain multiple Zones. For example:
```text
Plexus Mining Site
Main Room 10.30.0.0/24 -> Agent A
Room 2 10.40.0.0/24 -> Agent B
Immersion Room 10.50.0.0/24 -> Agent C
```
Zone names are unique case-insensitively among enabled Zones at the same Site.
They are not globally unique. A Zone can contain multiple canonical PostgreSQL
`CIDR` ranges.
## Routing Authority
Miner control resolves only from Hub-owned facts:
```text
miner UUID
-> miners.agent_zone_id
-> Agent assigned to that Zone
-> miner current IP recorded by MinerOps
-> local API3 operation
```
A control request from a browser contains none of the routing inputs. It cannot
select an IP address, Agent, Agent Zone, raw vendor command, credentials, or a
dry-run mode. MinerOps never falls back to another Agent merely because it is
assigned to the same Site.
A miner without a Zone is unavailable with `agent_zone_unassigned`. A missing
or disabled Zone is unavailable with `agent_zone_disabled`. A Zone without an
assigned Agent is unavailable with `agent_unavailable`.
## Durable Miner Ownership
`miners.agent_zone_id` is the authoritative reachability assignment. Snapshot
and discovery ingestion can assign a currently unassigned miner when the
authenticated Agent has a valid enabled Zone and Site boundaries agree.
An observation from a different Zone does not move an assigned miner. MinerOps
records or returns `agent_zone_conflict` or `site_conflict` while preserving the
existing assignment. Snapshot and discovery payloads may still be staged as
factual observations. Explicit administrative reassignment is deferred.
## Agent Assignment
Agent Zones v1 permits at most one Agent installation per non-null Zone. A Site
can therefore have multiple Agents when they occupy different Zones. Primary
and standby Agents, automatic failover, and Agent high availability are not
implemented.
An enabled Zone is usable for transitional check-in and control routing. A
disabled Zone preserves its durable associations but cannot be selected for a
new check-in and cannot route miner control.
## Check-In Compatibility
The Windows Agent may send `site_id` and optional `agent_zone_id` during
check-in. Existing configurations that omit `agent_zone_id` remain compatible:
- An established valid assignment is preserved.
- An unassigned Agent can resolve automatically only when the supplied Site has
exactly one enabled Zone.
- Zero enabled Zones returns `agent_zone_unavailable`.
- Multiple enabled Zones returns `agent_zone_ambiguous`.
- MinerOps never chooses the first Zone.
An explicit Zone must exist, be enabled, and belong to the supplied Site. An
Agent cannot move its established Zone or Site through check-in. Later
administrative Agent management will own reassignment.
## Permissions And APIs
The implemented management foundation reuses existing permissions:
- `GET /api/agent-zones` requires `agents.view`.
- `POST /api/agent-zones` requires `agents.manage`.
- `POST /api/agent/checkin` requires authenticated Agent identity.
The existing role matrix grants `agents.view` to Admin, Manager, Technician,
and Read Only. `agents.manage` is granted to Admin and Manager. No Agent Zone UI
is implemented in this milestone.
## Migration Compatibility
Migration 0016 creates one deterministic `Default Zone` for each pre-existing
Site. Existing miners and Agent assignments with a durable `site_id` are
backfilled to that Site's default Zone. Existing snapshots and discoveries are
associated when their durable Agent assignment makes that association factual.
Rows without a safe Site association remain unassigned.
Downgrade to migration 0015 is allowed only when the Zone configuration can be
represented by its one-Agent-per-Site model. Downgrade refuses before
structural changes when a Site has multiple Agents, non-default Zone topology,
custom Zone state, or network ranges.
## Future Relationship
One-time Agent enrollment tokens, Settings -> Agents, installer downloads,
Linux Agents, update management, administrative reassignment, and primary or
standby Agents are future work. An enrollment flow can use a Zone's configured
network ranges, but enrollment tokens and automatic range delivery are not
implemented by Agent Zones v1.
@@ -0,0 +1,248 @@
MinerOps Blocker #3 Stage A: old-Agent containment and upgrade runbook
Status: review candidate. This procedure has NOT been executed in production.
Scope: contain physical control; no Stages B-E protocol or control re-enablement.
The independent audit demonstrated that an old Agent can execute a previously
delivered command after the Hub marks it cancelled. Cancelling a database row,
revoking a Hub credential, advertising control unavailable, or replacing script
files does not revoke a cached command or loaded PowerShell functions. Execution
of any previously claimed command remains UNKNOWN until separately investigated.
Production approval requires independent security revalidation, an approved change
window, verified network enforcement, and the native Windows checks below. A new
database migration or production network infrastructure change requires explicit
approval. This runbook authorizes no production actions by itself.
Ownership and evidence
Assign an incident/change owner, a network operator, a Hub/database operator, and a
Windows operator. Record their identities, UTC timestamps, change ticket, and every
gate's evidence in a protected location. Keep enrollment tokens and Agent/miner
credentials out of ordinary logs and this checklist. Preserve required backups
and recovery state with their existing ACLs; do not overwrite agent-config.json.
Maintain one ledger containing ALL registered Agents, every distinct queue Agent
(including deleted registry entries), offline/unreachable Agents, alternative
installations and manually running executors, legacy shared identities, all Hub
replicas/workers, scheduled jobs, task hosts, physical networks, and IPv4/IPv6 paths.
For each Agent record its Site/Zone assignment, last check-in, host/network identity,
network fence evidence, process/task evidence, installed-file hashes, verified
artifact identifier, and release decision. Keep unverified entries quarantined.
Gate 1: restrict physical write access BEFORE stopping/upgrading anything
1. Arrange an approved maintenance window and identify the actual network control
that can keep old Agents from reaching ALL physical miners. Layer-4 rules cannot
distinguish a legacy read from a write on the same miner API port. Default to
denying Agent-to-miner traffic, including ports 4028 and 4433, alternative miner
management interfaces/ports, IPv6, alternate NICs/VPNs, and established sessions.
Telemetry may be interrupted. Do not substitute an unreviewed 'read-only' proxy.
2. Apply only separately approved network controls through the responsible operator.
A stopped process or a host-local rule alone is insufficient for an unreachable
machine that can later reboot/reconnect. Use enforcement that already applies
to that machine's traffic when it returns. Include unknown/retired installations
and aliases; trusting an IP address alone is insufficient when addresses can change.
3. Verify deny counters and session termination/conntrack handling. Use approved
synthetic targets/canaries, never a real write command to a physical miner.
A rule that blocks only NEW connections leaves a pre-existing API session exposed.
Check both the intended route and alternate routes. Keep the fence if evidence
is unavailable, ambiguous, or an offline Agent cannot be located.
Exit gate: verified deny enforcement covers every unverified/legacy executor,
including future reconnects and existing sessions. Failure means STOP; keep fences.
Gate 2: stop old executors and automatic restarts
1. Stop command production and processing on every old Hub/worker version, including
old schedule materializers and running requests that could still insert/deliver
commands. Hold ingress and scale/restart mechanisms as necessary under the
approved change procedure. Inventory the actual versions of every instance.
2. On each reachable Windows host disable/hold all relevant Scheduled Task triggers,
retry/restart policies, service recovery, watchdogs, startup/logon entries, GUI
launch actions, manual jobs, and external orchestration that can launch MinerOps.
Record original definitions and intended running/stopped state for recovery.
3. Stop MinerOpsAgent and any alternative/manual executor, then verify task instances
AND their PowerShell/child process trees have exited. Record command paths,
process start times and identities. An in-memory old module is still executable
even after its source file changes. Do not gracefully 'drain' by executing writes.
4. Keep pool/enrichment tasks held during runtime replacement and verify no alternate
old code copy can auto-start. Never run uninstall or erase config/state to stop it.
Exit gate: every reachable old executor is stopped and restart-held; every
unreachable/unverified Agent remains network-fenced. Do not remove a fence to
diagnose a failed stop. Escalate failures with evidence and keep maintenance holds.
Gate 3: update ALL Hub instances with no mixed-version execution
1. Capture protected queue/audit evidence and the relevant schedule/Agent inventory
before changes. Retain original request/result payloads, timestamps and errors.
Secure these exports because historical payloads may contain sensitive data.
2. Install only the independently reviewed Stage A artifact on every Hub instance
while executor fences/holds remain. Stop old replicas and workers fully, including
rolling-update stragglers and old connections/requests; do not allow a mixture of
active baseline and Stage A schedulers/queue consumers.
3. Verify each instance's actual artifact hashes and that physical admission is
fixed disabled: legacy write/diagnostic POSTs return 410 after authorization,
canonical typed controls return 409 physical_control_disabled_stage_a, and queue
SQL dispatches only the fixed read types. Do not use runtime flags to enable writes.
4. Run reviewed schema compatibility checks as appropriate. Stage A remediation adds
NO migration. Stop and request approval if the deployed schema needs one.
Exit gate: only reviewed containment Hub code is active; all old producers are
stopped. Keep Agents held. If any instance cannot be verified, keep ingress/worker
holds and physical fences rather than continuing a partial rolling deployment.
Gate 4: audited cancellation across ALL Agents, including offline/queue-only IDs
1. Use the canonical reviewed code and a real accountable operator users.id for
manual cutover retirement. Existing authenticated Agent polling/result rejection
is system-attributed by source and Agent ID; manual cutover supplies actor_user_id.
Do not impersonate another user, create invented execution evidence, or log secrets.
2. Inventory IDs using the UNION of registry and queue Agent IDs. Also reconcile the
fleet ledger, historical/retired identities, and old shared Agent ID. Do not use
only Agents currently checking in. A useful read-only inventory is:
SELECT agent_id FROM minerops_site_agents
UNION SELECT agent_id FROM minerops_agent_command_queue
ORDER BY agent_id;
3. In a separately approved administrative process configured for the intended DB,
call the reviewed repository operation for each ID, in committed bounded batches:
repository = AgentCommandRepository()
count = repository.retire_unsupported(agent_id, actor_user_id=operator_user_id)
Repeat for all IDs while progress is made, under an explicit time/batch limit.
This method never claims read work; do NOT substitute claim_pending or an Agent
HTTP poll as an administrative drain. Each transition and rejection audit commits
atomically. Database/audit failure rolls back and keeps the change window closed.
4. A batch is bounded at 1000 and uses SKIP LOCKED. A zero count may mean locked rows,
not completion. Resolve/retry approved lock holders with time limits; never
silently declare the drain finished. Query ALL Agents to verify a zero live count:
SELECT agent_id, command_type, command_status, count(*)
FROM minerops_agent_command_queue
WHERE command_status IN ('pending', 'claimed')
AND command_type NOT IN
('poll_known_miners_now', 'discover_new_miners', 'enrich_match_discovery')
GROUP BY agent_id, command_type, command_status;
5. Reconcile newly cancelled rows to agent.command.rejected records. Check command ID,
Agent ID, prior state, immutable rejection reason, source and operator attribution.
Current pending status does not establish that a command was never delivered.
Current claimed status, any retained claimed_at, completion/result/error evidence,
linked legacy result or non-creation audit history, an unlinked legacy result
for that Agent, or an Agent result submission
requires execution_outcome=unknown. Missing or ambiguous evidence is unknown.
Only a pending row with a complete pre-cancellation check finding no delivery
evidence is classified not_dispatched by the queue. Historical claim evidence
survives cancellation; an explicitly repaired/requeued pending row is still
unknown. No ordinary production requeue mechanism is introduced by Stage A.
Neither classification proves absence of out-of-band historical physical writes.
Repeated retirement must add no duplicate audit and must not rewrite history.
6. Schedule cancel/delete uses the stored schedule's Agent ID and the same rejection
audit policy; delete retains the schedule. This is NOT a fleet drain: orphaned or
foreign-Agent rows are handled by the per-Agent cutover sweep above. Never delete
queue/audit rows, reset claimed commands to pending, or rewrite terminal results.
Existing pre-remediation cancelled rows missing an audit must be preserved and
documented as a historical evidence gap. Do not invent a prior-state audit.
Exit gate: the global live-unsupported query returns no rows, locks are accounted
for, transition counts/audits reconcile, and no old producer can replenish work.
This gate does not revoke already delivered payloads; gates 1, 2 and 5 remain mandatory.
Gate 5: upgrade Windows code and verify installed bytes while stopped
1. Use a verified artifact prepared from the exact independently reviewed Stage A
boundary. Record its digest and a trusted expected SHA-256 manifest outside the
target installation. VERSION is unchanged at 1.0.0-alpha and is not upgrade proof.
2. Include all managed runtime scripts, especially MinerOpsAgentService.ps1,
MinerOpsControl.ps1, EnrichOneMiner.ps1, MinerOpsApi3EnrichmentPoller.ps1, the pool
pollers, task/GUI/tray scripts, and VERSION. Check the package's installer and
enrollment/credential-upgrade entry points as well. Include the manifest's origin
and review approval; hashes computed only from a suspect host are not a trust root.
3. Confirm the main task was positively stopped BEFORE invoking the existing
installer. It can auto-restart a task that it found running. Keep the external
network fence and restart hold throughout installation and failure recovery.
Do not pass filesystem trust-reset switches unless their existing reviewed
recovery workflow specifically calls for them. Preserve config/state/logs/backups.
4. Compare installed Get-FileHash -Algorithm SHA256 values to the trusted manifest.
Review task actions for the expected executable, canonical script path, arguments,
principal, and original/reviewed definition; check all aliases/duplicate tasks.
Verify ACLs, signer/source provenance where used, absence of old running processes,
and the task state consistent with the maintenance hold. Do not overwrite an
established task definition merely to force a convenient state.
5. Follow the existing credential transition/enrollment recovery workflow only if
required. Hub credentials and local miner credentials are different trust domains;
rotating a Hub secret cannot recall a cached physical command. Keep credentials
local and protected. Never restore an old credential/config to make a test pass.
Exit gate: installed bytes and process/task state match the approved ledger for
every Agent proposed for release. An inaccessible Agent, hash mismatch, duplicate
task, unexpected process, or trust-reset blocker remains fenced and stopped.
Gate 6: native Windows validation and fresh process startup
Before production release, run on a network-isolated native Windows PowerShell 5.1
host with simulated miners only. Linux PowerShell 7 tests are not evidence of SYSTEM
task lifecycle correctness. Validate these points and retain results:
- Actual SYSTEM/Scheduled Task stop, child cleanup, restart hold and reboot behavior.
- Installed package hash verification and fresh module loading after upgrade.
- Legacy/malformed/unsupported cached commands rejected with zero physical writes.
- Diagnostic write/parameter overrides rejected before any device connection;
fixed telemetry/discovery/API3 read selections remain compatible with simulators.
- Enrollment, credential-transition interruption/recovery, and running/stopped intent
retain existing protections, without credentials appearing in normal output.
- A full network fence prevents an old cached command from reaching a live simulator;
a separate unfenced positive control proves the simulator and old-executor setup.
1. Start only a fresh verified Agent process, initially still fenced, after all
preceding gates are satisfied. Verify executable/script paths, start time after
publication, loaded source provenance, per-Agent authentication, Zone assignment,
and unavailable control advertisement. The advertisement alone is not proof.
2. Recheck the unsupported queue and audit reconciliation after startup. Do not
restore read connectivity for a host with any unverified old executor.
3. Release the fence only for individually verified upgraded hosts under the approved
network change. Re-enable only reviewed telemetry schedules. Physical control
stays disabled on Hub AND Agent. Retain independent monitor/rollback evidence.
Exit gate: every released Agent has complete positive verification. Fleet-wide
containment cannot be claimed while unverified devices have an unfenced route.
Gate 7: returning offline Agents and rollback/recovery
Maintain a default quarantine for every offline, unknown, historical or unverified
Agent when it reconnects. A timestamp, IP address, same VERSION, successful Hub
authentication, or control_available=false claim cannot release it. Repeat stop,
hash/task verification, upgrade, fresh-start and relevant gates before restoring
miner connectivity. Keep retired Agent credentials retired; do not reopen legacy
shared-secret fallback for convenience.
If ANY gate fails:
- Keep or reinstate physical network fencing immediately. Stop/restart-hold Agent
executors and prevent old Hub producers from running. Preserve evidence and notify
the change owner; do not automatically resume the previous running state.
- Database/audit failure: leave rolled-back rows as evidence, resolve the error,
retry audited retirement, and re-run the global zero-live check. Never repair
audit integrity by deleting history or fabricating execution outcomes.
- Installer/recovery failure: preserve trust blockers, recovery tokens and protected
backups according to existing recovery instructions. Verify state before retry.
- If restoring old Hub or Agent binaries is unavoidable, those binaries stay stopped
and all affected miner routes stay fenced. An operational rollback does not restore
security containment. Do not reconnect until the reviewed Stage A bytes are again
installed and every failed gate is repeated successfully.
- If verification cannot be completed within the change window, leave the affected
host quarantined with a named owner. Partial success is not authority to un-fence it.
Disposable simulation available for independent review
agents/windows/tests/Test-MinerOpsOldAgentFence.ps1 loads AST functions from a
separate git archive of 2d89e80 and the audit's cached suspend payload. It requires
MINEROPS_STAGE_A_SIMULATION=1 and an explicitly provided simulated target. The
remediation artifact directory contains simulate_fence.py and simulated_miner.py
which create a temporary Docker INTERNAL network and fake API3 miner, then run the
old executor first with --network none (zero simulated writes) and separately on
the internal network (one simulated service-stop write). No host ports are exposed.
Only those created resources are removed. This demonstrates the coarse fence's
effect, not the adequacy of any production firewall/NAC design. Native Windows
evidence and actual fleet/network verification remain separate release gates.
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more