## Description Windows `persistent-task` created its startup and 5-minute health tasks via `schtasks` command-line flags, which register the task with an **interactive-token** principal. Every task run spawned a visible console window that briefly grabbed keyboard focus before vanishing — every 5 minutes, indefinitely (and at boot / proxy restart). Fixes #2453. This registers the tasks from Task Scheduler **XML** instead: user-scope tasks use an **S4U** principal (run whether the user is logged on or not, no stored password) with `<Hidden>true</Hidden>`, so runs execute in a non-interactive session and never draw a window. System-scope tasks keep the LocalSystem service account (which already has no desktop). ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ## Changes Made - `headroom/install/supervisors.py`: add `_windows_task_xml()` (S4U/hidden for user scope, LocalSystem for system scope), `_windows_boot_trigger()`, `_windows_health_trigger()` (PT5M repetition), and `_register_windows_task()` (writes UTF-16 XML to a temp file and calls `schtasks /Create /TN <n> /XML <file> /F`). Rewrite the Windows TASK branch of `install_supervisor` to register both tasks from XML. - `tests/test_install/test_supervisors.py`: unit tests asserting the XML carries `S4U` + `Hidden` + `PT5M` for user scope and `S-1-5-18` / `ServiceAccount` for system scope; updated the install-flow assertion to expect `schtasks /XML` registration for the startup and health tasks. ## Testing - [x] Unit tests pass ``` $ python -m pytest tests/test_install/test_supervisors.py -q collected 29 items tests\test_install\test_supervisors.py ............................. [100%] ============================= 29 passed in 1.48s ============================== ``` ## Real Behavior Proof - Environment: Windows 11 Pro 10.0.26200, Python 3.13.11 - Exact command / steps: python -m pytest tests/test_install/test_supervisors.py -q; ruff check + ruff format --check; mypy headroom/install/supervisors.py --ignore-missing-imports - Observed result: 29 passed; ruff clean; mypy exit 0. Generated XML contains <LogonType>S4U</LogonType> and <Hidden>true</Hidden> for user scope. - Not tested: live end-to-end `headroom install apply --preset persistent-task` on a physical desktop confirming zero console flash over a >5-minute window (no interactive Windows session in CI). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+108
-27
@@ -2,13 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
import click
|
||||
|
||||
@@ -242,6 +246,97 @@ def _linux_task_spec(manifest: DeploymentManifest, ensure_script: Path) -> tuple
|
||||
return None, content
|
||||
|
||||
|
||||
def _windows_current_user() -> str:
|
||||
"""Best-effort ``DOMAIN\\USER`` for the S4U task principal."""
|
||||
|
||||
user = os.environ.get("USERNAME") or getpass.getuser()
|
||||
domain = os.environ.get("USERDOMAIN")
|
||||
return f"{domain}\\{user}" if domain else user
|
||||
|
||||
|
||||
def _windows_task_xml(command: str, *, trigger_xml: str, scope: str) -> str:
|
||||
"""Render Task Scheduler XML that runs ``command`` without a visible window.
|
||||
|
||||
User-scope tasks use an S4U principal ("run whether user is logged on or
|
||||
not", no stored password) so each run happens in a non-interactive session
|
||||
and never draws a console window (issue #2453). System-scope tasks keep the
|
||||
LocalSystem service account, which already has no desktop.
|
||||
"""
|
||||
|
||||
if scope == "system":
|
||||
principal = (
|
||||
" <UserId>S-1-5-18</UserId>\n"
|
||||
" <LogonType>ServiceAccount</LogonType>\n"
|
||||
" <RunLevel>HighestAvailable</RunLevel>"
|
||||
)
|
||||
else:
|
||||
principal = (
|
||||
f" <UserId>{_xml_escape(_windows_current_user())}</UserId>\n"
|
||||
" <LogonType>S4U</LogonType>\n"
|
||||
" <RunLevel>LeastPrivilege</RunLevel>"
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-16"?>\n'
|
||||
'<Task version="1.2" '
|
||||
'xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
|
||||
" <Triggers>\n"
|
||||
f"{trigger_xml}\n"
|
||||
" </Triggers>\n"
|
||||
' <Principals>\n <Principal id="Author">\n'
|
||||
f"{principal}\n"
|
||||
" </Principal>\n </Principals>\n"
|
||||
" <Settings>\n"
|
||||
" <Hidden>true</Hidden>\n"
|
||||
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
|
||||
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
|
||||
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
|
||||
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
|
||||
" <StartWhenAvailable>true</StartWhenAvailable>\n"
|
||||
" </Settings>\n"
|
||||
' <Actions Context="Author">\n'
|
||||
f" <Exec>\n <Command>{_xml_escape(command)}</Command>\n </Exec>\n"
|
||||
" </Actions>\n"
|
||||
"</Task>\n"
|
||||
)
|
||||
|
||||
|
||||
def _windows_boot_trigger() -> str:
|
||||
return " <BootTrigger>\n <Enabled>true</Enabled>\n </BootTrigger>"
|
||||
|
||||
|
||||
def _windows_health_trigger() -> str:
|
||||
start = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
return (
|
||||
" <TimeTrigger>\n"
|
||||
f" <StartBoundary>{start}</StartBoundary>\n"
|
||||
" <Enabled>true</Enabled>\n"
|
||||
" <Repetition>\n"
|
||||
" <Interval>PT5M</Interval>\n"
|
||||
" <StopAtDurationEnd>false</StopAtDurationEnd>\n"
|
||||
" </Repetition>\n"
|
||||
" </TimeTrigger>"
|
||||
)
|
||||
|
||||
|
||||
def _register_windows_task(name: str, xml: str) -> None:
|
||||
"""Register ``xml`` as scheduled task ``name`` via ``schtasks /XML``."""
|
||||
|
||||
# schtasks reads the XML from a file; UTF-16 matches the declared encoding.
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".xml", encoding="utf-16", delete=False)
|
||||
try:
|
||||
tmp.write(xml)
|
||||
tmp.close()
|
||||
subprocess.run(
|
||||
["schtasks", "/Create", "/TN", name, "/XML", tmp.name, "/F"],
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
|
||||
"""Install service/task artifacts for the deployment."""
|
||||
|
||||
@@ -345,35 +440,21 @@ def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
|
||||
startup_name = f"{manifest.service_name}-startup"
|
||||
health_name = f"{manifest.service_name}-health"
|
||||
startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
|
||||
user_args = ["/RU", "SYSTEM"] if manifest.scope == "system" else []
|
||||
start_schedule = [
|
||||
"schtasks",
|
||||
"/Create",
|
||||
"/TN",
|
||||
# Register from task XML (not schtasks flags) so the principal is S4U /
|
||||
# hidden — flag-created tasks use an interactive token and flash a
|
||||
# focus-stealing console on every run (issue #2453).
|
||||
_register_windows_task(
|
||||
startup_name,
|
||||
"/TR",
|
||||
startup_cmd,
|
||||
"/SC",
|
||||
"ONSTART",
|
||||
"/F",
|
||||
*user_args,
|
||||
]
|
||||
health_schedule = [
|
||||
"schtasks",
|
||||
"/Create",
|
||||
"/TN",
|
||||
_windows_task_xml(
|
||||
startup_cmd, trigger_xml=_windows_boot_trigger(), scope=manifest.scope
|
||||
),
|
||||
)
|
||||
_register_windows_task(
|
||||
health_name,
|
||||
"/TR",
|
||||
startup_cmd,
|
||||
"/SC",
|
||||
"MINUTE",
|
||||
"/MO",
|
||||
"5",
|
||||
"/F",
|
||||
*user_args,
|
||||
]
|
||||
subprocess.run(start_schedule, check=True)
|
||||
subprocess.run(health_schedule, check=True)
|
||||
_windows_task_xml(
|
||||
startup_cmd, trigger_xml=_windows_health_trigger(), scope=manifest.scope
|
||||
),
|
||||
)
|
||||
records.extend(
|
||||
[
|
||||
ArtifactRecord(kind="windows-task", path=startup_name),
|
||||
|
||||
@@ -13,6 +13,9 @@ from headroom.install.supervisors import (
|
||||
_macos_launchd_plist,
|
||||
_render_unix_runner,
|
||||
_render_windows_runner,
|
||||
_windows_boot_trigger,
|
||||
_windows_health_trigger,
|
||||
_windows_task_xml,
|
||||
install_supervisor,
|
||||
remove_supervisor,
|
||||
render_runner_scripts,
|
||||
@@ -21,6 +24,31 @@ from headroom.install.supervisors import (
|
||||
)
|
||||
|
||||
|
||||
def test_windows_task_xml_user_scope_is_hidden_s4u() -> None:
|
||||
# #2453: user-scope tasks must run S4U (non-interactive, no window) and
|
||||
# hidden so the 5-minute health run never steals keyboard focus.
|
||||
xml = _windows_task_xml(
|
||||
"C:\\tmp\\default\\ensure-headroom.cmd",
|
||||
trigger_xml=_windows_health_trigger(),
|
||||
scope="user",
|
||||
)
|
||||
assert "<LogonType>S4U</LogonType>" in xml
|
||||
assert "<Hidden>true</Hidden>" in xml
|
||||
assert "<Interval>PT5M</Interval>" in xml
|
||||
assert "<Command>C:\\tmp\\default\\ensure-headroom.cmd</Command>" in xml
|
||||
|
||||
|
||||
def test_windows_task_xml_system_scope_uses_localsystem() -> None:
|
||||
xml = _windows_task_xml(
|
||||
"C:\\tmp\\default\\ensure-headroom.cmd",
|
||||
trigger_xml=_windows_boot_trigger(),
|
||||
scope="system",
|
||||
)
|
||||
assert "<UserId>S-1-5-18</UserId>" in xml
|
||||
assert "<LogonType>ServiceAccount</LogonType>" in xml
|
||||
assert "<BootTrigger>" in xml
|
||||
|
||||
|
||||
def _manifest(
|
||||
*,
|
||||
profile: str = "default",
|
||||
@@ -377,19 +405,16 @@ def test_install_supervisor_darwin_windows_and_unsupported(monkeypatch, tmp_path
|
||||
"sc.exe create headroom-default "
|
||||
'binPath= "cmd.exe /c \\"C:\\tmp\\default\\run-headroom.cmd\\"" start= auto'
|
||||
) in calls
|
||||
assert [
|
||||
"schtasks",
|
||||
"/Create",
|
||||
"/TN",
|
||||
"headroom-default-health",
|
||||
"/TR",
|
||||
"C:\\tmp\\default\\ensure-headroom.cmd",
|
||||
"/SC",
|
||||
"MINUTE",
|
||||
"/MO",
|
||||
"5",
|
||||
"/F",
|
||||
] in calls
|
||||
# #2453: tasks are registered from S4U/hidden XML via `schtasks /XML`, not
|
||||
# interactive-token flag creation. Assert the startup and health tasks are
|
||||
# each created from an XML file (the temp path varies).
|
||||
task_creates = [
|
||||
c for c in calls if isinstance(c, list) and c[:2] == ["schtasks", "/Create"] and "/XML" in c
|
||||
]
|
||||
created_names = {c[c.index("/TN") + 1] for c in task_creates}
|
||||
assert {"headroom-default-startup", "headroom-default-health"} <= created_names
|
||||
for c in task_creates:
|
||||
assert c[-1] == "/F"
|
||||
|
||||
monkeypatch.setattr("headroom.install.supervisors.sys.platform", "plan9")
|
||||
monkeypatch.setattr("headroom.install.supervisors.sys.platform", "plan9")
|
||||
|
||||
Reference in New Issue
Block a user