diff --git a/README.md b/README.md index d75cd8f2..ef2f23a1 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ The bootstrap will: 5. Drop you into a first-run onboarding wizard inside the WebUI. > Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2. +> For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md). If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser. diff --git a/docs/wsl-autostart.md b/docs/wsl-autostart.md new file mode 100644 index 00000000..0ae9f89e --- /dev/null +++ b/docs/wsl-autostart.md @@ -0,0 +1,126 @@ +# Windows / WSL auto-start + +Hermes WebUI runs well under WSL2, but native Windows login does not automatically start Linux user processes. This guide covers two supported options: + +1. **WSL session startup** — simple and low-risk. WebUI starts the next time you open a WSL shell. +2. **Windows Task Scheduler** — true Windows logon startup. Windows invokes `wsl.exe`, which runs the WSL launch script. + +Both paths use the same WSL launch script: + +```text +scripts/wsl/hermes_webui_autostart.sh +``` + +The script is safe to call repeatedly. It uses a lock file, checks the `/health` endpoint, checks a pid file, and writes logs before starting `start.sh --foreground` in the background. It does not hardcode a user path; by default it derives the repository root from its own location. + +## Script settings + +The WSL launcher supports these environment variables: + +| Variable | Default | Purpose | +|---|---|---| +| `HERMES_WEBUI_REPO` | repo containing the script | WebUI checkout to start | +| `HERMES_WEBUI_LOG_DIR` | `$HOME/.hermes/webui/logs` | Autostart and WebUI logs | +| `HERMES_WEBUI_HOST` | `127.0.0.1` | Host passed through to `start.sh` / `bootstrap.py` | +| `HERMES_WEBUI_PORT` | `8787` | WebUI port and health-check port | +| `HERMES_WEBUI_HEALTH_URL` | `http://127.0.0.1:$HERMES_WEBUI_PORT/health` | URL used to decide whether WebUI is already running | +| `HERMES_WEBUI_PID_FILE` | `$HERMES_WEBUI_LOG_DIR/hermes-webui.pid` | pid file used for duplicate prevention | +| `HERMES_WEBUI_REQUIRE_AGENT_PROCESS` | `0` | Optional: set to `1` only if your local setup requires a separate Hermes process before WebUI starts | + +Make the script executable once inside WSL: + +```bash +cd /path/to/hermes-webui +chmod +x scripts/wsl/hermes_webui_autostart.sh +``` + +Run it manually to verify your paths and logs: + +```bash +scripts/wsl/hermes_webui_autostart.sh +curl -fsS http://127.0.0.1:8787/health +``` + +Logs are written to: + +```text +$HOME/.hermes/webui/logs/webui_autostart.log +$HOME/.hermes/webui/logs/hermes_webui.log +``` + +## Option 1: WSL session startup + +This starts WebUI when your WSL login shell starts. It is the easiest option if you already open WSL during your day. + +Add this to `~/.profile` or `~/.bashrc` inside WSL, adjusting the repo path: + +```bash +if [ -x "$HOME/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" ]; then + HERMES_WEBUI_REPO="$HOME/hermes-webui" \ + "$HOME/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" >/dev/null 2>&1 & +fi +``` + +Open a new WSL terminal and check: + +```bash +curl -fsS http://127.0.0.1:8787/health +``` + +If you open several WSL terminals, the launcher should still start only one WebUI process because the lock, health check, and pid file all converge on "already running". + +## Option 2: Windows Task Scheduler startup + +Use this if you want WebUI to start automatically at Windows logon even before you open a WSL terminal. + +The helper PowerShell script is: + +```text +scripts/windows/setup_webui_autostart.ps1 +``` + +From Windows PowerShell, run it with the WSL path to the launch script: + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\scripts\windows\setup_webui_autostart.ps1 ` + -WslScriptPath "/home/your-user/hermes-webui/scripts/wsl/hermes_webui_autostart.sh" ` + -Distro "Ubuntu" +``` + +Notes: + +- `-Distro` is optional. Omit it to use your default WSL distro. +- The default task name is `HermesWebUIAutoStart`; pass `-TaskName` if you need a different name. +- The script is idempotent: rerunning it updates the existing scheduled task instead of creating duplicates. +- The task runs as the current Windows user at logon with least privilege. +- Add `-WhatIf` to preview the scheduled task registration. +- Add `-RunNow` to start the task immediately after registration. +- Add `-SkipValidation` only if you need to register the task before the WSL path exists. + +To inspect or remove the task later: + +```powershell +Get-ScheduledTask -TaskName HermesWebUIAutoStart +Unregister-ScheduledTask -TaskName HermesWebUIAutoStart -Confirm:$false +``` + +## Troubleshooting + +Check the WSL logs first: + +```bash +tail -n 80 "$HOME/.hermes/webui/logs/webui_autostart.log" +tail -n 80 "$HOME/.hermes/webui/logs/hermes_webui.log" +``` + +Common causes: + +| Symptom | Likely cause | Fix | +|---|---|---| +| Task exists but WebUI is not reachable | WSL script path is wrong for the selected distro | Re-run the PowerShell setup with the correct `-WslScriptPath` and `-Distro` | +| WebUI starts only after opening WSL | You used the WSL session startup option, not Task Scheduler | Install the Windows scheduled task | +| Multiple login events happen quickly | Normal Windows startup behavior | The WSL script should log `already running` and avoid duplicate processes | +| Health check fails but pid exists | WebUI is still booting or the port differs | Check `HERMES_WEBUI_PORT` and `hermes_webui.log` | + +If you want WSL2 systemd integration instead, see `docs/supervisor.md` for foreground process-supervisor guidance and adapt the Linux `systemd --user` pattern to your distro. diff --git a/scripts/windows/setup_webui_autostart.ps1 b/scripts/windows/setup_webui_autostart.ps1 new file mode 100644 index 00000000..08b88949 --- /dev/null +++ b/scripts/windows/setup_webui_autostart.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$WslScriptPath, + + [string]$Distro, + + [ValidateNotNullOrEmpty()] + [string]$TaskName = "HermesWebUIAutoStart", + + [switch]$RunNow, + + [switch]$SkipValidation +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function ConvertTo-WindowsArgument { + param( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + if ($Value -notmatch '[\s\"]') { + return $Value + } + + $escaped = $Value.Replace('"', '\"') + return '"' + $escaped + '"' +} + +function Get-WslExePath { + $systemWsl = Join-Path $env:SystemRoot "System32\wsl.exe" + if (Test-Path $systemWsl) { + return $systemWsl + } + return "wsl.exe" +} + +$wslExe = Get-WslExePath + +$wslArgs = @() +if ($Distro) { + $wslArgs += @("-d", $Distro) +} +$wslArgs += @("--exec", "bash", $WslScriptPath) +$actionArguments = ($wslArgs | ForEach-Object { ConvertTo-WindowsArgument -Value $_ }) -join " " + +if (-not $SkipValidation) { + $validationArgs = @() + if ($Distro) { + $validationArgs += @("-d", $Distro) + } + $validationArgs += @("--exec", "test", "-f", $WslScriptPath) + + & $wslExe @validationArgs + if ($LASTEXITCODE -ne 0) { + throw "WSL script path was not found inside the selected distro: $WslScriptPath" + } +} + +$description = "Auto-start Hermes WebUI inside WSL at Windows logon. Runs $WslScriptPath." +$action = New-ScheduledTaskAction -Execute $wslExe -Argument $actionArguments +$trigger = New-ScheduledTaskTrigger -AtLogOn +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel LeastPrivilege +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew +$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + +if ($existingTask) { + Write-Host "Updating existing scheduled task '$TaskName'." +} else { + Write-Host "Creating scheduled task '$TaskName'." +} + +if ($PSCmdlet.ShouldProcess($TaskName, "Register Windows Scheduled Task for Hermes WebUI WSL autostart")) { + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description $description ` + -Force | Out-Null + + Write-Host "Task '$TaskName' is installed." + Write-Host "Action: $wslExe $actionArguments" + + if ($RunNow) { + Start-ScheduledTask -TaskName $TaskName + Write-Host "Task '$TaskName' started." + } +} diff --git a/scripts/wsl/hermes_webui_autostart.sh b/scripts/wsl/hermes_webui_autostart.sh new file mode 100755 index 00000000..90726c01 --- /dev/null +++ b/scripts/wsl/hermes_webui_autostart.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +# WSL-friendly autostart launcher for Hermes WebUI. +# +# Safe defaults: +# - derives the repo from this script location, override with HERMES_WEBUI_REPO +# - uses a lock + pid file to avoid duplicate starts +# - treats a healthy /health endpoint as "already running" +# - writes logs under ~/.hermes/webui/logs unless HERMES_WEBUI_LOG_DIR is set + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)" +HERMES_WEBUI_REPO="${HERMES_WEBUI_REPO:-${DEFAULT_REPO}}" +HERMES_WEBUI_LOG_DIR="${HERMES_WEBUI_LOG_DIR:-${HOME}/.hermes/webui/logs}" +HERMES_WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}" +HERMES_WEBUI_PORT="${HERMES_WEBUI_PORT:-8787}" +HERMES_WEBUI_HEALTH_HOST="${HERMES_WEBUI_HEALTH_HOST:-127.0.0.1}" +HERMES_WEBUI_HEALTH_URL="${HERMES_WEBUI_HEALTH_URL:-http://${HERMES_WEBUI_HEALTH_HOST}:${HERMES_WEBUI_PORT}/health}" +HERMES_WEBUI_PID_FILE="${HERMES_WEBUI_PID_FILE:-${HERMES_WEBUI_LOG_DIR}/hermes-webui.pid}" +HERMES_WEBUI_LOCK_FILE="${HERMES_WEBUI_LOCK_FILE:-/tmp/hermes-webui-autostart.lock}" +AUTOSTART_LOG="${HERMES_WEBUI_LOG_DIR}/webui_autostart.log" +WEBUI_LOG="${HERMES_WEBUI_LOG_DIR}/hermes_webui.log" + +# Make the WSL launcher knobs visible to start.sh/bootstrap.py. +export HERMES_WEBUI_HOST HERMES_WEBUI_PORT + +mkdir -p "${HERMES_WEBUI_LOG_DIR}" +chmod 700 "${HERMES_WEBUI_LOG_DIR}" 2>/dev/null || true + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" | tee -a "${AUTOSTART_LOG}" +} + +webui_healthy() { + command -v curl >/dev/null 2>&1 \ + && curl -fsS --max-time 3 "${HERMES_WEBUI_HEALTH_URL}" >/dev/null 2>&1 +} + +pid_is_alive() { + [[ -s "${HERMES_WEBUI_PID_FILE}" ]] || return 1 + local pid + pid="$(cat "${HERMES_WEBUI_PID_FILE}" 2>/dev/null || true)" + [[ "${pid}" =~ ^[0-9]+$ ]] || return 1 + kill -0 "${pid}" >/dev/null 2>&1 +} + +validate_repo() { + if [[ ! -d "${HERMES_WEBUI_REPO}" ]]; then + log "Hermes WebUI repo not found: ${HERMES_WEBUI_REPO}" + exit 1 + fi + if [[ ! -f "${HERMES_WEBUI_REPO}/start.sh" ]]; then + log "start.sh not found under HERMES_WEBUI_REPO=${HERMES_WEBUI_REPO}" + exit 1 + fi +} + +maybe_require_agent_process() { + # Hermes WebUI usually launches the agent in-process, so this check is opt-in. + # Set HERMES_WEBUI_REQUIRE_AGENT_PROCESS=1 only if your setup depends on a + # separately running Hermes gateway/agent before WebUI starts. + if [[ "${HERMES_WEBUI_REQUIRE_AGENT_PROCESS:-0}" != "1" ]]; then + return 0 + fi + if ! pgrep -f "hermes" >/dev/null 2>&1; then + log "HERMES_WEBUI_REQUIRE_AGENT_PROCESS=1 but no Hermes process is running; skipping start" + exit 1 + fi +} + +acquire_lock() { + exec 9>"${HERMES_WEBUI_LOCK_FILE}" + if command -v flock >/dev/null 2>&1; then + if ! flock -n 9; then + log "Autostart already running; lock held at ${HERMES_WEBUI_LOCK_FILE}" + exit 0 + fi + else + log "flock not found; continuing without lock-based duplicate protection" + fi +} + +start_webui() { + validate_repo + maybe_require_agent_process + + if webui_healthy; then + log "Hermes WebUI already running at ${HERMES_WEBUI_HEALTH_URL}" + exit 0 + fi + + if pid_is_alive; then + log "Hermes WebUI already running with pid $(cat "${HERMES_WEBUI_PID_FILE}")" + exit 0 + fi + + rm -f "${HERMES_WEBUI_PID_FILE}" + log "Starting Hermes WebUI from ${HERMES_WEBUI_REPO} on ${HERMES_WEBUI_HOST}:${HERMES_WEBUI_PORT}" + + ( + cd "${HERMES_WEBUI_REPO}" + nohup bash "${HERMES_WEBUI_REPO}/start.sh" --foreground >>"${WEBUI_LOG}" 2>&1 & + printf '%s\n' "$!" >"${HERMES_WEBUI_PID_FILE}" + ) + + sleep "${HERMES_WEBUI_STARTUP_GRACE_SECONDS:-2}" + if webui_healthy; then + log "Hermes WebUI started and passed health check" + exit 0 + fi + + if pid_is_alive; then + log "Hermes WebUI process started with pid $(cat "${HERMES_WEBUI_PID_FILE}"); health check not ready yet" + exit 0 + fi + + log "Hermes WebUI failed to stay running; see ${WEBUI_LOG}" + exit 1 +} + +acquire_lock +start_webui diff --git a/tests/test_issue513_wsl_autostart.py b/tests/test_issue513_wsl_autostart.py new file mode 100644 index 00000000..44e62473 --- /dev/null +++ b/tests/test_issue513_wsl_autostart.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOC = REPO_ROOT / "docs" / "wsl-autostart.md" +WSL_SCRIPT = REPO_ROOT / "scripts" / "wsl" / "hermes_webui_autostart.sh" +POWERSHELL_SCRIPT = REPO_ROOT / "scripts" / "windows" / "setup_webui_autostart.ps1" +README = REPO_ROOT / "README.md" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_wsl_autostart_docs_cover_session_and_task_scheduler_options(): + doc = _read(DOC) + readme = _read(README) + + assert "docs/wsl-autostart.md" in readme + assert "WSL session startup" in doc + assert "Windows Task Scheduler" in doc + assert "scripts/wsl/hermes_webui_autostart.sh" in doc + assert "scripts/windows/setup_webui_autostart.ps1" in doc + assert "HERMES_WEBUI_REPO" in doc + assert "HERMES_WEBUI_LOG_DIR" in doc + assert "HERMES_WEBUI_REQUIRE_AGENT_PROCESS" in doc + assert "/root" not in doc + assert "C:\\Users\\Michael" not in doc + + +def test_wsl_autostart_launcher_has_safe_duplicate_prevention_and_exports_runtime_env(): + script = _read(WSL_SCRIPT) + + assert script.startswith("#!/usr/bin/env bash\n") + assert "set -euo pipefail" in script + assert "flock -n" in script + assert "HERMES_WEBUI_LOCK_FILE" in script + assert "HERMES_WEBUI_PID_FILE" in script + assert "curl -fsS --max-time 3" in script + assert "bash \"${HERMES_WEBUI_REPO}/start.sh\" --foreground" in script + assert "nohup" in script + + # The launcher documents HERMES_WEBUI_HOST/PORT as runtime knobs; they must + # be exported so bootstrap.py/server.py receive the selected WSL values. + assert re.search(r"^export HERMES_WEBUI_HOST HERMES_WEBUI_PORT$", script, re.MULTILINE) + + assert "/root" not in script + assert "/home/michael" not in script + + +def test_wsl_autostart_launcher_passes_bash_syntax_check(): + subprocess.run(["bash", "-n", str(WSL_SCRIPT)], check=True, cwd=REPO_ROOT) + + +def test_windows_task_scheduler_helper_is_idempotent_and_validates_wsl_script_path(): + script = _read(POWERSHELL_SCRIPT) + + assert "[CmdletBinding(SupportsShouldProcess = $true)]" in script + assert "Register-ScheduledTask" in script + assert "-Force" in script + assert "New-ScheduledTaskSettingsSet" in script + assert "-MultipleInstances IgnoreNew" in script + assert "Get-ScheduledTask -TaskName $TaskName" in script + assert "wsl.exe" in script + assert '"--exec", "bash", $WslScriptPath' in script + assert '"--exec", "test", "-f", $WslScriptPath' in script + assert "Start-ScheduledTask -TaskName $TaskName" in script + assert "/root" not in script + assert "C:\\Users\\Michael" not in script + + +def test_powershell_helper_passes_parser_when_pwsh_is_available(): + pwsh = None + for candidate in ("pwsh", "powershell"): + result = subprocess.run(["bash", "-lc", f"command -v {candidate}"], capture_output=True, text=True) + if result.returncode == 0: + pwsh = result.stdout.strip() + break + if not pwsh: + # Linux CI often does not include PowerShell. The source-string tests + # above still pin the safety/idempotency invariants in that case. + return + + subprocess.run( + [pwsh, "-NoProfile", "-Command", f"$null = [scriptblock]::Create((Get-Content -Raw '{POWERSHELL_SCRIPT.as_posix()}'))"], + check=True, + cwd=REPO_ROOT, + )