diff --git a/docs/Test-config-behavior.md b/docs/Test-config-behavior.md index 053c91f2..844a7c03 100644 --- a/docs/Test-config-behavior.md +++ b/docs/Test-config-behavior.md @@ -17,6 +17,7 @@ Test configuration has the following options: - **desc** - String description of the command - **host_run** - Optional string command to run on the host - **guest_run** - Optional string command to run on each client VM +- **guest_run_interactive** - Optional boolean (default: `false`). When `true`, `guest_run` is executed in the interactive desktop session (Session 1) via a UI agent instead of WinRM (Session 0). Useful for commands that need UI access. The command is synchronous with a default timeout of 120 seconds. - **guest_reboot** - This option is only applicable to post-start commands and is ignored for pre and post test commands. It controls whether the guest VM should be rebooted after certain operations, but has no effect in this context. - **files_action** - Array of file operations to perform @@ -33,12 +34,12 @@ Test configuration has the following options: Test configuration is executed in the following order: 1. **Pre test commands** - 1. Guest command is executed on each client VM + 1. Guest command is executed on each client VM (via WinRM or interactive session depending on `guest_run_interactive`) 2. Host command is executed on the host 3. Files action is executed 2. **Test execution** 3. **Post test commands** - 1. Guest command is executed on each client VM + 1. Guest command is executed on each client VM (via WinRM or interactive session depending on `guest_run_interactive`) 2. Host command is executed on the host 3. Files action is executed diff --git a/lib/all.rb b/lib/all.rb index b87e95ad..e5fc20bb 100644 --- a/lib/all.rb +++ b/lib/all.rb @@ -78,4 +78,5 @@ module AutoHCK autoload_relative :Tests, 'engines/hcktest/tests' autoload_relative :Tools, 'engines/hcktest/tools' autoload_relative :Trap, 'trap' + autoload_relative :UIExecutor, 'auxiliary/ui_agent/ui_executor' end diff --git a/lib/auxiliary/ui_agent/auto_hck_ui_agent.ps1 b/lib/auxiliary/ui_agent/auto_hck_ui_agent.ps1 new file mode 100644 index 00000000..ee2a1c8b --- /dev/null +++ b/lib/auxiliary/ui_agent/auto_hck_ui_agent.ps1 @@ -0,0 +1,115 @@ +# UIAgent - Runs in the interactive Windows session (Session 1). +# Watches a queue directory for command files, executes them with +# full desktop access, and writes JSON results. +# +# Communication protocol: +# Request: C:\UIAgent\queue\.ps1 (PowerShell script to execute) +# Response: C:\UIAgent\queue\.json (JSON with exit_code, stdout, stderr) +# The agent deletes the .ps1 after execution. + +$ErrorActionPreference = 'Stop' + +$QueueDir = 'C:\UIAgent\queue' +$LogFile = 'C:\UIAgent\agent.log' +$PollIntervalMs = 500 + +function Write-AgentLog { + param([string]$Message) + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $line = "[$ts] $Message" + Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue +} + +function Initialize-Agent { + if (-not (Test-Path $QueueDir)) { + New-Item -ItemType Directory -Path $QueueDir -Force | Out-Null + } + if (-not (Test-Path (Split-Path $LogFile))) { + New-Item -ItemType Directory -Path (Split-Path $LogFile) -Force | Out-Null + } + + # Clean up stale requests from a previous boot + Get-ChildItem -Path $QueueDir -Filter '*.ps1' -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + + Write-AgentLog "Agent started in session $([System.Diagnostics.Process]::GetCurrentProcess().SessionId)" + + # Signal to the host that initialization is complete and it's safe to submit commands + [System.IO.File]::WriteAllText('C:\UIAgent\agent.ready', (Get-Date -Format 'o')) +} + +# Runs a queued .ps1 in a child process, captures output, and writes +# a .json result that the host (UIExecutor) polls for. +function Invoke-QueuedCommand { + param([System.IO.FileInfo]$ScriptFile) + + $id = [System.IO.Path]::GetFileNameWithoutExtension($ScriptFile.Name) + $resultPath = Join-Path $QueueDir "$id.json" + + Write-AgentLog "Executing command: $id" + + $stdout = '' + $stderr = '' + $exitCode = 0 + + try { + $prevPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $ScriptFile.FullName 2>&1 + $ErrorActionPreference = $prevPref + + # Separate normal output from error records. + $stdoutLines = @() + $stderrLines = @() + foreach ($line in $output) { + if ($line -is [System.Management.Automation.ErrorRecord]) { + $stderrLines += $line.ToString() + } else { + $stdoutLines += $line.ToString() + } + } + $stdout = $stdoutLines -join "`n" + $stderr = $stderrLines -join "`n" + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } + catch { + $stderr = $_.Exception.Message + $exitCode = 1 + } + + $result = @{ + id = $id + exit_code = $exitCode + stdout = $stdout + stderr = $stderr + completed = (Get-Date -Format 'o') + } | ConvertTo-Json -Depth 5 + + try { + [System.IO.File]::WriteAllText($resultPath, $result) + Write-AgentLog "Command $id finished with exit code $exitCode" + } + finally { + Remove-Item -Path $ScriptFile.FullName -Force -ErrorAction SilentlyContinue + } +} + +# --- Main loop: poll the queue directory for new .ps1 requests --- +Initialize-Agent + +while ($true) { + try { + $scripts = Get-ChildItem -Path $QueueDir -Filter '*.ps1' -ErrorAction SilentlyContinue | + Sort-Object CreationTime + + foreach ($script in $scripts) { + Invoke-QueuedCommand -ScriptFile $script + } + } + catch { + Write-AgentLog "Error in main loop: $_" + } + + Start-Sleep -Milliseconds $PollIntervalMs +} diff --git a/lib/auxiliary/ui_agent/ui_executor.rb b/lib/auxiliary/ui_agent/ui_executor.rb new file mode 100644 index 00000000..c157bc57 --- /dev/null +++ b/lib/auxiliary/ui_agent/ui_executor.rb @@ -0,0 +1,278 @@ +# typed: false +# frozen_string_literal: true + +module AutoHCK + # Bridges WinRM (Session 0) to the interactive desktop (Session 1). + # + # WinRM runs in a non-interactive service session and cannot touch + # the UI. This class deploys a PowerShell agent into the user's + # desktop session via a scheduled task with LogonType Interactive, + # then communicates through a filesystem queue: + # + # 1. Host writes a command to C:\UIAgent\queue\.ps1 + # 2. Agent executes it and writes C:\UIAgent\queue\.json + # 3. Host polls for the .json result and cleans up + # + # If the agent dies (e.g. after a VM reboot that changes the + # interactive user), it is automatically redeployed on next use. + class UIExecutor + AGENT_DIR = 'C:\\UIAgent' + QUEUE_DIR = "#{AGENT_DIR}\\queue".freeze + AGENT_PATH = "#{AGENT_DIR}\\auto_hck_ui_agent.ps1".freeze + READY_PATH = "#{AGENT_DIR}\\agent.ready".freeze + LOG_PATH = "#{AGENT_DIR}\\agent.log".freeze + TASK_NAME = 'AutoHCK_UIAgent' + LOCAL_AGENT = File.join(__dir__, 'auto_hck_ui_agent.ps1') + + DEFAULT_TIMEOUT = 120 + POLL_INTERVAL = 2 + DEPLOY_VERIFY_RETRIES = 10 + DEPLOY_VERIFY_INTERVAL = 3 + + class DeployError < AutoHCKError; end + class CommandTimeoutError < AutoHCKError; end + class CommandError < AutoHCKError; end + + def initialize(tools, machine, logger, username) + @tools = tools + @machine = machine + @logger = logger + @username = username + @deployed = false + end + + # Deploys the agent if not already running + def deploy + if agent_alive? + @logger.info("UI agent already running on #{@machine}, skipping deploy") + @deployed = true + return + end + + @logger.info("Deploying UI agent on #{@machine}") + create_directories + upload_agent + register_startup_task + remove_stale_ready_marker + start_agent + verify_agent_running + @deployed = true + @logger.info("UI agent deployed and running on #{@machine}") + rescue StandardError => e + raise DeployError, "Failed to deploy UI agent on #{@machine}: #{e.message}" + end + + # Executes a PowerShell command in the interactive session. + # Deploys the agent on first call if not already running. + # Checks agent liveness before each command and redeploys if dead. + # If the agent dies during a command, the timeout catches it and + # redeploys once before raising. + def run(command, timeout: DEFAULT_TIMEOUT) + deploy unless @deployed + + unless agent_alive? + @logger.warn("UI agent appears dead on #{@machine}, redeploying before command") + @deployed = false + deploy + end + + execute_command(command, timeout) + rescue CommandTimeoutError + raise if agent_alive? + + @logger.warn("UI agent died during command on #{@machine}, redeploying and retrying") + @deployed = false + deploy + execute_command(command, timeout) + end + + def teardown + @logger.info("Tearing down UI agent on #{@machine}") + @tools.run_on_machine( + @machine, + 'Stop UI agent task', + "schtasks /Delete /TN #{TASK_NAME} /F" + ) + @deployed = false + rescue StandardError => e + @logger.warn("Failed to tear down UI agent on #{@machine}: #{e.message}") + end + + def agent_log + @tools.run_on_machine( + @machine, + 'Read UI agent log', + "Get-Content '#{LOG_PATH}' -ErrorAction SilentlyContinue" + ) + end + + private + + def create_directories + @tools.run_on_machine( + @machine, + 'Create UI agent directories', + "New-Item -ItemType Directory -Path '#{QUEUE_DIR}' -Force" + ) + end + + def upload_agent + @tools.upload_to_machine(@machine, LOCAL_AGENT, AGENT_PATH) + end + + def interactive_user + user = @tools.run_on_machine( + @machine, + 'Detect interactive session user', + '(Get-WmiObject -Class Win32_ComputerSystem).UserName' + ).to_s.strip + @logger.debug("Interactive session user on #{@machine}: '#{user}'") + user.empty? ? @username : user.split('\\').last + end + + # Interactive logon type places the process in Session 1 (desktop). + # The AtLogOn trigger re-launches the agent after VM reboots. + def register_startup_task + ps_cmd = <<~PS.gsub("\n", ' ').strip + $action = New-ScheduledTaskAction + -Execute 'powershell.exe' + -Argument '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File #{AGENT_PATH}'; + $trigger = New-ScheduledTaskTrigger -AtLogOn; + $principal = New-ScheduledTaskPrincipal + -UserId '#{interactive_user}' + -LogonType Interactive + -RunLevel Highest; + Register-ScheduledTask + -TaskName '#{TASK_NAME}' + -Action $action + -Trigger $trigger + -Principal $principal + -Force + PS + @tools.run_on_machine(@machine, 'Register UI agent scheduled task', ps_cmd) + end + + def start_agent + @tools.run_on_machine( + @machine, + 'Start UI agent task', + "schtasks /Run /TN #{TASK_NAME}" + ) + end + + # Checks ready marker, then process command line, then task status. + def agent_alive? + return false unless @tools.exists_on_machine?(@machine, READY_PATH) + + check = @tools.run_on_machine( + @machine, + 'Check UI agent process', + "Get-WmiObject Win32_Process -Filter \"Name='powershell.exe'\" | " \ + "Where-Object { $_.CommandLine -like '*auto_hck_ui_agent.ps1*' } | " \ + "Select-Object -First 1 | ForEach-Object { 'agent_running' }" + ) + return true if check.to_s.include?('agent_running') + + status = @tools.run_on_machine( + @machine, + 'Check UI agent task status', + "schtasks /Query /TN #{TASK_NAME} /FO CSV" + ) + rows = CSV.parse(status.to_s.strip, headers: true) + rows.each { |row| @logger.info("schtasks row: #{row.to_h}") } + rows.any? { |row| row['Status']&.strip == 'Running' } + rescue StandardError + false + end + + def remove_stale_ready_marker + @tools.run_on_machine( + @machine, + 'Remove stale agent ready marker', + "if (Test-Path '#{READY_PATH}') { Remove-Item -Path '#{READY_PATH}' -Force }" + ) + end + + # Polls for the ready marker that auto_hck_ui_agent.ps1 creates after init. + def verify_agent_running + DEPLOY_VERIFY_RETRIES.times do + if @tools.exists_on_machine?(@machine, READY_PATH) + @logger.info("UI agent ready marker found on #{@machine}") + return + end + + @logger.debug("Waiting for UI agent ready marker on #{@machine}...") + sleep DEPLOY_VERIFY_INTERVAL + end + raise DeployError, "UI agent did not become ready on #{@machine} after verification" + end + + def execute_command(command, timeout) + id = "cmd_#{SecureRandom.hex(8)}" + script_path = "#{QUEUE_DIR}\\#{id}.ps1" + result_path = "#{QUEUE_DIR}\\#{id}.json" + + submit_command(id, command, script_path) + result = wait_for_result(id, result_path, timeout) + log_result(id, result) + result + ensure + cleanup_result(result_path) + end + + # Drops a .ps1 into the queue; the agent picks it up automatically. + def submit_command(id, command, script_path) + @logger.info("Submitting UI command #{id} on #{@machine}") + escaped = command.gsub("'", "''") + @tools.run_on_machine( + @machine, + "Submit UI command #{id}", + "[System.IO.File]::WriteAllText('#{script_path}', '#{escaped}')" + ) + end + + # Polls until the agent writes the .json result or timeout expires. + def wait_for_result(id, result_path, timeout) + deadline = Time.now + timeout + loop do + if Time.now > deadline + raise CommandTimeoutError, + "UI command #{id} on #{@machine} timed out after #{timeout}s" + end + + if @tools.exists_on_machine?(@machine, result_path) + raw = @tools.run_on_machine( + @machine, + "Read UI result #{id}", + "Get-Content '#{result_path}' -Raw" + ) + begin + return JSON.parse(raw) + rescue JSON::ParserError + next + end + end + + sleep POLL_INTERVAL + end + end + + def cleanup_result(result_path) + @tools.delete_on_machine(@machine, result_path) + rescue StandardError => e + @logger.warn("Failed to clean up result file #{result_path}: #{e.message}") + end + + def log_result(id, result) + exit_code = result['exit_code'] + if exit_code.zero? + @logger.info("UI command #{id} succeeded on #{@machine} (exit_code=0)") + else + @logger.warn("UI command #{id} failed on #{@machine} (exit_code=#{exit_code})") + end + + @logger.debug("UI command #{id} stdout: #{result['stdout']}") unless result['stdout'].to_s.empty? + @logger.debug("UI command #{id} stderr: #{result['stderr']}") unless result['stderr'].to_s.empty? + end + end +end diff --git a/lib/engines/hcktest/tests.rb b/lib/engines/hcktest/tests.rb index 3affb7e4..48984b30 100644 --- a/lib/engines/hcktest/tests.rb +++ b/lib/engines/hcktest/tests.rb @@ -38,6 +38,7 @@ def initialize(client, support, project, target, tools) @test_results = [] @results_template = ERB.new(File.read('lib/templates/report.html.erb')) @replacement_map = ReplacementMap.new({ '@workspace@' => @project.workspace_path }) + @ui_executors = {} end sig { params(updated_tests: T::Array[Models::HLK::Test]).returns(T::Array[Models::HLK::Test]) } @@ -203,8 +204,31 @@ def run_command_on_client(client, command, desc, replacement) def run_guest_test_command(command, replacement) return unless command.guest_run - run_command_on_client(@client, command.guest_run, command.desc, replacement) - run_command_on_client(@support, command.guest_run, command.desc, replacement) unless @support.nil? + if command.guest_run_interactive + run_interactive_command_on_client(@client, command.guest_run, command.desc, replacement) + run_interactive_command_on_client(@support, command.guest_run, command.desc, replacement) unless @support.nil? + else + run_command_on_client(@client, command.guest_run, command.desc, replacement) + run_command_on_client(@support, command.guest_run, command.desc, replacement) unless @support.nil? + end + end + + def client_ui_executor(client) + @ui_executors[client.name] ||= UIExecutor.new(@tools, client.name, @logger, @project.config['windows_username']) + end + + def run_interactive_command_on_client(client, command, desc, replacement) + @logger.info("Running command in an interactive session (#{desc}) on client #{client.name}") + updated_command = client.replacement_map.merge(@replacement_map).merge(replacement).replace(command) + @logger.debug("Running interactive session command after replacement (#{desc}) " \ + "on client #{client.name}: #{updated_command}") + + result = client_ui_executor(client).run(updated_command) + return if result['exit_code'].zero? + + raise UIExecutor::CommandError, + "Interactive session command (#{desc}) on #{client.name} failed " \ + "(exit_code=#{result['exit_code']}): #{result['stderr']}" end def run_host_test_command(command) diff --git a/lib/models/command_info.rb b/lib/models/command_info.rb index db2bd94f..8b485881 100644 --- a/lib/models/command_info.rb +++ b/lib/models/command_info.rb @@ -51,6 +51,7 @@ class CommandInfo < T::Struct const :desc, String const :host_run, T.nilable(String) const :guest_run, T.nilable(String) + const :guest_run_interactive, T::Boolean, default: false const :guest_reboot, T::Boolean, default: false const :files_action, T::Array[FileActionConfig], default: [] end diff --git a/lib/setupmanagers/hckclient.rb b/lib/setupmanagers/hckclient.rb index fbe8a63c..30b3af16 100644 --- a/lib/setupmanagers/hckclient.rb +++ b/lib/setupmanagers/hckclient.rb @@ -81,13 +81,29 @@ def post_start_commands end def run_post_start_commands + @ui_executor = nil post_start_commands&.each do |command| desc = command.desc - @logger.info("Running command (#{desc}) on client #{@name}") - updated_command = @replacement_map.create_cmd(command.guest_run) - @logger.debug("Running command after replacement (#{desc}) on client #{@name}: #{updated_command}") - @tools.run_on_machine(@name, desc, updated_command) + if command.guest_run_interactive + @logger.info("Running command in an interactive session (#{desc}) on client #{@name}") + updated_command = @replacement_map.replace(command.guest_run) + @logger.debug("Running interactive session command after replacement (#{desc}) " \ + "on client #{@name}: #{updated_command}") + @ui_executor ||= UIExecutor.new(@tools, @name, @logger, @project.config['windows_username']) + result = @ui_executor.run(updated_command) + unless result['exit_code'].zero? + raise ClientRunError, + "Interactive session command (#{desc}) on #{@name} failed " \ + "(exit_code=#{result['exit_code']}): #{result['stderr']}" + end + else + @logger.info("Running command (#{desc}) on client #{@name}") + updated_command = @replacement_map.create_cmd(command.guest_run) + @logger.debug("Running command after replacement (#{desc}) on client #{@name}: #{updated_command}") + @tools.run_on_machine(@name, desc, updated_command) + end + next unless command.guest_reboot @logger.info("Rebooting client #{@name} after command (#{desc})")