diff --git a/agent/install-windows.ps1 b/agent/install-windows.ps1 index f37b1ed..44b05e2 100644 --- a/agent/install-windows.ps1 +++ b/agent/install-windows.ps1 @@ -6,20 +6,26 @@ .DESCRIPTION Installs JARVIS Agent as a Windows Service that auto-starts at boot. Requires: PowerShell 5.1+, internet access, and Administrator rights. + No Python installation needed — this installs the standalone .exe build. .EXAMPLE # Interactive install (prompts for registration key): - irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex # Silent install with key: - $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex #> $ErrorActionPreference = 'Stop' -$JARVIS_URL = 'https://jarvis.orbishosting.com' +# Fixed 2026-07-07: jarvis.orbishosting.com on the default port (80/443) is not +# reachable from outside the LAN at all (no FortiGate VIP forwards it) — every +# external install using the old default URL would have failed outright. Port +# 1972 is the confirmed-working external path (same fix applied to the GitHub +# webhook the same day). +$JARVIS_URL = 'http://jarvis.orbishosting.com:1972' $INSTALL_DIR = 'C:\ProgramData\jarvis-agent' $SERVICE_NAME = 'JARVISAgent' -$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py" +$AGENT_EXE = "$INSTALL_DIR\jarvis-agent-windows.exe" $CONFIG_FILE = "$INSTALL_DIR\config.json" function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan } @@ -39,49 +45,23 @@ if ($existing) { Start-Sleep 2 } try { - & python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null + if (Test-Path $AGENT_EXE) { & $AGENT_EXE remove 2>$null } } catch {} Write-OK "Existing service removed." } -# ── Check / install Python ──────────────────────────────────────────────────── -Write-Step "Checking Python..." -$py = Get-Command python -ErrorAction SilentlyContinue -if (-not $py) { - Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run." - } - winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements - $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User") - $py = Get-Command python -ErrorAction SilentlyContinue - if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" } -} -$pyVersion = & python --version 2>&1 -Write-OK $pyVersion - -# ── Install pywin32 ─────────────────────────────────────────────────────────── -Write-Step "Checking pywin32..." -$checkWin32 = & python -c "import win32service; print('ok')" 2>&1 -if ($checkWin32 -ne 'ok') { - Write-Host " Installing pywin32..." -ForegroundColor Yellow - & python -m pip install --quiet pywin32 - & python -m pywin32_postinstall -install 2>$null - Write-OK "pywin32 installed." -} else { - Write-OK "pywin32 already installed." -} - # ── Create install dir ──────────────────────────────────────────────────────── Write-Step "Creating install directory..." New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null Write-OK $INSTALL_DIR -# ── Download agent script ───────────────────────────────────────────────────── +# ── Download agent exe ───────────────────────────────────────────────────────── +# No Python/pywin32 dependency anymore — this is a self-contained PyInstaller +# build with everything it needs bundled in. Write-Step "Downloading JARVIS agent..." try { - Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing - Write-OK "Agent downloaded to $AGENT_SCRIPT" + Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.exe" -OutFile $AGENT_EXE -UseBasicParsing + Write-OK "Agent downloaded to $AGENT_EXE" } catch { Write-Fail "Failed to download agent: $_" } @@ -121,8 +101,7 @@ Write-OK "Config written to $CONFIG_FILE" # ── Install Windows Service ─────────────────────────────────────────────────── Write-Step "Installing Windows service..." -$pyPath = (Get-Command python).Source -& $pyPath "$AGENT_SCRIPT" --startup auto install +& $AGENT_EXE --startup auto install if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." } Write-OK "Service '$SERVICE_NAME' installed." diff --git a/agent/jarvis-agent-windows.exe b/agent/jarvis-agent-windows.exe new file mode 100644 index 0000000..62a41cf Binary files /dev/null and b/agent/jarvis-agent-windows.exe differ diff --git a/agent/jarvis-agent-windows.py b/agent/jarvis-agent-windows.py index 48369ce..f567edb 100644 --- a/agent/jarvis-agent-windows.py +++ b/agent/jarvis-agent-windows.py @@ -35,7 +35,7 @@ INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") CONFIG_PATH = INSTALL_DIR / "config.json" STATE_PATH = INSTALL_DIR / "state.json" LOG_PATH = INSTALL_DIR / "jarvis-agent.log" -AGENT_VERSION = "3.1" +AGENT_VERSION = "3.2" # Set by the service wrapper so self_update knows to stop instead of exec _is_service = False @@ -92,7 +92,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, body = json.dumps(payload).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -109,7 +109,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, def api_get(url: str, headers: dict = {}, timeout: int = 10, ssl_verify: bool = True) -> dict: req = urllib.request.Request(url) - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -375,18 +375,28 @@ def _sysinfo_snapshot() -> dict: # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: + # Added: supports both script-mode (plain .py, run via a system Python) and + # frozen-mode (standalone PyInstaller .exe — sys.frozen is set, __file__ isn't + # meaningful/writable the way it is for a real .py file on disk). A running + # .exe can't be overwritten in place on Windows, but CAN be renamed while + # running, so frozen mode uses a download-new/rename-old/rename-new swap + # instead of the direct overwrite the script-mode path uses. jarvis_url = cfg.get("jarvis_url", "").rstrip("/") - default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + is_frozen = bool(getattr(sys, "frozen", False)) + if is_frozen: + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.exe" if jarvis_url else "" + else: + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) if not update_url: return False - script_path = os.path.abspath(__file__) + target_path = os.path.abspath(sys.executable) if is_frozen else os.path.abspath(__file__) ssl_verify = bool(cfg.get("ssl_verify", True)) try: # Download expected hash hash_url = update_url + ".sha256" req_hash = urllib.request.Request(hash_url) - req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req_hash.add_header("Host", _host_header) expected_hash = None @@ -397,13 +407,13 @@ def self_update(cfg: dict) -> bool: except Exception: pass - # Download new script + # Download new script/exe req = urllib.request.Request(update_url) - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) ctx = _make_ssl_ctx(ssl_verify) - with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + with urllib.request.urlopen(req, timeout=60, context=ctx) as resp: new_content = resp.read() # Verify hash @@ -413,20 +423,42 @@ def self_update(cfg: dict) -> bool: log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") return False - with open(script_path, "rb") as f: + with open(target_path, "rb") as f: current = f.read() - if new_content != current: - log(f"Update verified — replacing {script_path} and restarting...") - with open(script_path, "wb") as f: + if new_content == current: + return False + + log(f"Update verified — replacing {target_path} and restarting...") + if is_frozen: + # Can't overwrite a running exe, but can rename it and drop the new + # one in its place; the old copy is cleaned up on the next update. + old_path = target_path + ".old" + new_path = target_path + ".new" + with open(new_path, "wb") as f: f.write(new_content) - if _is_service: - # Signal the main loop to exit; SCM failure-recovery will restart us - log("Running as service — stopping for SCM-managed restart after update.") - _stop_event.set() - else: - os.execv(sys.executable, [sys.executable] + sys.argv) - return True - return False + try: + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + os.rename(target_path, old_path) + os.rename(new_path, target_path) + else: + with open(target_path, "wb") as f: + f.write(new_content) + + if _is_service: + # Signal the main loop to exit; SCM failure-recovery will restart us + log("Running as service — stopping for SCM-managed restart after update.") + _stop_event.set() + elif is_frozen: + # sys.argv[0] is already the exe's own path for a frozen app — don't + # prepend sys.executable again or the new process misreads its own + # path as a command-line argument. + os.execv(sys.executable, sys.argv) + else: + os.execv(sys.executable, [sys.executable] + sys.argv) + return True except Exception as e: log(f"Self-update check failed: {e}") return False diff --git a/public_html/admin/index.php b/public_html/admin/index.php index 21f0ab9..74a01cf 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -493,7 +493,7 @@ if ($action) { $latestVersions = [ 'linux' => '3.1', 'proxmox' => '3.1', - 'windows' => '3.0', + 'windows' => '3.2', 'macos' => '3.0', 'homeassistant' => null, ]; diff --git a/public_html/agent/install-windows.ps1 b/public_html/agent/install-windows.ps1 index f37b1ed..44b05e2 100644 --- a/public_html/agent/install-windows.ps1 +++ b/public_html/agent/install-windows.ps1 @@ -6,20 +6,26 @@ .DESCRIPTION Installs JARVIS Agent as a Windows Service that auto-starts at boot. Requires: PowerShell 5.1+, internet access, and Administrator rights. + No Python installation needed — this installs the standalone .exe build. .EXAMPLE # Interactive install (prompts for registration key): - irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex # Silent install with key: - $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex #> $ErrorActionPreference = 'Stop' -$JARVIS_URL = 'https://jarvis.orbishosting.com' +# Fixed 2026-07-07: jarvis.orbishosting.com on the default port (80/443) is not +# reachable from outside the LAN at all (no FortiGate VIP forwards it) — every +# external install using the old default URL would have failed outright. Port +# 1972 is the confirmed-working external path (same fix applied to the GitHub +# webhook the same day). +$JARVIS_URL = 'http://jarvis.orbishosting.com:1972' $INSTALL_DIR = 'C:\ProgramData\jarvis-agent' $SERVICE_NAME = 'JARVISAgent' -$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py" +$AGENT_EXE = "$INSTALL_DIR\jarvis-agent-windows.exe" $CONFIG_FILE = "$INSTALL_DIR\config.json" function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan } @@ -39,49 +45,23 @@ if ($existing) { Start-Sleep 2 } try { - & python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null + if (Test-Path $AGENT_EXE) { & $AGENT_EXE remove 2>$null } } catch {} Write-OK "Existing service removed." } -# ── Check / install Python ──────────────────────────────────────────────────── -Write-Step "Checking Python..." -$py = Get-Command python -ErrorAction SilentlyContinue -if (-not $py) { - Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run." - } - winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements - $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User") - $py = Get-Command python -ErrorAction SilentlyContinue - if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" } -} -$pyVersion = & python --version 2>&1 -Write-OK $pyVersion - -# ── Install pywin32 ─────────────────────────────────────────────────────────── -Write-Step "Checking pywin32..." -$checkWin32 = & python -c "import win32service; print('ok')" 2>&1 -if ($checkWin32 -ne 'ok') { - Write-Host " Installing pywin32..." -ForegroundColor Yellow - & python -m pip install --quiet pywin32 - & python -m pywin32_postinstall -install 2>$null - Write-OK "pywin32 installed." -} else { - Write-OK "pywin32 already installed." -} - # ── Create install dir ──────────────────────────────────────────────────────── Write-Step "Creating install directory..." New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null Write-OK $INSTALL_DIR -# ── Download agent script ───────────────────────────────────────────────────── +# ── Download agent exe ───────────────────────────────────────────────────────── +# No Python/pywin32 dependency anymore — this is a self-contained PyInstaller +# build with everything it needs bundled in. Write-Step "Downloading JARVIS agent..." try { - Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing - Write-OK "Agent downloaded to $AGENT_SCRIPT" + Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.exe" -OutFile $AGENT_EXE -UseBasicParsing + Write-OK "Agent downloaded to $AGENT_EXE" } catch { Write-Fail "Failed to download agent: $_" } @@ -121,8 +101,7 @@ Write-OK "Config written to $CONFIG_FILE" # ── Install Windows Service ─────────────────────────────────────────────────── Write-Step "Installing Windows service..." -$pyPath = (Get-Command python).Source -& $pyPath "$AGENT_SCRIPT" --startup auto install +& $AGENT_EXE --startup auto install if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." } Write-OK "Service '$SERVICE_NAME' installed." diff --git a/public_html/agent/jarvis-agent-windows.exe b/public_html/agent/jarvis-agent-windows.exe new file mode 100644 index 0000000..62a41cf Binary files /dev/null and b/public_html/agent/jarvis-agent-windows.exe differ diff --git a/public_html/agent/jarvis-agent-windows.exe.sha256 b/public_html/agent/jarvis-agent-windows.exe.sha256 new file mode 100644 index 0000000..3973d3c --- /dev/null +++ b/public_html/agent/jarvis-agent-windows.exe.sha256 @@ -0,0 +1 @@ +ad9b59c09e5862c5abc35f73999aa2666f8401817b053c385505fa420ca473e7 diff --git a/public_html/agent/jarvis-agent-windows.py b/public_html/agent/jarvis-agent-windows.py index 23d6440..f567edb 100644 --- a/public_html/agent/jarvis-agent-windows.py +++ b/public_html/agent/jarvis-agent-windows.py @@ -35,12 +35,11 @@ INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") CONFIG_PATH = INSTALL_DIR / "config.json" STATE_PATH = INSTALL_DIR / "state.json" LOG_PATH = INSTALL_DIR / "jarvis-agent.log" -AGENT_VERSION = "3.1" +AGENT_VERSION = "3.2" # Set by the service wrapper so self_update knows to stop instead of exec -_is_service = False -_stop_event = threading.Event() -_update_restart = False # True when stopping for self-update; triggers SCM restart +_is_service = False +_stop_event = threading.Event() # ── Logging ──────────────────────────────────────────────────────────────────── @@ -93,7 +92,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, body = json.dumps(payload).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -110,7 +109,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, def api_get(url: str, headers: dict = {}, timeout: int = 10, ssl_verify: bool = True) -> dict: req = urllib.request.Request(url) - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -376,18 +375,28 @@ def _sysinfo_snapshot() -> dict: # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: + # Added: supports both script-mode (plain .py, run via a system Python) and + # frozen-mode (standalone PyInstaller .exe — sys.frozen is set, __file__ isn't + # meaningful/writable the way it is for a real .py file on disk). A running + # .exe can't be overwritten in place on Windows, but CAN be renamed while + # running, so frozen mode uses a download-new/rename-old/rename-new swap + # instead of the direct overwrite the script-mode path uses. jarvis_url = cfg.get("jarvis_url", "").rstrip("/") - default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + is_frozen = bool(getattr(sys, "frozen", False)) + if is_frozen: + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.exe" if jarvis_url else "" + else: + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) if not update_url: return False - script_path = os.path.abspath(__file__) + target_path = os.path.abspath(sys.executable) if is_frozen else os.path.abspath(__file__) ssl_verify = bool(cfg.get("ssl_verify", True)) try: # Download expected hash hash_url = update_url + ".sha256" req_hash = urllib.request.Request(hash_url) - req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req_hash.add_header("Host", _host_header) expected_hash = None @@ -398,13 +407,13 @@ def self_update(cfg: dict) -> bool: except Exception: pass - # Download new script + # Download new script/exe req = urllib.request.Request(update_url) - req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2") if _host_header: req.add_header("Host", _host_header) ctx = _make_ssl_ctx(ssl_verify) - with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + with urllib.request.urlopen(req, timeout=60, context=ctx) as resp: new_content = resp.read() # Verify hash @@ -414,21 +423,42 @@ def self_update(cfg: dict) -> bool: log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") return False - with open(script_path, "rb") as f: + with open(target_path, "rb") as f: current = f.read() - if new_content != current: - log(f"Update verified — replacing {script_path} and restarting...") - with open(script_path, "wb") as f: + if new_content == current: + return False + + log(f"Update verified — replacing {target_path} and restarting...") + if is_frozen: + # Can't overwrite a running exe, but can rename it and drop the new + # one in its place; the old copy is cleaned up on the next update. + old_path = target_path + ".old" + new_path = target_path + ".new" + with open(new_path, "wb") as f: f.write(new_content) - if _is_service: - global _update_restart - _update_restart = True - log("Running as service — stopping for SCM-managed restart after update.") - _stop_event.set() - else: - os.execv(sys.executable, [sys.executable] + sys.argv) - return True - return False + try: + if os.path.exists(old_path): + os.remove(old_path) + except Exception: + pass + os.rename(target_path, old_path) + os.rename(new_path, target_path) + else: + with open(target_path, "wb") as f: + f.write(new_content) + + if _is_service: + # Signal the main loop to exit; SCM failure-recovery will restart us + log("Running as service — stopping for SCM-managed restart after update.") + _stop_event.set() + elif is_frozen: + # sys.argv[0] is already the exe's own path for a frozen app — don't + # prepend sys.executable again or the new process misreads its own + # path as a command-line argument. + os.execv(sys.executable, sys.argv) + else: + os.execv(sys.executable, [sys.executable] + sys.argv) + return True except Exception as e: log(f"Self-update check failed: {e}") return False @@ -592,9 +622,6 @@ if _HAS_WIN32: (self._svc_name_, ""), ) main() - if _update_restart: - # Non-zero exit triggers SCM failure recovery → automatic restart - sys.exit(1) if __name__ == "__main__": diff --git a/public_html/agent/jarvis-agent-windows.py.sha256 b/public_html/agent/jarvis-agent-windows.py.sha256 index 2750c62..a8b46b9 100644 --- a/public_html/agent/jarvis-agent-windows.py.sha256 +++ b/public_html/agent/jarvis-agent-windows.py.sha256 @@ -1 +1 @@ -224a634375b5d49ccc0a012e0e122ade5f8a1302615450dffbf9a03eac6b7a19 +fff217657488830084780115665d0c772af1f7fe31f2084d61a7560424a5a91a