diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0b74632
--- /dev/null
+++ b/.dockerignore
@@ -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*
diff --git a/agents/windows/Enroll-MinerOpsAgent.ps1 b/agents/windows/Enroll-MinerOpsAgent.ps1
new file mode 100644
index 0000000..7e2668e
--- /dev/null
+++ b/agents/windows/Enroll-MinerOpsAgent.ps1
@@ -0,0 +1,323 @@
+#Requires -RunAsAdministrator
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = "Stop"
+
+$Target = "C:\MinerOpsAgent"
+$ConfigPath = Join-Path $Target "agent-config.json"
+$PendingConfigPath = Join-Path $Target "agent-config.json.pending"
+$RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json"
+$EnrollmentPath = Join-Path $PSScriptRoot "enrollment.json"
+$Installer = Join-Path $PSScriptRoot "install.ps1"
+
+$RequiredFiles = @(
+ "install.ps1",
+ "MinerOpsAgentService.ps1",
+ "MinerOpsAgentTray.ps1",
+ "MinerOpsAgentGUI.ps1",
+ "MinerOpsApi3EnrichmentPoller.ps1",
+ "MinerOpsPoolPoller.ps1",
+ "MinerOpsPoolPollerTask.ps1",
+ "EnrichOneMiner.ps1",
+ "MinerOpsControl.ps1",
+ "VERSION",
+ "enrollment.json"
+)
+
+function Protect-MinerOpsCredentialFile {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path
+ )
+
+ $acl = New-Object System.Security.AccessControl.FileSecurity
+ $acl.SetAccessRuleProtection($true, $false)
+
+ $identities = @(
+ (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")),
+ (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")),
+ [System.Security.Principal.WindowsIdentity]::GetCurrent().User
+ )
+
+ foreach ($identity in $identities) {
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
+ $identity,
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
+ [System.Security.AccessControl.InheritanceFlags]::None,
+ [System.Security.AccessControl.PropagationFlags]::None,
+ [System.Security.AccessControl.AccessControlType]::Allow
+ )
+
+ $acl.AddAccessRule($rule)
+ }
+
+ Set-Acl -LiteralPath $Path -AclObject $acl
+}
+
+function ConvertFrom-MinerOpsSecureString {
+ param(
+ [Parameter(Mandatory = $true)]
+ [Security.SecureString] $SecureValue
+ )
+
+ $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
+ $SecureValue
+ )
+
+ try {
+ return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
+ }
+ finally {
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
+ }
+}
+
+# Validate the complete package before modifying the Agent host or consuming
+# the single-use enrollment token.
+foreach ($name in $RequiredFiles) {
+ $source = Join-Path $PSScriptRoot $name
+
+ if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
+ throw "Enrollment package is missing $name"
+ }
+}
+
+if (Test-Path -LiteralPath $ConfigPath) {
+ throw @"
+C:\MinerOpsAgent\agent-config.json already exists.
+This enrollment bootstrapper is for a new Agent identity and will not overwrite
+an existing identity. Use the normal MinerOps upgrade procedure instead.
+"@
+}
+
+foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) {
+ if (Test-Path -LiteralPath $recoveryPath) {
+ throw @"
+A previous enrollment recovery file exists at:
+$recoveryPath
+
+Do not create or redeem another enrollment until that recovery state has been
+reviewed.
+"@
+ }
+}
+
+$enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw |
+ ConvertFrom-Json
+
+$HubUrl = ([string] $enrollment.hub_url).TrimEnd("/")
+$EnrollmentToken = ([string] $enrollment.token).Trim()
+
+$parsedHub = $null
+
+if (
+ -not [Uri]::TryCreate(
+ $HubUrl,
+ [UriKind]::Absolute,
+ [ref] $parsedHub
+ ) -or
+ $parsedHub.Scheme -notin @("http", "https")
+) {
+ throw "enrollment.json contains an invalid MinerOps Hub URL."
+}
+
+if (
+ $EnrollmentToken.Length -lt 32 -or
+ $EnrollmentToken.Length -gt 256
+) {
+ throw "enrollment.json contains an invalid enrollment token."
+}
+
+Write-Host ""
+Write-Host "MinerOps Windows Agent enrollment"
+Write-Host "Hub: $HubUrl"
+Write-Host ""
+Write-Host "API3 credentials stay only on this Windows Agent."
+Write-Host "Leave the account blank if API3 features are not being configured yet."
+Write-Host ""
+
+$Api3Account = (Read-Host "WhatsMiner API3 account").Trim()
+$Api3PasswordPlain = ""
+
+if ($Api3Account) {
+ $Api3PasswordSecure = Read-Host `
+ "WhatsMiner API3 password" `
+ -AsSecureString
+
+ $Api3PasswordPlain = ConvertFrom-MinerOpsSecureString `
+ -SecureValue $Api3PasswordSecure
+}
+
+$Redeemed = $false
+$ConfigPersisted = $false
+
+try {
+ New-Item -ItemType Directory -Force -Path $Target | Out-Null
+
+ # Create protected empty files BEFORE token redemption. If persistence is
+ # not possible, fail before consuming the single-use token.
+ [IO.File]::WriteAllText($PendingConfigPath, "")
+ Protect-MinerOpsCredentialFile -Path $PendingConfigPath
+
+ [IO.File]::WriteAllText($RecoveryConfigPath, "")
+ Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath
+
+ $requestBody = @{
+ token = $EnrollmentToken
+ } | ConvertTo-Json -Compress
+
+ Write-Host ""
+ Write-Host "Redeeming one-time MinerOps enrollment..."
+
+ $response = Invoke-RestMethod `
+ -Method Post `
+ -Uri "$HubUrl/api/agent/enroll" `
+ -ContentType "application/json" `
+ -Body $requestBody `
+ -TimeoutSec 30
+
+ $Redeemed = $true
+
+ if (-not $response.data) {
+ throw "MinerOps enrollment returned no Agent data."
+ }
+
+ $agent = $response.data
+
+ foreach ($required in @(
+ "agent_id",
+ "agent_secret",
+ "site_id",
+ "site_name",
+ "agent_zone_id",
+ "agent_zone_name"
+ )) {
+ if (-not ([string] $agent.$required).Trim()) {
+ throw "MinerOps enrollment response is missing $required."
+ }
+ }
+
+ $ranges = @()
+
+ foreach ($cidr in @($agent.ip_ranges)) {
+ $network = ([string] $cidr).Trim()
+
+ if (-not $network) {
+ continue
+ }
+
+ $ranges += [pscustomobject]@{
+ name = "$($agent.agent_zone_name) - $network"
+ cidr = $network
+ enabled = $true
+ api_port = 4028
+ api3_port = 4433
+ }
+ }
+
+ $config = [ordered]@{
+ hub_url = $HubUrl
+ agent_id = [string] $agent.agent_id
+ site_id = [string] $agent.site_id
+ agent_zone_id = [string] $agent.agent_zone_id
+ site_name = [string] $agent.site_name
+ agent_secret = [string] $agent.agent_secret
+ poll_interval_seconds = 120
+ ip_ranges = @($ranges)
+ api3_account = $Api3Account
+ api3_password = $Api3PasswordPlain
+ api3_port = 4433
+ }
+
+ $configJson = $config | ConvertTo-Json -Depth 20
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+
+ # Write a protected recovery copy first. If anything after redemption
+ # fails, the permanent Agent credential is not lost with the consumed token.
+ [IO.File]::WriteAllText(
+ $RecoveryConfigPath,
+ $configJson + [Environment]::NewLine,
+ $utf8NoBom
+ )
+
+ [IO.File]::WriteAllText(
+ $PendingConfigPath,
+ $configJson + [Environment]::NewLine,
+ $utf8NoBom
+ )
+
+ Move-Item `
+ -LiteralPath $PendingConfigPath `
+ -Destination $ConfigPath
+
+ $ConfigPersisted = $true
+
+ Remove-Item -LiteralPath $RecoveryConfigPath -Force
+
+ # The extracted bootstrap token is no longer needed once permanent
+ # credentials have been persisted locally.
+ Remove-Item -LiteralPath $EnrollmentPath -Force
+
+ $EnrollmentToken = $null
+ $requestBody = $null
+ $Api3PasswordPlain = $null
+
+ Write-Host "Permanent Agent identity saved locally."
+ Write-Host "Installing MinerOps Agent runtime..."
+
+ & $Installer
+
+ $mainTask = Get-ScheduledTask `
+ -TaskName "MinerOpsAgent" `
+ -ErrorAction Stop
+
+ if ([string] $mainTask.State -ne "Running") {
+ Start-ScheduledTask -TaskName "MinerOpsAgent" -ErrorAction Stop
+ }
+
+ Write-Host ""
+ Write-Host "MinerOps Agent enrollment completed."
+ Write-Host "Agent ID: $($agent.agent_id)"
+ Write-Host "Site: $($agent.site_name)"
+ Write-Host "Agent Zone: $($agent.agent_zone_name)"
+ Write-Host ""
+ Write-Host "Delete the downloaded ZIP and extracted setup folder after verification."
+}
+catch {
+ if (-not $Redeemed) {
+ foreach ($temporaryPath in @(
+ $PendingConfigPath,
+ $RecoveryConfigPath
+ )) {
+ if (Test-Path -LiteralPath $temporaryPath) {
+ Remove-Item `
+ -LiteralPath $temporaryPath `
+ -Force `
+ -ErrorAction SilentlyContinue
+ }
+ }
+ }
+ elseif ($ConfigPersisted) {
+ Write-Warning @"
+The enrollment token was consumed and the permanent Agent identity WAS saved
+to C:\MinerOpsAgent\agent-config.json.
+
+Do not create a replacement enrollment. Correct the installation problem and
+rerun install.ps1 from this package.
+"@
+ }
+ else {
+ Write-Warning @"
+The enrollment token was consumed.
+
+A protected credential recovery file may exist at:
+C:\MinerOpsAgent\agent-config.recovery.json
+
+Do not create or redeem another enrollment until that recovery file has been
+reviewed.
+"@
+ }
+
+ throw
+}
diff --git a/agents/windows/README.md b/agents/windows/README.md
index 9a4a3c0..9664599 100644
--- a/agents/windows/README.md
+++ b/agents/windows/README.md
@@ -30,9 +30,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
@@ -43,35 +46,52 @@ 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
+
+For an already enrolled/configured Agent, do not run the enrollment
+bootstrapper. 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.
+
+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.
+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.
diff --git a/agents/windows/tests/Test-MinerOpsEnrollment.ps1 b/agents/windows/tests/Test-MinerOpsEnrollment.ps1
new file mode 100644
index 0000000..cd3dbd3
--- /dev/null
+++ b/agents/windows/tests/Test-MinerOpsEnrollment.ps1
@@ -0,0 +1,117 @@
+$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 '/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 'Protect-MinerOpsCredentialFile' `
+ "Credential file ACL protection is missing."
+
+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 'WindowsIdentity\]::GetCurrent\(\)\.User' `
+ "Installing user ACL entry is missing."
+
+Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
+ "Atomic final config move is missing."
+
+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."
+
+$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(
+ '-Destination $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."
diff --git a/backend/Dockerfile b/backend/Dockerfile
index f45d4c4..ca576c5 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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"]
\ No newline at end of file
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/agent_enrollment/models.py b/backend/app/agent_enrollment/models.py
index 4186840..a938b8c 100644
--- a/backend/app/agent_enrollment/models.py
+++ b/backend/app/agent_enrollment/models.py
@@ -92,3 +92,7 @@ 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."""
diff --git a/backend/app/agent_enrollment/router.py b/backend/app/agent_enrollment/router.py
index ee58989..087c58a 100644
--- a/backend/app/agent_enrollment/router.py
+++ b/backend/app/agent_enrollment/router.py
@@ -3,7 +3,7 @@
from typing import Annotated
from uuid import UUID
-from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
+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 (
@@ -17,8 +17,15 @@ from app.agent_enrollment.models import (
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
@@ -85,6 +92,50 @@ def list_agent_enrollments(
)
+
+@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,
+ )
+ 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/enroll",
response_model=AgentEnrollResponse,
diff --git a/backend/app/agent_packages/__init__.py b/backend/app/agent_packages/__init__.py
new file mode 100644
index 0000000..9eab608
--- /dev/null
+++ b/backend/app/agent_packages/__init__.py
@@ -0,0 +1 @@
+"""Downloadable MinerOps Agent deployment packages."""
diff --git a/backend/app/agent_packages/windows.py b/backend/app/agent_packages/windows.py
new file mode 100644
index 0000000..9d65b10
--- /dev/null
+++ b/backend/app/agent_packages/windows.py
@@ -0,0 +1,164 @@
+"""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",
+ "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,
+ 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)
+
+ 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,
+ }
+
+ instructions = """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:
+
+ .\\Enroll-MinerOpsAgent.ps1
+
+The enrollment credential is short-lived and single-use.
+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()
diff --git a/backend/app/agent_registry/__init__.py b/backend/app/agent_registry/__init__.py
new file mode 100644
index 0000000..26ec0b1
--- /dev/null
+++ b/backend/app/agent_registry/__init__.py
@@ -0,0 +1 @@
+"""Read-only Agent registry projection."""
diff --git a/backend/app/agent_registry/dependencies.py b/backend/app/agent_registry/dependencies.py
new file mode 100644
index 0000000..49d05e2
--- /dev/null
+++ b/backend/app/agent_registry/dependencies.py
@@ -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())
diff --git a/backend/app/agent_registry/models.py b/backend/app/agent_registry/models.py
new file mode 100644
index 0000000..ea5eec3
--- /dev/null
+++ b/backend/app/agent_registry/models.py
@@ -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"]
+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
diff --git a/backend/app/agent_registry/repository.py b/backend/app/agent_registry/repository.py
new file mode 100644
index 0000000..4cd37a4
--- /dev/null
+++ b/backend/app/agent_registry/repository.py
@@ -0,0 +1,127 @@
+"""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'
+ ELSE 'legacy'
+ END AS credential_state
+ 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
+ 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()
diff --git a/backend/app/agent_registry/router.py b/backend/app/agent_registry/router.py
new file mode 100644
index 0000000..94eab00
--- /dev/null
+++ b/backend/app/agent_registry/router.py
@@ -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),
+ },
+ )
diff --git a/backend/app/agent_registry/service.py b/backend/app/agent_registry/service.py
new file mode 100644
index 0000000..f200e6c
--- /dev/null
+++ b/backend/app/agent_registry/service.py
@@ -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,
+ )
diff --git a/backend/app/frontend/agent_management_view.py b/backend/app/frontend/agent_management_view.py
new file mode 100644
index 0000000..35a99f0
--- /dev/null
+++ b/backend/app/frontend/agent_management_view.py
@@ -0,0 +1,274 @@
+"""Semantic HTML for the Internal Alpha Agent Management page."""
+
+
+def agent_management_content(can_manage_agents: bool) -> str:
+ management = """
+
+
+
+
Agent deployment
+
Add Agent
+
+ Create a short-lived one-time enrollment for an Agent Zone.
+ Windows is the currently supported deployment platform.
+
+
+
+
+
+
+
+
+
One-time credential
+
+ Enrollment created
+
+
+
+
+ This token is displayed only for this enrollment step.
+ Keep it private. MinerOps does not store the plaintext token.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+ """ if can_manage_agents else """
+
+
+
+
Agent deployment
+
Agent enrollment
+
+
+
+ Your account can view Agent status but cannot create Agent
+ enrollments.
+