mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
Refactored & Modularized Agent Roles
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,7 +9,6 @@ Borealis-Server.exe
|
||||
/Server/
|
||||
/ElectronApp/
|
||||
/Logs/
|
||||
/Temp/
|
||||
|
||||
# On-the-Fly Downloaded Dependencies
|
||||
/Dependencies/NodeJS/
|
||||
@@ -20,6 +19,7 @@ Borealis-Server.exe
|
||||
# Misc Files/Folders
|
||||
.vs/s
|
||||
__pycache__
|
||||
/Agent/Python_API_Endpoints/__pycache__/
|
||||
/Update_Staging/
|
||||
agent_settings.json
|
||||
users.json
|
||||
|
||||
46
AGENTS.md
46
AGENTS.md
@@ -9,7 +9,7 @@ Today the stable core focuses on workflow-driven API and automation scenarios. R
|
||||
- `Borealis.ps1` is the entry point for every component. It bootstraps dependencies, clones bundled virtual environments, and spins up server, agent, Vite, or Flask modes on demand.
|
||||
- Bundled assets live under `Data/Agent`, `Data/Server`, and `Dependencies`. Launching installs copies into sibling `Agent/` and `Server/` directories so the development tree stays clean and the runtime stays portable.
|
||||
- The server stack spans NodeJS + Vite for live development and Flask (`Data/Server/server.py`) for production APIs, backed by Python helpers (`Data/Server/Python_API_Endpoints`) for OCR, scripting, and other services.
|
||||
- Agents run inside the packaged Python venv (`Data/Agent` mirrored to `Agent/`). `borealis-agent.py` handles the primary connection, with `agent_supervisor.py` and the PowerShell watchdog managing SYSTEM-level operations and resilience.
|
||||
- Agents run inside the packaged Python venv (`Data/Agent` mirrored to `Agent/`). `agent.py` handles the primary connection and hot-loads roles from `Data/Agent/Roles` at startup.
|
||||
|
||||
## Dependencies & Packaging
|
||||
`Dependencies/` holds the installers/download payloads Borealis bootstraps on first launch: Python, 7-Zip, AutoHotkey, and NodeJS. Versions are hard-pinned in `Borealis.ps1`; upgrading any runtime requires updating those version constants before repackaging. Nothing self-updates, so Codex should coordinate dependency bumps carefully and test both server and agent bootstrap paths.
|
||||
@@ -20,20 +20,44 @@ Today the stable core focuses on workflow-driven API and automation scenarios. R
|
||||
Agents establish REST calls to the Flask backend on port 5000 and keep a WebSocket session for interactive features such as screenshot capture. Future plans include WebRTC for higher-performance remote desktop. No authentication or enrollment handshake exists yet, so agents are implicitly trusted once launched.
|
||||
|
||||
### Execution Contexts
|
||||
`agent_supervisor.py` runs as `NT AUTHORITY\SYSTEM` via a scheduled task created during installation. `Scripts/watchdog.ps1` checks every five minutes that the supervisor stays alive and restarts it when needed. The primary `borealis-agent.py` process runs as the interactive user to cover foreground automation while delegating privileged work to the supervisor.
|
||||
The agent runs in the interactive user session. SYSTEM-level script execution is provided by the ScriptExec SYSTEM role using ephemeral scheduled tasks; no separate supervisor or watchdog is required.
|
||||
|
||||
### Logging & State
|
||||
All runtime logs live under `Logs/<ServiceName>` relative to the project root (`Logs/Agent` for the agent family). The project avoids writing to `%ProgramData%`, `%AppData%`, or other system directories so the entire footprint stays under the Borealis folder. Log rotation is not yet implemented; contributions should consider a built-in retention strategy. Configuration and state currently live alongside the agent code.
|
||||
|
||||
## Roles & Extensibility
|
||||
- Role modules follow a `role_<purpose>.py` naming convention and should implement a configurable expiration window so the agent can abandon long-running work when the server signals a timeout.
|
||||
- At present, roles aggregate inside `Data/Agent/agent_roles.py`. The desired end state is a `Agent/Borealis/Roles/` directory where each role lives in its own file and is auto-discovered or explicitly registered on startup.
|
||||
- New roles should expose clear hooks for initialization, execution, cancellation, and cleanup. They must tolerate the agent being restarted, handle both Windows and (eventual) Linux paths, and avoid blocking the main event loop.
|
||||
- Planned script execution split examples: `role_ScriptExec_SYSTEM.py` for privileged script or task execution and `role_ScriptExec_CURRENTUSER.py` for interactive scripts and tasks, keeping core orchestration in `borealis-agent.py`.
|
||||
- Roles live under `Data/Agent/Roles/` and are auto‑discovered at startup; no changes are needed in `agent.py` when adding new roles.
|
||||
- Naming convention: `role_<Purpose>.py` per role.
|
||||
- Role interface (per module):
|
||||
- `ROLE_NAME`: canonical role name used by config (e.g., `screenshot`, `script_exec_system`).
|
||||
- `ROLE_CONTEXTS`: list of contexts this role runs in (`interactive`, `system`).
|
||||
- `class Role(ctx)`: optional hooks the agent loader will call:
|
||||
- `register_events()`: bind any Socket.IO listeners.
|
||||
- `on_config(roles: List[dict])`: start/stop per‑role tasks based on server config.
|
||||
- `stop_all()`: cancel tasks and cleanup.
|
||||
- Standard roles currently shipped:
|
||||
- `role_DeviceInventory.py` — collects and periodically posts device inventory/summary.
|
||||
- `role_Screenshot.py` — region overlay + periodic capture with WebSocket updates.
|
||||
- `role_ScriptExec_CURRENTUSER.py` — runs PowerShell in the logged‑in session and provides the tray icon (restart/quit).
|
||||
- `role_ScriptExec_SYSTEM.py` — runs PowerShell as SYSTEM via ephemeral Scheduled Tasks.
|
||||
- `role_Macro.py` — macro and key/text send helpers.
|
||||
- Considerations:
|
||||
- SYSTEM role requires administrative rights to create/run scheduled tasks as SYSTEM. If elevation is unavailable or policies restrict task creation, SYSTEM jobs will fail gracefully and report errors to the server.
|
||||
- Roles are “hot‑loaded” on startup only (no dynamic import while running).
|
||||
- Roles must avoid blocking the main event loop and be resilient to restarts.
|
||||
|
||||
## Packaging Notes
|
||||
- `Borealis.ps1` deploys `agent.py`, `role_manager.py`, `Roles/`, and `Python_API_Endpoints/` into `Agent/Borealis/`.
|
||||
- If packaging a single‑file EXE (PyInstaller), ensure `Roles/` and `Python_API_Endpoints/` are included as data files so role auto‑discovery works at runtime.
|
||||
|
||||
## Migration Summary
|
||||
- Replaced monolithic role code with modular roles under `Data/Agent/Roles/`.
|
||||
- Removed legacy helpers: `agent_supervisor.py`, `agent_roles.py`, `tray_launcher.py`, `agent_info.py`, and `script_agent.py` (functionality is now inside roles).
|
||||
- `agent.py` contains only core transport/config logic and role loading.
|
||||
|
||||
## Operational Guidance
|
||||
- Launch or test a single agent locally with `.\Borealis.ps1 -Agent` (or combine with `-AgentAction install|repair|launch|remove` as needed). The same entry point manages the server (`-Server`) with either Vite or Flask flags.
|
||||
- When debugging, tail files under `Logs/Agent` and inspect the watchdog output to confirm the supervisor is running. Use the PowerShell packaging scripts in `Data/Agent/Scripts` to reinstall scheduled tasks if they drift.
|
||||
- When debugging, tail files under `Logs/Agent`. Use the PowerShell packaging scripts in `Data/Agent/Scripts` to reinstall the user logon scheduled task if it drifts.
|
||||
- Updates today require manually stopping related processes (`taskkill /IM "node.exe" /IM "pythonw.exe" /IM "python.exe" /F`) followed by a fresh run of `Borealis.ps1 -Agent`. This is a known limitation; future work should automate graceful agent restarts and remote updates without collateral downtime.
|
||||
- Known stability gaps include suspected Python memory leaks in both the server and agents under multi-day workloads, occasional heartbeat mismatches, and the flashing watchdog console window. A more robust keepalive should eventually remove the watchdog dependency.
|
||||
- Expect the agent to remain running for days or weeks; contributions should focus on reconnect logic, light resource usage, and graceful shutdown/restart semantics.
|
||||
@@ -53,3 +77,11 @@ Windows is the reference environment today. `Borealis.ps1` owns the full deploym
|
||||
|
||||
## Security Outlook
|
||||
Security and authentication are intentionally deferred. There is currently no agent/server handshake, credential model, or ACL on powerful endpoints, so deployments must remain in controlled environments. A future milestone will introduce mutual registration, scoped API tokens, and hardened remote execution surfaces; until then, prioritize resilience and modularity while acknowledging the risk.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
198
Borealis.ps1
198
Borealis.ps1
@@ -62,6 +62,44 @@ $symbols = @{
|
||||
Info = [char]0x2139
|
||||
}
|
||||
|
||||
# Admin/Elevation helpers for Agent deployment
|
||||
function Test-IsAdmin {
|
||||
try {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
||||
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Request-AgentElevation {
|
||||
param(
|
||||
[string]$ScriptPath,
|
||||
[string]$AgentActionParam,
|
||||
[switch]$Auto
|
||||
)
|
||||
if (Test-IsAdmin) { return $true }
|
||||
|
||||
if (-not $Auto) {
|
||||
Write-Host "" # spacer
|
||||
Write-Host "Agent requires Administrator permissions to register scheduled tasks and run reliably." -ForegroundColor Yellow -BackgroundColor Black
|
||||
Write-Host "Grant elevated permissions now? (Y/N)" -ForegroundColor Yellow -BackgroundColor Black
|
||||
$resp = Read-Host
|
||||
if ($resp -notin @('y','Y','yes','YES')) { return $false }
|
||||
}
|
||||
|
||||
$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File', '"' + $ScriptPath + '"', '-Agent')
|
||||
if ($AgentActionParam -and $AgentActionParam.Trim()) {
|
||||
$args += @('-AgentAction', $AgentActionParam)
|
||||
}
|
||||
try {
|
||||
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $args -WindowStyle Normal | Out-Null
|
||||
return $false # stop current non-elevated instance
|
||||
} catch {
|
||||
Write-Host "Elevation was denied or failed." -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure log directories
|
||||
function Ensure-AgentLogDir {
|
||||
$logRoot = Join-Path $scriptDir 'Logs'
|
||||
@@ -324,91 +362,21 @@ function Install_Agent_Dependencies {
|
||||
}
|
||||
|
||||
function Ensure-AgentTasks {
|
||||
# Registers SYSTEM Supervisor + 5-min Watchdog via UAC-elevated stub
|
||||
param(
|
||||
[string]$ScriptRoot
|
||||
)
|
||||
$supName = 'Borealis Agent - Supervisor'
|
||||
$py = Join-Path $ScriptRoot 'Agent\Scripts\python.exe'
|
||||
$supScript = Join-Path $ScriptRoot 'Data\Agent\agent_supervisor.py'
|
||||
$wdName = 'Borealis Agent - Watchdog'
|
||||
# Per-user tray helper task (ensure within same elevation to avoid second UAC)
|
||||
param([string]$ScriptRoot)
|
||||
$userTaskName = 'Borealis Agent'
|
||||
$userExe = Join-Path $ScriptRoot 'Agent\Scripts\pythonw.exe'
|
||||
$userScript = Join-Path $ScriptRoot 'Agent\Borealis\tray_launcher.py'
|
||||
|
||||
# Elevate and run the external registrar script with parameters
|
||||
$regScript = Join-Path $ScriptRoot 'Data\Agent\Scripts\register_agent_tasks.ps1'
|
||||
$wdSource = Join-Path $ScriptRoot 'Data\Agent\Scripts\watchdog.ps1'
|
||||
if (-not (Test-Path $regScript)) { Write-Host "Register helper script not found: $regScript" -ForegroundColor Red; return }
|
||||
if (-not (Test-Path $wdSource)) { Write-Host "Watchdog script not found: $wdSource" -ForegroundColor Red; return }
|
||||
|
||||
# Launch registrar elevated using -EncodedCommand to avoid quoting/binding issues
|
||||
$qSupName = $supName -replace "'","''"
|
||||
$qPy = $py -replace "'","''"
|
||||
$qSupScript = $supScript -replace "'","''"
|
||||
$qWdName = $wdName -replace "'","''"
|
||||
$qWdSource = $wdSource -replace "'","''"
|
||||
$qRegScript = $regScript -replace "'","''"
|
||||
$qUserTaskName = $userTaskName -replace "'","''"
|
||||
$qUserExe = $userExe -replace "'","''"
|
||||
$qUserScript = $userScript -replace "'","''"
|
||||
$agentScript = Join-Path $ScriptRoot 'Agent\Borealis\agent.py'
|
||||
if (-not (Test-Path $userExe)) { Write-Host "pythonw.exe not found under Agent\Scripts" -ForegroundColor Yellow; return }
|
||||
if (-not (Test-Path $agentScript)) { Write-Host "Agent script not found under Agent\Borealis" -ForegroundColor Yellow; return }
|
||||
try { Unregister-ScheduledTask -TaskName $userTaskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
|
||||
$usrArg = ('-W ignore::SyntaxWarning "{0}"' -f $agentScript)
|
||||
$usrAction = New-ScheduledTaskAction -Execute $userExe -Argument $usrArg
|
||||
$usrTrig = New-ScheduledTaskTrigger -AtLogOn
|
||||
$usrSet = New-ScheduledTaskSettingsSet -Hidden -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$qUserPrincipal= $currentUser -replace "'","''"
|
||||
$inline = @"
|
||||
`$p = @{
|
||||
SupName = '$qSupName'
|
||||
PythonExe = '$qPy'
|
||||
SupScript = '$qSupScript'
|
||||
WdName = '$qWdName'
|
||||
WdSource = '$qWdSource'
|
||||
UserTaskName = '$qUserTaskName'
|
||||
UserExe = '$qUserExe'
|
||||
UserScript = '$qUserScript'
|
||||
UserPrincipal = '$qUserPrincipal'
|
||||
}
|
||||
& '$qRegScript' @p
|
||||
"@
|
||||
$bytes = [System.Text.Encoding]::Unicode.GetBytes($inline)
|
||||
$encoded = [Convert]::ToBase64String($bytes)
|
||||
|
||||
# Use a temporary VBS shim to elevate and run PowerShell fully hidden (no flashing console)
|
||||
$tempDir = Join-Path $ScriptRoot 'Temp'
|
||||
if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null }
|
||||
$vbsPath = Join-Path $tempDir 'RunAgentRegistrarElevatedHidden.vbs'
|
||||
$vbs = @'
|
||||
Set sh = CreateObject("Shell.Application")
|
||||
cmd = "powershell.exe"
|
||||
args = " -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand " & "<ENC>"
|
||||
sh.ShellExecute cmd, args, "", "runas", 0
|
||||
'@
|
||||
$vbs = $vbs -replace '<ENC>', $encoded
|
||||
Write-AgentLog -FileName 'AgentTaskRegistration.log' -Message 'Preparing elevated hidden registrar VBS shim.'
|
||||
Set-Content -Path $vbsPath -Value $vbs -Encoding ASCII -Force
|
||||
try {
|
||||
Write-AgentLog -FileName 'AgentTaskRegistration.log' -Message 'Invoking wscript.exe to run registrar hidden with elevation.'
|
||||
Start-Process -FilePath 'wscript.exe' -ArgumentList ('"{0}"' -f $vbsPath) -WindowStyle Hidden -Wait | Out-Null
|
||||
} catch {
|
||||
Write-Host "Failed to elevate for task registration." -ForegroundColor Red
|
||||
Write-AgentLog -FileName 'AgentTaskRegistration.log' -Message ("Registrar elevation failed: " + $_)
|
||||
} finally {
|
||||
Remove-Item $vbsPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
# Best-effort: wait briefly for tasks to be registered so subsequent steps see them
|
||||
try {
|
||||
$maxWaitSec = 30
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
try { $ts = Get-ScheduledTask -TaskName $supName -ErrorAction SilentlyContinue } catch { $ts = $null }
|
||||
} while (-not $ts -and $sw.Elapsed.TotalSeconds -lt $maxWaitSec)
|
||||
$sw.Stop()
|
||||
if ($ts) {
|
||||
Write-AgentLog -FileName 'AgentTaskRegistration.log' -Message "Supervisor task detected after $([int]$sw.Elapsed.TotalSeconds)s."
|
||||
} else {
|
||||
Write-AgentLog -FileName 'AgentTaskRegistration.log' -Message "Supervisor task not detected within timeout; continuing."
|
||||
}
|
||||
} catch {}
|
||||
$usrPrin = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Limited
|
||||
Register-ScheduledTask -TaskName $userTaskName -Action $usrAction -Trigger $usrTrig -Settings $usrSet -Principal $usrPrin -Force | Out-Null
|
||||
try { Start-ScheduledTask -TaskName $userTaskName | Out-Null } catch {}
|
||||
}
|
||||
function InstallOrUpdate-BorealisAgent {
|
||||
Write-Host "Ensuring Agent Dependencies Exist..." -ForegroundColor DarkCyan
|
||||
@@ -422,10 +390,10 @@ function InstallOrUpdate-BorealisAgent {
|
||||
Write-Host "Deploying Borealis Agent..." -ForegroundColor Blue
|
||||
|
||||
$venvFolder = "Agent"
|
||||
$agentSourcePath = "Data\Agent\borealis-agent.py"
|
||||
$agentSourcePath = "Data\Agent\agent.py"
|
||||
$agentRequirements = "Data\Agent\agent-requirements.txt"
|
||||
$agentDestinationFolder = "$venvFolder\Borealis"
|
||||
$agentDestinationFile = "$venvFolder\Borealis\borealis-agent.py"
|
||||
$agentDestinationFile = "$venvFolder\Borealis\agent.py"
|
||||
$venvPython = Join-Path $scriptDir $venvFolder | Join-Path -ChildPath 'Scripts\python.exe'
|
||||
|
||||
Run-Step "Create Virtual Python Environment" {
|
||||
@@ -446,12 +414,14 @@ function InstallOrUpdate-BorealisAgent {
|
||||
if (Test-Path $agentSourcePath) {
|
||||
Remove-Item $agentDestinationFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -Path $agentDestinationFolder -ItemType Directory -Force | Out-Null
|
||||
Copy-Item "Data\Agent\borealis-agent.py" $agentDestinationFolder -Recurse
|
||||
Copy-Item "Data\Agent\agent_info.py" $agentDestinationFolder -Recurse
|
||||
Copy-Item "Data\Agent\agent_roles.py" $agentDestinationFolder -Recurse
|
||||
Copy-Item "Data\Agent\agent.py" $agentDestinationFolder -Recurse
|
||||
# agent_info has been migrated into roles; no longer copied
|
||||
# Legacy agent_roles kept for compatibility only if needed
|
||||
Copy-Item "Data\Agent\Python_API_Endpoints" $agentDestinationFolder -Recurse
|
||||
Copy-Item "Data\Agent\role_manager.py" $agentDestinationFolder -Force
|
||||
if (Test-Path "Data\Agent\Roles") { Copy-Item "Data\Agent\Roles" $agentDestinationFolder -Recurse }
|
||||
Copy-Item "Data\Agent\agent_deployment.py" $agentDestinationFolder -Force
|
||||
Copy-Item "Data\Agent\tray_launcher.py" $agentDestinationFolder -Force
|
||||
# tray is now embedded in CURRENTUSER role; no launcher to copy
|
||||
if (Test-Path "Data\Agent\Borealis.ico") { Copy-Item "Data\Agent\Borealis.ico" $agentDestinationFolder -Force }
|
||||
}
|
||||
. "$venvFolder\Scripts\Activate"
|
||||
@@ -496,8 +466,7 @@ if (-not $choice) {
|
||||
Write-Host "[Experimental]" -ForegroundColor Red
|
||||
Write-Host " 5) Update Borealis " -NoNewLine -ForegroundColor DarkGray
|
||||
Write-Host "[Requires Re-Build]" -ForegroundColor Red
|
||||
Write-Host " 6) Perform AutoHotKey Automation Testing " -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host "[Experimental - Dev Testing]" -ForegroundColor Red
|
||||
# (Removed) AutoHotKey experimental testing
|
||||
Write-Host "Type a number and press " -NoNewLine
|
||||
Write-Host "<ENTER>" -ForegroundColor DarkCyan
|
||||
$choice = Read-Host
|
||||
@@ -613,6 +582,15 @@ switch ($choice) {
|
||||
"2" {
|
||||
$host.UI.RawUI.WindowTitle = "Borealis Agent"
|
||||
Write-Host " "
|
||||
# Ensure elevation before showing Agent menu
|
||||
$scriptPath = $PSCommandPath
|
||||
if (-not $scriptPath -or $scriptPath -eq '') { $scriptPath = $MyInvocation.MyCommand.Definition }
|
||||
# If already elevated, skip prompt; otherwise prompt, then relaunch directly to the Agent menu via -Agent
|
||||
$cont = Request-AgentElevation -ScriptPath $scriptPath -AgentActionParam $AgentAction
|
||||
if (-not $cont -and -not (Test-IsAdmin)) { return }
|
||||
if (Test-IsAdmin) {
|
||||
Write-Host "Escalated Permissions Granted > Agent is Eligible for Deployment." -ForegroundColor Green
|
||||
}
|
||||
Write-Host "Agent Menu:" -ForegroundColor Cyan
|
||||
Write-Host " 1) Install/Update Agent"
|
||||
Write-Host " 2) Repair Borealis Agent"
|
||||
@@ -627,7 +605,7 @@ switch ($choice) {
|
||||
'3' { Remove-BorealisAgent; break }
|
||||
'4' {
|
||||
$venvPythonw = Join-Path $scriptDir 'Agent\Scripts\pythonw.exe'
|
||||
$helper = Join-Path $scriptDir 'Agent\Borealis\borealis-agent.py'
|
||||
$helper = Join-Path $scriptDir 'Agent\Borealis\agent.py'
|
||||
if (-not (Test-Path $venvPythonw)) { Write-Host "pythonw.exe not found under Agent\Scripts" -ForegroundColor Yellow }
|
||||
if (-not (Test-Path $helper)) { Write-Host "Helper not found under Agent\Borealis" -ForegroundColor Yellow }
|
||||
if ((Test-Path $venvPythonw) -and (Test-Path $helper)) {
|
||||
@@ -758,43 +736,7 @@ switch ($choice) {
|
||||
Exit 0
|
||||
}
|
||||
|
||||
"6" {
|
||||
$host.UI.RawUI.WindowTitle = "AutoHotKey Automation Testing"
|
||||
Write-Host " "
|
||||
Write-Host "Lauching AutoHotKey Testing Script..." -ForegroundColor Blue
|
||||
$venvFolder = "Macro_Testing"
|
||||
$scriptSourcePath = "Data\Experimental\Macros\Macro_Script.py"
|
||||
$scriptRequirements = "Data\Experimental\Macros\macro-requirements.txt"
|
||||
$scriptDestinationFolder= "$venvFolder\Borealis"
|
||||
$scriptDestinationFile = "$venvFolder\Borealis\Macro_Script.py"
|
||||
$venvPython = Join-Path $scriptDir $venvFolder | Join-Path -ChildPath 'Scripts\python.exe'
|
||||
|
||||
Run-Step "Create Virtual Python Environment" {
|
||||
if (-not (Test-Path "$venvFolder\Scripts\Activate")) {
|
||||
$pythonForVenv = $pythonExe
|
||||
if (-not (Test-Path $pythonForVenv)) {
|
||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($pyCmd) { $pythonForVenv = $pyCmd.Source }
|
||||
elseif ($pythonCmd) { $pythonForVenv = $pythonCmd.Source }
|
||||
else { Write-Host "Python not found. Install Python or run Server setup (option 1)." -ForegroundColor Red; exit 1 }
|
||||
}
|
||||
& $pythonForVenv -m venv $venvFolder
|
||||
}
|
||||
if (Test-Path $scriptSourcePath) {
|
||||
Remove-Item $scriptDestinationFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -Path $scriptDestinationFolder -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $scriptSourcePath $scriptDestinationFile -Force
|
||||
Copy-Item "Dependencies\AutoHotKey" $scriptDestinationFolder -Recurse
|
||||
}
|
||||
. "$venvFolder\Scripts\Activate"
|
||||
}
|
||||
|
||||
Run-Step "Install Python Dependencies" { if (Test-Path $scriptRequirements) { & $venvPython -m pip install --disable-pip-version-check -q -r $scriptRequirements | Out-Null } }
|
||||
Write-Host "`nLaunching Macro Testing Script..." -ForegroundColor Blue
|
||||
Write-Host "===================================================================================="
|
||||
& $venvPython -W ignore::SyntaxWarning $scriptDestinationFile
|
||||
}
|
||||
# (Removed) case "6" experimental AHK test
|
||||
|
||||
default { Write-Host "Invalid selection. Exiting..." -ForegroundColor Red; exit 1 }
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ launch_agent() {
|
||||
run_step "Install System Dependencies" install_core_dependencies
|
||||
|
||||
venvFolder="Agent"
|
||||
agentSourcePath="Data/Agent/borealis-agent.py"
|
||||
agentSourcePath="Data/Agent/agent.py"
|
||||
agentRequirements="Data/Agent/agent-requirements.txt"
|
||||
agentDestinationFolder="${venvFolder}/Borealis"
|
||||
|
||||
@@ -169,13 +169,16 @@ launch_agent() {
|
||||
|
||||
run_step "Copy Agent Script" bash -c "
|
||||
mkdir -p '${agentDestinationFolder}'
|
||||
cp '${agentSourcePath}' '${agentDestinationFolder}/'
|
||||
cp 'Data/Agent/agent.py' '${agentDestinationFolder}/'
|
||||
cp 'Data/Agent/role_manager.py' '${agentDestinationFolder}/'
|
||||
if [ -d 'Data/Agent/Roles' ]; then cp -r 'Data/Agent/Roles' '${agentDestinationFolder}/'; fi
|
||||
if [ -d 'Data/Agent/Python_API_Endpoints' ]; then cp -r 'Data/Agent/Python_API_Endpoints' '${agentDestinationFolder}/'; fi
|
||||
"
|
||||
|
||||
echo -e "\n${GREEN}Launching Borealis Agent...${RESET}"
|
||||
echo "===================================================================================="
|
||||
source '${venvFolder}/bin/activate'
|
||||
python3 "${agentDestinationFolder}/borealis-agent.py"
|
||||
python3 "${agentDestinationFolder}/agent.py"
|
||||
}
|
||||
|
||||
# Main menu
|
||||
|
||||
@@ -6,7 +6,7 @@ $venvDir = "$packagingDir\Pyinstaller_Virtual_Environment"
|
||||
$distDir = "$packagingDir\dist"
|
||||
$buildDir = "$packagingDir\build"
|
||||
$specPath = "$packagingDir"
|
||||
$agentScript = "borealis-agent.py"
|
||||
$agentScript = "agent.py"
|
||||
$outputName = "Borealis-Agent"
|
||||
$finalExeName = "$outputName.exe"
|
||||
$requirementsPath = "agent-requirements.txt"
|
||||
|
||||
2
Data/Agent/Roles/__init__.py
Normal file
2
Data/Agent/Roles/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Roles package for Borealis Agent
|
||||
|
||||
280
Data/Agent/Roles/role_DeviceInventory.py
Normal file
280
Data/Agent/Roles/role_DeviceInventory.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import shutil
|
||||
import string
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
psutil = None
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except Exception:
|
||||
aiohttp = None
|
||||
|
||||
|
||||
ROLE_NAME = 'device_inventory'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
|
||||
def detect_agent_os():
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
if plat.startswith('win'):
|
||||
try:
|
||||
import winreg # type: ignore
|
||||
reg_path = r"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
|
||||
access = getattr(winreg, 'KEY_READ', 0x20019)
|
||||
try:
|
||||
access |= winreg.KEY_WOW64_64KEY
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, access)
|
||||
except OSError:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ)
|
||||
|
||||
def _get(name, default=None):
|
||||
try:
|
||||
return winreg.QueryValueEx(key, name)[0]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
product_name = _get("ProductName", "")
|
||||
display_version = _get("DisplayVersion", "")
|
||||
release_id = _get("ReleaseId", "")
|
||||
build_number = _get("CurrentBuildNumber", "") or _get("CurrentBuild", "")
|
||||
ubr = _get("UBR", None)
|
||||
|
||||
try:
|
||||
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
||||
except Exception:
|
||||
build_int = 0
|
||||
if build_int >= 22000:
|
||||
major_label = "11"
|
||||
elif build_int >= 10240:
|
||||
major_label = "10"
|
||||
else:
|
||||
major_label = platform.release()
|
||||
|
||||
os_name = f"Windows {major_label}"
|
||||
version_label = display_version or release_id or ""
|
||||
if isinstance(ubr, int):
|
||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||
else:
|
||||
try:
|
||||
build_str = f"{build_number}.{int(ubr)}" if build_number and ubr else (build_number or "")
|
||||
except Exception:
|
||||
build_str = build_number or ""
|
||||
|
||||
parts = [os_name]
|
||||
if product_name and product_name.lower().startswith('windows '):
|
||||
try:
|
||||
tail = product_name.split(' ', 2)[2]
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
except Exception:
|
||||
pass
|
||||
if version_label:
|
||||
parts.append(version_label)
|
||||
if build_str:
|
||||
parts.append(f"Build {build_str}")
|
||||
return " ".join([p for p in parts if p]).strip() or platform.platform()
|
||||
except Exception:
|
||||
return platform.platform()
|
||||
elif plat == 'darwin':
|
||||
try:
|
||||
out = subprocess.run(["sw_vers", "-productVersion"], capture_output=True, text=True, timeout=3)
|
||||
ver = (out.stdout or '').strip()
|
||||
return f"macOS {ver}" if ver else "macOS"
|
||||
except Exception:
|
||||
return "macOS"
|
||||
else:
|
||||
try:
|
||||
import distro # type: ignore
|
||||
name = distro.name(pretty=True) or distro.id()
|
||||
ver = distro.version()
|
||||
return f"{name} {ver}".strip()
|
||||
except Exception:
|
||||
return platform.platform()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def collect_summary(CONFIG):
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'os': CONFIG.data.get('agent_operating_system', detect_agent_os()),
|
||||
'username': os.environ.get('USERNAME') or os.environ.get('USER') or '',
|
||||
'domain': os.environ.get('USERDOMAIN') or '',
|
||||
'uptime_sec': int(time.time() - psutil.boot_time()) if psutil else None,
|
||||
}
|
||||
except Exception:
|
||||
return {'hostname': socket.gethostname()}
|
||||
|
||||
|
||||
def collect_software():
|
||||
# Placeholder: fuller inventory can be added later
|
||||
return []
|
||||
|
||||
|
||||
def collect_memory():
|
||||
entries = []
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
if plat == 'windows':
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-CimInstance Win32_PhysicalMemory | "
|
||||
"Select-Object BankLabel,Speed,SerialNumber,Capacity | ConvertTo-Json"
|
||||
)
|
||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for stick in data:
|
||||
entries.append({
|
||||
'slot': stick.get('BankLabel', 'unknown'),
|
||||
'speed': str(stick.get('Speed', 'unknown')),
|
||||
'serial': stick.get('SerialNumber', 'unknown'),
|
||||
'capacity': stick.get('Capacity', 'unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if not entries and psutil:
|
||||
try:
|
||||
vm = psutil.virtual_memory()
|
||||
entries.append({'slot': 'physical', 'speed': 'unknown', 'serial': 'unknown', 'capacity': vm.total})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def collect_storage():
|
||||
disks = []
|
||||
try:
|
||||
if psutil:
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
'drive': part.device,
|
||||
'disk_type': 'Removable' if isinstance(part.opts, str) and 'removable' in part.opts.lower() else 'Fixed Disk',
|
||||
'usage': usage.percent,
|
||||
'total': usage.total,
|
||||
'free': usage.free,
|
||||
'used': usage.used,
|
||||
})
|
||||
else:
|
||||
# Fallback basic detection on Windows via drive letters
|
||||
if IS_WINDOWS:
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:\\"
|
||||
if os.path.exists(drive):
|
||||
try:
|
||||
usage = shutil.disk_usage(drive)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
'drive': drive,
|
||||
'disk_type': 'Fixed Disk',
|
||||
'usage': (usage.used / usage.total * 100) if usage.total else 0,
|
||||
'total': usage.total,
|
||||
'free': usage.free,
|
||||
'used': usage.used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return disks
|
||||
|
||||
|
||||
def collect_network():
|
||||
adapters = []
|
||||
try:
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
|
||||
"ForEach-Object { $_ | Select-Object -Property InterfaceAlias, MacAddress } | ConvertTo-Json"
|
||||
)
|
||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for a in data:
|
||||
adapters.append({'adapter': a.get('InterfaceAlias', 'unknown'), 'ips': [], 'mac': a.get('MacAddress', 'unknown')})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[1]
|
||||
ip = parts[3].split("/")[0]
|
||||
adapters.append({'adapter': name, 'ips': [ip], 'mac': 'unknown'})
|
||||
except Exception:
|
||||
pass
|
||||
return adapters
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
try:
|
||||
# Set OS string once
|
||||
self.ctx.config.data['agent_operating_system'] = detect_agent_os()
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
# Start periodic reporter
|
||||
try:
|
||||
self.task = self.ctx.loop.create_task(self._report_loop())
|
||||
except Exception:
|
||||
self.task = None
|
||||
|
||||
def stop_all(self):
|
||||
try:
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _report_loop(self):
|
||||
while True:
|
||||
try:
|
||||
details = {
|
||||
'summary': collect_summary(self.ctx.config),
|
||||
'software': collect_software(),
|
||||
'memory': collect_memory(),
|
||||
'storage': collect_storage(),
|
||||
'network': collect_network(),
|
||||
}
|
||||
url = (self.ctx.config.data.get('borealis_server_url', 'http://localhost:5000') or '').rstrip('/') + '/api/agent/details'
|
||||
payload = {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'hostname': details.get('summary', {}).get('hostname', socket.gethostname()),
|
||||
'details': details,
|
||||
}
|
||||
if aiohttp is not None:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(url, json=payload, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(300)
|
||||
|
||||
122
Data/Agent/Roles/role_Macro.py
Normal file
122
Data/Agent/Roles/role_Macro.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import asyncio
|
||||
import importlib.util
|
||||
|
||||
|
||||
ROLE_NAME = 'macro'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
def _load_macro_engines():
|
||||
try:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
path = os.path.join(base, 'Python_API_Endpoints', 'macro_engines.py')
|
||||
spec = importlib.util.spec_from_file_location('macro_engines', path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
except Exception:
|
||||
class _Dummy:
|
||||
def list_windows(self):
|
||||
return []
|
||||
def send_keypress_to_window(self, handle, key):
|
||||
return False, 'unavailable'
|
||||
def type_text_to_window(self, handle, text):
|
||||
return False, 'unavailable'
|
||||
return _Dummy()
|
||||
|
||||
|
||||
macro_engines = _load_macro_engines()
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
self.tasks = {}
|
||||
|
||||
def stop_all(self):
|
||||
for t in list(self.tasks.values()):
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.tasks.clear()
|
||||
|
||||
def on_config(self, roles_cfg):
|
||||
macro_roles = [r for r in roles_cfg if (r.get('role') == 'macro')]
|
||||
new_ids = {r.get('node_id') for r in macro_roles if r.get('node_id')}
|
||||
old_ids = set(self.tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
for rid in removed:
|
||||
t = self.tasks.pop(rid, None)
|
||||
if t:
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
for rcfg in macro_roles:
|
||||
nid = rcfg.get('node_id')
|
||||
if nid and nid not in self.tasks:
|
||||
self.tasks[nid] = asyncio.create_task(self._macro_task(rcfg))
|
||||
|
||||
async def _macro_task(self, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
last_trigger_value = 0
|
||||
has_run_once = False
|
||||
while True:
|
||||
window_handle = cfg.get('window_handle')
|
||||
macro_type = cfg.get('macro_type', 'keypress')
|
||||
operation_mode = cfg.get('operation_mode', 'Continuous')
|
||||
key = cfg.get('key')
|
||||
text = cfg.get('text')
|
||||
interval_ms = int(cfg.get('interval_ms', 1000))
|
||||
randomize = cfg.get('randomize_interval', False)
|
||||
random_min = int(cfg.get('random_min', 750))
|
||||
random_max = int(cfg.get('random_max', 950))
|
||||
active = cfg.get('active', True)
|
||||
trigger = int(cfg.get('trigger', 0))
|
||||
|
||||
async def emit_macro_status(success, message=""):
|
||||
try:
|
||||
await self.ctx.sio.emit('macro_status', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'success': success,
|
||||
'message': message,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not (active is True or str(active).lower() == 'true'):
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
try:
|
||||
send_macro = False
|
||||
if operation_mode == 'Run Once':
|
||||
if not has_run_once:
|
||||
send_macro = True
|
||||
has_run_once = True
|
||||
elif operation_mode == 'Continuous':
|
||||
send_macro = True
|
||||
elif operation_mode == 'Trigger-Continuous':
|
||||
send_macro = (trigger == 1)
|
||||
elif operation_mode == 'Trigger-Once':
|
||||
if trigger == 1 and last_trigger_value != 1:
|
||||
send_macro = True
|
||||
else:
|
||||
send_macro = False
|
||||
|
||||
if send_macro:
|
||||
ok = False
|
||||
if macro_type == 'keypress' and key:
|
||||
ok = bool(macro_engines.send_keypress_to_window(window_handle, key))
|
||||
elif macro_type == 'text' and text:
|
||||
ok = bool(macro_engines.type_text_to_window(window_handle, text))
|
||||
await emit_macro_status(ok, 'sent' if ok else 'failed')
|
||||
last_trigger_value = trigger
|
||||
except Exception as e:
|
||||
await emit_macro_status(False, str(e))
|
||||
# interval wait
|
||||
await asyncio.sleep(max(0.05, (interval_ms or 1000) / 1000.0))
|
||||
|
||||
322
Data/Agent/Roles/role_Screenshot.py
Normal file
322
Data/Agent/Roles/role_Screenshot.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import os
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import traceback
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PIL import ImageGrab
|
||||
import importlib.util
|
||||
|
||||
|
||||
ROLE_NAME = 'screenshot'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
# Load macro engines from the local Python_API_Endpoints directory for window listings
|
||||
def _load_macro_engines():
|
||||
try:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
path = os.path.join(base, 'Python_API_Endpoints', 'macro_engines.py')
|
||||
spec = importlib.util.spec_from_file_location('macro_engines', path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
except Exception:
|
||||
class _Dummy:
|
||||
def list_windows(self):
|
||||
return []
|
||||
return _Dummy()
|
||||
|
||||
|
||||
macro_engines = _load_macro_engines()
|
||||
|
||||
|
||||
overlay_green_thickness = 4
|
||||
overlay_gray_thickness = 2
|
||||
handle_size = overlay_green_thickness * 2
|
||||
extra_top_padding = overlay_green_thickness * 2 + 4
|
||||
|
||||
|
||||
overlay_widgets = {}
|
||||
|
||||
|
||||
class ScreenshotRegion(QtWidgets.QWidget):
|
||||
def __init__(self, ctx, node_id, x=100, y=100, w=300, h=200, alias=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.node_id = node_id
|
||||
self.alias = alias
|
||||
self.setGeometry(
|
||||
x - handle_size,
|
||||
y - handle_size - extra_top_padding,
|
||||
w + handle_size * 2,
|
||||
h + handle_size * 2 + extra_top_padding,
|
||||
)
|
||||
self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
||||
self.resize_dir = None
|
||||
self.drag_offset = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
h = self.height()
|
||||
|
||||
p.setPen(QtGui.QPen(QtGui.QColor(130, 130, 130), overlay_gray_thickness))
|
||||
p.drawRect(handle_size, handle_size + extra_top_padding, w - handle_size * 2, h - handle_size * 2 - extra_top_padding)
|
||||
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(QtGui.QBrush(QtGui.QColor(0, 191, 255)))
|
||||
edge = overlay_green_thickness * 3
|
||||
|
||||
p.drawRect(0, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(0, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(0, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(0, h - edge, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, h - edge, overlay_green_thickness, edge)
|
||||
|
||||
long = overlay_green_thickness * 6
|
||||
p.drawRect((w - long) // 2, extra_top_padding, long, overlay_green_thickness)
|
||||
p.drawRect((w - long) // 2, h - overlay_green_thickness, long, overlay_green_thickness)
|
||||
p.drawRect(0, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
p.drawRect(w - overlay_green_thickness, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (w - bar_width) // 2
|
||||
bar_y = 6
|
||||
p.setBrush(QtGui.QColor(0, 191, 255))
|
||||
p.drawRect(bar_x, bar_y - bar_height - 10, bar_width, bar_height * 4)
|
||||
|
||||
def get_geometry(self):
|
||||
g = self.geometry()
|
||||
return (
|
||||
g.x() + handle_size,
|
||||
g.y() + handle_size + extra_top_padding,
|
||||
g.width() - handle_size * 2,
|
||||
g.height() - handle_size * 2 - extra_top_padding,
|
||||
)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == QtCore.Qt.LeftButton:
|
||||
pos = e.pos()
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (self.width() - bar_width) // 2
|
||||
bar_y = 2
|
||||
bar_rect = QtCore.QRect(bar_x, bar_y, bar_width, bar_height)
|
||||
|
||||
if bar_rect.contains(pos):
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
return
|
||||
|
||||
m = handle_size
|
||||
dirs = []
|
||||
if pos.x() <= m:
|
||||
dirs.append('left')
|
||||
if pos.x() >= self.width() - m:
|
||||
dirs.append('right')
|
||||
if pos.y() <= m + extra_top_padding:
|
||||
dirs.append('top')
|
||||
if pos.y() >= self.height() - m:
|
||||
dirs.append('bottom')
|
||||
if dirs:
|
||||
self.resize_dir = '_'.join(dirs)
|
||||
self._start_geom = self.geometry()
|
||||
self._start_pos = e.globalPos()
|
||||
else:
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self.resize_dir and self._start_geom and self._start_pos:
|
||||
dx = e.globalX() - self._start_pos.x()
|
||||
dy = e.globalY() - self._start_pos.y()
|
||||
geom = QtCore.QRect(self._start_geom)
|
||||
if 'left' in self.resize_dir:
|
||||
new_x = geom.x() + dx
|
||||
new_w = geom.width() - dx
|
||||
geom.setX(new_x)
|
||||
geom.setWidth(new_w)
|
||||
if 'right' in self.resize_dir:
|
||||
geom.setWidth(self._start_geom.width() + dx)
|
||||
if 'top' in self.resize_dir:
|
||||
new_y = geom.y() + dy
|
||||
new_h = geom.height() - dy
|
||||
geom.setY(new_y)
|
||||
geom.setHeight(new_h)
|
||||
if 'bottom' in self.resize_dir:
|
||||
geom.setHeight(self._start_geom.height() + dy)
|
||||
self.setGeometry(geom)
|
||||
elif self.drag_offset and e.buttons() & QtCore.Qt.LeftButton:
|
||||
self.move(e.globalPos() - self.drag_offset)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
self.drag_offset = None
|
||||
self.resize_dir = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
x, y, w, h = self.get_geometry()
|
||||
self.ctx.config.data['regions'][self.node_id] = {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': self.node_id,
|
||||
'image_base64': '',
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
}))
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
self.tasks = {}
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('list_agent_windows')
|
||||
async def _handle_list_windows(payload):
|
||||
try:
|
||||
windows = macro_engines.list_windows()
|
||||
except Exception:
|
||||
windows = []
|
||||
await sio.emit('agent_window_list', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'windows': windows,
|
||||
})
|
||||
|
||||
def _close_overlay(self, node_id: str):
|
||||
w = overlay_widgets.pop(node_id, None)
|
||||
if w:
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop_all(self):
|
||||
for t in list(self.tasks.values()):
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.tasks.clear()
|
||||
# Close all widgets
|
||||
for nid in list(overlay_widgets.keys()):
|
||||
self._close_overlay(nid)
|
||||
|
||||
def on_config(self, roles_cfg):
|
||||
# Filter only screenshot roles
|
||||
screenshot_roles = [r for r in roles_cfg if (r.get('role') == 'screenshot')]
|
||||
|
||||
# Optional: forward interval to SYSTEM helper via hook
|
||||
try:
|
||||
if screenshot_roles and 'send_service_control' in self.ctx.hooks:
|
||||
interval_ms = int(screenshot_roles[0].get('interval', 1000))
|
||||
try:
|
||||
self.ctx.hooks['send_service_control']({'type': 'screenshot_config', 'interval_ms': interval_ms})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cancel tasks that are no longer present
|
||||
new_ids = {r.get('node_id') for r in screenshot_roles if r.get('node_id')}
|
||||
old_ids = set(self.tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
for rid in removed:
|
||||
t = self.tasks.pop(rid, None)
|
||||
if t:
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
# Remove stored region and overlay
|
||||
self.ctx.config.data.get('regions', {}).pop(rid, None)
|
||||
try:
|
||||
self._close_overlay(rid)
|
||||
except Exception:
|
||||
pass
|
||||
if removed:
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start tasks for all screenshot roles in config
|
||||
for rcfg in screenshot_roles:
|
||||
nid = rcfg.get('node_id')
|
||||
if not nid:
|
||||
continue
|
||||
if nid in self.tasks:
|
||||
continue
|
||||
task = asyncio.create_task(self._screenshot_task(rcfg))
|
||||
self.tasks[nid] = task
|
||||
|
||||
async def _screenshot_task(self, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
alias = cfg.get('alias', '')
|
||||
reg = self.ctx.config.data.setdefault('regions', {})
|
||||
r = reg.get(nid)
|
||||
if r:
|
||||
region = (r['x'], r['y'], r['w'], r['h'])
|
||||
else:
|
||||
region = (
|
||||
cfg.get('x', 100),
|
||||
cfg.get('y', 100),
|
||||
cfg.get('w', 300),
|
||||
cfg.get('h', 200),
|
||||
)
|
||||
reg[nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if nid not in overlay_widgets:
|
||||
widget = ScreenshotRegion(self.ctx, nid, *region, alias=alias)
|
||||
overlay_widgets[nid] = widget
|
||||
widget.show()
|
||||
|
||||
await self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': '',
|
||||
'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]
|
||||
})
|
||||
|
||||
interval = cfg.get('interval', 1000) / 1000.0
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.ctx.config.data.get('max_task_workers', 8))
|
||||
try:
|
||||
while True:
|
||||
x, y, w, h = overlay_widgets[nid].get_geometry()
|
||||
grab = partial(ImageGrab.grab, bbox=(x, y, x + w, y + h))
|
||||
img = await loop.run_in_executor(executor, grab)
|
||||
buf = BytesIO(); img.save(buf, format='PNG')
|
||||
encoded = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
await self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': encoded,
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
})
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
217
Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py
Normal file
217
Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import tempfile
|
||||
import uuid
|
||||
from PyQt5 import QtWidgets, QtGui
|
||||
|
||||
|
||||
ROLE_NAME = 'script_exec_currentuser'
|
||||
ROLE_CONTEXTS = ['interactive']
|
||||
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
|
||||
def _write_temp_script(content: str, suffix: str):
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), "Borealis", "quick_jobs")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, path = tempfile.mkstemp(prefix="bj_", suffix=suffix, dir=temp_dir, text=True)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
|
||||
fh.write(content or "")
|
||||
return path
|
||||
|
||||
|
||||
async def _run_powershell_local(path: str):
|
||||
if IS_WINDOWS:
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = "powershell.exe"
|
||||
else:
|
||||
ps = "pwsh"
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ps,
|
||||
"-ExecutionPolicy", "Bypass" if IS_WINDOWS else "Bypass",
|
||||
"-NoProfile",
|
||||
"-File", path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=(0x08000000 if IS_WINDOWS else 0)
|
||||
)
|
||||
out_b, err_b = await proc.communicate()
|
||||
return proc.returncode, (out_b or b"").decode(errors='replace'), (err_b or b"").decode(errors='replace')
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
async def _run_powershell_via_user_task(content: str):
|
||||
if not IS_WINDOWS:
|
||||
return -999, '', 'Windows only'
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = 'powershell.exe'
|
||||
path = None
|
||||
out_path = None
|
||||
import tempfile as _tf
|
||||
try:
|
||||
temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'Temp'))
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, path = _tf.mkstemp(prefix='usr_task_', suffix='.ps1', dir=temp_dir, text=True)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
out_path = os.path.join(temp_dir, f'out_{uuid.uuid4().hex}.txt')
|
||||
name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ CurrentUser"
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{name}"
|
||||
$ps = "{ps}"
|
||||
$scr = "{path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(ps, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out_b, err_b = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (err_b or out_b or b'').decode(errors='replace')
|
||||
# Wait a short time for output file; best-effort
|
||||
import time as _t
|
||||
deadline = _t.time() + 30
|
||||
out_data = ''
|
||||
while _t.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1)
|
||||
# Cleanup best-effort
|
||||
try:
|
||||
await asyncio.create_subprocess_exec('powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', f"try {{ Unregister-ScheduledTask -TaskName '{name}' -Confirm:$false }} catch {{}}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if path and os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if out_path and os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
# Setup tray icon in interactive session
|
||||
try:
|
||||
self._setup_tray()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def _on_quick_job_run(payload):
|
||||
try:
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if not target or target != hostname.lower():
|
||||
return
|
||||
job_id = payload.get('job_id')
|
||||
script_type = (payload.get('script_type') or '').lower()
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
content = payload.get('script_content') or ''
|
||||
if run_mode == 'system':
|
||||
return
|
||||
if script_type != 'powershell':
|
||||
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })
|
||||
return
|
||||
if run_mode == 'admin':
|
||||
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
|
||||
else:
|
||||
rc, out, err = await _run_powershell_via_user_task(content)
|
||||
if rc == -999:
|
||||
path = _write_temp_script(content, '.ps1')
|
||||
rc, out, err = await _run_powershell_local(path)
|
||||
status = 'Success' if rc == 0 else 'Failed'
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': status,
|
||||
'stdout': out,
|
||||
'stderr': err,
|
||||
})
|
||||
except Exception as e:
|
||||
try:
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': str(e),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _setup_tray(self):
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
icon = None
|
||||
try:
|
||||
icon_path = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'Borealis.ico'))
|
||||
if os.path.isfile(icon_path):
|
||||
icon = QtGui.QIcon(icon_path)
|
||||
except Exception:
|
||||
pass
|
||||
if icon is None:
|
||||
icon = app.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon)
|
||||
self.tray = QtWidgets.QSystemTrayIcon(icon)
|
||||
self.tray.setToolTip('Borealis Agent')
|
||||
menu = QtWidgets.QMenu()
|
||||
act_restart = menu.addAction('Restart Agent')
|
||||
act_quit = menu.addAction('Quit Agent')
|
||||
act_restart.triggered.connect(self._restart_agent)
|
||||
act_quit.triggered.connect(self._quit_agent)
|
||||
self.tray.setContextMenu(menu)
|
||||
self.tray.show()
|
||||
|
||||
def _restart_agent(self):
|
||||
try:
|
||||
# __file__ => Agent/Borealis/Roles/...
|
||||
borealis_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
venv_root = os.path.abspath(os.path.join(borealis_dir, os.pardir))
|
||||
venv_scripts = os.path.join(venv_root, 'Scripts')
|
||||
pyw = os.path.join(venv_scripts, 'pythonw.exe')
|
||||
exe = pyw if os.path.isfile(pyw) else sys.executable
|
||||
agent_script = os.path.join(borealis_dir, 'agent.py')
|
||||
import subprocess
|
||||
subprocess.Popen([exe, '-W', 'ignore::SyntaxWarning', agent_script], cwd=borealis_dir)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
|
||||
def _quit_agent(self):
|
||||
try:
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
153
Data/Agent/Roles/role_ScriptExec_SYSTEM.py
Normal file
153
Data/Agent/Roles/role_ScriptExec_SYSTEM.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import os
|
||||
import asyncio
|
||||
import tempfile
|
||||
import uuid
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
|
||||
ROLE_NAME = 'script_exec_system'
|
||||
ROLE_CONTEXTS = ['system']
|
||||
|
||||
|
||||
def _project_root():
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
|
||||
def _run_powershell_script_content(content: str):
|
||||
temp_dir = os.path.join(_project_root(), "Temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, path = tempfile.mkstemp(prefix="sj_", suffix=".ps1", dir=temp_dir, text=True)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
|
||||
fh.write(content or "")
|
||||
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = "powershell.exe"
|
||||
try:
|
||||
flags = 0x08000000 if os.name == 'nt' else 0
|
||||
proc = subprocess.run(
|
||||
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60*60,
|
||||
creationflags=flags,
|
||||
)
|
||||
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
finally:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_powershell_via_system_task(content: str):
|
||||
ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if not os.path.isfile(ps_exe):
|
||||
ps_exe = 'powershell.exe'
|
||||
try:
|
||||
os.makedirs(os.path.join(_project_root(), 'Temp'), exist_ok=True)
|
||||
script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(_project_root(), 'Temp'), text=True)
|
||||
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
out_path = os.path.join(_project_root(), 'Temp', f'out_{uuid.uuid4().hex}.txt')
|
||||
task_name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ SYSTEM"
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{task_name}"
|
||||
$ps = "{ps_exe}"
|
||||
$scr = "{script_path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
proc = subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (proc.stderr or proc.stdout or 'scheduled task creation failed')
|
||||
deadline = time.time() + 60
|
||||
out_data = ''
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
cleanup_ps = f"try {{ Unregister-ScheduledTask -TaskName '{task_name}' -Confirm:$false }} catch {{}}"
|
||||
subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cleanup_ps], capture_output=True, text=True)
|
||||
try:
|
||||
if os.path.isfile(script_path):
|
||||
os.remove(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
class Role:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
def register_events(self):
|
||||
sio = self.ctx.sio
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def _on_quick_job_run(payload):
|
||||
try:
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if target and target != hostname.lower():
|
||||
return
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
if run_mode != 'system':
|
||||
return
|
||||
job_id = payload.get('job_id')
|
||||
script_type = (payload.get('script_type') or '').lower()
|
||||
content = payload.get('script_content') or ''
|
||||
if script_type != 'powershell':
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': f"Unsupported type: {script_type}"
|
||||
})
|
||||
return
|
||||
rc, out, err = _run_powershell_via_system_task(content)
|
||||
if rc == -999:
|
||||
rc, out, err = _run_powershell_script_content(content)
|
||||
status = 'Success' if rc == 0 else 'Failed'
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': status,
|
||||
'stdout': out,
|
||||
'stderr': err,
|
||||
})
|
||||
except Exception as e:
|
||||
try:
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': str(e),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -11,8 +11,8 @@ PyQt5
|
||||
qasync
|
||||
|
||||
# Computer Vision & OCR Dependencies
|
||||
opencv-python # Computer vision processing
|
||||
Pillow # Image processing (Windows)
|
||||
opencv-python # Computer vision processing
|
||||
Pillow # Image processing (Windows)
|
||||
###mss # Fast cross-platform screen capture
|
||||
|
||||
# WebRTC Video Libraries
|
||||
|
||||
@@ -42,8 +42,7 @@ except Exception:
|
||||
from PIL import ImageGrab
|
||||
|
||||
# New modularized components
|
||||
import agent_info
|
||||
import agent_roles
|
||||
from role_manager import RoleManager
|
||||
|
||||
# //////////////////////////////////////////////////////////////////////////
|
||||
# CORE SECTION: CONFIG MANAGER
|
||||
@@ -351,7 +350,7 @@ def detect_agent_os():
|
||||
print(f"[WARN] OS detection failed: {e}")
|
||||
return "Unknown"
|
||||
|
||||
CONFIG.data['agent_operating_system'] = agent_info.detect_agent_os()
|
||||
CONFIG.data['agent_operating_system'] = detect_agent_os()
|
||||
CONFIG._write()
|
||||
|
||||
# //////////////////////////////////////////////////////////////////////////
|
||||
@@ -361,8 +360,8 @@ CONFIG._write()
|
||||
sio = socketio.AsyncClient(reconnection=True, reconnection_attempts=0, reconnection_delay=5)
|
||||
role_tasks = {}
|
||||
background_tasks = []
|
||||
roles_ctx = None
|
||||
AGENT_LOOP = None
|
||||
ROLE_MANAGER = None
|
||||
|
||||
# ---------------- Local IPC Bridge (Service -> Agent) ----------------
|
||||
def start_agent_bridge_pipe(loop_ref):
|
||||
@@ -563,13 +562,9 @@ async def _run_powershell_with_credentials(path: str, username: str, password: s
|
||||
|
||||
async def stop_all_roles():
|
||||
print("[DEBUG] Stopping all roles.")
|
||||
for task in list(role_tasks.values()):
|
||||
print(f"[DEBUG] Cancelling task for node: {task}")
|
||||
task.cancel()
|
||||
role_tasks.clear()
|
||||
# Close overlays managed in agent_roles module
|
||||
try:
|
||||
agent_roles.close_all_overlays()
|
||||
if ROLE_MANAGER is not None:
|
||||
ROLE_MANAGER.stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -587,7 +582,7 @@ async def send_heartbeat():
|
||||
payload = {
|
||||
"agent_id": AGENT_ID,
|
||||
"hostname": socket.gethostname(),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", agent_info.detect_agent_os()),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", detect_agent_os()),
|
||||
"last_seen": int(time.time())
|
||||
}
|
||||
await sio.emit("agent_heartbeat", payload)
|
||||
@@ -608,16 +603,15 @@ async def send_heartbeat():
|
||||
## Moved to agent_info module
|
||||
|
||||
def collect_summary():
|
||||
# Moved to agent_info.collect_summary
|
||||
return agent_info.collect_summary(CONFIG)
|
||||
# migrated to role_DeviceInventory
|
||||
return {}
|
||||
|
||||
def collect_software():
|
||||
# Moved to agent_info.collect_software
|
||||
return agent_info.collect_software()
|
||||
# migrated to role_DeviceInventory
|
||||
return []
|
||||
|
||||
def collect_memory():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_memory()
|
||||
# migrated to role_DeviceInventory
|
||||
entries = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -706,8 +700,7 @@ def collect_memory():
|
||||
return entries
|
||||
|
||||
def collect_storage():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_storage()
|
||||
# migrated to role_DeviceInventory
|
||||
disks = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -824,8 +817,7 @@ def collect_storage():
|
||||
return disks
|
||||
|
||||
def collect_network():
|
||||
# Delegated to agent_info module
|
||||
return agent_info.collect_network()
|
||||
# migrated to role_DeviceInventory
|
||||
adapters = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
@@ -906,7 +898,7 @@ async def connect():
|
||||
await sio.emit("agent_heartbeat", {
|
||||
"agent_id": AGENT_ID,
|
||||
"hostname": socket.gethostname(),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", agent_info.detect_agent_os()),
|
||||
"agent_operating_system": CONFIG.data.get("agent_operating_system", detect_agent_os()),
|
||||
"last_seen": int(time.time())
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -947,98 +939,23 @@ async def on_agent_config(cfg):
|
||||
|
||||
print(f"[CONFIG] Received New Agent Config with {len(roles)} Role(s).")
|
||||
|
||||
new_ids = {r.get('node_id') for r in roles if r.get('node_id')}
|
||||
old_ids = set(role_tasks.keys())
|
||||
removed = old_ids - new_ids
|
||||
|
||||
for rid in removed:
|
||||
print(f"[DEBUG] Removing node {rid} from regions/overlays.")
|
||||
CONFIG.data['regions'].pop(rid, None)
|
||||
try:
|
||||
agent_roles.close_overlay(rid)
|
||||
except Exception:
|
||||
pass
|
||||
if removed:
|
||||
CONFIG._write()
|
||||
|
||||
for task in list(role_tasks.values()):
|
||||
task.cancel()
|
||||
role_tasks.clear()
|
||||
|
||||
# Forward screenshot config to service helper (interval only)
|
||||
try:
|
||||
for role_cfg in roles:
|
||||
if role_cfg.get('role') == 'screenshot':
|
||||
interval_ms = int(role_cfg.get('interval', 1000))
|
||||
send_service_control({ 'type': 'screenshot_config', 'interval_ms': interval_ms })
|
||||
send_service_control({'type': 'screenshot_config', 'interval_ms': interval_ms})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for role_cfg in roles:
|
||||
nid = role_cfg.get('node_id')
|
||||
role = role_cfg.get('role')
|
||||
if role == 'screenshot':
|
||||
print(f"[DEBUG] Starting screenshot task for {nid}")
|
||||
task = asyncio.create_task(agent_roles.screenshot_task(roles_ctx, role_cfg))
|
||||
role_tasks[nid] = task
|
||||
elif role == 'macro':
|
||||
print(f"[DEBUG] Starting macro task for {nid}")
|
||||
task = asyncio.create_task(agent_roles.macro_task(roles_ctx, role_cfg))
|
||||
role_tasks[nid] = task
|
||||
|
||||
@sio.on('quick_job_run')
|
||||
async def on_quick_job_run(payload):
|
||||
try:
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if not target or target != socket.gethostname().lower():
|
||||
return
|
||||
job_id = payload.get('job_id')
|
||||
script_type = (payload.get('script_type') or '').lower()
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
content = payload.get('script_content') or ''
|
||||
# Only handle non-SYSTEM runs here; SYSTEM runs are handled by the LocalSystem service agent
|
||||
if run_mode == 'system':
|
||||
# Ignore: handled by SYSTEM supervisor/agent
|
||||
return
|
||||
if script_type != 'powershell':
|
||||
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })
|
||||
return
|
||||
rc = 0; out = ''; err = ''
|
||||
if run_mode == 'admin':
|
||||
# Admin credentialed runs are disabled in current design
|
||||
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
|
||||
else:
|
||||
# Prefer ephemeral scheduled task in current user context
|
||||
rc, out, err = await _run_powershell_via_user_task(content)
|
||||
if rc == -999:
|
||||
# Fallback to direct execution
|
||||
path = _write_temp_script(content, '.ps1')
|
||||
rc, out, err = await _run_powershell_local(path)
|
||||
status = 'Success' if rc == 0 else 'Failed'
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': status,
|
||||
'stdout': out,
|
||||
'stderr': err,
|
||||
})
|
||||
if ROLE_MANAGER is not None:
|
||||
ROLE_MANAGER.on_config(roles)
|
||||
except Exception as e:
|
||||
try:
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': str(e),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[WARN] role manager apply config failed: {e}")
|
||||
|
||||
@sio.on('list_agent_windows')
|
||||
async def handle_list_agent_windows(data):
|
||||
windows = agent_roles.get_window_list()
|
||||
await sio.emit('agent_window_list', {
|
||||
'agent_id': AGENT_ID,
|
||||
'windows': windows
|
||||
})
|
||||
## Script execution and list windows handlers are registered by roles
|
||||
|
||||
# ---------------- Config Watcher ----------------
|
||||
async def config_watcher():
|
||||
@@ -1159,17 +1076,31 @@ if __name__=='__main__':
|
||||
pass
|
||||
dummy_window=PersistentWindow(); dummy_window.show()
|
||||
# Initialize roles context for role tasks
|
||||
roles_ctx = agent_roles.RolesContext(sio=sio, agent_id=AGENT_ID, config=CONFIG)
|
||||
# Initialize role manager and hot-load roles from Roles/
|
||||
try:
|
||||
hooks = {'send_service_control': send_service_control}
|
||||
ROLE_MANAGER = RoleManager(
|
||||
base_dir=os.path.dirname(__file__),
|
||||
context='interactive',
|
||||
sio=sio,
|
||||
agent_id=AGENT_ID,
|
||||
config=CONFIG,
|
||||
loop=loop,
|
||||
hooks=hooks,
|
||||
)
|
||||
ROLE_MANAGER.load()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
background_tasks.append(loop.create_task(config_watcher()))
|
||||
background_tasks.append(loop.create_task(connect_loop()))
|
||||
background_tasks.append(loop.create_task(idle_task()))
|
||||
# Start periodic heartbeats
|
||||
background_tasks.append(loop.create_task(send_heartbeat()))
|
||||
background_tasks.append(loop.create_task(agent_info.send_agent_details(AGENT_ID, CONFIG)))
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[FATAL] Event loop crashed: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
print("[FATAL] Agent exited unexpectedly.")
|
||||
|
||||
@@ -24,7 +24,7 @@ def project_paths():
|
||||
"borealis_dir": borealis_dir,
|
||||
"logs_dir": logs_dir,
|
||||
"temp_dir": temp_dir,
|
||||
"agent_script": os.path.join(borealis_dir, "tray_launcher.py"),
|
||||
"agent_script": os.path.join(borealis_dir, "agent.py"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,845 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import getpass
|
||||
import datetime
|
||||
import shutil
|
||||
import string
|
||||
|
||||
import requests
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
psutil = None # graceful degradation if unavailable
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
# ---------------- Helpers for hidden subprocess on Windows ----------------
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
CREATE_NO_WINDOW = 0x08000000 if IS_WINDOWS else 0
|
||||
|
||||
|
||||
def _run_hidden(cmd_list, timeout=None):
|
||||
"""Run a subprocess hidden on Windows (no visible console window)."""
|
||||
kwargs = {"capture_output": True, "text": True}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
if IS_WINDOWS:
|
||||
kwargs["creationflags"] = CREATE_NO_WINDOW
|
||||
return subprocess.run(cmd_list, **kwargs)
|
||||
|
||||
|
||||
def _run_powershell_hidden(ps_cmd: str, timeout: int = 60):
|
||||
"""Run a powershell -NoProfile -Command string fully hidden on Windows."""
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = "powershell.exe"
|
||||
return _run_hidden([ps, "-NoProfile", "-Command", ps_cmd], timeout=timeout)
|
||||
|
||||
|
||||
def detect_agent_os():
|
||||
"""
|
||||
Detects the full, user-friendly operating system name and version.
|
||||
Examples:
|
||||
- "Windows 11"
|
||||
- "Windows 10"
|
||||
- "Fedora Workstation 42"
|
||||
- "Ubuntu 22.04 LTS"
|
||||
- "macOS Sonoma"
|
||||
Falls back to a generic name if detection fails.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat.startswith('win'):
|
||||
try:
|
||||
import winreg # Only available on Windows
|
||||
|
||||
reg_path = r"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
|
||||
access = winreg.KEY_READ
|
||||
try:
|
||||
access |= winreg.KEY_WOW64_64KEY
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, access)
|
||||
except OSError:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ)
|
||||
|
||||
def _get(name, default=None):
|
||||
try:
|
||||
return winreg.QueryValueEx(key, name)[0]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
product_name = _get("ProductName", "") # e.g., "Windows 11 Pro"
|
||||
edition_id = _get("EditionID", "") # e.g., "Professional"
|
||||
display_version = _get("DisplayVersion", "") # e.g., "24H2" / "22H2"
|
||||
release_id = _get("ReleaseId", "") # e.g., "2004" on older Windows 10
|
||||
build_number = _get("CurrentBuildNumber", "") or _get("CurrentBuild", "")
|
||||
ubr = _get("UBR", None) # Update Build Revision (int)
|
||||
|
||||
try:
|
||||
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
||||
except Exception:
|
||||
build_int = 0
|
||||
if build_int >= 22000:
|
||||
major_label = "11"
|
||||
elif build_int >= 10240:
|
||||
major_label = "10"
|
||||
else:
|
||||
major_label = platform.release()
|
||||
|
||||
edition = ""
|
||||
pn = product_name or ""
|
||||
if pn.lower().startswith("windows "):
|
||||
tokens = pn.split()
|
||||
if len(tokens) >= 3:
|
||||
edition = " ".join(tokens[2:])
|
||||
if not edition and edition_id:
|
||||
eid_map = {
|
||||
"Professional": "Pro",
|
||||
"ProfessionalN": "Pro N",
|
||||
"ProfessionalEducation": "Pro Education",
|
||||
"ProfessionalWorkstation": "Pro for Workstations",
|
||||
"Enterprise": "Enterprise",
|
||||
"EnterpriseN": "Enterprise N",
|
||||
"EnterpriseS": "Enterprise LTSC",
|
||||
"Education": "Education",
|
||||
"EducationN": "Education N",
|
||||
"Core": "Home",
|
||||
"CoreN": "Home N",
|
||||
"CoreSingleLanguage": "Home Single Language",
|
||||
"IoTEnterprise": "IoT Enterprise",
|
||||
}
|
||||
edition = eid_map.get(edition_id, edition_id)
|
||||
|
||||
os_name = f"Windows {major_label}"
|
||||
version_label = display_version or release_id or ""
|
||||
|
||||
if isinstance(ubr, int):
|
||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||
else:
|
||||
try:
|
||||
build_str = f"{build_number}.{int(ubr)}" if build_number and ubr is not None else build_number
|
||||
except Exception:
|
||||
build_str = build_number
|
||||
|
||||
parts = ["Microsoft", os_name]
|
||||
if edition:
|
||||
parts.append(edition)
|
||||
if version_label:
|
||||
parts.append(version_label)
|
||||
if build_str:
|
||||
parts.append(f"Build {build_str}")
|
||||
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
|
||||
except Exception:
|
||||
return f"Windows {platform.release()}"
|
||||
|
||||
elif plat.startswith('linux'):
|
||||
try:
|
||||
import distro # External package, better for Linux OS detection
|
||||
name = distro.name(pretty=True) # e.g., "Fedora Workstation 42"
|
||||
if name:
|
||||
return name
|
||||
else:
|
||||
return f"{platform.system()} {platform.release()}"
|
||||
except ImportError:
|
||||
return f"{platform.system()} {platform.release()}"
|
||||
|
||||
elif plat.startswith('darwin'):
|
||||
version = platform.mac_ver()[0]
|
||||
macos_names = {
|
||||
"14": "Sonoma",
|
||||
"13": "Ventura",
|
||||
"12": "Monterey",
|
||||
"11": "Big Sur",
|
||||
"10.15": "Catalina"
|
||||
}
|
||||
pretty_name = macos_names.get(".".join(version.split(".")[:2]), "")
|
||||
return f"macOS {pretty_name or version}"
|
||||
|
||||
else:
|
||||
return f"Unknown OS ({platform.system()} {platform.release()})"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WARN] OS detection failed: {e}")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _detect_virtual_machine() -> bool:
|
||||
"""Best-effort detection of whether this system is a virtual machine.
|
||||
|
||||
Uses platform-specific signals but avoids heavy dependencies.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat == "linux":
|
||||
# Prefer systemd-detect-virt if available
|
||||
try:
|
||||
out = subprocess.run([
|
||||
"systemd-detect-virt", "--vm"
|
||||
], capture_output=True, text=True, timeout=3)
|
||||
if out.returncode == 0 and (out.stdout or "").strip():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to DMI sysfs strings
|
||||
for p in (
|
||||
"/sys/class/dmi/id/product_name",
|
||||
"/sys/class/dmi/id/sys_vendor",
|
||||
"/sys/class/dmi/id/board_vendor",
|
||||
):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
s = (fh.read() or "").lower()
|
||||
if any(k in s for k in (
|
||||
"kvm", "vmware", "virtualbox", "qemu", "xen", "hyper-v", "microsoft corporation")):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif plat == "windows":
|
||||
# Inspect model/manufacturer via CIM
|
||||
try:
|
||||
ps_cmd = (
|
||||
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||
"$model = [string]$cs.Model; $manu = [string]$cs.Manufacturer; "
|
||||
"Write-Output ($model + '|' + $manu)"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=6)
|
||||
s = (out.stdout or "").strip().lower()
|
||||
if any(k in s for k in ("virtual", "vmware", "virtualbox", "kvm", "qemu", "xen", "hyper-v")):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: registry BIOS strings often include virtualization hints
|
||||
try:
|
||||
import winreg # type: ignore
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System") as k:
|
||||
for name in ("SystemBiosVersion", "VideoBiosVersion"):
|
||||
try:
|
||||
val, _ = winreg.QueryValueEx(k, name)
|
||||
s = str(val).lower()
|
||||
if any(x in s for x in ("virtual", "vmware", "virtualbox", "qemu", "xen", "hyper-v")):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif plat == "darwin":
|
||||
# macOS guest detection is tricky; limited heuristic
|
||||
try:
|
||||
out = subprocess.run([
|
||||
"sysctl", "-n", "machdep.cpu.features"
|
||||
], capture_output=True, text=True, timeout=3)
|
||||
s = (out.stdout or "").lower()
|
||||
if "vmm" in s:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _detect_device_type_non_vm() -> str:
|
||||
"""Classify non-VM device as Laptop, Desktop, or Server.
|
||||
|
||||
This is intentionally conservative; if unsure, returns 'Desktop'.
|
||||
"""
|
||||
try:
|
||||
plat = platform.system().lower()
|
||||
|
||||
if plat == "windows":
|
||||
# Prefer PCSystemTypeEx, then chassis types, then battery presence
|
||||
try:
|
||||
ps_cmd = (
|
||||
"$cs = Get-CimInstance Win32_ComputerSystem; "
|
||||
"$typeEx = [int]($cs.PCSystemTypeEx); "
|
||||
"$type = [int]($cs.PCSystemType); "
|
||||
"$ch = (Get-CimInstance Win32_SystemEnclosure).ChassisTypes; "
|
||||
"$hasBatt = @(Get-CimInstance Win32_Battery).Count -gt 0; "
|
||||
"Write-Output ($typeEx.ToString() + '|' + $type.ToString() + '|' + "
|
||||
"([string]::Join(',', $ch)) + '|' + $hasBatt)"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=6)
|
||||
resp = (out.stdout or "").strip()
|
||||
parts = resp.split("|")
|
||||
type_ex = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else None
|
||||
type_pc = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
chassis = []
|
||||
if len(parts) > 2 and parts[2].strip():
|
||||
for t in parts[2].split(','):
|
||||
t = t.strip()
|
||||
if t.isdigit():
|
||||
chassis.append(int(t))
|
||||
has_batt = False
|
||||
if len(parts) > 3:
|
||||
has_batt = parts[3].strip().lower() in ("true", "1")
|
||||
|
||||
# PCSystemTypeEx mapping per MS docs
|
||||
if type_ex in (4, 5, 7, 8):
|
||||
return "Server"
|
||||
if type_ex == 2:
|
||||
return "Laptop"
|
||||
if type_ex in (1, 3):
|
||||
return "Desktop"
|
||||
|
||||
# Fallback to PCSystemType
|
||||
if type_pc in (4, 5):
|
||||
return "Server"
|
||||
if type_pc == 2:
|
||||
return "Laptop"
|
||||
if type_pc in (1, 3):
|
||||
return "Desktop"
|
||||
|
||||
# ChassisType mapping (DMTF)
|
||||
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||
server_types = {17, 23}
|
||||
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||
if any(ct in laptop_types for ct in chassis):
|
||||
return "Laptop"
|
||||
if any(ct in server_types for ct in chassis):
|
||||
return "Server"
|
||||
if any(ct in desktop_types for ct in chassis):
|
||||
return "Desktop"
|
||||
|
||||
if has_batt:
|
||||
return "Laptop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
if plat == "linux":
|
||||
# hostnamectl exposes chassis when available
|
||||
try:
|
||||
out = subprocess.run(["hostnamectl"], capture_output=True, text=True, timeout=3)
|
||||
for line in (out.stdout or "").splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
if k.strip().lower() == "chassis":
|
||||
val = v.strip().lower()
|
||||
if "laptop" in val:
|
||||
return "Laptop"
|
||||
if "desktop" in val:
|
||||
return "Desktop"
|
||||
if "server" in val:
|
||||
return "Server"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DMI chassis type numeric
|
||||
try:
|
||||
with open("/sys/class/dmi/id/chassis_type", "r", encoding="utf-8", errors="ignore") as fh:
|
||||
s = (fh.read() or "").strip()
|
||||
ct = int(s)
|
||||
laptop_types = {8, 9, 10, 14, 30, 31}
|
||||
server_types = {17, 23}
|
||||
desktop_types = {3, 4, 5, 6, 7, 15, 16, 24, 35}
|
||||
if ct in laptop_types:
|
||||
return "Laptop"
|
||||
if ct in server_types:
|
||||
return "Server"
|
||||
if ct in desktop_types:
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Battery presence heuristic
|
||||
try:
|
||||
if os.path.isdir("/sys/class/power_supply"):
|
||||
for name in os.listdir("/sys/class/power_supply"):
|
||||
if name.lower().startswith("bat"):
|
||||
return "Laptop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
if plat == "darwin":
|
||||
try:
|
||||
out = subprocess.run(["sysctl", "-n", "hw.model"], capture_output=True, text=True, timeout=3)
|
||||
model = (out.stdout or "").strip()
|
||||
if model:
|
||||
if model.lower().startswith("macbook"):
|
||||
return "Laptop"
|
||||
# iMac, Macmini, MacPro -> treat as Desktop
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
|
||||
def detect_device_type() -> str:
|
||||
"""Return one of: 'Laptop', 'Desktop', 'Server', 'Virtual Machine'."""
|
||||
try:
|
||||
if _detect_virtual_machine():
|
||||
return "Virtual Machine"
|
||||
return _detect_device_type_non_vm()
|
||||
except Exception:
|
||||
return "Desktop"
|
||||
|
||||
|
||||
def _get_internal_ip():
|
||||
"""Best-effort detection of primary IPv4 address without external reachability.
|
||||
|
||||
Order of attempts:
|
||||
1) psutil.net_if_addrs() – first non-loopback, non-APIPA IPv4
|
||||
2) UDP connect trick to 8.8.8.8 (common technique)
|
||||
3) Windows: PowerShell Get-NetIPAddress
|
||||
4) Linux/macOS: `ip -o -4 addr show` or `hostname -I`
|
||||
"""
|
||||
# 1) psutil interfaces
|
||||
try:
|
||||
if psutil:
|
||||
for name, addrs in (psutil.net_if_addrs() or {}).items():
|
||||
for a in addrs:
|
||||
if getattr(a, "family", None) == socket.AF_INET:
|
||||
ip = a.address
|
||||
if (
|
||||
ip
|
||||
and not ip.startswith("127.")
|
||||
and not ip.startswith("169.254.")
|
||||
and ip != "0.0.0.0"
|
||||
):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) UDP connect trick
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
if ip:
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
plat = platform.system().lower()
|
||||
# 3) Windows PowerShell
|
||||
if plat == "windows":
|
||||
try:
|
||||
ps_cmd = (
|
||||
"Get-NetIPAddress -AddressFamily IPv4 | "
|
||||
"Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\\.254\\.' -and $_.IPAddress -notmatch '^127\\.' } | "
|
||||
"Sort-Object -Property PrefixLength | Select-Object -First 1 -ExpandProperty IPAddress"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=20)
|
||||
val = (out.stdout or "").strip()
|
||||
if val:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) Linux/macOS
|
||||
try:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=10)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
ip = parts[3].split("/")[0]
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=5)
|
||||
val = (out.stdout or "").strip().split()
|
||||
for ip in val:
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def collect_summary(config):
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
domain = os.environ.get("USERDOMAIN") or socket.gethostname()
|
||||
last_user = f"{domain}\\{username}" if username else "unknown"
|
||||
except Exception:
|
||||
last_user = "unknown"
|
||||
|
||||
try:
|
||||
last_reboot = "unknown"
|
||||
# First, prefer psutil if available
|
||||
if psutil:
|
||||
try:
|
||||
last_reboot = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(psutil.boot_time()),
|
||||
)
|
||||
except Exception:
|
||||
last_reboot = "unknown"
|
||||
if last_reboot == "unknown":
|
||||
plat = platform.system().lower()
|
||||
if plat == "windows":
|
||||
# Try WMIC, then robust PowerShell fallback regardless of WMIC presence
|
||||
raw = ""
|
||||
try:
|
||||
out = _run_hidden(["wmic", "os", "get", "lastbootuptime"], timeout=20)
|
||||
raw = "".join(out.stdout.splitlines()[1:]).strip()
|
||||
except Exception:
|
||||
raw = ""
|
||||
# If WMIC didn't yield a value, try CIM and format directly in PowerShell
|
||||
if not raw:
|
||||
try:
|
||||
ps_cmd = (
|
||||
"(Get-CimInstance Win32_OperatingSystem).LastBootUpTime | "
|
||||
"ForEach-Object { (Get-Date -Date $_ -Format 'yyyy-MM-dd HH:mm:ss') }"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=20)
|
||||
raw = (out.stdout or "").strip()
|
||||
if raw:
|
||||
last_reboot = raw
|
||||
except Exception:
|
||||
raw = ""
|
||||
# Parse WMIC-style if we had it
|
||||
if last_reboot == "unknown" and raw:
|
||||
try:
|
||||
boot = datetime.datetime.strptime(raw.split(".")[0], "%Y%m%d%H%M%S")
|
||||
last_reboot = boot.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["uptime", "-s"], capture_output=True, text=True, timeout=10)
|
||||
val = out.stdout.strip()
|
||||
if val:
|
||||
last_reboot = val
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
last_reboot = "unknown"
|
||||
|
||||
created = config.data.get("created")
|
||||
if not created:
|
||||
created = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
config.data["created"] = created
|
||||
try:
|
||||
config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# External IP detection with fallbacks
|
||||
external_ip = "unknown"
|
||||
for url in ("https://api.ipify.org", "https://api64.ipify.org", "https://ifconfig.me/ip"):
|
||||
try:
|
||||
external_ip = requests.get(url, timeout=5).text.strip()
|
||||
if external_ip:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"operating_system": config.data.get("agent_operating_system", detect_agent_os()),
|
||||
"device_type": config.data.get("device_type", detect_device_type()),
|
||||
"last_user": last_user,
|
||||
"internal_ip": _get_internal_ip(),
|
||||
"external_ip": external_ip,
|
||||
"last_reboot": last_reboot,
|
||||
"created": created,
|
||||
}
|
||||
|
||||
|
||||
def collect_software():
|
||||
items = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if plat == "windows":
|
||||
try:
|
||||
out = _run_hidden(["wmic", "product", "get", "name,version"], timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
if line.strip() and not line.lower().startswith("name"):
|
||||
parts = line.strip().split(" ")
|
||||
name = parts[0].strip()
|
||||
version = parts[-1].strip() if len(parts) > 1 else ""
|
||||
if name:
|
||||
items.append({"name": name, "version": version})
|
||||
except FileNotFoundError:
|
||||
ps_cmd = (
|
||||
"Get-ItemProperty "
|
||||
"'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',"
|
||||
"'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' "
|
||||
"| Where-Object { $_.DisplayName } "
|
||||
"| Select-Object DisplayName,DisplayVersion "
|
||||
"| ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for pkg in data:
|
||||
name = pkg.get("DisplayName")
|
||||
if name:
|
||||
items.append({
|
||||
"name": name,
|
||||
"version": pkg.get("DisplayVersion", "")
|
||||
})
|
||||
elif plat == "linux":
|
||||
out = subprocess.run(["dpkg-query", "-W", "-f=${Package}\t${Version}\n"], capture_output=True, text=True)
|
||||
for line in out.stdout.splitlines():
|
||||
if "\t" in line:
|
||||
name, version = line.split("\t", 1)
|
||||
items.append({"name": name, "version": version})
|
||||
else:
|
||||
out = subprocess.run([sys.executable, "-m", "pip", "list", "--format", "json"], capture_output=True, text=True)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
for pkg in data:
|
||||
items.append({"name": pkg.get("name"), "version": pkg.get("version")})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_software failed: {e}")
|
||||
return items[:100]
|
||||
|
||||
|
||||
def collect_memory():
|
||||
entries = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if plat == "windows":
|
||||
try:
|
||||
out = _run_hidden(["wmic", "memorychip", "get", "BankLabel,Speed,SerialNumber,Capacity"], timeout=60)
|
||||
lines = [l for l in out.stdout.splitlines() if l.strip() and "BankLabel" not in l]
|
||||
for line in lines:
|
||||
parts = [p for p in line.split() if p]
|
||||
if len(parts) >= 4:
|
||||
entries.append({
|
||||
"slot": parts[0],
|
||||
"speed": parts[1],
|
||||
"serial": parts[2],
|
||||
"capacity": parts[3],
|
||||
})
|
||||
except FileNotFoundError:
|
||||
ps_cmd = (
|
||||
"Get-CimInstance Win32_PhysicalMemory | "
|
||||
"Select-Object BankLabel,Speed,SerialNumber,Capacity | ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for stick in data:
|
||||
entries.append({
|
||||
"slot": stick.get("BankLabel", "unknown"),
|
||||
"speed": str(stick.get("Speed", "unknown")),
|
||||
"serial": stick.get("SerialNumber", "unknown"),
|
||||
"capacity": stick.get("Capacity", "unknown"),
|
||||
})
|
||||
elif plat == "linux":
|
||||
out = subprocess.run(["dmidecode", "-t", "17"], capture_output=True, text=True)
|
||||
slot = speed = serial = capacity = None
|
||||
for line in out.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("Locator:"):
|
||||
slot = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Speed:"):
|
||||
speed = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Serial Number:"):
|
||||
serial = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Size:"):
|
||||
capacity = line.split(":", 1)[1].strip()
|
||||
elif not line and slot:
|
||||
entries.append({
|
||||
"slot": slot,
|
||||
"speed": speed or "unknown",
|
||||
"serial": serial or "unknown",
|
||||
"capacity": capacity or "unknown",
|
||||
})
|
||||
slot = speed = serial = capacity = None
|
||||
if slot:
|
||||
entries.append({
|
||||
"slot": slot,
|
||||
"speed": speed or "unknown",
|
||||
"serial": serial or "unknown",
|
||||
"capacity": capacity or "unknown",
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_memory failed: {e}")
|
||||
|
||||
if not entries:
|
||||
try:
|
||||
if psutil:
|
||||
vm = psutil.virtual_memory()
|
||||
entries.append({
|
||||
"slot": "physical",
|
||||
"speed": "unknown",
|
||||
"serial": "unknown",
|
||||
"capacity": vm.total,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def collect_storage():
|
||||
disks = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if psutil:
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
"drive": part.device,
|
||||
"disk_type": "Removable" if "removable" in part.opts.lower() else "Fixed Disk",
|
||||
"usage": usage.percent,
|
||||
"total": usage.total,
|
||||
"free": usage.free,
|
||||
"used": usage.used,
|
||||
})
|
||||
elif plat == "windows":
|
||||
found = False
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:\\"
|
||||
if os.path.exists(drive):
|
||||
try:
|
||||
usage = shutil.disk_usage(drive)
|
||||
except Exception:
|
||||
continue
|
||||
disks.append({
|
||||
"drive": drive,
|
||||
"disk_type": "Fixed Disk",
|
||||
"usage": (usage.used / usage.total * 100) if usage.total else 0,
|
||||
"total": usage.total,
|
||||
"free": usage.free,
|
||||
"used": usage.used,
|
||||
})
|
||||
found = True
|
||||
if not found:
|
||||
try:
|
||||
out = _run_hidden(["wmic", "logicaldisk", "get", "DeviceID,Size,FreeSpace"], timeout=60)
|
||||
lines = [l for l in out.stdout.splitlines() if l.strip()][1:]
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
drive, free, size = parts[0], parts[1], parts[2]
|
||||
try:
|
||||
total = float(size)
|
||||
free_bytes = float(free)
|
||||
used = total - free_bytes
|
||||
usage = (used / total * 100) if total else 0
|
||||
disks.append({
|
||||
"drive": drive,
|
||||
"disk_type": "Fixed Disk",
|
||||
"usage": usage,
|
||||
"total": total,
|
||||
"free": free_bytes,
|
||||
"used": used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["df", "-hP"], capture_output=True, text=True)
|
||||
for line in out.stdout.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 6:
|
||||
try:
|
||||
usage_str = parts[4].rstrip('%')
|
||||
usage = float(usage_str)
|
||||
except Exception:
|
||||
usage = 0
|
||||
total = parts[1]
|
||||
free_bytes = parts[3]
|
||||
used = parts[2]
|
||||
disks.append({
|
||||
"drive": parts[0],
|
||||
"disk_type": "Mounted",
|
||||
"usage": usage,
|
||||
"total": total,
|
||||
"free": free_bytes,
|
||||
"used": used,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_storage failed: {e}")
|
||||
return disks
|
||||
|
||||
|
||||
def collect_network():
|
||||
adapters = []
|
||||
plat = platform.system().lower()
|
||||
try:
|
||||
if psutil:
|
||||
for name, addrs in psutil.net_if_addrs().items():
|
||||
ips = [a.address for a in addrs if getattr(a, "family", None) == socket.AF_INET]
|
||||
mac = next((a.address for a in addrs if getattr(a, "family", None) == getattr(psutil, "AF_LINK", object)), "unknown")
|
||||
adapters.append({"adapter": name, "ips": ips, "mac": mac})
|
||||
elif plat == "windows":
|
||||
ps_cmd = (
|
||||
"Get-NetIPConfiguration | "
|
||||
"Select-Object InterfaceAlias,@{Name='IPv4';Expression={$_.IPv4Address.IPAddress}},"
|
||||
"@{Name='MAC';Expression={$_.NetAdapter.MacAddress}} | ConvertTo-Json"
|
||||
)
|
||||
out = _run_powershell_hidden(ps_cmd, timeout=60)
|
||||
data = json.loads(out.stdout or "[]")
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
for a in data:
|
||||
ip = a.get("IPv4")
|
||||
adapters.append({
|
||||
"adapter": a.get("InterfaceAlias", "unknown"),
|
||||
"ips": [ip] if ip else [],
|
||||
"mac": a.get("MAC", "unknown"),
|
||||
})
|
||||
else:
|
||||
out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=60)
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[1]
|
||||
ip = parts[3].split("/")[0]
|
||||
adapters.append({"adapter": name, "ips": [ip], "mac": "unknown"})
|
||||
except Exception as e:
|
||||
print(f"[WARN] collect_network failed: {e}")
|
||||
return adapters
|
||||
|
||||
|
||||
async def send_agent_details(agent_id, config):
|
||||
"""Collect detailed agent data and send to server periodically."""
|
||||
while True:
|
||||
try:
|
||||
details = {
|
||||
"summary": collect_summary(config),
|
||||
"software": collect_software(),
|
||||
"memory": collect_memory(),
|
||||
"storage": collect_storage(),
|
||||
"network": collect_network(),
|
||||
}
|
||||
url = config.data.get("borealis_server_url", "http://localhost:5000") + "/api/agent/details"
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"hostname": details.get("summary", {}).get("hostname", socket.gethostname()),
|
||||
"details": details,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(url, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to send agent details: {e}")
|
||||
# Report every ~2 minutes
|
||||
await asyncio.sleep(120)
|
||||
@@ -1,352 +0,0 @@
|
||||
import os
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import traceback
|
||||
import random
|
||||
import importlib.util
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PIL import ImageGrab
|
||||
|
||||
|
||||
class RolesContext:
|
||||
def __init__(self, sio, agent_id, config):
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
|
||||
|
||||
# Load macro engines from the local Python_API_Endpoints directory
|
||||
MACRO_ENGINE_PATH = os.path.join(os.path.dirname(__file__), "Python_API_Endpoints", "macro_engines.py")
|
||||
spec = importlib.util.spec_from_file_location("macro_engines", MACRO_ENGINE_PATH)
|
||||
macro_engines = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(macro_engines)
|
||||
|
||||
|
||||
# Overlay visuals
|
||||
overlay_green_thickness = 4
|
||||
overlay_gray_thickness = 2
|
||||
handle_size = overlay_green_thickness * 2
|
||||
extra_top_padding = overlay_green_thickness * 2 + 4
|
||||
|
||||
|
||||
# Track active screenshot overlay widgets per node_id
|
||||
overlay_widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
|
||||
def get_window_list():
|
||||
"""Return a list of windows from macro engines."""
|
||||
try:
|
||||
return macro_engines.list_windows()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def close_overlay(node_id: str):
|
||||
w = overlay_widgets.pop(node_id, None)
|
||||
if w:
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def close_all_overlays():
|
||||
for node_id, widget in list(overlay_widgets.items()):
|
||||
try:
|
||||
widget.close()
|
||||
except Exception:
|
||||
pass
|
||||
overlay_widgets.clear()
|
||||
|
||||
|
||||
class ScreenshotRegion(QtWidgets.QWidget):
|
||||
def __init__(self, ctx: RolesContext, node_id, x=100, y=100, w=300, h=200, alias=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.node_id = node_id
|
||||
self.alias = alias
|
||||
self.setGeometry(
|
||||
x - handle_size,
|
||||
y - handle_size - extra_top_padding,
|
||||
w + handle_size * 2,
|
||||
h + handle_size * 2 + extra_top_padding,
|
||||
)
|
||||
self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
||||
self.resize_dir = None
|
||||
self.drag_offset = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
h = self.height()
|
||||
|
||||
# draw gray capture box
|
||||
p.setPen(QtGui.QPen(QtGui.QColor(130, 130, 130), overlay_gray_thickness))
|
||||
p.drawRect(handle_size, handle_size + extra_top_padding, w - handle_size * 2, h - handle_size * 2 - extra_top_padding)
|
||||
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(QtGui.QBrush(QtGui.QColor(0, 191, 255)))
|
||||
edge = overlay_green_thickness * 3
|
||||
|
||||
# corner handles
|
||||
p.drawRect(0, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(0, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, extra_top_padding, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, extra_top_padding, overlay_green_thickness, edge)
|
||||
p.drawRect(0, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(0, h - edge, overlay_green_thickness, edge)
|
||||
p.drawRect(w - edge, h - overlay_green_thickness, edge, overlay_green_thickness)
|
||||
p.drawRect(w - overlay_green_thickness, h - edge, overlay_green_thickness, edge)
|
||||
|
||||
# side handles
|
||||
long = overlay_green_thickness * 6
|
||||
p.drawRect((w - long) // 2, extra_top_padding, long, overlay_green_thickness)
|
||||
p.drawRect((w - long) // 2, h - overlay_green_thickness, long, overlay_green_thickness)
|
||||
p.drawRect(0, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
p.drawRect(w - overlay_green_thickness, (h + extra_top_padding - long) // 2, overlay_green_thickness, long)
|
||||
|
||||
# grabber bar
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (w - bar_width) // 2
|
||||
bar_y = 6
|
||||
p.setBrush(QtGui.QColor(0, 191, 255))
|
||||
p.drawRect(bar_x, bar_y - bar_height - 10, bar_width, bar_height * 4)
|
||||
|
||||
def get_geometry(self):
|
||||
g = self.geometry()
|
||||
return (
|
||||
g.x() + handle_size,
|
||||
g.y() + handle_size + extra_top_padding,
|
||||
g.width() - handle_size * 2,
|
||||
g.height() - handle_size * 2 - extra_top_padding,
|
||||
)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == QtCore.Qt.LeftButton:
|
||||
pos = e.pos()
|
||||
bar_width = overlay_green_thickness * 6
|
||||
bar_height = overlay_green_thickness
|
||||
bar_x = (self.width() - bar_width) // 2
|
||||
bar_y = 2
|
||||
bar_rect = QtCore.QRect(bar_x, bar_y, bar_width, bar_height)
|
||||
|
||||
if bar_rect.contains(pos):
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
return
|
||||
|
||||
m = handle_size
|
||||
dirs = []
|
||||
if pos.x() <= m:
|
||||
dirs.append('left')
|
||||
if pos.x() >= self.width() - m:
|
||||
dirs.append('right')
|
||||
if pos.y() <= m + extra_top_padding:
|
||||
dirs.append('top')
|
||||
if pos.y() >= self.height() - m:
|
||||
dirs.append('bottom')
|
||||
if dirs:
|
||||
self.resize_dir = '_'.join(dirs)
|
||||
self._start_geom = self.geometry()
|
||||
self._start_pos = e.globalPos()
|
||||
else:
|
||||
self.drag_offset = e.globalPos() - self.frameGeometry().topLeft()
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self.resize_dir and self._start_geom and self._start_pos:
|
||||
dx = e.globalX() - self._start_pos.x()
|
||||
dy = e.globalY() - self._start_pos.y()
|
||||
geom = QtCore.QRect(self._start_geom)
|
||||
if 'left' in self.resize_dir:
|
||||
new_x = geom.x() + dx
|
||||
new_w = geom.width() - dx
|
||||
geom.setX(new_x)
|
||||
geom.setWidth(new_w)
|
||||
if 'right' in self.resize_dir:
|
||||
geom.setWidth(self._start_geom.width() + dx)
|
||||
if 'top' in self.resize_dir:
|
||||
new_y = geom.y() + dy
|
||||
new_h = geom.height() - dy
|
||||
geom.setY(new_y)
|
||||
geom.setHeight(new_h)
|
||||
if 'bottom' in self.resize_dir:
|
||||
geom.setHeight(self._start_geom.height() + dy)
|
||||
self.setGeometry(geom)
|
||||
elif self.drag_offset and e.buttons() & QtCore.Qt.LeftButton:
|
||||
self.move(e.globalPos() - self.drag_offset)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
self.drag_offset = None
|
||||
self.resize_dir = None
|
||||
self._start_geom = None
|
||||
self._start_pos = None
|
||||
x, y, w, h = self.get_geometry()
|
||||
self.ctx.config.data['regions'][self.node_id] = {'x': x, 'y': y, 'w': w, 'h': h}
|
||||
try:
|
||||
self.ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
# Emit a zero-image update so the server knows new geometry immediately
|
||||
asyncio.create_task(self.ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': self.ctx.agent_id,
|
||||
'node_id': self.node_id,
|
||||
'image_base64': '',
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
}))
|
||||
|
||||
|
||||
async def screenshot_task(ctx: RolesContext, cfg):
|
||||
nid = cfg.get('node_id')
|
||||
alias = cfg.get('alias', '')
|
||||
r = ctx.config.data['regions'].get(nid)
|
||||
if r:
|
||||
region = (r['x'], r['y'], r['w'], r['h'])
|
||||
else:
|
||||
region = (
|
||||
cfg.get('x', 100),
|
||||
cfg.get('y', 100),
|
||||
cfg.get('w', 300),
|
||||
cfg.get('h', 200),
|
||||
)
|
||||
ctx.config.data['regions'][nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]}
|
||||
try:
|
||||
ctx.config._write()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if nid not in overlay_widgets:
|
||||
widget = ScreenshotRegion(ctx, nid, *region, alias=alias)
|
||||
overlay_widgets[nid] = widget
|
||||
widget.show()
|
||||
|
||||
await ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': '',
|
||||
'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]
|
||||
})
|
||||
|
||||
interval = cfg.get('interval', 1000) / 1000.0
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=ctx.config.data.get('max_task_workers', 8))
|
||||
try:
|
||||
while True:
|
||||
x, y, w, h = overlay_widgets[nid].get_geometry()
|
||||
grab = partial(ImageGrab.grab, bbox=(x, y, x + w, y + h))
|
||||
img = await loop.run_in_executor(executor, grab)
|
||||
buf = BytesIO(); img.save(buf, format='PNG')
|
||||
encoded = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
await ctx.sio.emit('agent_screenshot_task', {
|
||||
'agent_id': ctx.agent_id,
|
||||
'node_id': nid,
|
||||
'image_base64': encoded,
|
||||
'x': x, 'y': y, 'w': w, 'h': h
|
||||
})
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
print(f"[TASK] Screenshot role {nid} cancelled.")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Screenshot task {nid} failed: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
async def macro_task(ctx: RolesContext, cfg):
|
||||
"""Improved macro task supporting operation modes, live config, and feedback."""
|
||||
nid = cfg.get('node_id')
|
||||
|
||||
last_trigger_value = 0
|
||||
has_run_once = False
|
||||
|
||||
while True:
|
||||
window_handle = cfg.get('window_handle')
|
||||
macro_type = cfg.get('macro_type', 'keypress')
|
||||
operation_mode = cfg.get('operation_mode', 'Continuous')
|
||||
key = cfg.get('key')
|
||||
text = cfg.get('text')
|
||||
interval_ms = int(cfg.get('interval_ms', 1000))
|
||||
randomize = cfg.get('randomize_interval', False)
|
||||
random_min = int(cfg.get('random_min', 750))
|
||||
random_max = int(cfg.get('random_max', 950))
|
||||
active = cfg.get('active', True)
|
||||
trigger = int(cfg.get('trigger', 0))
|
||||
|
||||
async def emit_macro_status(success, message=""):
|
||||
await ctx.sio.emit('macro_status', {
|
||||
"agent_id": ctx.agent_id,
|
||||
"node_id": nid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000)
|
||||
})
|
||||
|
||||
if not (active is True or str(active).lower() == "true"):
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
|
||||
try:
|
||||
send_macro = False
|
||||
|
||||
if operation_mode == "Run Once":
|
||||
if not has_run_once:
|
||||
send_macro = True
|
||||
has_run_once = True
|
||||
elif operation_mode == "Continuous":
|
||||
send_macro = True
|
||||
elif operation_mode == "Trigger-Continuous":
|
||||
send_macro = (trigger == 1)
|
||||
elif operation_mode == "Trigger-Once":
|
||||
if last_trigger_value == 0 and trigger == 1:
|
||||
send_macro = True
|
||||
last_trigger_value = trigger
|
||||
else:
|
||||
send_macro = True
|
||||
|
||||
if send_macro:
|
||||
if macro_type == 'keypress' and key:
|
||||
result = macro_engines.send_keypress_to_window(window_handle, key)
|
||||
elif macro_type == 'typed_text' and text:
|
||||
result = macro_engines.type_text_to_window(window_handle, text)
|
||||
else:
|
||||
await emit_macro_status(False, "Invalid macro type or missing key/text")
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
|
||||
if isinstance(result, tuple):
|
||||
success, err = result
|
||||
else:
|
||||
success, err = bool(result), ""
|
||||
|
||||
if success:
|
||||
await emit_macro_status(True, f"Macro sent: {macro_type}")
|
||||
else:
|
||||
await emit_macro_status(False, err or "Unknown macro engine failure")
|
||||
else:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
if send_macro:
|
||||
if randomize:
|
||||
ms = random.randint(random_min, random_max)
|
||||
else:
|
||||
ms = interval_ms
|
||||
await asyncio.sleep(ms / 1000.0)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(f"[TASK] Macro role {nid} cancelled.")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Macro task {nid} failed: {e}")
|
||||
traceback.print_exc()
|
||||
await emit_macro_status(False, str(e))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import threading
|
||||
import datetime
|
||||
import json
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
# Optional pywin32 imports for per-session launching
|
||||
try:
|
||||
import win32ts
|
||||
import win32con
|
||||
import win32process
|
||||
import win32security
|
||||
import win32profile
|
||||
import win32api
|
||||
import pywintypes
|
||||
except Exception:
|
||||
win32ts = None
|
||||
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
|
||||
AGENT_DIR = os.path.join(ROOT, 'Agent')
|
||||
BOREALIS_DIR = os.path.join(AGENT_DIR, 'Borealis')
|
||||
LOG_DIR = os.path.join(ROOT, 'Logs', 'Agent')
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, 'Supervisor.log')
|
||||
PID_FILE = os.path.join(LOG_DIR, 'script_agent.pid')
|
||||
|
||||
# Internal state for process + backoff
|
||||
_script_proc = None
|
||||
_spawn_backoff = 5 # seconds (exponential backoff start)
|
||||
_max_backoff = 300 # cap at 5 minutes
|
||||
_next_spawn_time = 0.0
|
||||
_last_disable_log = 0.0
|
||||
_last_fail_log = 0.0
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
try:
|
||||
# simple size-based rotation (~1MB)
|
||||
try:
|
||||
if os.path.isfile(LOG_FILE) and os.path.getsize(LOG_FILE) > 1_000_000:
|
||||
bak = LOG_FILE + '.1'
|
||||
try:
|
||||
if os.path.isfile(bak):
|
||||
os.remove(bak)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.replace(LOG_FILE, bak)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
with open(LOG_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{ts}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def venv_python():
|
||||
try:
|
||||
exe_dir = os.path.join(AGENT_DIR, 'Scripts')
|
||||
py = os.path.join(exe_dir, 'python.exe')
|
||||
if os.path.isfile(py):
|
||||
return py
|
||||
except Exception:
|
||||
pass
|
||||
return sys.executable
|
||||
|
||||
|
||||
def venv_pythonw():
|
||||
try:
|
||||
exe_dir = os.path.join(AGENT_DIR, 'Scripts')
|
||||
pyw = os.path.join(exe_dir, 'pythonw.exe')
|
||||
if os.path.isfile(pyw):
|
||||
return pyw
|
||||
except Exception:
|
||||
pass
|
||||
return venv_python()
|
||||
|
||||
|
||||
def _settings_path():
|
||||
return os.path.join(ROOT, 'agent_settings.json')
|
||||
|
||||
|
||||
def load_settings():
|
||||
cfg = {}
|
||||
try:
|
||||
path = _settings_path()
|
||||
if os.path.isfile(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return cfg or {}
|
||||
|
||||
|
||||
def _psutil_process_exists(pid: int) -> bool:
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
if pid <= 0:
|
||||
return False
|
||||
p = psutil.Process(pid)
|
||||
return p.is_running() and (p.status() != psutil.STATUS_ZOMBIE)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _win_process_exists(pid: int) -> bool:
|
||||
try:
|
||||
if pid <= 0:
|
||||
return False
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
OpenProcess = kernel32.OpenProcess
|
||||
OpenProcess.restype = wintypes.HANDLE
|
||||
OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
CloseHandle = kernel32.CloseHandle
|
||||
CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if h:
|
||||
try:
|
||||
CloseHandle(h)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def process_exists(pid: int) -> bool:
|
||||
# Prefer psutil if available; else Win32 API
|
||||
return _psutil_process_exists(pid) or _win_process_exists(pid)
|
||||
|
||||
|
||||
def _read_pid_file() -> int:
|
||||
try:
|
||||
if os.path.isfile(PID_FILE):
|
||||
with open(PID_FILE, 'r', encoding='utf-8') as f:
|
||||
s = f.read().strip()
|
||||
return int(s)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _write_pid_file(pid: int):
|
||||
try:
|
||||
with open(PID_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(str(pid))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _clear_pid_file():
|
||||
try:
|
||||
if os.path.isfile(PID_FILE):
|
||||
os.remove(PID_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_script_agent():
|
||||
"""Ensure LocalSystem script_agent.py is running; restart if not, with backoff and PID tracking."""
|
||||
global _script_proc, _spawn_backoff, _next_spawn_time, _last_disable_log, _last_fail_log
|
||||
|
||||
# Allow disabling via config
|
||||
try:
|
||||
cfg = load_settings()
|
||||
if not cfg.get('enable_system_script_agent', True):
|
||||
now = time.time()
|
||||
if now - _last_disable_log > 60:
|
||||
log('System script agent disabled by config (enable_system_script_agent=false)')
|
||||
_last_disable_log = now
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If we have a running child process, keep it
|
||||
try:
|
||||
if _script_proc is not None:
|
||||
if _script_proc.poll() is None:
|
||||
return
|
||||
else:
|
||||
# Child exited; clear PID file for safety
|
||||
_clear_pid_file()
|
||||
_script_proc = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If PID file points to a living process, don't spawn
|
||||
try:
|
||||
pid = _read_pid_file()
|
||||
if pid and process_exists(pid):
|
||||
return
|
||||
elif pid and not process_exists(pid):
|
||||
_clear_pid_file()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Honor backoff window
|
||||
if time.time() < _next_spawn_time:
|
||||
return
|
||||
|
||||
py = venv_python()
|
||||
script = os.path.join(ROOT, 'Data', 'Agent', 'script_agent.py')
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[py, '-W', 'ignore::SyntaxWarning', script],
|
||||
creationflags=(0x08000000 if os.name == 'nt' else 0),
|
||||
)
|
||||
_script_proc = proc
|
||||
_write_pid_file(proc.pid)
|
||||
log(f'Launched script_agent.py (pid {proc.pid})')
|
||||
# reset backoff on success
|
||||
_spawn_backoff = 5
|
||||
_next_spawn_time = 0.0
|
||||
except Exception as e:
|
||||
msg = f'Failed to launch script_agent.py: {e}'
|
||||
now = time.time()
|
||||
# rate-limit identical failure logs to once per 10s
|
||||
if now - _last_fail_log > 10:
|
||||
log(msg)
|
||||
_last_fail_log = now
|
||||
# exponential backoff
|
||||
_spawn_backoff = min(_spawn_backoff * 2, _max_backoff)
|
||||
_next_spawn_time = time.time() + _spawn_backoff
|
||||
|
||||
|
||||
def _enable_privileges():
|
||||
try:
|
||||
hProc = win32api.GetCurrentProcess()
|
||||
hTok = win32security.OpenProcessToken(hProc, win32con.TOKEN_ADJUST_PRIVILEGES | win32con.TOKEN_QUERY)
|
||||
for name in [
|
||||
win32security.SE_ASSIGNPRIMARYTOKEN_NAME,
|
||||
win32security.SE_INCREASE_QUOTA_NAME,
|
||||
win32security.SE_TCB_NAME,
|
||||
win32security.SE_BACKUP_NAME,
|
||||
win32security.SE_RESTORE_NAME,
|
||||
]:
|
||||
try:
|
||||
luid = win32security.LookupPrivilegeValue(None, name)
|
||||
win32security.AdjustTokenPrivileges(hTok, False, [(luid, win32con.SE_PRIVILEGE_ENABLED)])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def active_sessions():
|
||||
ids = []
|
||||
try:
|
||||
if win32ts is None:
|
||||
return ids
|
||||
for s in win32ts.WTSEnumerateSessions(None, 1, 0):
|
||||
sid, _, state = s
|
||||
if state == win32ts.WTSActive:
|
||||
ids.append(sid)
|
||||
except Exception:
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def launch_helper_in_session(session_id):
|
||||
try:
|
||||
if win32ts is None:
|
||||
return False
|
||||
_enable_privileges()
|
||||
hUser = win32ts.WTSQueryUserToken(session_id)
|
||||
primary = win32security.DuplicateTokenEx(
|
||||
hUser,
|
||||
win32con.MAXIMUM_ALLOWED,
|
||||
win32security.SECURITY_ATTRIBUTES(),
|
||||
win32security.SecurityImpersonation,
|
||||
win32con.TOKEN_PRIMARY,
|
||||
)
|
||||
env = win32profile.CreateEnvironmentBlock(primary, True)
|
||||
si = win32process.STARTUPINFO()
|
||||
si.lpDesktop = 'winsta0\\default'
|
||||
cmd = f'"{venv_pythonw()}" -W ignore::SyntaxWarning "{os.path.join(BOREALIS_DIR, "borealis-agent.py")}"'
|
||||
flags = getattr(win32con, 'CREATE_UNICODE_ENVIRONMENT', 0x00000400)
|
||||
win32process.CreateProcessAsUser(primary, None, cmd, None, None, False, flags, env, BOREALIS_DIR, si)
|
||||
log(f'Started user helper in session {session_id}')
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f'Failed to start helper in session {session_id}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def manage_user_helpers_loop():
|
||||
known = set()
|
||||
while True:
|
||||
try:
|
||||
cur = set(active_sessions())
|
||||
for sid in cur:
|
||||
if sid not in known:
|
||||
launch_helper_in_session(sid)
|
||||
known = cur
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def main():
|
||||
log('Supervisor starting')
|
||||
t = threading.Thread(target=manage_user_helpers_loop, daemon=True)
|
||||
t.start()
|
||||
while True:
|
||||
ensure_script_agent()
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
95
Data/Agent/role_manager.py
Normal file
95
Data/Agent/role_manager.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import importlib.util
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class RoleManager:
|
||||
"""
|
||||
Discovers and loads role modules from Data/Agent/Roles.
|
||||
Each role module should expose:
|
||||
- ROLE_NAME: str
|
||||
- ROLE_CONTEXTS: List[str] (e.g., ["interactive"], ["system"], or ["interactive","system"])
|
||||
- class Role(ctx): with methods:
|
||||
- register_events(): optional, bind socket events
|
||||
- on_config(roles: List[dict]): optional, apply server config
|
||||
- stop_all(): optional, cancel tasks/cleanup
|
||||
|
||||
The ctx passed to each Role is a simple object storing common references.
|
||||
"""
|
||||
|
||||
class Ctx:
|
||||
def __init__(self, sio, agent_id, config, loop, hooks: Optional[dict] = None):
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.hooks = hooks or {}
|
||||
|
||||
def __init__(self, base_dir: str, context: str, sio, agent_id: str, config, loop, hooks: Optional[dict] = None):
|
||||
self.base_dir = base_dir
|
||||
self.context = context # "interactive" or "system"
|
||||
self.sio = sio
|
||||
self.agent_id = agent_id
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.hooks = hooks or {}
|
||||
self.roles: Dict[str, object] = {}
|
||||
|
||||
def _iter_role_files(self) -> List[str]:
|
||||
roles_dir = os.path.join(self.base_dir, 'Roles')
|
||||
if not os.path.isdir(roles_dir):
|
||||
return []
|
||||
files = []
|
||||
for fn in os.listdir(roles_dir):
|
||||
if fn.lower().startswith('role_') and fn.lower().endswith('.py'):
|
||||
files.append(os.path.join(roles_dir, fn))
|
||||
return sorted(files)
|
||||
|
||||
def load(self):
|
||||
for path in self._iter_role_files():
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(os.path.splitext(os.path.basename(path))[0], path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
role_name = getattr(mod, 'ROLE_NAME', None)
|
||||
role_contexts = getattr(mod, 'ROLE_CONTEXTS', ['interactive', 'system'])
|
||||
RoleClass = getattr(mod, 'Role', None)
|
||||
|
||||
if not role_name or not RoleClass:
|
||||
continue
|
||||
if self.context not in (role_contexts or []):
|
||||
continue
|
||||
|
||||
try:
|
||||
ctx = RoleManager.Ctx(self.sio, self.agent_id, self.config, self.loop, hooks=self.hooks)
|
||||
role_obj = RoleClass(ctx)
|
||||
# Optional event registration
|
||||
if hasattr(role_obj, 'register_events'):
|
||||
try:
|
||||
role_obj.register_events()
|
||||
except Exception:
|
||||
pass
|
||||
self.roles[role_name] = role_obj
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def on_config(self, roles_cfg: List[dict]):
|
||||
for role in list(self.roles.values()):
|
||||
try:
|
||||
if hasattr(role, 'on_config'):
|
||||
role.on_config(roles_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop_all(self):
|
||||
for role in list(self.roles.values()):
|
||||
try:
|
||||
if hasattr(role, 'stop_all'):
|
||||
role.stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import socketio
|
||||
import platform
|
||||
import time
|
||||
import uuid
|
||||
import tempfile
|
||||
import contextlib
|
||||
|
||||
|
||||
def get_project_root():
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def get_server_url():
|
||||
# Try to reuse the agent config if present
|
||||
cfg_path = os.path.join(get_project_root(), "agent_settings.json")
|
||||
try:
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
url = data.get("borealis_server_url")
|
||||
if isinstance(url, str) and url.strip():
|
||||
return url.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return "http://localhost:5000"
|
||||
|
||||
|
||||
def run_powershell_script_content(content: str):
|
||||
# Store ephemeral script under <ProjectRoot>/Temp
|
||||
temp_dir = os.path.join(get_project_root(), "Temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, path = tempfile.mkstemp(prefix="sj_", suffix=".ps1", dir=temp_dir, text=True)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
|
||||
fh.write(content or "")
|
||||
|
||||
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps):
|
||||
ps = "powershell.exe"
|
||||
try:
|
||||
flags = 0x08000000 if os.name == 'nt' else 0 # CREATE_NO_WINDOW
|
||||
proc = subprocess.run(
|
||||
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60*60,
|
||||
creationflags=flags,
|
||||
)
|
||||
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
finally:
|
||||
# Best-effort cleanup of the ephemeral script
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
sio = socketio.AsyncClient(reconnection=True)
|
||||
hostname = socket.gethostname()
|
||||
|
||||
@sio.event
|
||||
async def connect():
|
||||
print("[ScriptAgent] Connected to server")
|
||||
# Identify as script agent (no heartbeat to avoid UI duplication)
|
||||
try:
|
||||
await sio.emit("connect_agent", {"agent_id": f"{hostname}-script"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@sio.on("quick_job_run")
|
||||
async def on_quick_job_run(payload):
|
||||
# Treat as generic script_run internally
|
||||
try:
|
||||
target = (payload.get('target_hostname') or '').strip().lower()
|
||||
if target and target != hostname.lower():
|
||||
return
|
||||
run_mode = (payload.get('run_mode') or 'current_user').lower()
|
||||
# Only the SYSTEM service handles system-mode jobs; ignore others
|
||||
if run_mode != 'system':
|
||||
return
|
||||
job_id = payload.get('job_id')
|
||||
script_type = (payload.get('script_type') or '').lower()
|
||||
content = payload.get('script_content') or ''
|
||||
if script_type != 'powershell':
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': f"Unsupported type: {script_type}"
|
||||
})
|
||||
return
|
||||
# Preferred: run via ephemeral scheduled task under SYSTEM for isolation
|
||||
rc, out, err = run_powershell_via_system_task(content)
|
||||
if rc == -999:
|
||||
# Fallback to direct execution if task creation not available
|
||||
rc, out, err = run_powershell_script_content(content)
|
||||
status = 'Success' if rc == 0 else 'Failed'
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': job_id,
|
||||
'status': status,
|
||||
'stdout': out,
|
||||
'stderr': err,
|
||||
})
|
||||
except Exception as e:
|
||||
try:
|
||||
await sio.emit('quick_job_result', {
|
||||
'job_id': payload.get('job_id') if isinstance(payload, dict) else None,
|
||||
'status': 'Failed',
|
||||
'stdout': '',
|
||||
'stderr': str(e),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@sio.event
|
||||
async def disconnect():
|
||||
print("[ScriptAgent] Disconnected")
|
||||
|
||||
async def heartbeat_loop():
|
||||
# Minimal heartbeat so device appears online even without a user helper
|
||||
while True:
|
||||
try:
|
||||
await sio.emit("agent_heartbeat", {
|
||||
"agent_id": f"{hostname}-script",
|
||||
"hostname": hostname,
|
||||
"agent_operating_system": f"{platform.system()} {platform.release()} (Service)",
|
||||
"last_seen": int(time.time())
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(30)
|
||||
|
||||
url = get_server_url()
|
||||
while True:
|
||||
try:
|
||||
await sio.connect(url, transports=['websocket'])
|
||||
# Heartbeat while connected
|
||||
hb = asyncio.create_task(heartbeat_loop())
|
||||
try:
|
||||
await sio.wait()
|
||||
finally:
|
||||
try:
|
||||
hb.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[ScriptAgent] reconnect in 5s: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
def run_powershell_via_system_task(content: str):
|
||||
"""Create an ephemeral scheduled task under SYSTEM to run the script.
|
||||
Returns (rc, stdout, stderr). If the environment lacks PowerShell ScheduledTasks module, returns (-999, '', 'unavailable').
|
||||
"""
|
||||
ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if not os.path.isfile(ps_exe):
|
||||
ps_exe = 'powershell.exe'
|
||||
try:
|
||||
os.makedirs(os.path.join(get_project_root(), 'Temp'), exist_ok=True)
|
||||
# Write the target script
|
||||
script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(get_project_root(), 'Temp'), text=True)
|
||||
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(content or '')
|
||||
# Output capture path
|
||||
out_path = os.path.join(get_project_root(), 'Temp', f'out_{uuid.uuid4().hex}.txt')
|
||||
task_name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ SYSTEM"
|
||||
# Build PS to create/run task with DeleteExpiredTaskAfter
|
||||
task_ps = f"""
|
||||
$ErrorActionPreference='Continue'
|
||||
$task = "{task_name}"
|
||||
$ps = "{ps_exe}"
|
||||
$scr = "{script_path}"
|
||||
$out = "{out_path}"
|
||||
try {{ Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}}
|
||||
$action = New-ScheduledTaskAction -Execute $ps -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $scr + '" *> "' + $out + '"')
|
||||
$settings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $task | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Get-ScheduledTask -TaskName $task | Out-Null
|
||||
"""
|
||||
# Run task creation
|
||||
proc = subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', task_ps], capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return -999, '', (proc.stderr or proc.stdout or 'scheduled task creation failed')
|
||||
# Wait up to 60s for output to be written
|
||||
deadline = time.time() + 60
|
||||
out_data = ''
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
|
||||
with open(out_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out_data = f.read()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
# Cleanup task (best-effort)
|
||||
cleanup_ps = f"try {{ Unregister-ScheduledTask -TaskName '{task_name}' -Confirm:$false }} catch {{}}"
|
||||
subprocess.run([ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cleanup_ps], capture_output=True, text=True)
|
||||
# Best-effort removal of temp script and output files
|
||||
try:
|
||||
if os.path.isfile(script_path):
|
||||
os.remove(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if os.path.isfile(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return 0, out_data or '', ''
|
||||
except Exception as e:
|
||||
return -999, '', str(e)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Ensure only a single instance of the script agent runs (Windows-only lock)
|
||||
def _acquire_singleton_lock() -> bool:
|
||||
try:
|
||||
lock_dir = os.path.join(get_project_root(), 'Logs', 'Agent')
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
lock_path = os.path.join(lock_dir, 'script_agent.lock')
|
||||
# Keep handle open for process lifetime
|
||||
fh = open(lock_path, 'a')
|
||||
try:
|
||||
import msvcrt # type: ignore
|
||||
# Lock 1 byte non-blocking; released on handle close/process exit
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
globals()['_LOCK_FH'] = fh
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
except Exception:
|
||||
# If we cannot establish a lock, continue (do not prevent agent)
|
||||
return True
|
||||
|
||||
if not _acquire_singleton_lock():
|
||||
print('[ScriptAgent] Another instance is running; exiting.')
|
||||
sys.exit(0)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,117 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import signal
|
||||
from PyQt5 import QtWidgets, QtGui
|
||||
|
||||
|
||||
def project_paths():
|
||||
# Expected layout when running from venv: <Root>\Agent\Borealis
|
||||
borealis_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
agent_dir = os.path.abspath(os.path.join(borealis_dir, os.pardir))
|
||||
venv_scripts = os.path.join(agent_dir, 'Scripts')
|
||||
pyw = os.path.join(venv_scripts, 'pythonw.exe')
|
||||
py = os.path.join(venv_scripts, 'python.exe')
|
||||
icon_path = os.path.join(borealis_dir, 'Borealis.ico')
|
||||
agent_script = os.path.join(borealis_dir, 'borealis-agent.py')
|
||||
return {
|
||||
'borealis_dir': borealis_dir,
|
||||
'venv_scripts': venv_scripts,
|
||||
'pythonw': pyw if os.path.isfile(pyw) else sys.executable,
|
||||
'python': py if os.path.isfile(py) else sys.executable,
|
||||
'agent_script': agent_script,
|
||||
'icon': icon_path if os.path.isfile(icon_path) else None,
|
||||
}
|
||||
|
||||
|
||||
class TrayApp(QtWidgets.QSystemTrayIcon):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
paths = project_paths()
|
||||
self.paths = paths
|
||||
icon = QtGui.QIcon(paths['icon']) if paths['icon'] else app.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon)
|
||||
super().__init__(icon)
|
||||
self.setToolTip('Borealis Agent')
|
||||
self.menu = QtWidgets.QMenu()
|
||||
|
||||
self.action_show_console = self.menu.addAction('Switch to Foreground Mode')
|
||||
self.action_hide_console = self.menu.addAction('Switch to Background Mode')
|
||||
self.action_restart = self.menu.addAction('Restart Agent')
|
||||
self.menu.addSeparator()
|
||||
self.action_quit = self.menu.addAction('Quit Agent and Tray')
|
||||
|
||||
self.action_show_console.triggered.connect(self.switch_to_console)
|
||||
self.action_hide_console.triggered.connect(self.switch_to_background)
|
||||
self.action_restart.triggered.connect(self.restart_agent)
|
||||
self.action_quit.triggered.connect(self.quit_all)
|
||||
self.setContextMenu(self.menu)
|
||||
|
||||
self.proc = None
|
||||
self.console_mode = False
|
||||
# Start in background mode by default
|
||||
self.switch_to_background()
|
||||
self.show()
|
||||
|
||||
def _start_agent(self, console=False):
|
||||
self._stop_agent()
|
||||
exe = self.paths['python'] if console else self.paths['pythonw']
|
||||
args = [exe, '-W', 'ignore::SyntaxWarning', self.paths['agent_script']]
|
||||
creationflags = 0
|
||||
if not console and os.name == 'nt':
|
||||
# CREATE_NO_WINDOW
|
||||
creationflags = 0x08000000
|
||||
try:
|
||||
self.proc = subprocess.Popen(args, cwd=self.paths['borealis_dir'], creationflags=creationflags)
|
||||
self.console_mode = console
|
||||
self._update_actions(console)
|
||||
except Exception:
|
||||
self.proc = None
|
||||
|
||||
def _stop_agent(self):
|
||||
if self.proc is not None:
|
||||
try:
|
||||
if os.name == 'nt':
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
else:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self.proc = None
|
||||
|
||||
def _update_actions(self, console):
|
||||
self.action_show_console.setEnabled(not console)
|
||||
self.action_hide_console.setEnabled(console)
|
||||
|
||||
def switch_to_console(self):
|
||||
self._start_agent(console=True)
|
||||
|
||||
def switch_to_background(self):
|
||||
self._start_agent(console=False)
|
||||
|
||||
def restart_agent(self):
|
||||
# Restart using current mode
|
||||
self._start_agent(console=self.console_mode)
|
||||
|
||||
# Service controls removed in task-centric architecture
|
||||
|
||||
def quit_all(self):
|
||||
self._stop_agent()
|
||||
self.hide()
|
||||
self.app.quit()
|
||||
|
||||
|
||||
def main():
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
tray = TrayApp(app)
|
||||
return app.exec_()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,43 +0,0 @@
|
||||
# ---------------------- Information Gathering -----------------------
|
||||
import os
|
||||
from ahk import AHK
|
||||
|
||||
# Get the directory containing this script (cross-platform)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Build the path to the AutoHotkey binary (adjust filename if needed)
|
||||
ahk_bin_path = os.path.join(script_dir, 'AutoHotKey', 'AutoHotkey64.exe')
|
||||
|
||||
# ---------------------- Information Analysis ----------------------
|
||||
# Confirm that the AHK binary exists at the given path
|
||||
if not os.path.isfile(ahk_bin_path):
|
||||
raise FileNotFoundError(f"AutoHotkey binary not found at: {ahk_bin_path}")
|
||||
|
||||
# ---------------------- Information Processing ----------------------
|
||||
# Initialize AHK instance with explicit executable_path
|
||||
ahk = AHK(executable_path=ahk_bin_path)
|
||||
|
||||
window_title = 'New Tab - Google Chrome' # Change this to your target window
|
||||
|
||||
# Find the window by its title
|
||||
target_window = ahk.find_window(title=window_title)
|
||||
|
||||
if target_window is None:
|
||||
print(f"Window with title '{window_title}' not found.")
|
||||
else:
|
||||
# Bring the target window to the foreground
|
||||
target_window.activate()
|
||||
|
||||
# Wait briefly to ensure the window is focused
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
# Send keystrokes/text to the window
|
||||
text = "Hello from Python and AutoHotkey!"
|
||||
for c in text:
|
||||
ahk.send(c)
|
||||
import time
|
||||
time.sleep(0.05) # slow down for debugging
|
||||
ahk.send('{ENTER}')
|
||||
|
||||
print("Sent keystrokes to the window.")
|
||||
@@ -1,4 +0,0 @@
|
||||
#////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Experimental/Macros/macro-requirements.txt
|
||||
|
||||
# Macro Automation
|
||||
ahk # AutoHotKey
|
||||
Reference in New Issue
Block a user