From 4c93e26516b5d7d092f2070e3b67699a3bc274b6 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 17:43:16 +0000 Subject: [PATCH 01/20] fix: restore automatic startup after system restart --- .github/workflows/startup.yml | 39 +++ .gitignore | 3 + README.md | 7 +- app/console.py | 19 +- app/python_runtime.py | 18 +- craftbot.py | 502 +++++++++++++++++++-------------- installer/startup.py | 126 +++++++++ launcher/README.md | 8 + run.py | 17 +- scripts/package_source.py | 1 + scripts/test_install_e2e.py | 1 + tests/test_autostart.py | 393 ++++++++++++++++++++++++++ tests/test_craftbot_service.py | 132 +++------ 13 files changed, 959 insertions(+), 307 deletions(-) create mode 100644 .github/workflows/startup.yml create mode 100644 installer/startup.py create mode 100644 tests/test_autostart.py diff --git a/.github/workflows/startup.yml b/.github/workflows/startup.yml new file mode 100644 index 00000000..a5de9993 --- /dev/null +++ b/.github/workflows/startup.yml @@ -0,0 +1,39 @@ +name: Startup regressions + +on: + push: + paths: + - 'craftbot.py' + - 'run.py' + - 'installer/**' + - 'app/console.py' + - 'app/python_runtime.py' + - 'tests/test_autostart.py' + - 'tests/test_craftbot_service.py' + - '.github/workflows/startup.yml' + pull_request: + paths: + - 'craftbot.py' + - 'run.py' + - 'installer/**' + - 'app/console.py' + - 'app/python_runtime.py' + - 'tests/test_autostart.py' + - 'tests/test_craftbot_service.py' + - '.github/workflows/startup.yml' + +jobs: + startup: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pip install pytest==9.0.2 + - name: Check login startup and browser-only shortcuts + run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py -q diff --git a/.gitignore b/.gitignore index aefbca0d..90bfd604 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ agent_file_system/*.local-backup /research/ /research_notes/ /workspace/ + +# Per-install automatic startup preference (managed installs keep it in user data). +/startup.json diff --git a/README.md b/README.md index 9372ae2e..8c2b887c 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,16 @@ python craftbot.py stop # Stop CraftBot python craftbot.py restart # Restart CraftBot python craftbot.py status # Check if it's running and if auto-start is enabled python craftbot.py logs # See recent log output +python craftbot.py autostart disable # Don't launch automatically at future logins +python craftbot.py autostart enable # Restore automatic startup at login +python craftbot.py autostart status # Check automatic startup registration python craftbot.py uninstall # Stop, remove auto-start, and uninstall packages ``` > [!TIP] -> After `install` or `start`, a **CraftBot desktop shortcut** is created automatically. If you close the browser, just double-click the shortcut to reopen it. +> After `install` or `start`, a **CraftBot desktop shortcut** is created automatically. It only opens the browser; it never starts or restarts CraftBot. Automatic startup runs CraftBot in the background when you log in after a restart, without opening a browser. If you disabled startup, run `python craftbot.py start` before using the shortcut. + +Both the installer and `python craftbot.py install` enable automatic startup by default. Use `python craftbot.py install --no-autostart` to opt out during installation. Reinstall and Repair refresh the startup configuration and preserve an explicit opt-out and custom ports. Disabling automatic startup does not stop the current session; use `python craftbot.py stop` for that. --- diff --git a/app/console.py b/app/console.py index 25c86dec..923b8cac 100644 --- a/app/console.py +++ b/app/console.py @@ -4,9 +4,26 @@ import sys -def configure_console() -> None: +def configure_console(*, log_file: str = None) -> None: if sys.platform != "win32": return + # pythonw launched by Task Scheduler / HKCU Run has no standard handles. + # Give it real files before any startup code flushes output or passes the + # streams to a subprocess. Installation already supplies these handles, + # which used to hide this crash until the first login after installation. + if sys.stdout is None or sys.stderr is None: + try: + output = open( + log_file or os.devnull, "a", encoding="utf-8", errors="replace" + ) + except OSError: + output = open(os.devnull, "w", encoding="utf-8") + if sys.stdout is None: + sys.stdout = output + if sys.stderr is None: + sys.stderr = output + if sys.stdin is None: + sys.stdin = open(os.devnull, "r", encoding="utf-8") for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: diff --git a/app/python_runtime.py b/app/python_runtime.py index c71123ed..cf478ab2 100644 --- a/app/python_runtime.py +++ b/app/python_runtime.py @@ -173,10 +173,22 @@ def reexec_if_needed() -> None: return env = {**os.environ, _REEXEC_MARK: "1"} argv = [target, *sys.argv] - sys.stdout.flush() - sys.stderr.flush() + for stream in (sys.stdout, sys.stderr): + if stream is not None: + stream.flush() if sys.platform == "win32": # execv on Windows spawns a detached child and returns the console # immediately — run as a subprocess and forward the exit code. - sys.exit(subprocess.run(argv, env=env).returncode) + # Python-level replacements for absent pythonw handles (notably the + # startup log) are not inherited unless explicitly passed here. + sys.exit( + subprocess.run( + argv, + env=env, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + creationflags=subprocess.CREATE_NO_WINDOW, + ).returncode + ) os.execve(target, argv, env) diff --git a/craftbot.py b/craftbot.py index e05a8dd5..ae878c7e 100644 --- a/craftbot.py +++ b/craftbot.py @@ -12,6 +12,9 @@ python craftbot.py status Show if CraftBot is running python craftbot.py logs [-n N] Show last N log lines (default: 50) python craftbot.py install [options] Register for auto-start on boot/login + python craftbot.py autostart enable Enable automatic startup at login + python craftbot.py autostart disable Disable automatic startup (keep installed) + python craftbot.py autostart status Show automatic startup status python craftbot.py uninstall Remove auto-start registration python craftbot.py repair [options] Re-copy current EXE over the installed copy and restart (frozen EXE only) @@ -28,6 +31,7 @@ --agent-app-port PORT First Agent App port, apps get PORT to PORT+99 (default: 3100) --conda Use conda environment --no-conda Don't use conda + --no-autostart Install without automatic startup at login Examples: python craftbot.py start # Start in background (browser mode) @@ -39,31 +43,21 @@ """ import sys +import os # In windowed (console=False) PyInstaller builds, sys.stdout and sys.stderr # are None. Lots of libraries (including run.py at import time) call # sys.stdout.isatty() unconditionally and crash with AttributeError. Install -# a dummy file-like before ANY other import that might transitively hit +# an output handle before ANY other import that might transitively hit # stdout. Must be the very first thing this module does. if sys.stdout is None or sys.stderr is None: - import io - - class _NullIO(io.TextIOBase): - def isatty(self) -> bool: - return False - - def write(self, s: str) -> int: - return len(s) - - def flush(self) -> None: - pass - + # Real handles can also be passed through an interpreter handoff. + output = open(os.devnull, "w", encoding="utf-8") if sys.stdout is None: - sys.stdout = _NullIO() + sys.stdout = output if sys.stderr is None: - sys.stderr = _NullIO() + sys.stderr = output -import os import shlex import shutil import signal @@ -76,6 +70,7 @@ def flush(self) -> None: from installer import helpers as _helpers from installer import metadata as _metadata from installer import payload as _payload +from installer import startup as _startup # Store platform once so static analysers don't short-circuit platform branches _PLATFORM: str = sys.platform @@ -390,6 +385,21 @@ def _remove_pid() -> None: pass +def _claim_service_process() -> bool: + """Publish a login service's PID without replacing an already running app.""" + if _owned_pid(): + print("CraftBot is already running; skipping automatic startup.") + return False + _write_pid(os.getpid()) + return True + + +def _release_service_process(pid: int) -> None: + # A manual restart may already have replaced our PID record. + if _owned_pid() == pid: + _remove_pid() + + def _parse_port_arg(args: List[str], flag: str, default: int) -> int: prefix = f"{flag}=" for i, arg in enumerate(args): @@ -749,7 +759,7 @@ def cmd_start(extra_args: List[str]) -> bool: # Create a desktop shortcut so the user can reopen the browser anytime if "--cli" not in run_args: if _PLATFORM == "win32": - _create_desktop_shortcut_windows() + _create_desktop_shortcut_windows(extra_args) else: _create_desktop_shortcut_unix(extra_args) @@ -993,29 +1003,34 @@ def _ensure_ico() -> Optional[str]: return None -def _create_desktop_shortcut_windows() -> None: +def _ps_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _create_desktop_shortcut_windows(extra_args: Optional[List[str]] = None) -> None: """Create a .lnk shortcut on the Windows Desktop with the CraftBot icon.""" desktop = _find_desktop() if not desktop: return shortcut_path = os.path.join(desktop, SHORTCUT_NAME) - if os.path.exists(shortcut_path): - return # already exists, don't recreate + # Refresh existing links too: upgrades and custom ports must take effect. ico_path = _ensure_ico() try: # Write the PS script to a temp file with UTF-8-BOM so PowerShell # handles non-ASCII paths (e.g. Japanese Desktop folder) correctly. import tempfile + browser_command = '/c start "" ' + _frontend_url(extra_args or []) ps_lines = [ "$ws = New-Object -ComObject WScript.Shell", - f'$s = $ws.CreateShortcut("{shortcut_path}")', + f"$s = $ws.CreateShortcut({_ps_literal(shortcut_path)})", '$s.TargetPath = "cmd.exe"', - f'$s.Arguments = "/c start {BROWSER_URL}"', + f"$s.Arguments = {_ps_literal(browser_command)}", + '$s.WorkingDirectory = ""', "$s.WindowStyle = 7", # minimized (hides the cmd flash) ] if ico_path: - ps_lines.append(f'$s.IconLocation = "{ico_path},0"') + ps_lines.append(f"$s.IconLocation = {_ps_literal(ico_path + ',0')}") ps_lines += [ '$s.Description = "Open CraftBot in your browser"', "$s.Save()", @@ -1037,6 +1052,8 @@ def _create_desktop_shortcut_windows() -> None: ], capture_output=True, timeout=15, + check=True, + **_quiet_windows_flags(), ) finally: try: @@ -1063,16 +1080,6 @@ def _installed_launch() -> Optional[List[str]]: return _metadata.launch_command(INSTALL_METADATA_FILE) -def _shortcut_start_command(extra_args: List[str]) -> Optional[str]: - restart_args = _port_args(extra_args) - if IS_FROZEN: - launch = _installed_launch() - if not launch: - return None - return shlex.join(launch + restart_args) - return shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) - - def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> None: """Create a desktop shortcut on Linux or macOS.""" if extra_args is None: @@ -1085,24 +1092,7 @@ def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> Non if _PLATFORM == "darwin": # macOS does not support XDG .desktop files — create a double-clickable .command script shortcut_path = os.path.join(desktop, "CraftBot.command") - backend_url = _backend_url(extra_args) - start_cmd = _shortcut_start_command(extra_args) - if not start_cmd: - print(" (Could not create desktop shortcut: no installed agent found)") - return - content = ( - "#!/bin/sh\n" - f"cd {shlex.quote(BASE_DIR)} || exit 1\n" - f"backend_status=$(curl -sS -o /dev/null -w '%{{http_code}}' " - f"{shlex.quote(backend_url)} 2>/dev/null || true)\n" - f"if curl -fsS {shlex.quote(browser_url)} >/dev/null 2>&1 " - '&& [ "$backend_status" -ge 100 ] 2>/dev/null ' - '&& [ "$backend_status" -lt 500 ] 2>/dev/null; then\n' - f" open {shlex.quote(browser_url)}\n" - "else\n" - f" exec {start_cmd}\n" - "fi\n" - ) + content = f"#!/bin/sh\nexec open {shlex.quote(browser_url)}\n" with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) @@ -1135,12 +1125,46 @@ def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> Non # ─── Auto-start: Windows Task Scheduler ─────────────────────────────────────── +def _quiet_windows_flags() -> dict: + return {"creationflags": 0x08000000} if _PLATFORM == "win32" else {} + + +def _autostart_launch(run_args: List[str]) -> Optional[List[str]]: + launch = _installed_launch() if IS_FROZEN else [_python_exe(), RUN_SCRIPT] + if not launch: + print("No installed CraftBot found — run install first.") + return None + args = _build_run_args(run_args) + if os.path.basename(launch[-1]) == "run.py" and "--service" not in args: + args.append("--service") + return launch + args + + +def _remove_windows_registry() -> bool: + try: + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, + winreg.KEY_SET_VALUE, + ) as key: + winreg.DeleteValue(key, TASK_NAME) + return True + except FileNotFoundError: + return True + except OSError as exc: + print(f"Could not remove the old login registry entry: {exc}") + return False + + def _install_windows_registry(action: str) -> bool: """Fallback: register auto-start via HKCU Run registry key (no admin needed).""" try: import winreg - key = winreg.OpenKey( + key = winreg.CreateKeyEx( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, @@ -1153,63 +1177,90 @@ def _install_windows_registry(action: str) -> bool: return False -def _install_windows(run_args: List[str]) -> None: - if IS_FROZEN: - # Register the installed CraftBot for auto-start: [python, run.py]. - launch = _installed_launch() - if not launch: - print( - f" {RED}✗{RESET} {WHITE}No installed CraftBot found — run install first.{RESET}" - ) - return - # 8.3 short paths avoid quoting trouble in Task Scheduler's XML for - # paths containing spaces, which %LOCALAPPDATA%\Programs often does. - quoted = " ".join(f'"{_to_short_path(part)}"' for part in launch) - action = f"{quoted} {' '.join(run_args)}".strip() - else: - python = _python_exe() - # Use 8.3 short paths to avoid Unicode/long-path failures in schtasks /tr - python_s = _to_short_path(python) - script_s = _to_short_path(RUN_SCRIPT) - action = f'"{python_s}" "{script_s}" {" ".join(run_args)}' +def _install_windows(run_args: List[str]) -> bool: + import csv + import tempfile - # Try Task Scheduler first; silently fall back to Registry on failure + command = _autostart_launch(run_args) + if not command: + return False + directory = ( + os.path.dirname(command[1]) + if len(command) > 1 and command[1].endswith(".py") + else BASE_DIR + ) + # XML separates the executable from its arguments, preserving Unicode and + # spaces without relying on 8.3 names. Configure background-app settings + # explicitly; schtasks /sc ONLOGON otherwise inherits battery/72h limits. registered = False + xml_file = None try: + identity = subprocess.run( + ["whoami", "/user", "/fo", "csv", "/nh"], + capture_output=True, + text=True, + check=True, + timeout=10, + **_quiet_windows_flags(), + ) + user = next(csv.reader([identity.stdout.strip()]))[1] + if not user.startswith("S-1-"): + raise ValueError("Could not determine the current Windows user SID") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False, encoding="utf-16" + ) as f: + xml_file = f.name + f.write(_startup.windows_task_xml(command, directory, user)) result = subprocess.run( - [ - "schtasks", - "/create", - "/tn", - TASK_NAME, - "/tr", - action, - "/sc", - "ONLOGON", - "/f", - ], + ["schtasks", "/create", "/tn", TASK_NAME, "/xml", xml_file, "/f"], capture_output=True, text=True, timeout=30, + **_quiet_windows_flags(), ) registered = result.returncode == 0 - except Exception: - pass - - if not registered: - registered = _install_windows_registry(action) + except (OSError, subprocess.SubprocessError, ValueError, IndexError) as exc: + print(f"Task Scheduler registration unavailable: {exc}") + finally: + if xml_file: + os.remove(xml_file) + if registered: + # An earlier non-admin installation may have used HKCU Run. Keep only + # one launch trigger, rather than starting two agents at the next login. + registered = _remove_windows_registry() + else: + # Remove an obsolete task before falling back, so its old action cannot + # race the updated registry action. A missing task is fine. + try: + query = subprocess.run( + ["schtasks", "/query", "/tn", TASK_NAME], + capture_output=True, + timeout=10, + **_quiet_windows_flags(), + ) + if query.returncode == 0: + subprocess.run( + ["schtasks", "/delete", "/tn", TASK_NAME, "/f"], + capture_output=True, + check=True, + timeout=15, + **_quiet_windows_flags(), + ) + registered = _install_windows_registry(subprocess.list2cmdline(command)) + except (OSError, subprocess.SubprocessError) as exc: + print(f"Could not replace the old startup task: {exc}") if registered: print("Auto-start registered. CraftBot will start automatically on login.") - print(f"Open CraftBot: {BROWSER_URL}") - _create_desktop_shortcut_windows() + _create_desktop_shortcut_windows(run_args) else: print( - "Could not register auto-start. Use 'python craftbot.py start' to start manually." + "Could not register auto-start. Run 'python craftbot.py autostart enable' to retry." ) + return registered -def _uninstall_windows() -> None: +def _uninstall_windows(*, stop: bool = True) -> bool: removed_any = False # Remove from Task Scheduler @@ -1249,25 +1300,26 @@ def _uninstall_windows() -> None: if not removed_any: print("No auto-start registration found (already uninstalled?).") + return not _is_installed() # ─── Auto-start: Linux systemd (user service) ───────────────────────────────── -def _install_linux(run_args: List[str]) -> None: +def _install_linux(run_args: List[str]) -> bool: service_dir = os.path.expanduser("~/.config/systemd/user") os.makedirs(service_dir, exist_ok=True) service_file = os.path.join(service_dir, f"{SYSTEMD_SERVICE}.service") - if IS_FROZEN: - launch = _installed_launch() - if not launch: - print("Error: no installed CraftBot found — run install first.") - return - exec_start = f"{' '.join(launch)} {' '.join(run_args)}".strip() - else: - python = _installed_python() - exec_start = f"{python} {RUN_SCRIPT} {' '.join(run_args)}" + command = _autostart_launch(run_args) + if not command: + return False + exec_start = " ".join(_startup.systemd_quote(arg, command=True) for arg in command) + directory = ( + os.path.dirname(command[1]) + if len(command) > 1 and command[1].endswith(".py") + else BASE_DIR + ) content = f"""[Unit] Description=CraftBot AI Agent @@ -1276,7 +1328,7 @@ def _install_linux(run_args: List[str]) -> None: [Service] Type=simple ExecStart={exec_start} -WorkingDirectory={BASE_DIR} +WorkingDirectory={_startup.systemd_quote(directory)} Restart=on-failure RestartSec=5 Environment=PYTHONUNBUFFERED=1 @@ -1296,9 +1348,10 @@ def _install_linux(run_args: List[str]) -> None: print("CraftBot will start automatically when you log in.") print(f"\nOpen CraftBot: {BROWSER_URL}") print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(run_args) print(f"\nTo start it now: systemctl --user start {SYSTEMD_SERVICE}") print(f"To view logs: journalctl --user -u {SYSTEMD_SERVICE} -f") + return True except subprocess.CalledProcessError as e: print(f"Error enabling systemd service: {e}") print(f"Service file written to: {service_file}") @@ -1307,9 +1360,10 @@ def _install_linux(run_args: List[str]) -> None: ) except FileNotFoundError: print("systemctl not found. Is systemd running on this system?") + return False -def _uninstall_linux() -> None: +def _uninstall_linux(*, stop: bool = True) -> bool: service_file = os.path.expanduser( f"~/.config/systemd/user/{SYSTEMD_SERVICE}.service" ) @@ -1319,13 +1373,15 @@ def _uninstall_linux() -> None: capture_output=True, timeout=10, ) - subprocess.run( - ["systemctl", "--user", "stop", SYSTEMD_SERVICE], - capture_output=True, - timeout=10, - ) + if stop: + subprocess.run( + ["systemctl", "--user", "stop", SYSTEMD_SERVICE], + capture_output=True, + timeout=10, + ) except Exception: pass + if os.path.isfile(service_file): os.remove(service_file) print("Auto-start removed (service file deleted).") @@ -1338,101 +1394,72 @@ def _uninstall_linux() -> None: except Exception: pass + return not os.path.isfile(service_file) + # ─── Auto-start: macOS launchd ──────────────────────────────────────────────── -def _install_macos(run_args: List[str]) -> None: +def _install_macos(run_args: List[str]) -> bool: + import plistlib + agents_dir = os.path.expanduser("~/Library/LaunchAgents") os.makedirs(agents_dir, exist_ok=True) plist_file = os.path.join(agents_dir, f"{LAUNCHD_LABEL}.plist") - if IS_FROZEN: - launch = _installed_launch() - if not launch: - print("Error: no installed CraftBot found — run install first.") - return - program_args = launch + run_args - else: - python = _installed_python() - program_args = [python, RUN_SCRIPT] + run_args - program_args_xml = "\n".join(f" {a}" for a in program_args) - - content = f""" - - - - Label - {LAUNCHD_LABEL} - ProgramArguments - -{program_args_xml} - - WorkingDirectory - {BASE_DIR} - RunAtLoad - - KeepAlive - - StandardOutPath - {LOG_FILE} - StandardErrorPath - {LOG_FILE} - EnvironmentVariables - - PYTHONUNBUFFERED - 1 - - - -""" - with open(plist_file, "w") as f: - f.write(content) - - # `launchctl load` honours RunAtLoad, so loading the agent starts CraftBot - # then and there. When one is already running — the normal case, since - # install starts it first — that second process fights the first for ports - # 7925/7926 and one of them loses. Writing the plist is enough on its own: - # launchd bootstraps ~/Library/LaunchAgents at login, so auto-start works - # from the next login whether or not it was loaded now. - if _owned_pid(): - print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") - print("CraftBot is already running — auto-start begins at your next login.") - _print_launchd_hints(plist_file) - return + program_args = _autostart_launch(run_args) + if not program_args: + return False + directory = ( + os.path.dirname(program_args[1]) + if len(program_args) > 1 and program_args[1].endswith(".py") + else BASE_DIR + ) + with open(plist_file, "wb") as f: + plistlib.dump( + { + "Label": LAUNCHD_LABEL, + "ProgramArguments": program_args, + "WorkingDirectory": directory, + "RunAtLoad": True, + "KeepAlive": False, + "StandardOutPath": LOG_FILE, + "StandardErrorPath": LOG_FILE, + "EnvironmentVariables": {"PYTHONUNBUFFERED": "1"}, + }, + f, + ) - try: - subprocess.run(["launchctl", "load", plist_file], check=True, timeout=10) - print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") - print("CraftBot will start automatically when you log in.") - _print_launchd_hints(plist_file) - except subprocess.CalledProcessError as e: - print(f"Error loading launchd agent: {e}") - print(f"Plist written to: {plist_file}") - print(f"Try manually: launchctl load {plist_file}") + # launchd reads ~/Library/LaunchAgents at login. Do not load RunAtLoad + # now: changing the login preference must not start a second agent. + print(f"Auto-start registered as launchd agent '{LAUNCHD_LABEL}'.") + print("CraftBot will start automatically at your next login.") + _print_launchd_hints(plist_file, run_args) + return True -def _print_launchd_hints(plist_file: str) -> None: +def _print_launchd_hints(plist_file: str, run_args: List[str]) -> None: print(f"\nOpen CraftBot: {BROWSER_URL}") print(f" Tip: Bookmark {BROWSER_URL} so you never have to remember it!") - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(run_args) print(f"\nPlist file: {plist_file}") -def _uninstall_macos() -> None: +def _uninstall_macos(*, stop: bool = True) -> bool: plist_file = os.path.expanduser(f"~/Library/LaunchAgents/{LAUNCHD_LABEL}.plist") if os.path.isfile(plist_file): try: - subprocess.run( - ["launchctl", "unload", plist_file], capture_output=True, timeout=10 - ) + if stop: + subprocess.run( + ["launchctl", "unload", plist_file], capture_output=True, timeout=10 + ) except Exception: pass os.remove(plist_file) print("Auto-start removed.") else: print("No launchd agent found.") + return not os.path.isfile(plist_file) # ─── Install / Uninstall dispatch ───────────────────────────────────────────── @@ -1445,12 +1472,18 @@ def _is_installed() -> bool: # Check Task Scheduler try: result = subprocess.run( - ["schtasks", "/query", "/tn", TASK_NAME], + ["schtasks", "/query", "/tn", TASK_NAME, "/xml"], capture_output=True, + text=True, timeout=10, ) if result.returncode == 0: - return True + import xml.etree.ElementTree as ET + + root = ET.fromstring(result.stdout) + enabled = root.find("{*}Settings/{*}Enabled") + if enabled is None or enabled.text != "false": + return True except Exception: pass # Check Registry fallback @@ -1475,7 +1508,73 @@ def _is_installed() -> bool: service_file = os.path.expanduser( f"~/.config/systemd/user/{SYSTEMD_SERVICE}.service" ) - return os.path.isfile(service_file) + if not os.path.isfile(service_file): + return False + try: + return ( + subprocess.run( + ["systemctl", "--user", "is-enabled", "--quiet", SYSTEMD_SERVICE], + capture_output=True, + timeout=10, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _startup_options(extra_args: List[str]) -> tuple: + settings = _startup.read(paths.STATE_ROOT) + enabled = settings["enabled"] and "--no-autostart" not in extra_args + return _startup.merge_args(settings["args"], extra_args), enabled + + +def _configure_autostart(enabled: bool, run_args: List[str]) -> bool: + """Refresh OS registration on every install/repair, honoring an opt-out.""" + try: + if enabled: + ok = _helpers.dispatch_per_platform( + win=_install_windows, mac=_install_macos, linux=_install_linux + )(_build_run_args(run_args)) + else: + ok = _helpers.dispatch_per_platform( + win=_uninstall_windows, mac=_uninstall_macos, linux=_uninstall_linux + )(stop=False) + if not ok: + print( + "Automatic startup could not be updated; installation is not complete." + ) + return False + _startup.write(paths.STATE_ROOT, enabled, run_args) + return True + except (OSError, subprocess.SubprocessError) as exc: + print(f"Could not update automatic startup: {exc}") + return False + + +def cmd_autostart(args: List[str]) -> bool: + if not args or args[0] not in ("enable", "disable", "status"): + print("Usage: python craftbot.py autostart enable|disable|status [options]") + return False + action = args[0] + if action == "status": + print( + "Automatic startup at login: " + + ("enabled" if _is_installed() else "disabled") + ) + return True + try: + settings = _startup.read(paths.STATE_ROOT) + except (ValueError, OSError): + # An explicit command can repair corrupt preference data. + settings = {"args": []} + run_args = _startup.merge_args(settings["args"], args[1:]) + if not _configure_autostart(action == "enable", run_args): + return False + print( + f"Automatic startup {'enabled' if action == 'enable' else 'disabled'} for future logins." + ) + return True def _remove_legacy_agent_bundle(target_dir: str) -> None: @@ -1526,6 +1625,7 @@ def _full_install_frozen( if not IS_FROZEN: raise RuntimeError("_full_install_frozen called outside frozen mode") + extra_args, auto_start = _startup_options(extra_args) target_dir = os.path.normpath(target_dir) from app import paths, provision @@ -1643,15 +1743,13 @@ def _full_install_frozen( except OSError as e: print(f" (could not copy icon: {e})") - # 5. Register auto-start. - run_args = _build_run_args(extra_args, service_mode=True) - _helpers.dispatch_per_platform( - win=_install_windows, mac=_install_macos, linux=_install_linux - )(run_args) - - # 6. Start. + # Start before registering RunAtLoad on macOS, as in source installs. if not cmd_start(extra_args): raise RuntimeError("CraftBot installed but failed to start.") + if not _configure_autostart(auto_start, extra_args): + raise RuntimeError( + "CraftBot started but automatic startup could not be configured." + ) def cmd_install(extra_args: List[str]) -> bool: @@ -1667,6 +1765,7 @@ def cmd_install(extra_args: List[str]) -> bool: _full_install_frozen(target_dir, extra_args) return True + extra_args, auto_start = _startup_options(extra_args) _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── install_script = os.path.join(BASE_DIR, "install.py") @@ -1757,21 +1856,10 @@ def cmd_install(extra_args: List[str]) -> bool: print() # ── Step 3: Register auto-start ──────────────────────────────────────── - if _is_installed(): - print( - f"\n {DIM}▸ STEP 3/3 ░░ AUTO-START ALREADY REGISTERED — SKIPPING{RESET}" - ) - if _PLATFORM == "win32": - _create_desktop_shortcut_windows() - elif _PLATFORM != "darwin": - _create_desktop_shortcut_unix() - else: - _retro_step(3, 3, "Registering auto-start") - run_args = _build_run_args(extra_args, service_mode=True) - _helpers.dispatch_per_platform( - win=_install_windows, mac=_install_macos, linux=_install_linux - )(run_args) - print() + _retro_step(3, 3, "Updating automatic startup") + if not _configure_autostart(auto_start, extra_args): + return False + print() print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") print(f" {DIM}░░{RESET} {ORANGE}{_frontend_url(extra_args)}{RESET}") @@ -2094,6 +2182,10 @@ def main() -> None: if not cmd_install(rest): sys.exit(1) + elif command == "autostart": + if not cmd_autostart(rest): + sys.exit(1) + elif command == "uninstall": cmd_uninstall() diff --git a/installer/startup.py b/installer/startup.py new file mode 100644 index 00000000..86cde625 --- /dev/null +++ b/installer/startup.py @@ -0,0 +1,126 @@ +"""Persistent login preferences and service definitions (stdlib only).""" + +import json +import os +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +PORT_FLAGS = ("--frontend-port", "--backend-port", "--agent-app-port") +MODE_FLAGS = ("--cli", "--conda", "--no-conda", "--dev-ui") + + +def read(state_root) -> dict: + try: + data = json.loads( + (Path(state_root) / "startup.json").read_text(encoding="utf-8") + ) + except FileNotFoundError: + return {"enabled": True, "args": []} + if ( + not isinstance(data, dict) + or not isinstance(data.get("enabled"), bool) + or not isinstance(data.get("args"), list) + or not all(isinstance(arg, str) for arg in data["args"]) + ): + raise ValueError( + "Invalid startup.json; use 'craftbot.py autostart enable' or 'disable' to reset it" + ) + return data + + +def saved_args(args) -> list: + """Only runtime choices persist; e.g. --no-open-browser is per invocation.""" + result = [] + it = iter(args) + for arg in it: + if arg in PORT_FLAGS: + value = next(it, None) + if value is not None: + result.extend([arg, value]) + elif arg.split("=", 1)[0] in PORT_FLAGS or arg in MODE_FLAGS: + result.append(arg) + return result + + +def merge_args(saved, extra) -> list: + """Reuse installed ports during Repair; explicit arguments override them.""" + replaced = {arg.split("=", 1)[0] for arg in extra} + if replaced.intersection(("--conda", "--no-conda")): + replaced.update(("--conda", "--no-conda")) + result = [] + it = iter(saved_args(saved)) + for arg in it: + value = next(it, None) if arg in PORT_FLAGS else None + if arg.split("=", 1)[0] not in replaced: + result.append(arg) + if value is not None: + result.append(value) + return result + [arg for arg in extra if arg != "--no-autostart"] + + +def write(state_root, enabled: bool, args) -> None: + root = Path(state_root) + root.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=root, + prefix=".startup-", + suffix=".json", + delete=False, + ) as f: + temporary = f.name + json.dump({"enabled": enabled, "args": saved_args(args)}, f, indent=2) + try: + os.replace(temporary, root / "startup.json") + finally: + Path(temporary).unlink(missing_ok=True) + + +def windows_task_xml(command, directory: str, user: str) -> str: + """No battery restrictions, time limit, elevation, or cross-user login trigger.""" + task = ET.Element( + "Task", + version="1.2", + xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task", + ) + + def child(parent, name, text=None, **attrs): + element = ET.SubElement(parent, name, **attrs) + element.text = text + return element + + trigger = child(child(task, "Triggers"), "LogonTrigger") + child(trigger, "Enabled", "true") + child(trigger, "UserId", user) + principal = child(child(task, "Principals"), "Principal", id="Author") + child(principal, "UserId", user) + child(principal, "LogonType", "InteractiveToken") + child(principal, "RunLevel", "LeastPrivilege") + settings = child(task, "Settings") + for name, value in ( + ("MultipleInstancesPolicy", "IgnoreNew"), + ("DisallowStartIfOnBatteries", "false"), + ("StopIfGoingOnBatteries", "false"), + ("StartWhenAvailable", "true"), + ("AllowStartOnDemand", "true"), + ("Enabled", "true"), + ("ExecutionTimeLimit", "PT0S"), + ): + child(settings, name, value) + action = child(child(task, "Actions", Context="Author"), "Exec") + child(action, "Command", command[0]) + child(action, "Arguments", subprocess.list2cmdline(command[1:])) + child(action, "WorkingDirectory", directory) + return ET.tostring(task, encoding="unicode") + + +def systemd_quote(value: str, *, command=False) -> str: + # Unit-file quoting is not shell quoting. Escape specifiers, and disable + # environment expansion in ExecStart arguments by doubling dollars. + value = value.replace("%", "%%") + if command: + value = value.replace("$", "$$") + return json.dumps(value, ensure_ascii=False) diff --git a/launcher/README.md b/launcher/README.md index 4dd6209a..64e3b311 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -50,6 +50,14 @@ State is read the same way `craftbot.py` writes it in source mode: `craftbot.pid` beside `craftbot.py`, and `.agent-ready` under the user data directory for "running and actually serving" versus "still starting". +Install and Repair refresh login startup through `craftbot.py install`, using +the current interpreter, source directory and saved ports. Automatic startup +defaults to enabled; `craftbot.py autostart disable` opts out without uninstalling +or stopping the current session, and `autostart enable` restores it. The choice +is kept in `startup.json` under the user data directory and survives Repair. +Desktop shortcuts only open the browser. Login startup writes its own PID and +logs, so the launcher can also recognize an agent started after a reboot. + ### Files the launcher owns | | Windows | macOS | Linux | diff --git a/run.py b/run.py index 3ffdc2ca..41509a44 100644 --- a/run.py +++ b/run.py @@ -44,7 +44,9 @@ from app import node_runtime from app.console import configure_console -configure_console() +configure_console( + log_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "craftbot.log") +) multiprocessing.freeze_support() @@ -79,6 +81,8 @@ def _bootstrap_state(): CRAFTBOT_HOME is seeded in a dev checkout too. """ if _paths.CODE_ROOT.resolve() == _paths.STATE_ROOT.resolve(): + # Login tasks do not inherit the installer's working directory. + os.chdir(_paths.STATE_ROOT) return import shutil as _shutil @@ -1484,6 +1488,17 @@ def _patched_init(self, *args, **kwargs): # interpreter (the one the dependencies live in) before doing anything. python_runtime.reexec_if_needed() + # Login services run this script directly, rather than through cmd_start. + # Record their identity too, so status/stop/the installer can find them. + # Consume the internal flag here; it must not reach the agent's CLI. + if "--service" in sys.argv: + sys.argv.remove("--service") + import craftbot + + if not craftbot._claim_service_process(): + sys.exit(0) + atexit.register(craftbot._release_service_process, os.getpid()) + args_list = sys.argv[1:] args = set(args_list) diff --git a/scripts/package_source.py b/scripts/package_source.py index b5560921..703a011e 100644 --- a/scripts/package_source.py +++ b/scripts/package_source.py @@ -147,6 +147,7 @@ def build(output: Path) -> int: "installer/helpers.py", "installer/metadata.py", "installer/payload.py", + "installer/startup.py", ] with zipfile.ZipFile(output) as zf: names = set(zf.namelist()) diff --git a/scripts/test_install_e2e.py b/scripts/test_install_e2e.py index 3e85165f..dae8829d 100644 --- a/scripts/test_install_e2e.py +++ b/scripts/test_install_e2e.py @@ -129,6 +129,7 @@ def main() -> int: # are exec'd from source so their absence is silent until called. required = [ "run.py", + "installer/startup.py", "app/provision/__init__.py", "app/data/action/read_pdf.py", "craftos_integrations/providers/whatsapp_web/bridge.js", diff --git a/tests/test_autostart.py b/tests/test_autostart.py new file mode 100644 index 00000000..a0a46619 --- /dev/null +++ b/tests/test_autostart.py @@ -0,0 +1,393 @@ +"""Login startup must work without the installer's console or environment.""" + +import json +import os +import plistlib +import shutil +import subprocess +import sys +import textwrap +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +import craftbot +from installer import startup + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """Override the suite's model fixture: startup must need only the stdlib.""" + + +@pytest.fixture(autouse=True) +def isolated_startup(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", tmp_path) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr(craftbot, "PID_FILE", str(tmp_path / "craftbot.pid")) + monkeypatch.setattr(craftbot, "LOG_FILE", str(tmp_path / "craftbot.log")) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: None) + monkeypatch.setattr(craftbot, "_close_console_window", lambda: None) + monkeypatch.setattr(craftbot, "_warn_path_issues", lambda: None) + monkeypatch.setattr(craftbot.time, "sleep", lambda seconds: None) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_install_refreshes_registration_and_preserves_opt_out( + tmp_path, monkeypatch, enabled +): + startup.write(tmp_path, enabled, ["--frontend-port", "8123", "--backend-port=8124"]) + events = [] + monkeypatch.setattr( + craftbot, "cmd_start", lambda args: events.append(("start", args)) or True + ) + # An existing registration is no reason to skip refreshing it. + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr( + craftbot, + "_configure_autostart", + lambda on, args: events.append((on, args)) or True, + ) + assert craftbot.cmd_install(["--no-open-browser"]) + assert events == [ + ( + "start", + ["--frontend-port", "8123", "--backend-port=8124", "--no-open-browser"], + ), + ( + enabled, + ["--frontend-port", "8123", "--backend-port=8124", "--no-open-browser"], + ), + ] + + +def test_fresh_install_defaults_to_automatic_startup(monkeypatch): + choices = [] + monkeypatch.setattr(craftbot, "cmd_start", lambda args: True) + monkeypatch.setattr( + craftbot, "_configure_autostart", lambda on, args: choices.append(on) or True + ) + assert craftbot.cmd_install([]) + assert choices == [True] + + +def test_install_opt_out_and_registration_failure_are_not_silent(monkeypatch): + choices = [] + monkeypatch.setattr(craftbot, "cmd_start", lambda args: True) + monkeypatch.setattr( + craftbot, + "_configure_autostart", + lambda on, args: choices.append((on, args)) or False, + ) + assert not craftbot.cmd_install(["--no-autostart"]) + assert choices == [(False, [])] + + +def test_enable_disable_persist_without_stopping_current_session(tmp_path, monkeypatch): + events = [] + + def dispatch(**handlers): + if "_install_windows" in getattr(handlers["win"], "__name__", ""): + return lambda args: events.append(("enable", args)) or True + return lambda **kwargs: events.append(("disable", kwargs)) or True + + monkeypatch.setattr(craftbot._helpers, "dispatch_per_platform", dispatch) + assert craftbot.cmd_autostart(["disable", "--frontend-port", "8123"]) + assert startup.read(tmp_path) == { + "enabled": False, + "args": ["--frontend-port", "8123"], + } + assert events[0] == ("disable", {"stop": False}) + assert craftbot.cmd_autostart(["enable"]) + assert events[1] == ("enable", ["--frontend-port", "8123", "--no-open-browser"]) + assert startup.read(tmp_path)["enabled"] is True + + +def test_repair_port_override_does_not_keep_old_port(): + assert startup.merge_args( + ["--frontend-port", "8123", "--backend-port=8124", "--conda"], + ["--frontend-port=9123", "--no-conda", "--no-autostart"], + ) == ["--backend-port=8124", "--frontend-port=9123", "--no-conda"] + + +def test_startup_failure_does_not_persist_success(tmp_path, monkeypatch): + monkeypatch.setattr( + craftbot._helpers, "dispatch_per_platform", lambda **kw: lambda args: False + ) + assert not craftbot.cmd_autostart(["enable"]) + assert not (tmp_path / "startup.json").exists() + + +def test_windows_registration_replaces_task_and_removes_registry_duplicate( + tmp_path, monkeypatch +): + monkeypatch.setattr(craftbot, "_PLATFORM", "win32") + python = str(tmp_path / "Python & tools" / "pythonw.exe") + monkeypatch.setattr(craftbot, "_python_exe", lambda: python) + definitions = [] + + def run(cmd, **kwargs): + if cmd[0] == "whoami": + return subprocess.CompletedProcess( + cmd, 0, '"domain\\user","S-1-5-21-123"\n' + ) + assert cmd[:2] == ["schtasks", "/create"] + definitions.append( + ET.fromstring(Path(cmd[cmd.index("/xml") + 1]).read_text(encoding="utf-16")) + ) + return subprocess.CompletedProcess(cmd, 0) + + cleaned = [] + monkeypatch.setattr(craftbot.subprocess, "run", run) + monkeypatch.setattr( + craftbot, "_remove_windows_registry", lambda: cleaned.append(True) or True + ) + assert craftbot._install_windows(["--frontend-port", "8123"]) + root = definitions[0] + + def value(path): + return root.findtext("/".join("{*}" + part for part in path.split("/"))) + + assert value("Actions/Exec/Command") == python + assert value("Actions/Exec/WorkingDirectory") == str(tmp_path) + arguments = value("Actions/Exec/Arguments") + assert "--service" in arguments and "--no-open-browser" in arguments + assert "--frontend-port 8123" in arguments + assert value("Triggers/LogonTrigger/UserId") == "S-1-5-21-123" + assert value("Settings/DisallowStartIfOnBatteries") == "false" + assert value("Settings/StopIfGoingOnBatteries") == "false" + assert value("Settings/ExecutionTimeLimit") == "PT0S" + assert value("Settings/StartWhenAvailable") == "true" + assert cleaned == [True] + + +def test_windows_registry_fallback_replaces_old_task_first(monkeypatch): + events = [] + + def run(cmd, **kwargs): + events.append(cmd[:2]) + if cmd[0] == "whoami": + return subprocess.CompletedProcess(cmd, 0, '"user","S-1-5-21-123"') + return subprocess.CompletedProcess(cmd, 1 if "/create" in cmd else 0) + + monkeypatch.setattr(craftbot.subprocess, "run", run) + monkeypatch.setattr( + craftbot, + "_install_windows_registry", + lambda action: events.append(action) or True, + ) + assert craftbot._install_windows([]) + assert events[-2] == ["schtasks", "/delete"] + assert "--service" in events[-1] + assert "--no-open-browser" in events[-1] + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_unix_registration_quotes_paths_and_keeps_ports( + tmp_path, monkeypatch, platform +): + home = tmp_path / "Home & space" + home.mkdir() + monkeypatch.setattr(craftbot, "_PLATFORM", platform) + monkeypatch.setattr( + craftbot.os.path, "expanduser", lambda path: str(home / path.removeprefix("~/")) + ) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/Python & tools/python3") + monkeypatch.setattr( + craftbot.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0), + ) + if platform == "darwin": + assert craftbot._install_macos(["--frontend-port", "8123"]) + data = plistlib.loads( + (home / "Library/LaunchAgents/com.craftbot.agent.plist").read_bytes() + ) + assert data["ProgramArguments"] == [ + "/Python & tools/python3", + craftbot.RUN_SCRIPT, + "--frontend-port", + "8123", + "--no-open-browser", + "--service", + ] + assert data["RunAtLoad"] is True + else: + assert craftbot._install_linux(["--frontend-port", "8123"]) + content = (home / ".config/systemd/user/craftbot.service").read_text() + assert 'ExecStart="/Python & tools/python3"' in content + assert '"--service"' in content and '"8123"' in content + assert craftbot._uninstall_linux(stop=False) + assert not (home / ".config/systemd/user/craftbot.service").exists() + + +def test_service_pid_does_not_replace_running_app(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot, "_owned_pid", lambda: 123) + assert not craftbot._claim_service_process() + assert not Path(craftbot.PID_FILE).exists() + + +def test_service_pid_is_published_and_cleared(): + assert craftbot._claim_service_process() + assert craftbot._owned_pid() == os.getpid() + craftbot._release_service_process(os.getpid()) + assert not Path(craftbot.PID_FILE).exists() + + +@pytest.mark.skipif( + sys.platform != "win32", reason="requires actual pythonw.exe handles" +) +def test_cold_pythonw_startup_logs_and_survives_interpreter_handoff(tmp_path): + pythonw = Path(sys.executable).with_name("pythonw.exe") + if not pythonw.exists(): + pytest.skip("pythonw.exe is not installed") + repo = Path(__file__).resolve().parents[1] + (tmp_path / "config.json").write_text( + json.dumps({"python_executable": sys.executable}) + ) + probe = tmp_path / "startup_probe.py" + probe.write_text( + textwrap.dedent("""\ + import json, os, pathlib, sys + sys.path.insert(0, sys.argv[1]) + work = pathlib.Path(sys.argv[2]) + from app import python_runtime + from app.console import configure_console + if not os.environ.get("CRAFTBOT_PY_REEXEC"): + (work / "initial.json").write_text(json.dumps({"stdout_missing": sys.stdout is None})) + configure_console(log_file=str(work / "startup.log")) + python_runtime.CONFIG_FILE = str(work / "config.json") + python_runtime.reexec_if_needed() + print("HANDOFF COMPLETE", flush=True) + (work / "result.json").write_text(json.dumps({"python": sys.executable})) + """), + encoding="utf-8", + ) + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("CRAFTBOT_", "CONDA_")) + and k not in ("PYTHONPATH", "VIRTUAL_ENV") + } + proc = subprocess.run( + [str(pythonw), str(probe), str(repo), str(tmp_path)], + cwd=os.environ["WINDIR"], + env=env, + close_fds=True, + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, + timeout=30, + ) + assert proc.returncode == 0 + assert json.loads((tmp_path / "initial.json").read_text())["stdout_missing"] is True + assert ( + json.loads((tmp_path / "result.json").read_text())["python"] == sys.executable + ) + assert "HANDOFF COMPLETE" in (tmp_path / "startup.log").read_text() + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows shortcuts") +def test_real_windows_shortcut_refreshes_url_without_launching_agent( + tmp_path, monkeypatch +): + desktop = tmp_path / "Desktop O'Hare $literal" + desktop.mkdir() + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_ensure_ico", lambda: None) + craftbot._create_desktop_shortcut_windows([]) + craftbot._create_desktop_shortcut_windows(["--frontend-port", "8123"]) + link = desktop / "CraftBot.lnk" + inspect = tmp_path / "inspect.ps1" + inspect.write_text( + "$ws = New-Object -ComObject WScript.Shell\n" + f"$s = $ws.CreateShortcut({craftbot._ps_literal(str(link))})\n" + "$s | Select-Object TargetPath,Arguments | ConvertTo-Json\n", + encoding="utf-8-sig", + ) + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(inspect), + ], + capture_output=True, + text=True, + check=True, + ) + shortcut = json.loads(result.stdout) + assert Path(shortcut["TargetPath"]).name.lower() == "cmd.exe" + assert shortcut["Arguments"] == '/c start "" http://localhost:8123' + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires pythonw.exe") +def test_real_run_service_entry_from_unrelated_working_directory(tmp_path): + """Run the actual entry point, stopping at preflight before real servers start.""" + repo = Path(__file__).resolve().parents[1] + tree = tmp_path / "installed CraftBot" + for relative in ( + "run.py", + "craftbot.py", + "app/__init__.py", + "app/paths.py", + "app/console.py", + "app/python_runtime.py", + "app/runtime_preflight.py", + "app/node_runtime.py", + "installer/__init__.py", + "installer/helpers.py", + "installer/metadata.py", + "installer/payload.py", + "installer/startup.py", + ): + dest = tree / relative + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(repo / relative, dest) + # A disposable source installation: no user data or real app dependencies. + (tree / "install.py").touch() + (tree / "requirements.txt").touch() + pythonw = Path(sys.executable).with_name("pythonw.exe") + (tree / "config.json").write_text(json.dumps({"python_executable": str(pythonw)})) + (tree / "craftbot.pid").write_text('{"pid": 2147483647, "started": 1}') + runner = tmp_path / "entry_probe.py" + runner.write_text( + textwrap.dedent("""\ + import json, os, pathlib, runpy, sys + tree = pathlib.Path(sys.argv[1]) + sys.path.insert(0, str(tree)) + from app import runtime_preflight + def reached_preflight(**kwargs): + import craftbot + assert craftbot._owned_pid() == os.getpid() + assert "--service" not in sys.argv + assert pathlib.Path.cwd() == tree + print("SERVICE ENTRY OK", flush=True) + (tree / "preflight-reached").touch() + raise SystemExit(0) + runtime_preflight.ensure_runtime_dependencies = reached_preflight + sys.argv = [str(tree / "run.py"), "--service", "--no-open-browser"] + runpy.run_path(str(tree / "run.py"), run_name="__main__") + """), + encoding="utf-8", + ) + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("CRAFTBOT_", "CONDA_")) + and k not in ("PYTHONPATH", "VIRTUAL_ENV") + } + child = subprocess.run( + [str(pythonw), str(runner), str(tree)], + cwd=os.environ["WINDIR"], + env=env, + close_fds=True, + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, + timeout=30, + ) + assert child.returncode == 0, (tree / "craftbot.log").read_text(encoding="utf-8") + assert (tree / "preflight-reached").exists() + assert not (tree / "craftbot.pid").exists() + assert "SERVICE ENTRY OK" in (tree / "craftbot.log").read_text(encoding="utf-8") diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 8a39e267..4f794e7a 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import shlex import subprocess import sys @@ -8,6 +7,20 @@ import craftbot +@pytest.fixture(autouse=True) +def configured_context_window(): + """Service management runs before the agent's model dependencies exist.""" + + +@pytest.fixture(autouse=True) +def isolated_service_state(tmp_path, monkeypatch): + monkeypatch.setattr(craftbot, "PID_FILE", str(tmp_path / "craftbot.pid")) + monkeypatch.setattr(craftbot, "LOG_FILE", str(tmp_path / "craftbot.log")) + monkeypatch.setattr(craftbot, "_orphan_listeners", lambda args=None: []) + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", tmp_path) + monkeypatch.setattr(craftbot.paths, "AGENT_READY_FILE", tmp_path / ".agent-ready") + + class _ExitedProcess: pid = 12345 @@ -67,106 +80,33 @@ def fake_popen(*args, **kwargs): assert events == [] -def test_macos_source_shortcut_uses_custom_backend_port(tmp_path, monkeypatch, capsys): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "Craft Bot" - base_dir.mkdir() - python_exe = "/Applications/Python 3.10/bin/python3.10" - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) - - craftbot._create_desktop_shortcut_unix(["--backend-port", "8123"]) - - shortcut = desktop / "CraftBot.command" - content = shortcut.read_text() - assert f"cd {shlex.quote(str(base_dir))}" in content - assert "curl -fsS http://localhost:7925" in content - assert ( - "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" - in content - ) - assert '[ "$backend_status" -ge 100 ]' in content - assert '[ "$backend_status" -lt 500 ]' in content - assert "curl -fsS http://localhost:8123" not in content - assert "http://localhost:7926" not in content - assert "open http://localhost:7925" in content - assert ( - f"exec {shlex.quote(python_exe)} craftbot.py start --backend-port 8123" - in content - ) - - output = capsys.readouterr().out - assert "Desktop shortcut created" in output - - -def test_macos_source_shortcut_accepts_equals_backend_port(tmp_path, monkeypatch): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") - - craftbot._create_desktop_shortcut_unix(["--backend-port=8123"]) - - content = (desktop / "CraftBot.command").read_text() - assert ( - "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" - in content - ) - assert "craftbot.py start --backend-port=8123" in content - - -def test_shortcut_keeps_agent_app_port(tmp_path, monkeypatch): +@pytest.mark.parametrize( + "platform,filename", [("darwin", "CraftBot.command"), ("linux", "CraftBot.desktop")] +) +@pytest.mark.parametrize("frozen", [False, True]) +def test_unix_shortcuts_only_open_the_configured_url( + tmp_path, monkeypatch, platform, filename, frozen +): desktop = tmp_path / "Desktop" desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", False) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_PLATFORM", platform) + monkeypatch.setattr(craftbot, "IS_FROZEN", frozen) monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") - + # Replace an older shortcut which may have started the app itself. + (desktop / filename).write_text("old shortcut: craftbot.py start") craftbot._create_desktop_shortcut_unix( - ["--backend-port", "17926", "--agent-app-port", "13100", "--no-conda"] + ["--frontend-port=8123", "--backend-port", "8124"] ) - - content = (desktop / "CraftBot.command").read_text() - assert "craftbot.py start --backend-port 17926 --agent-app-port 13100" in content - assert "--no-conda" not in content - - -def test_macos_frozen_shortcut_starts_installed_agent_executable(tmp_path, monkeypatch): - desktop = tmp_path / "Desktop" - desktop.mkdir() - base_dir = tmp_path / "CraftBot" - base_dir.mkdir() - installed_agent = "/Applications/CraftBot/CraftBot Agent" - - monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") - monkeypatch.setattr(craftbot, "IS_FROZEN", True) - monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) - monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/bad/python") - monkeypatch.setattr(craftbot, "_installed_launch", lambda: [installed_agent]) - - craftbot._create_desktop_shortcut_unix(["--frontend-port", "9000"]) - - content = (desktop / "CraftBot.command").read_text() - assert f"exec {shlex.quote(installed_agent)} --frontend-port 9000" in content - assert "craftbot.py start" not in content - assert "/bad/python" not in content + content = (desktop / filename).read_text() + assert "http://localhost:8123" in content + assert "7925" not in content + assert "craftbot.py" not in content + assert "run.py" not in content + assert "curl" not in content + if platform == "darwin": + assert content == "#!/bin/sh\nexec open http://localhost:8123\n" + else: + assert "Exec=" in content def test_start_ignores_stale_ready_marker_when_child_exits( From a97a2f17764f6ffdf94e73ae13f5ec3715359034 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 17:54:50 +0000 Subject: [PATCH 02/20] test: exercise full installs through native startup managers --- .github/workflows/startup-acceptance.yml | 82 +++++++ scripts/test_startup_acceptance.py | 289 +++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 .github/workflows/startup-acceptance.yml create mode 100644 scripts/test_startup_acceptance.py diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml new file mode 100644 index 00000000..eb153f78 --- /dev/null +++ b/.github/workflows/startup-acceptance.yml @@ -0,0 +1,82 @@ +name: Startup acceptance + +on: + push: + branches: [fix/system-restart-auto-launch] + paths: + - 'craftbot.py' + - 'run.py' + - 'installer/**' + - 'app/console.py' + - 'app/python_runtime.py' + - 'scripts/test_startup_acceptance.py' + - '.github/workflows/startup-acceptance.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: startup-acceptance-${{ github.ref }} + cancel-in-progress: true + +jobs: + install-and-start: + name: ${{ matrix.method }} (${{ matrix.os }}) + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + method: [cli, installer] + runs-on: ${{ matrix.os }} + env: + PYTHONUNBUFFERED: '1' + PYTHONUTF8: '1' + PIP_NO_CACHE_DIR: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Linux desktop and service prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev xvfb dbus-user-session + sudo loginctl enable-linger "$USER" + sudo systemctl start "user@$(id -u).service" + echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" + echo 'DISPLAY=:99' >> "$GITHUB_ENV" + Xvfb :99 -screen 0 1280x720x24 > "$RUNNER_TEMP/xvfb.log" 2>&1 & + - name: Build source payload + shell: bash + run: | + cd app/ui_layer/browser/frontend + npm ci + npx vite build + cd ../../../.. + python scripts/package_source.py + - uses: dtolnay/rust-toolchain@stable + if: matrix.method == 'installer' + - uses: Swatinem/rust-cache@v2 + if: matrix.method == 'installer' + with: + workspaces: launcher + - name: Build native installer + if: matrix.method == 'installer' + working-directory: launcher + run: cargo build --release --locked + # Hosted runners test a real installation and real startup managers. + # They do NOT prove an OS reboot, login trigger or interactive GUI login. + - name: Install, start through OS manager, repair and toggle auto-start + run: python scripts/test_startup_acceptance.py --method ${{ matrix.method }} + - name: Upload acceptance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: startup-${{ matrix.method }}-${{ matrix.os }} + path: startup-evidence/ diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py new file mode 100644 index 00000000..35a51bf2 --- /dev/null +++ b/scripts/test_startup_acceptance.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Full install and OS startup-manager acceptance on disposable hosted runners. + +This deliberately refuses to run on a developer's machine: it installs real +dependencies, replaces the current user's CraftBot startup registration, and +stops processes on CraftBot's ports. It does NOT reboot the machine or claim +to exercise a real login trigger. See result.json for the exact coverage. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import time +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +EVIDENCE = ROOT / "startup-evidence" + + +def run(command, *, cwd=None, check=True, timeout=3600): + print(f"$ {subprocess.list2cmdline([str(arg) for arg in command])}", flush=True) + # Save complete output without flooding CI logs with pip/npm progress. + with (EVIDENCE / "commands.log").open("a", encoding="utf-8") as log: + log.write(f"\n$ {command}\n") + log.flush() + result = subprocess.run( + command, + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + timeout=timeout, + ) + if check and result.returncode: + raise RuntimeError(f"Command exited {result.returncode}: {command}") + return result.returncode + + +def port_open(port): + try: + with socket.create_connection(("127.0.0.1", port), timeout=2): + return True + except OSError: + return False + + +def user_data(): + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / "CraftBot" + if sys.platform == "darwin": + return Path.home() / "Library/Application Support/CraftBot" + return ( + Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "craftbot" + ) + + +def wait_ready(tree, state, timeout=900): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if (state / ".agent-ready").exists() and (tree / "craftbot.pid").exists(): + try: + with urllib.request.urlopen( + "http://127.0.0.1:7925", timeout=5 + ) as response: + html = response.read().decode("utf-8") + if 'id="root"' in html and port_open(7926): + return json.loads((tree / "craftbot.pid").read_text()) + except (OSError, ValueError): + pass + time.sleep(5) + raise RuntimeError( + "CraftBot did not become ready (PID, agent-ready, frontend and backend)" + ) + + +def start_registered(): + if sys.platform == "win32": + run(["schtasks", "/run", "/tn", "CraftBot"], timeout=30) + elif sys.platform == "darwin": + plist = Path.home() / "Library/LaunchAgents/com.craftbot.agent.plist" + run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist)], timeout=30) + else: + # Only the display required by the virtual desktop is supplied. Do + # not import PATH, conda activation, or the install shell environment. + run(["systemctl", "--user", "set-environment", "DISPLAY=:99"], timeout=30) + run(["systemctl", "--user", "start", "craftbot"], timeout=30) + + +def stop_registered(): + if sys.platform == "win32": + run(["schtasks", "/end", "/tn", "CraftBot"], check=False, timeout=30) + elif sys.platform == "darwin": + run( + ["launchctl", "bootout", f"gui/{os.getuid()}/com.craftbot.agent"], + check=False, + timeout=30, + ) + else: + run(["systemctl", "--user", "stop", "craftbot"], check=False, timeout=30) + + +def check_shortcut(): + desktop = Path.home() / "Desktop" + if sys.platform == "win32": + target = str(desktop / "CraftBot.lnk").replace("'", "''") + ps = EVIDENCE / "shortcut.ps1" + ps.write_text( + f"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('{target}')\n" + "@{target=$s.TargetPath; arguments=$s.Arguments} | ConvertTo-Json\n", + encoding="utf-8-sig", + ) + result = subprocess.check_output( + ["powershell", "-NoProfile", "-File", str(ps)], text=True + ) + (EVIDENCE / "shortcut.json").write_text(result, encoding="utf-8") + data = json.loads(result) + assert Path(data["target"]).name.lower() == "cmd.exe", data + assert data["arguments"] == '/c start "" http://localhost:7925', data + else: + path = desktop / ( + "CraftBot.command" if sys.platform == "darwin" else "CraftBot.desktop" + ) + content = path.read_text() + shutil.copy2(path, EVIDENCE / path.name) + if sys.platform == "darwin": + assert content == "#!/bin/sh\nexec open http://localhost:7925\n", content + else: + action = next( + line for line in content.splitlines() if line.startswith("Exec=") + ) + assert action in ( + "Exec=xdg-open http://localhost:7925", + "Exec=sensible-browser http://localhost:7925", + ), action + + +def collect(tree, state): + for name, directory in ( + ("code", tree), + ("state", state), + ("launcher", user_data()), + ): + for filename in ( + "craftbot.log", + "craftbot.pid", + "startup.json", + "launcher.log", + "config.json", + ): + source = directory / filename + if source.is_file(): + shutil.copy2(source, EVIDENCE / f"{name}-{filename}") + if sys.platform == "win32": + run(["schtasks", "/query", "/tn", "CraftBot", "/xml"], check=False, timeout=30) + elif sys.platform == "darwin": + run( + ["launchctl", "print", f"gui/{os.getuid()}/com.craftbot.agent"], + check=False, + timeout=30, + ) + else: + run( + ["journalctl", "--user", "-u", "craftbot", "--no-pager", "-n", "250"], + check=False, + timeout=30, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--method", choices=("cli", "installer"), required=True) + args = parser.parse_args() + if ( + os.environ.get("GITHUB_ACTIONS") != "true" + or os.environ.get("RUNNER_ENVIRONMENT") != "github-hosted" + ): + parser.error( + "This destructive acceptance test requires a disposable GitHub-hosted runner" + ) + EVIDENCE.mkdir(exist_ok=True) + # Startup must use durable registered paths, not a development override. + for key in list(os.environ): + if key.startswith(("CRAFTBOT_", "CONDA_")) or key in ( + "PYTHONPATH", + "VIRTUAL_ENV", + ): + os.environ.pop(key) + (Path.home() / "Desktop").mkdir(exist_ok=True) + tree = Path(os.environ["RUNNER_TEMP"]) / f"CraftBot {args.method} installation" + state = tree if args.method == "cli" else user_data() + python = sys.executable + installer = ( + ROOT + / "launcher/target/release" + / ("CraftBotInstaller.exe" if sys.platform == "win32" else "CraftBotInstaller") + ) + result = { + "method": args.method, + "platform": sys.platform, + "os_reboot_tested": False, + "login_trigger_tested": False, + "passed": [], + } + + def cli(*arguments, check=True): + return run( + [python, str(tree / "craftbot.py"), *arguments], cwd=tree, check=check + ) + + try: + if port_open(7925) or port_open(7926): + raise RuntimeError( + "Fresh runner already has a listener on CraftBot's ports" + ) + if args.method == "cli": + with zipfile.ZipFile(ROOT / "dist/CraftBot-src.zip") as archive: + archive.extractall(tree) + cli("install", "--no-open-browser") + else: + os.environ["CRAFTBOT_SRC_ZIP"] = str(ROOT / "dist/CraftBot-src.zip") + run([str(installer), "--headless", "install", str(tree)]) + record = json.loads((user_data() / "launcher.json").read_text()) + python = record["python"] + assert Path(record["install_dir"]).resolve() == tree.resolve() + initial = wait_ready(tree, state) + result["passed"].append("full_install_and_readiness") + assert json.loads((state / "startup.json").read_text())["enabled"] is True + check_shortcut() + result["passed"].append("desktop_shortcut_contains_only_browser_open") + + cli("stop", check=False) + assert not port_open(7925) and not port_open(7926), "stop left a server running" + assert not (state / ".agent-ready").exists(), "stop left stale readiness" + start_registered() + restarted = wait_ready(tree, state) + assert initial["pid"] != restarted["pid"], "did not start a new process" + result["passed"].append("real_os_startup_manager_and_readiness") + + cli("autostart", "disable") + assert json.loads((state / "startup.json").read_text())["enabled"] is False + assert wait_ready(tree, state, timeout=30)["pid"] == restarted["pid"], ( + "disable stopped the current app" + ) + result["passed"].append("disable_keeps_current_app_running") + stop_registered() + cli("stop", check=False) + + if args.method == "installer": + run([str(installer), "--headless", "repair"]) + else: + cli("install", "--no-open-browser") + wait_ready(tree, state) + assert json.loads((state / "startup.json").read_text())["enabled"] is False + result["passed"].append("repair_preserves_opt_out") + cli("autostart", "enable") + cli("stop", check=False) + start_registered() + wait_ready(tree, state) + result["passed"].append("re_enable_and_startup_manager_readiness") + result["success"] = True + except Exception as exc: + result["success"] = False + result["error"] = str(exc) + print(f"FAILED: {exc}", flush=True) + finally: + (EVIDENCE / "result.json").write_text( + json.dumps(result, indent=2), encoding="utf-8" + ) + try: + collect(tree, state) + finally: + stop_registered() + if (tree / "craftbot.py").exists(): + cli("stop", check=False) + cli("autostart", "disable", check=False) + print(json.dumps(result, indent=2), flush=True) + return 0 if result["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 2c8a60aaefacd1ce624c3cd1042ccffecd79ee50 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 18:01:23 +0000 Subject: [PATCH 03/20] fix: preserve login state and correct systemd service paths --- craftbot.py | 15 ++++++++-- installer/startup.py | 9 ++++++ run.py | 16 +++++++--- scripts/test_startup_acceptance.py | 6 +++- tests/test_autostart.py | 47 +++++++++++++++++++++++++++--- tests/test_craftbot_service.py | 9 ++++++ 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/craftbot.py b/craftbot.py index ae878c7e..25962300 100644 --- a/craftbot.py +++ b/craftbot.py @@ -818,6 +818,10 @@ def cmd_stop(quiet: bool = False, args: Optional[List[str]] = None) -> bool: if _stop_orphan_listeners(args): stopped = True + # A forced termination (and a reboot) cannot run the agent's cleanup. + # Do not leave the previous process's readiness behind after stop. + _clear_agent_ready() + if not stopped and not quiet: if had_record: print(f" {DIM}▸ CraftBot not running — cleaned up stale PID file{RESET}") @@ -1135,8 +1139,13 @@ def _autostart_launch(run_args: List[str]) -> Optional[List[str]]: print("No installed CraftBot found — run install first.") return None args = _build_run_args(run_args) - if os.path.basename(launch[-1]) == "run.py" and "--service" not in args: - args.append("--service") + if os.path.basename(launch[-1]) == "run.py": + if "--service" not in args: + args.append("--service") + # Login managers do not inherit CRAFTBOT_HOME / XDG_DATA_HOME from + # the installation shell. Keep the same data and preferences after + # login, including when the user relocated them. + args.append(f"--state-root={paths.STATE_ROOT.resolve()}") return launch + args @@ -1328,7 +1337,7 @@ def _install_linux(run_args: List[str]) -> bool: [Service] Type=simple ExecStart={exec_start} -WorkingDirectory={_startup.systemd_quote(directory)} +WorkingDirectory={_startup.systemd_path(directory)} Restart=on-failure RestartSec=5 Environment=PYTHONUNBUFFERED=1 diff --git a/installer/startup.py b/installer/startup.py index 86cde625..04f84f57 100644 --- a/installer/startup.py +++ b/installer/startup.py @@ -124,3 +124,12 @@ def systemd_quote(value: str, *, command=False) -> str: if command: value = value.replace("$", "$$") return json.dumps(value, ensure_ascii=False) + + +def systemd_path(value: str) -> str: + # WorkingDirectory is one literal path, not a word list like ExecStart. + # systemd does not strip quotes here: a quoted path fails the absolute + # path check. Spaces are literal; only unit specifiers need escaping. + if "\n" in value or "\r" in value: + raise ValueError("systemd working directories cannot contain newlines") + return value.replace("%", "%%") diff --git a/run.py b/run.py index 41509a44..5991b287 100644 --- a/run.py +++ b/run.py @@ -33,16 +33,24 @@ import atexit from typing import Tuple, Optional, Dict, Any, List -from app.runtime_preflight import ( +# Internal service argument: apply before importing any app module, because +# app.paths resolves STATE_ROOT at import time. Keep it out of the agent's +# arguments; interpreter handoffs inherit the resulting environment. +for _argument in sys.argv[1:]: + if _argument.startswith("--state-root="): + os.environ["CRAFTBOT_HOME"] = _argument.split("=", 1)[1] + sys.argv.remove(_argument) + +from app.runtime_preflight import ( # noqa: E402 ensure_runtime_dependencies, mark_runtime_dependencies_checked, ) -from app import python_runtime +from app import python_runtime # noqa: E402 # Single resolved Node runtime — see app/node_runtime.py. install.py # downloads the sidecar when nothing suitable exists; run.py never installs. -from app import node_runtime -from app.console import configure_console +from app import node_runtime # noqa: E402 +from app.console import configure_console # noqa: E402 configure_console( log_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "craftbot.log") diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py index 35a51bf2..15f453ae 100644 --- a/scripts/test_startup_acceptance.py +++ b/scripts/test_startup_acceptance.py @@ -71,7 +71,11 @@ def wait_ready(tree, state, timeout=900): "http://127.0.0.1:7925", timeout=5 ) as response: html = response.read().decode("utf-8") - if 'id="root"' in html and port_open(7926): + with urllib.request.urlopen( + "http://127.0.0.1:7926/api/state", timeout=5 + ) as response: + backend = json.load(response) + if 'id="root"' in html and isinstance(backend, dict) and backend: return json.loads((tree / "craftbot.pid").read_text()) except (OSError, ValueError): pass diff --git a/tests/test_autostart.py b/tests/test_autostart.py index a0a46619..2fdfea41 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -212,6 +212,7 @@ def test_unix_registration_quotes_paths_and_keeps_ports( "8123", "--no-open-browser", "--service", + f"--state-root={tmp_path.resolve()}", ] assert data["RunAtLoad"] is True else: @@ -236,6 +237,34 @@ def test_service_pid_is_published_and_cleared(): assert not Path(craftbot.PID_FILE).exists() +@pytest.mark.skipif( + sys.platform != "linux" or not shutil.which("systemd-analyze"), + reason="requires systemd's real unit parser", +) +def test_generated_linux_unit_is_accepted_by_systemd(tmp_path, monkeypatch): + home = tmp_path / "Home with spaces" + home.mkdir() + monkeypatch.setattr( + craftbot.os.path, "expanduser", lambda path: str(home / path.removeprefix("~/")) + ) + monkeypatch.setattr(craftbot, "_python_exe", lambda: sys.executable) + real_run = subprocess.run + monkeypatch.setattr( + craftbot.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0), + ) + assert craftbot._install_linux([]) + unit = home / ".config/systemd/user/craftbot.service" + result = real_run( + ["systemd-analyze", "verify", str(unit)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + @pytest.mark.skipif( sys.platform != "win32", reason="requires actual pythonw.exe handles" ) @@ -324,7 +353,10 @@ def test_real_windows_shortcut_refreshes_url_without_launching_agent( @pytest.mark.skipif(sys.platform != "win32", reason="requires pythonw.exe") -def test_real_run_service_entry_from_unrelated_working_directory(tmp_path): +@pytest.mark.parametrize("relocated_state", [False, True]) +def test_real_run_service_entry_from_unrelated_working_directory( + tmp_path, monkeypatch, relocated_state +): """Run the actual entry point, stopping at preflight before real servers start.""" repo = Path(__file__).resolve().parents[1] tree = tmp_path / "installed CraftBot" @@ -352,23 +384,30 @@ def test_real_run_service_entry_from_unrelated_working_directory(tmp_path): pythonw = Path(sys.executable).with_name("pythonw.exe") (tree / "config.json").write_text(json.dumps({"python_executable": str(pythonw)})) (tree / "craftbot.pid").write_text('{"pid": 2147483647, "started": 1}') + state = tmp_path / "relocated user data" if relocated_state else tree + monkeypatch.setattr(craftbot.paths, "STATE_ROOT", state) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tree / "run.py")) + monkeypatch.setattr(craftbot, "_python_exe", lambda: str(pythonw)) + command = craftbot._autostart_launch([]) runner = tmp_path / "entry_probe.py" runner.write_text( textwrap.dedent("""\ import json, os, pathlib, runpy, sys tree = pathlib.Path(sys.argv[1]) + state = pathlib.Path(sys.argv[2]) + command = sys.argv[3:] sys.path.insert(0, str(tree)) from app import runtime_preflight def reached_preflight(**kwargs): import craftbot assert craftbot._owned_pid() == os.getpid() assert "--service" not in sys.argv - assert pathlib.Path.cwd() == tree + assert pathlib.Path.cwd() == state print("SERVICE ENTRY OK", flush=True) (tree / "preflight-reached").touch() raise SystemExit(0) runtime_preflight.ensure_runtime_dependencies = reached_preflight - sys.argv = [str(tree / "run.py"), "--service", "--no-open-browser"] + sys.argv = command runpy.run_path(str(tree / "run.py"), run_name="__main__") """), encoding="utf-8", @@ -380,7 +419,7 @@ def reached_preflight(**kwargs): and k not in ("PYTHONPATH", "VIRTUAL_ENV") } child = subprocess.run( - [str(pythonw), str(runner), str(tree)], + [str(pythonw), str(runner), str(tree), str(state), *command[1:]], cwd=os.environ["WINDIR"], env=env, close_fds=True, diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 4f794e7a..0426d2c4 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -28,6 +28,15 @@ def wait(self, timeout=None): return 1 +@pytest.mark.parametrize("pid", [None, 12345]) +def test_stop_clears_readiness_even_after_a_previous_crash(monkeypatch, pid): + craftbot.paths.AGENT_READY_FILE.write_text("stale process") + monkeypatch.setattr(craftbot, "_owned_pid", lambda: pid) + monkeypatch.setattr(craftbot, "_kill_owned_pid", lambda value: None) + craftbot.cmd_stop(quiet=True) + assert not craftbot.paths.AGENT_READY_FILE.exists() + + class _RunningProcess: pid = 23456 From 5d49ceaa8b820bf7f7ec930ef4eaeb97038978a6 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 18:07:42 +0000 Subject: [PATCH 04/20] fix: recover installer Python downloads after upstream retirement --- .github/workflows/startup-acceptance.yml | 3 + .github/workflows/startup.yml | 6 +- app/provision/runtimes.py | 44 ++++++------ launcher/src/python.rs | 90 +++++++++++++++++++----- scripts/test_startup_acceptance.py | 18 +++++ tests/test_python_downloads.py | 67 ++++++++++++++++++ 6 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 tests/test_python_downloads.py diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml index eb153f78..02ceb335 100644 --- a/.github/workflows/startup-acceptance.yml +++ b/.github/workflows/startup-acceptance.yml @@ -9,6 +9,8 @@ on: - 'installer/**' - 'app/console.py' - 'app/python_runtime.py' + - 'app/provision/runtimes.py' + - 'launcher/**' - 'scripts/test_startup_acceptance.py' - '.github/workflows/startup-acceptance.yml' workflow_dispatch: @@ -66,6 +68,7 @@ jobs: if: matrix.method == 'installer' with: workspaces: launcher + cache-on-failure: true - name: Build native installer if: matrix.method == 'installer' working-directory: launcher diff --git a/.github/workflows/startup.yml b/.github/workflows/startup.yml index a5de9993..8564e549 100644 --- a/.github/workflows/startup.yml +++ b/.github/workflows/startup.yml @@ -8,6 +8,8 @@ on: - 'installer/**' - 'app/console.py' - 'app/python_runtime.py' + - 'app/provision/runtimes.py' + - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' - '.github/workflows/startup.yml' @@ -18,6 +20,8 @@ on: - 'installer/**' - 'app/console.py' - 'app/python_runtime.py' + - 'app/provision/runtimes.py' + - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' - '.github/workflows/startup.yml' @@ -36,4 +40,4 @@ jobs: python-version: '3.10' - run: python -m pip install pytest==9.0.2 - name: Check login startup and browser-only shortcuts - run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py -q + run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py -q diff --git a/app/provision/runtimes.py b/app/provision/runtimes.py index 458e79e7..8a441194 100644 --- a/app/provision/runtimes.py +++ b/app/provision/runtimes.py @@ -23,16 +23,13 @@ # pip. python.org's embeddable package is Windows-only and ships without pip, # so it cannot serve the same role. # -# The release tag and patch version are RESOLVED FROM THE API, never -# hardcoded. Hardcoding them was tried and produced a silent 404: the guessed -# tag, patch version and platform triple were all wrong, so the download -# failed and the install aborted at the first stage. The asset name embeds -# all three (cpython-3.10.21+20260825-x86_64-pc-windows-msvc-install_only), -# so any one being stale breaks it. Same approach as node_runtime, which -# reads nodejs.org/dist/index.json rather than pinning a version. +# Resolve actual asset URLs from release metadata instead of constructing +# filenames. New releases stopped including Python 3.10 after its EOL, so +# retain the last published 3.10 release while these dependency locks need it. PBS_API = ( "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" ) +PBS_310_API = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009" # The version the locks are generated for. NOT a minimum: a lock is valid # for exactly one (platform, python) pair, so accepting "3.10 or newer" would @@ -175,8 +172,8 @@ def _pbs_triple() -> Optional[str]: def _pbs_download_url(log: LogFn) -> Optional[str]: """Resolve the download URL for a TARGET_PYTHON build, or None. - Asks the API for the newest release and picks the asset matching this - machine's triple. `install_only` is the variant that extracts to a + Tries the newest release, then the last release containing Python 3.10, + and picks the asset matching this machine's triple. `install_only` extracts to a ready-to-run tree; the full builds carry object files and headers nobody here needs. """ @@ -195,21 +192,24 @@ def _pbs_download_url(log: LogFn) -> Optional[str]: except ImportError: ctx = ssl.create_default_context() - try: - req = urllib.request.Request(PBS_API, headers={"User-Agent": "CraftBot"}) - data = json.loads(urllib.request.urlopen(req, timeout=60, context=ctx).read()) - except Exception as e: - log(f" could not reach the Python download index: {str(e)[:160]}") - return None - prefix = f"cpython-{TARGET_PYTHON[0]}.{TARGET_PYTHON[1]}." suffix = f"-{triple}-install_only.tar.gz" - for asset in data.get("assets", []): - name = asset.get("name") or "" - if name.startswith(prefix) and name.endswith(suffix): - return asset.get("browser_download_url") - - log(f" no {prefix}*{suffix} in release {data.get('tag_name')}") + indexes = [PBS_API] + ([PBS_310_API] if TARGET_PYTHON == (3, 10) else []) + for index in indexes: + try: + req = urllib.request.Request(index, headers={"User-Agent": "CraftBot"}) + with urllib.request.urlopen(req, timeout=60, context=ctx) as response: + data = json.load(response) + except Exception as e: + log(f" could not reach the Python download index: {str(e)[:160]}") + return None + for asset in data.get("assets", []): + name = asset.get("name") or "" + if name.startswith(prefix) and name.endswith(suffix): + url = asset.get("browser_download_url") + if url: + return url + log(f" no {prefix}*{suffix} in release {data.get('tag_name')}") return None diff --git a/launcher/src/python.rs b/launcher/src/python.rs index 33511602..93f0af9b 100644 --- a/launcher/src/python.rs +++ b/launcher/src/python.rs @@ -8,9 +8,9 @@ //! module puts it. When install.py later runs its own `python` stage it //! finds this one already there and accepts it; nothing is downloaded twice. //! -//! The release tag, patch version and platform triple are resolved from the -//! GitHub API rather than hardcoded, for the reason recorded in runtimes.py: -//! a hardcoded asset name went stale and produced a silent 404. +//! Asset URLs are resolved from GitHub release metadata, including the final +//! Python 3.10 release when the newest release no longer carries that version. +//! This mirrors app/provision/runtimes.py and its dependency-lock constraint. use crate::download; use crate::logger; @@ -23,6 +23,8 @@ pub const TARGET: (u32, u32) = (3, 10); const PBS_API: &str = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"; +const PBS_310_API: &str = + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009"; /// The interpreter to run CraftBot with, downloading it if needed. pub fn ensure( @@ -130,29 +132,83 @@ fn triple() -> Option { } fn resolve_url(triple: &str) -> Result { - let data = download::get_json(PBS_API) - .map_err(|e| format!("could not reach the Python download index: {e}"))?; + resolve_url_with(triple, download::get_json) +} + +fn resolve_url_with( + triple: &str, + mut fetch: impl FnMut(&str) -> Result, +) -> Result { let prefix = format!("cpython-{}.{}.", TARGET.0, TARGET.1); let suffix = format!("-{triple}-install_only.tar.gz"); - let assets = data - .get("assets") - .and_then(|a| a.as_array()) - .cloned() - .unwrap_or_default(); - for asset in &assets { - let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); - if name.starts_with(&prefix) && name.ends_with(&suffix) { - if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { - return Ok(url.to_string()); + let mut indexes = vec![PBS_API]; + if TARGET == (3, 10) { + indexes.push(PBS_310_API); + } + for index in indexes { + let data = + fetch(index).map_err(|e| format!("could not reach the Python download index: {e}"))?; + if let Some(assets) = data.get("assets").and_then(|a| a.as_array()) { + for asset in assets { + let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name.starts_with(&prefix) && name.ends_with(&suffix) { + if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { + if !url.is_empty() { + return Ok(url.to_string()); + } + } + } } } } - let tag = data.get("tag_name").and_then(|t| t.as_str()).unwrap_or("?"); Err(format!( - "no {prefix}*{suffix} in python-build-standalone release {tag}" + "no {prefix}*{suffix} in the checked python-build-standalone releases" )) } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resolves_retired_python_from_last_supported_release() { + for triple in [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ] { + let mut requests = Vec::new(); + let url = resolve_url_with(triple, |index| { + requests.push(index.to_string()); + Ok(if index == PBS_API { + json!({"assets": []}) + } else { + json!({"assets": [{ + "name": format!("cpython-3.10.22+20261009-{triple}-install_only.tar.gz"), + "browser_download_url": "https://example.test/compatible-python.tar.gz" + }]}) + }) + }) + .unwrap(); + assert_eq!(requests, [PBS_API, PBS_310_API]); + assert_eq!(url, "https://example.test/compatible-python.tar.gz"); + } + } + + #[test] + fn does_not_substitute_a_different_python_version() { + let result = resolve_url_with("x86_64-unknown-linux-gnu", |_| { + Ok(json!({"assets": [{ + "name": "cpython-3.11.0+20261110-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.test/wrong-python.tar.gz" + }]})) + }); + assert!(result.is_err()); + } +} + /// Extract a .tar.gz, preserving permissions — the executable bit on /// bin/python3 is the whole point on macOS and Linux. fn extract_tar_gz(archive: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py index 15f453ae..4beaf437 100644 --- a/scripts/test_startup_acceptance.py +++ b/scripts/test_startup_acceptance.py @@ -63,6 +63,7 @@ def user_data(): def wait_ready(tree, state, timeout=900): + started = time.monotonic() deadline = time.monotonic() + timeout while time.monotonic() < deadline: if (state / ".agent-ready").exists() and (tree / "craftbot.pid").exists(): @@ -79,6 +80,23 @@ def wait_ready(tree, state, timeout=900): return json.loads((tree / "craftbot.pid").read_text()) except (OSError, ValueError): pass + if time.monotonic() - started > 60: + # A dead startup process cannot become ready. Fail promptly with + # its logs instead of waiting the full model-download deadline. + probe = subprocess.run( + [ + sys.executable, + "-c", + "import craftbot; raise SystemExit(0 if craftbot._owned_pid() else 1)", + ], + cwd=tree, + capture_output=True, + timeout=30, + ) + if probe.returncode == 1: + raise RuntimeError( + "Startup process exited before CraftBot became ready" + ) time.sleep(5) raise RuntimeError( "CraftBot did not become ready (PID, agent-ready, frontend and backend)" diff --git a/tests/test_python_downloads.py b/tests/test_python_downloads.py new file mode 100644 index 00000000..062ef506 --- /dev/null +++ b/tests/test_python_downloads.py @@ -0,0 +1,67 @@ +"""A newer upstream release must not strand installs pinned to Python 3.10.""" + +import io +import json +import urllib.request + +import pytest + +from app.provision import runtimes + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """Download selection needs no model configuration.""" + + +@pytest.mark.parametrize( + "triple", + [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ], +) +def test_retired_python_uses_last_supported_release(monkeypatch, triple): + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: triple) + requests = [] + + def fetch(request, **kwargs): + requests.append(request.full_url) + assets = ( + [] + if request.full_url == runtimes.PBS_API + else [ + { + "name": f"cpython-3.10.22+20261009-{triple}-install_only.tar.gz", + "browser_download_url": "https://example.test/compatible-python.tar.gz", + } + ] + ) + return io.BytesIO(json.dumps({"assets": assets}).encode()) + + monkeypatch.setattr(urllib.request, "urlopen", fetch) + assert ( + runtimes._pbs_download_url(lambda message: None) + == "https://example.test/compatible-python.tar.gz" + ) + assert requests == [runtimes.PBS_API, runtimes.PBS_310_API] + + +def test_missing_matching_python_does_not_select_a_different_version(monkeypatch): + data = { + "assets": [ + { + "name": "cpython-3.11.0+20261110-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.test/wrong-python.tar.gz", + } + ] + } + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: "x86_64-unknown-linux-gnu") + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda *a, **kw: io.BytesIO(json.dumps(data).encode()), + ) + assert runtimes._pbs_download_url(lambda message: None) is None From 79699469d7293b2340b198116c3b64db098947ee Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 18:55:43 +0000 Subject: [PATCH 05/20] test: verify real reboot identities and graphical logins --- .github/workflows/restart-vm-probe.yml | 44 +++++ scripts/verify_restart.py | 262 +++++++++++++++++++++++++ tests/test_restart_verifier.py | 35 ++++ 3 files changed, 341 insertions(+) create mode 100644 .github/workflows/restart-vm-probe.yml create mode 100644 scripts/verify_restart.py create mode 100644 tests/test_restart_verifier.py diff --git a/.github/workflows/restart-vm-probe.yml b/.github/workflows/restart-vm-probe.yml new file mode 100644 index 00000000..78b93e22 --- /dev/null +++ b/.github/workflows/restart-vm-probe.yml @@ -0,0 +1,44 @@ +name: Reboot VM capability probe + +on: + push: + branches: [fix/system-restart-auto-launch] + paths: ['.github/workflows/restart-vm-probe.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos-hypervisor: + # Standard runners are free for this public repository. Check an Intel + # runner explicitly: Apple Silicon runners do not expose nested macOS VMs. + runs-on: macos-15-intel + timeout-minutes: 10 + steps: + - name: Inspect available virtualization + run: | + sw_vers + uname -m + sysctl kern.hv_support + df -h / + cat > "$RUNNER_TEMP/hv-probe.c" <<'EOF' + #include + #include + int main(void) { + hv_return_t result = hv_vm_create(HV_VM_DEFAULT); + printf("hv_vm_create: 0x%x\n", result); + if (result == HV_SUCCESS) { + printf("hv_vm_destroy: 0x%x\n", hv_vm_destroy()); + } + return 0; + } + EOF + cat > "$RUNNER_TEMP/hv-entitlements.plist" <<'EOF' + + + com.apple.security.hypervisor + EOF + clang "$RUNNER_TEMP/hv-probe.c" -framework Hypervisor -o "$RUNNER_TEMP/hv-probe" + codesign --force --sign - --entitlements "$RUNNER_TEMP/hv-entitlements.plist" "$RUNNER_TEMP/hv-probe" + "$RUNNER_TEMP/hv-probe" diff --git a/scripts/verify_restart.py b/scripts/verify_restart.py new file mode 100644 index 00000000..032e17d3 --- /dev/null +++ b/scripts/verify_restart.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Record proof of an OS reboot, graphical login and CraftBot startup state. + +Run on a disposable machine from a separate controller account. This verifier +never starts CraftBot, triggers its startup manager, or logs in the test user. +The provisioning controller must perform those actions independently. In +particular, do not SSH into the Linux test account before checking its GUI +login: doing so could start its systemd user service and mask a login failure. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import importlib.util +import json +import os +from pathlib import Path +import re +import socket +import subprocess +import sys +import time +import urllib.request + + +def output(command): + return subprocess.check_output( + command, + text=True, + encoding="utf-8", + timeout=30, + env={**os.environ, "LC_ALL": "C"}, + ).strip() + + +def powershell(script): + return json.loads( + output( + [ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); " + script, + ] + ) + ) + + +def boot_state(): + if sys.platform == "win32": + return powershell( + "$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; " + "@{id=$boot.ToUniversalTime().ToString('o'); " + "epoch=([DateTimeOffset]$boot).ToUnixTimeSeconds(); " + "machine=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid} " + "| ConvertTo-Json -Compress" + ) + if sys.platform == "darwin": + boot = output(["sysctl", "-n", "kern.boottime"]) + hardware = output(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"]) + return { + "id": output(["sysctl", "-n", "kern.bootsessionuuid"]), + "epoch": int(re.search(r"sec = (\d+)", boot).group(1)), + "machine": re.search(r'"IOPlatformUUID" = "([^"]+)"', hardware).group(1), + } + return { + "id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), + "epoch": int( + re.search(r"^btime (\d+)$", Path("/proc/stat").read_text(), re.M).group(1) + ), + "machine": Path("/etc/machine-id").read_text().strip(), + } + + +def graphical_login(user, boot): + if sys.platform == "win32": + # An SSH/WinRM service session is not proof of desktop login. Require + # Explorer running as the test user in a nonzero interactive session. + sessions = powershell( + "$sessions = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" " + "| ForEach-Object { $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwner; " + "@{user=$owner.User; session=$_.SessionId; pid=$_.ProcessId; " + "created=([DateTimeOffset]$_.CreationDate).ToUnixTimeSeconds()} }); " + "ConvertTo-Json -InputObject $sessions -Compress" + ) + for session in sessions: + if ( + session["user"].lower() == user.lower() + and session["session"] > 0 + and session["created"] >= boot["epoch"] + ): + return session + elif sys.platform == "darwin": + owner = output(["stat", "-f", "%Su", "/dev/console"]) + if owner == user: + uid = output(["id", "-u", user]) + # print is read-only: bootstrap/kickstart here would invalidate + # the test by creating the very login domain being verified. + output(["launchctl", "print", f"gui/{uid}"]) + return {"user": user, "console_owner": owner, "gui_domain": f"gui/{uid}"} + else: + for row in output( + ["loginctl", "list-sessions", "--no-legend", "--no-pager"] + ).splitlines(): + session_id = row.split()[0] + details = output( + [ + "loginctl", + "show-session", + session_id, + "-p", + "Name", + "-p", + "Type", + "-p", + "Class", + "-p", + "Active", + "-p", + "Service", + "-p", + "Remote", + ] + ) + session = dict(line.split("=", 1) for line in details.splitlines()) + if ( + session.get("Name") == user + and session.get("Type") in ("x11", "wayland") + and session.get("Active") == "yes" + ): + return {"id": session_id, **session} + raise RuntimeError(f"No graphical desktop login found for {user}") + + +def process_state(tree): + spec = importlib.util.spec_from_file_location( + "restart_helpers", tree / "installer/helpers.py" + ) + helpers = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helpers) + try: + record = json.loads((tree / "craftbot.pid").read_text()) + except FileNotFoundError: + return None + actual = helpers.process_start_time(record["pid"]) + if ( + actual is None + or record.get("started") is None + or abs(actual - record["started"]) > 2 + ): + return None + return {"pid": record["pid"], "started": actual} + + +def port_open(port): + try: + with socket.create_connection(("127.0.0.1", port), timeout=2): + return True + except OSError: + return False + + +def observe(tree, state, user, expected, frontend, backend): + boot = boot_state() + login = graphical_login(user, boot) + process = process_state(tree) + if expected == "running": + if process is None or process["started"] < boot["epoch"]: + raise RuntimeError("No live CraftBot process from this boot") + ready = state / ".agent-ready" + if not ready.is_file() or ready.stat().st_mtime < boot["epoch"]: + raise RuntimeError("Missing readiness marker from this boot") + with urllib.request.urlopen( + f"http://127.0.0.1:{frontend}", timeout=5 + ) as response: + assert 'id="root"' in response.read().decode("utf-8"), ( + "Frontend did not serve the app" + ) + with urllib.request.urlopen( + f"http://127.0.0.1:{backend}/api/state", timeout=10 + ) as response: + assert json.load(response), "Backend did not return application state" + elif process is not None or port_open(frontend) or port_open(backend): + raise RuntimeError("CraftBot started despite automatic startup being disabled") + return {"boot": boot, "login": login, "process": process, "expected": expected} + + +def require_reboot(before, after): + if before["boot"]["machine"] != after["boot"]["machine"]: + raise RuntimeError("Evidence comes from a different machine") + if ( + before["boot"]["id"] == after["boot"]["id"] + or after["boot"]["epoch"] <= before["boot"]["epoch"] + ): + raise RuntimeError("OS boot identity did not change: this was not a reboot") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tree", type=Path, required=True) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--user", required=True) + parser.add_argument("--expect", choices=("running", "stopped"), default="running") + parser.add_argument( + "--baseline", type=Path, help="previous observation; require a new boot" + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--frontend-port", type=int, default=7925) + parser.add_argument("--backend-port", type=int, default=7926) + args = parser.parse_args() + previous = json.loads(args.baseline.read_text()) if args.baseline else None + binding = { + "tree": str(args.tree.resolve()), + "state": str(args.state.resolve()), + "user": args.user, + } + if previous and previous["installation"] != binding: + parser.error("The baseline describes a different installation or user") + deadline = time.monotonic() + args.timeout + error = None + while time.monotonic() < deadline: + try: + result = observe( + args.tree, + args.state, + args.user, + args.expect, + args.frontend_port, + args.backend_port, + ) + if previous: + require_reboot(previous, result) + result.update( + { + "installation": binding, + "recorded_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "os_reboot_verified": bool(previous), + "graphical_login_verified": True, + } + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2), encoding="utf-8") + print(json.dumps(result, indent=2)) + return 0 + except ( + OSError, + ValueError, + RuntimeError, + AssertionError, + subprocess.SubprocessError, + ) as exc: + error = exc + time.sleep(5) + print(f"FAIL: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_restart_verifier.py b/tests/test_restart_verifier.py new file mode 100644 index 00000000..7a879af1 --- /dev/null +++ b/tests/test_restart_verifier.py @@ -0,0 +1,35 @@ +"""A service restart must never be reported as an operating-system reboot.""" + +import pytest + +from scripts.verify_restart import require_reboot + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """The reboot verifier is independent of the model configuration.""" + + +def test_restarting_the_process_without_rebooting_is_rejected(): + before = {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}} + after = { + "boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}, + "process": {"pid": 456}, + } + with pytest.raises(RuntimeError, match="not a reboot"): + require_reboot(before, after) + + +def test_different_machine_is_not_a_reboot(): + with pytest.raises(RuntimeError, match="different machine"): + require_reboot( + {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}}, + {"boot": {"machine": "vm-b", "id": "boot-b", "epoch": 200}}, + ) + + +def test_changed_boot_identity_on_the_same_machine_is_accepted(): + require_reboot( + {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}}, + {"boot": {"machine": "vm-a", "id": "boot-b", "epoch": 200}}, + ) From 3aaba79da53fce90d2680301974a43203b114b75 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:02:05 +0000 Subject: [PATCH 06/20] test: provision free reboot probes and save native installers --- .github/workflows/launcher.yml | 8 +++++ .github/workflows/restart-vm-probe.yml | 48 ++++++++++++++++++++++++++ scripts/macos_reboot_probe.cjs | 28 +++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 scripts/macos_reboot_probe.cjs diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index cff2f4ab..23f2d9ce 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -71,3 +71,11 @@ jobs: - name: Build run: cargo build --release + + - name: Save installers for disposable reboot tests + if: github.head_ref == 'fix/system-restart-auto-launch' || github.ref_name == 'fix/system-restart-auto-launch' + uses: actions/upload-artifact@v4 + with: + name: reboot-installer-${{ matrix.os_label }} + path: launcher/target/release/CraftBotInstaller* + retention-days: 7 diff --git a/.github/workflows/restart-vm-probe.yml b/.github/workflows/restart-vm-probe.yml index 78b93e22..d1c900fd 100644 --- a/.github/workflows/restart-vm-probe.yml +++ b/.github/workflows/restart-vm-probe.yml @@ -42,3 +42,51 @@ jobs: clang "$RUNNER_TEMP/hv-probe.c" -framework Hypervisor -o "$RUNNER_TEMP/hv-probe" codesign --force --sign - --entitlements "$RUNNER_TEMP/hv-entitlements.plist" "$RUNNER_TEMP/hv-probe" "$RUNNER_TEMP/hv-probe" + + macos-reboot: + # A disposable standard runner is itself a VM. Determine whether evidence + # can survive its reboot before attempting the full installation tests. + runs-on: macos-15-intel + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Prepare independent evidence uploader + run: | + mkdir -p "$RUNNER_TEMP/reboot-probe" + cd "$RUNNER_TEMP/reboot-probe" + npm install --no-audit --no-fund @actions/artifact@2 + cp "$GITHUB_WORKSPACE/scripts/macos_reboot_probe.cjs" . + - uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const cp = require('child_process'); + const path = require('path'); + const root = path.join(process.env.RUNNER_TEMP, 'reboot-probe'); + const env = {}; + for (const key of ['ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RESULTS_URL', 'ACTIONS_RUNTIME_URL', 'GITHUB_RUN_ID']) { + if (process.env[key]) env[key] = process.env[key]; + } + fs.writeFileSync(path.join(root, 'runtime.json'), JSON.stringify(env), {mode: 0o600}); + fs.writeFileSync(path.join(root, 'before.json'), JSON.stringify({ + boot: cp.execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {encoding:'utf8'}).trim(), + user: cp.execFileSync('/usr/bin/stat', ['-f', '%Su', '/dev/console'], {encoding:'utf8'}).trim() + })); + const plist = ` + Labelcom.craftbot.reboot-probe + UserNamerunner + RunAtLoad + ProgramArguments${process.execPath}${root}/macos_reboot_probe.cjs + StandardOutPath${root}/probe.log + StandardErrorPath${root}/probe.err + `; + fs.writeFileSync(path.join(root, 'probe.plist'), plist); + cp.execFileSync('sudo', ['cp', path.join(root, 'probe.plist'), '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); + cp.execFileSync('sudo', ['chown', 'root:wheel', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); + cp.execFileSync('sudo', ['chmod', '644', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); + cp.execFileSync('sudo', ['launchctl', 'bootstrap', 'system', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); + - name: Reboot disposable Mac VM + run: | + echo 'Rebooting this disposable VM. The independent LaunchDaemon will upload the changed boot identity.' + sudo shutdown -r +1 + sleep 600 diff --git a/scripts/macos_reboot_probe.cjs b/scripts/macos_reboot_probe.cjs new file mode 100644 index 00000000..c32495b7 --- /dev/null +++ b/scripts/macos_reboot_probe.cjs @@ -0,0 +1,28 @@ +// Runs only in a disposable GitHub-hosted Mac VM. No application is started. +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); +const root = __dirname; +const before = JSON.parse(fs.readFileSync(path.join(root, 'before.json'))); +const boot = cp.execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {encoding: 'utf8'}).trim(); +if (boot === before.boot) process.exit(0); +Object.assign(process.env, JSON.parse(fs.readFileSync(path.join(root, 'runtime.json')))); +const {DefaultArtifactClient} = require('@actions/artifact'); +async function main() { + for (let attempt = 0; attempt < 60; attempt++) { + try { + const user = cp.execFileSync('/usr/bin/stat', ['-f', '%Su', '/dev/console'], {encoding: 'utf8'}).trim(); + const result = {before, after: {boot, user}, rebootVerified: boot !== before.boot, recordedAt: new Date().toISOString()}; + const file = path.join(root, 'after.json'); + fs.writeFileSync(file, JSON.stringify(result, null, 2)); + await new DefaultArtifactClient().uploadArtifact('macos-actual-reboot-probe', [file], root, {retentionDays: 7}); + return; + } catch (error) { + // Do not print the runtime credentials or network request headers. + console.log(`Evidence upload attempt ${attempt + 1} failed: ${error.name}`); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + process.exitCode = 1; +} +main(); From 9ecf4822a0cb5783f54d34a4fb9d4884f9413931 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:06:15 +0000 Subject: [PATCH 07/20] test: wait for delayed login tasks and preserve reboot probe window --- .github/workflows/restart-vm-probe.yml | 5 +++++ scripts/verify_restart.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/restart-vm-probe.yml b/.github/workflows/restart-vm-probe.yml index d1c900fd..449f79dd 100644 --- a/.github/workflows/restart-vm-probe.yml +++ b/.github/workflows/restart-vm-probe.yml @@ -89,4 +89,9 @@ jobs: run: | echo 'Rebooting this disposable VM. The independent LaunchDaemon will upload the changed boot identity.' sudo shutdown -r +1 + # The runner normally reports its shutdown as job cancellation and + # the hosted service immediately deletes the VM. Pause only this + # disposable job's listener/worker so the independent boot probe has + # a chance to report before the bounded job timeout tears it down. + sudo pkill -STOP -f 'Runner[.](Listener|Worker)' sleep 600 diff --git a/scripts/verify_restart.py b/scripts/verify_restart.py index 032e17d3..131d358d 100644 --- a/scripts/verify_restart.py +++ b/scripts/verify_restart.py @@ -221,6 +221,7 @@ def main(): parser.error("The baseline describes a different installation or user") deadline = time.monotonic() + args.timeout error = None + stopped_since = None while time.monotonic() < deadline: try: result = observe( @@ -233,6 +234,16 @@ def main(): ) if previous: require_reboot(previous, result) + # A delayed login task must not turn an immediate absence into a + # false opt-out pass. Require two minutes of continuous absence + # after a graphical login, with no service intervention. + if args.expect == "stopped": + if stopped_since is None: + stopped_since = time.monotonic() + if time.monotonic() - stopped_since < 120: + time.sleep(5) + continue + result["stopped_observation_seconds"] = time.monotonic() - stopped_since result.update( { "installation": binding, @@ -253,6 +264,7 @@ def main(): subprocess.SubprocessError, ) as exc: error = exc + stopped_since = None time.sleep(5) print(f"FAIL: {error}", file=sys.stderr) return 1 From c0a57c689cdb82a3fcb32a8b6f891b49f159d7db Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:08:39 +0000 Subject: [PATCH 08/20] test: diagnose hosted Mac reboot lifecycle --- .github/workflows/restart-vm-probe.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/restart-vm-probe.yml b/.github/workflows/restart-vm-probe.yml index 449f79dd..da580b97 100644 --- a/.github/workflows/restart-vm-probe.yml +++ b/.github/workflows/restart-vm-probe.yml @@ -87,11 +87,12 @@ jobs: cp.execFileSync('sudo', ['launchctl', 'bootstrap', 'system', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); - name: Reboot disposable Mac VM run: | + ps -axo pid,ppid,comm echo 'Rebooting this disposable VM. The independent LaunchDaemon will upload the changed boot identity.' sudo shutdown -r +1 # The runner normally reports its shutdown as job cancellation and # the hosted service immediately deletes the VM. Pause only this # disposable job's listener/worker so the independent boot probe has # a chance to report before the bounded job timeout tears it down. - sudo pkill -STOP -f 'Runner[.](Listener|Worker)' + sudo pkill -STOP -f '[Rr]unner[.](Listener|Worker)' || true sleep 600 From 43c82647b51ac1983b2ee2b7015dbb7f6deb8e3a Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:17:08 +0000 Subject: [PATCH 09/20] test: prepare bounded free Mac VM validation lab --- .github/workflows/restart-mac-lab.yml | 88 +++++++++++++++++++++++++++ scripts/restart-controller.pub | 1 + 2 files changed, 89 insertions(+) create mode 100644 .github/workflows/restart-mac-lab.yml create mode 100644 scripts/restart-controller.pub diff --git a/.github/workflows/restart-mac-lab.yml b/.github/workflows/restart-mac-lab.yml new file mode 100644 index 00000000..d8f82799 --- /dev/null +++ b/.github/workflows/restart-mac-lab.yml @@ -0,0 +1,88 @@ +name: Disposable Mac reboot lab + +on: + push: + branches: [fix/system-restart-auto-launch] + paths: ['.github/workflows/restart-mac-lab.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + nested-mac: + runs-on: macos-15-intel + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install VM and temporary controller tools + run: | + brew install qemu cloudflared + mkdir -p "$RUNNER_TEMP/mac-lab" + cd "$RUNNER_TEMP/mac-lab" + npm install --no-audit --no-fund ws@8 + - name: Start key-authenticated disposable controller + run: | + cd "$RUNNER_TEMP/mac-lab" + cp "$GITHUB_WORKSPACE/scripts/restart-controller.pub" authorized_keys + chmod 600 authorized_keys + ssh-keygen -q -t ed25519 -N '' -f host_key + cat > sshd_config < relay.cjs <<'EOF' + const {WebSocketServer} = require('ws'); + const net = require('net'); + new WebSocketServer({host:'127.0.0.1', port:9090}).on('connection', ws => { + const tcp = net.createConnection({host:'127.0.0.1',port:22022}); + ws.on('message', data => tcp.write(data)); + tcp.on('data', data => { if(ws.readyState===1) ws.send(data); }); + tcp.on('error', () => ws.close()); + ws.on('error', () => tcp.destroy()); + ws.on('close', () => tcp.destroy()); + tcp.on('close', () => ws.close()); + }); + EOF + node relay.cjs > relay.log 2>&1 & + cloudflared tunnel --url http://127.0.0.1:9090 --no-autoupdate > tunnel.log 2>&1 & + for i in {1..30}; do + if grep -Eo 'https://[-a-z0-9]+\.trycloudflare\.com' tunnel.log > endpoint.txt; then break; fi + sleep 2 + done + test -s endpoint.txt + cp host_key.pub controller-host-key.pub + - uses: actions/upload-artifact@v4 + with: + name: temporary-mac-controller + path: | + ${{ runner.temp }}/mac-lab/endpoint.txt + ${{ runner.temp }}/mac-lab/controller-host-key.pub + retention-days: 1 + - name: Run bounded nested VM validation session + run: | + for i in {1..330}; do + if test -f "$RUNNER_TEMP/mac-lab/done"; then exit 0; fi + sleep 30 + done + echo 'Validation session timed out' + exit 1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: mac-nested-reboot-evidence + path: ${{ runner.temp }}/mac-lab/evidence/ + retention-days: 7 diff --git a/scripts/restart-controller.pub b/scripts/restart-controller.pub new file mode 100644 index 00000000..151ac113 --- /dev/null +++ b/scripts/restart-controller.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCZ6j4AD7QiG7RYPaTv8nQi592+3gDcKJ1b6BFwQ19lyMbZULh7PHsl4vHu3/KxYjaOHuu04BOWa8XiSkpIlwmMF47i8rwBl7s0wYLQXlRv8MbFgTbjVrkeHcUr7DBQagDrlsoSO3imd03zwfePDYNabZMXuG2E1yHhqo0Eh3K67AxPLrBKUVyhLIcoCaDHslLnC8bOG/0ZEV3Q8/0JTNMi99Qu5v0BsZMSJYOUKkpkkK7Sb4yxxEWICMpKTRF/tCfFU5zn9RCUmaDJGHzstGJ/3IChHO0iVBGe6UFFki0jULXFlhjxlfmnusrDG3XaoJ6T/28j9uRfXLyDPpVPG+EPzidqMmOrQC7vlYx6PrPiWid23AE4to3kKSfPZGAlUvO2AZNV+qEtUPyD149gCxtokwfa+fpiYKa18Q5VR/Rj2T5r0YY1jclnYOThaQ3zIshoE+RdQ7UmTfQQFhWQclIcyX/OwkN44NoGZbwl64f82s2MwLp1SifwmWZSBWoHupc= craftbot-disposable-validation From 5b51193020d1a7bd26f984ca387b78e41a027af3 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:35:16 +0000 Subject: [PATCH 10/20] fix: retain Ubuntu 22.04 compatibility in native installer builds --- .github/workflows/launcher.yml | 15 +++++++++++++-- .github/workflows/release.yml | 3 ++- .github/workflows/startup-acceptance.yml | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 23f2d9ce..5e28ade1 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -25,7 +25,9 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Build against the oldest supported glibc, not the moving latest + # runner (whose binaries cannot launch on older Ubuntu releases). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows @@ -44,6 +46,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy + targets: ${{ matrix.os_label == 'macos' && 'x86_64-apple-darwin,aarch64-apple-darwin' || '' }} - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -72,10 +75,18 @@ jobs: - name: Build run: cargo build --release + - name: Build universal Mac installer for reboot validation + if: matrix.os_label == 'macos' && (github.head_ref == 'fix/system-restart-auto-launch' || github.ref_name == 'fix/system-restart-auto-launch') + run: | + cargo build --release --locked --target x86_64-apple-darwin + lipo -create target/release/CraftBotInstaller target/x86_64-apple-darwin/release/CraftBotInstaller -output target/release/CraftBotInstaller-universal + mv target/release/CraftBotInstaller-universal target/release/CraftBotInstaller + lipo -info target/release/CraftBotInstaller + - name: Save installers for disposable reboot tests if: github.head_ref == 'fix/system-restart-auto-launch' || github.ref_name == 'fix/system-restart-auto-launch' uses: actions/upload-artifact@v4 with: name: reboot-installer-${{ matrix.os_label }} - path: launcher/target/release/CraftBotInstaller* + path: launcher/target/release/CraftBotInstaller${{ runner.os == 'Windows' && '.exe' || '' }} retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13929b5c..01496142 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,7 +142,8 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Keep the installer compatible with glibc 2.35 (Ubuntu 22.04+). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml index 02ceb335..b6293877 100644 --- a/.github/workflows/startup-acceptance.yml +++ b/.github/workflows/startup-acceptance.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest, ubuntu-latest] + os: [windows-latest, macos-latest, ubuntu-22.04] method: [cli, installer] runs-on: ${{ matrix.os }} env: From b550997ef5d3141b3ac8a889c8a6002e7025ecb3 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 19:38:01 +0000 Subject: [PATCH 11/20] fix: separate native build caches by runner ABI --- .github/workflows/launcher.yml | 1 + .github/workflows/release.yml | 1 + .github/workflows/startup-acceptance.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 5e28ade1..a301c7a9 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -52,6 +52,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Same insurance as the Release workflow: Slint has no C build deps, # but a few crates probe for these headers. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01496142..0b7310be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Slint's Linux backend loads these at runtime through dlopen, and its # build has no C dependencies — this is insurance for the few crates diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml index b6293877..903af32f 100644 --- a/.github/workflows/startup-acceptance.yml +++ b/.github/workflows/startup-acceptance.yml @@ -68,6 +68,7 @@ jobs: if: matrix.method == 'installer' with: workspaces: launcher + prefix-key: native-${{ matrix.os }} cache-on-failure: true - name: Build native installer if: matrix.method == 'installer' From 00311e795f911b74538b73ec2ff41f4d2cd65cef Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 20:12:32 +0000 Subject: [PATCH 12/20] Validate installed browser startup with the release frontend build --- .github/workflows/startup-acceptance.yml | 2 + launcher/README.md | 6 ++ scripts/test_startup_acceptance.py | 9 +++ scripts/verify_startup_ui.py | 72 ++++++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 scripts/verify_startup_ui.py diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml index 903af32f..b8fafba8 100644 --- a/.github/workflows/startup-acceptance.yml +++ b/.github/workflows/startup-acceptance.yml @@ -56,6 +56,8 @@ jobs: Xvfb :99 -screen 0 1280x720x24 > "$RUNNER_TEMP/xvfb.log" 2>&1 & - name: Build source payload shell: bash + env: + VITE_BACKEND_PORT: '7926' run: | cd app/ui_layer/browser/frontend npm ci diff --git a/launcher/README.md b/launcher/README.md index 64e3b311..62e0480b 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -81,6 +81,12 @@ Rust 1.85+ (`rustup` installs it). No system libraries are needed on Windows or macOS; on Linux the runtime uses the system's X11/Wayland and OpenGL via `dlopen`, with a software renderer as fallback. +Linux release binaries are built on Ubuntu 22.04 to keep their glibc +requirements compatible with that release and newer distributions. Avoid +building release artifacts on `ubuntu-latest`, which can silently raise the +minimum glibc version. Minimal X11 desktops also need `libxkbcommon-x11-0` +(in addition to their display server and a browser). + The CraftBot version is baked in at build time: `CRAFTBOT_VERSION=1.4.0 cargo build --release`, or a `VERSION` file at the repo root, or "latest" for a dev build (downloads the newest release). diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py index 4beaf437..aa1f81c7 100644 --- a/scripts/test_startup_acceptance.py +++ b/scripts/test_startup_acceptance.py @@ -265,6 +265,15 @@ def cli(*arguments, check=True): restarted = wait_ready(tree, state) assert initial["pid"] != restarted["pid"], "did not start a new process" result["passed"].append("real_os_startup_manager_and_readiness") + run( + [ + python, + str(ROOT / "scripts/verify_startup_ui.py"), + "--output", + str(EVIDENCE / "browser"), + ] + ) + result["passed"].append("browser_receives_backend_state_after_startup") cli("autostart", "disable") assert json.loads((state / "startup.json").read_text())["enabled"] is False diff --git a/scripts/verify_startup_ui.py b/scripts/verify_startup_ui.py new file mode 100644 index 00000000..d7f4c115 --- /dev/null +++ b/scripts/verify_startup_ui.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Check the installed browser bundle connects and receives backend state. + +Run with the installed environment's Python after verifying OS startup. This +opens a browser only; it never starts CraftBot or its startup registration. +""" + +import argparse +import json +from pathlib import Path + +from playwright.sync_api import sync_playwright + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default="http://localhost:7925") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--headed", action="store_true") + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + frames = [] + errors = [] + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=not args.headed) + page = browser.new_page(viewport={"width": 1280, "height": 800}) + + def record_frame(payload): + try: + message = json.loads(payload) + # Record types only: never save session tokens or app state. + frames.append(message.get("type")) + except (ValueError, AttributeError): + pass + + page.on("websocket", lambda socket: socket.on("framereceived", record_frame)) + page.on("pageerror", lambda error: errors.append(str(error))) + try: + page.goto(args.url, wait_until="domcontentloaded", timeout=60000) + for _ in range(120): + if "init" in frames: + break + page.wait_for_timeout(1000) + assert "init" in frames, ( + f"Browser never received backend init; frames={frames}; errors={errors}" + ) + page.locator(".cb-splash").wait_for(state="detached", timeout=30000) + page.screenshot(path=str(args.output / "browser-ready.png")) + (args.output / "browser-result.json").write_text( + json.dumps( + { + "url": page.url, + "backend_init_received": True, + "startup_splash_removed": True, + "page_errors": errors, + }, + indent=2, + ), + encoding="utf-8", + ) + print( + "PASS: installed frontend received backend state and left the startup splash" + ) + except Exception: + page.screenshot(path=str(args.output / "browser-failure.png")) + raise + finally: + browser.close() + + +if __name__ == "__main__": + main() From eaf62dcaa183ba8bfe7351718c039ddd9abb5438 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 20:26:40 +0000 Subject: [PATCH 13/20] Keep Python runtime downloads working when GitHub API is rate limited --- app/provision/runtimes.py | 14 +++++++++ launcher/src/python.rs | 56 ++++++++++++++++++++++++++++++++-- tests/test_python_downloads.py | 27 ++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/app/provision/runtimes.py b/app/provision/runtimes.py index 8a441194..a8b2e72c 100644 --- a/app/provision/runtimes.py +++ b/app/provision/runtimes.py @@ -30,6 +30,14 @@ "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" ) PBS_310_API = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009" +PBS_310_ARCHIVE = "https://github.com/astral-sh/python-build-standalone/releases/download/20261009/cpython-3.10.22%2B20261009" +PBS_310_TRIPLES = { + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +} # The version the locks are generated for. NOT a minimum: a lock is valid # for exactly one (platform, python) pair, so accepting "3.10 or newer" would @@ -202,6 +210,12 @@ def _pbs_download_url(log: LogFn) -> Optional[str]: data = json.load(response) except Exception as e: log(f" could not reach the Python download index: {str(e)[:160]}") + # Shared/public IPs can exhaust GitHub's unauthenticated API quota. + # Release assets have a separate download endpoint. Keep the known + # final 3.10 build available even when metadata cannot be queried. + if TARGET_PYTHON == (3, 10) and triple in PBS_310_TRIPLES: + log(" using the archived Python 3.10.22 release download") + return f"{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz" return None for asset in data.get("assets", []): name = asset.get("name") or "" diff --git a/launcher/src/python.rs b/launcher/src/python.rs index 93f0af9b..1fe308ee 100644 --- a/launcher/src/python.rs +++ b/launcher/src/python.rs @@ -25,6 +25,24 @@ const PBS_API: &str = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"; const PBS_310_API: &str = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20261009"; +const PBS_310_ARCHIVE: &str = "https://github.com/astral-sh/python-build-standalone/releases/download/20261009/cpython-3.10.22%2B20261009"; + +fn archived_url(triple: &str) -> Option { + if TARGET == (3, 10) + && matches!( + triple, + "x86_64-pc-windows-msvc" + | "x86_64-unknown-linux-gnu" + | "aarch64-unknown-linux-gnu" + | "x86_64-apple-darwin" + | "aarch64-apple-darwin" + ) + { + Some(format!("{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz")) + } else { + None + } +} /// The interpreter to run CraftBot with, downloading it if needed. pub fn ensure( @@ -146,8 +164,23 @@ fn resolve_url_with( indexes.push(PBS_310_API); } for index in indexes { - let data = - fetch(index).map_err(|e| format!("could not reach the Python download index: {e}"))?; + let data = match fetch(index) { + Ok(data) => data, + Err(error) => { + // GitHub API quotas are shared by public egress IP. Assets + // use a separate endpoint, so metadata throttling must not + // strand a fresh install whose compatible build is known. + if let Some(url) = archived_url(triple) { + logger::log(&format!( + "Python index unavailable ({error}); using archived 3.10.22 download" + )); + return Ok(url); + } + return Err(format!( + "could not reach the Python download index: {error}" + )); + } + }; if let Some(assets) = data.get("assets").and_then(|a| a.as_array()) { for asset in assets { let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); @@ -171,6 +204,25 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn api_rate_limit_does_not_block_known_python_downloads() { + for triple in [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ] { + let url = + resolve_url_with(triple, |_| Err("HTTP 403 rate limit exceeded".into())).unwrap(); + assert_eq!( + url, + format!("{PBS_310_ARCHIVE}-{triple}-install_only.tar.gz") + ); + } + assert!(resolve_url_with("unsupported-target", |_| Err("HTTP 403".into())).is_err()); + } + #[test] fn resolves_retired_python_from_last_supported_release() { for triple in [ diff --git a/tests/test_python_downloads.py b/tests/test_python_downloads.py index 062ef506..4c11689c 100644 --- a/tests/test_python_downloads.py +++ b/tests/test_python_downloads.py @@ -3,6 +3,7 @@ import io import json import urllib.request +import urllib.error import pytest @@ -14,6 +15,32 @@ def configured_context_window(): """Download selection needs no model configuration.""" +@pytest.mark.parametrize("triple", sorted(runtimes.PBS_310_TRIPLES)) +def test_api_rate_limit_uses_known_release_asset(monkeypatch, triple): + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: triple) + + def rate_limited(request, **kwargs): + raise urllib.error.HTTPError( + request.full_url, 403, "rate limit exceeded", {}, None + ) + + monkeypatch.setattr(urllib.request, "urlopen", rate_limited) + assert runtimes._pbs_download_url(lambda message: None) == ( + f"{runtimes.PBS_310_ARCHIVE}-{triple}-install_only.tar.gz" + ) + + +def test_unavailable_index_never_falls_back_to_wrong_python_version(monkeypatch): + monkeypatch.setattr(runtimes, "TARGET_PYTHON", (3, 11)) + monkeypatch.setattr(runtimes, "_pbs_triple", lambda: "x86_64-unknown-linux-gnu") + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda *a, **kw: (_ for _ in ()).throw(OSError("offline")), + ) + assert runtimes._pbs_download_url(lambda message: None) is None + + @pytest.mark.parametrize( "triple", [ From c2a42921d553b93ee4973604f4b1f60f11558827 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 20:40:43 +0000 Subject: [PATCH 14/20] Correct the Windows native runtime registry path --- app/provision/verify.py | 2 +- tests/test_native_runtime.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/test_native_runtime.py diff --git a/app/provision/verify.py b/app/provision/verify.py index 8e5de901..fa7f7723 100644 --- a/app/provision/verify.py +++ b/app/provision/verify.py @@ -63,7 +63,7 @@ def vcredist_installed() -> bool: try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\VisualStudio .0\VC\Runtimesd", + r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64", ) as key: installed, _ = winreg.QueryValueEx(key, "Installed") return bool(installed) diff --git a/tests/test_native_runtime.py b/tests/test_native_runtime.py new file mode 100644 index 00000000..e2c8b057 --- /dev/null +++ b/tests/test_native_runtime.py @@ -0,0 +1,34 @@ +"""Recognize the Windows runtime installed by Microsoft's redistributable.""" + +from contextlib import nullcontext +import sys +from types import SimpleNamespace + +import pytest + +from app.provision import verify + + +@pytest.fixture(autouse=True) +def configured_context_window(): + """The OS prerequisite probe needs no model configuration.""" + + +@pytest.mark.parametrize("installed", [0, 1]) +def test_vcredist_reads_microsoft_runtime_registration(monkeypatch, installed): + registration = r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" + key = object() + + def open_key(hive, path): + if hive != "HKLM" or path != registration: + raise FileNotFoundError(path) + return nullcontext(key) + + monkeypatch.setitem(sys.modules, "winreg", SimpleNamespace( + HKEY_LOCAL_MACHINE="HKLM", + OpenKey=open_key, + QueryValueEx=lambda handle, name: (installed, 4), + )) + monkeypatch.setattr(verify.sys, "platform", "win32") + monkeypatch.setattr(verify.os.path, "isfile", lambda path: False) + assert verify.vcredist_installed() is bool(installed) From 49822eb956ee9a01401534be712d3608b25c4d5d Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 20:50:31 +0000 Subject: [PATCH 15/20] Wait for OS process teardown in startup acceptance checks --- scripts/test_startup_acceptance.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py index aa1f81c7..a621c66d 100644 --- a/scripts/test_startup_acceptance.py +++ b/scripts/test_startup_acceptance.py @@ -62,6 +62,16 @@ def user_data(): ) +def wait_stopped(timeout=30): + """Allow the OS to finish terminating children after the stop command.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not port_open(7925) and not port_open(7926): + return + time.sleep(0.5) + raise RuntimeError("stop left a server running after 30 seconds") + + def wait_ready(tree, state, timeout=900): started = time.monotonic() deadline = time.monotonic() + timeout @@ -259,7 +269,7 @@ def cli(*arguments, check=True): result["passed"].append("desktop_shortcut_contains_only_browser_open") cli("stop", check=False) - assert not port_open(7925) and not port_open(7926), "stop left a server running" + wait_stopped() assert not (state / ".agent-ready").exists(), "stop left stale readiness" start_registered() restarted = wait_ready(tree, state) From b435e85ef25b86e7d50506c45ffe297f564ceb5d Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 20:53:08 +0000 Subject: [PATCH 16/20] Cover native runtime recognition and reboot evidence in CI --- .github/workflows/startup.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/startup.yml b/.github/workflows/startup.yml index 8564e549..35912446 100644 --- a/.github/workflows/startup.yml +++ b/.github/workflows/startup.yml @@ -9,6 +9,10 @@ on: - 'app/console.py' - 'app/python_runtime.py' - 'app/provision/runtimes.py' + - 'app/provision/verify.py' + - 'tests/test_native_runtime.py' + - 'tests/test_restart_verifier.py' + - 'scripts/verify_restart.py' - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' @@ -21,6 +25,10 @@ on: - 'app/console.py' - 'app/python_runtime.py' - 'app/provision/runtimes.py' + - 'app/provision/verify.py' + - 'tests/test_native_runtime.py' + - 'tests/test_restart_verifier.py' + - 'scripts/verify_restart.py' - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' @@ -40,4 +48,4 @@ jobs: python-version: '3.10' - run: python -m pip install pytest==9.0.2 - name: Check login startup and browser-only shortcuts - run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py -q + run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py tests/test_native_runtime.py tests/test_restart_verifier.py -q From 3a94d6ee29a8bee043755d7292b282ad821be73e Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 21:07:46 +0000 Subject: [PATCH 17/20] Open the native installer without requiring an OpenGL driver --- launcher/Cargo.toml | 6 ++---- launcher/README.md | 12 ++++++------ launcher/src/main.rs | 13 +++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 31f4b6fb..1e3f5498 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -15,10 +15,8 @@ path = "src/main.rs" [dependencies] # UI. Slint draws its own widgets, so nothing needs to be present on the -# user's machine. Both renderers are enabled: femtovg (GPU) is the default -# and the software renderer is the fallback for machines with no usable -# OpenGL — Windows Sandbox, most VMs, some remote desktops. That fallback is -# the difference between "opens a window" and "does nothing when clicked". +# user's machine. Software rendering is the default so a basic display +# driver is sufficient. femtovg remains available as an explicit override. slint = { version = "1.17", default-features = false, features = [ "std", "compat-1-2", diff --git a/launcher/README.md b/launcher/README.md index 62e0480b..86061a9f 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -79,7 +79,7 @@ cargo test Rust 1.85+ (`rustup` installs it). No system libraries are needed on Windows or macOS; on Linux the runtime uses the system's X11/Wayland and OpenGL via -`dlopen`, with a software renderer as fallback. +`dlopen`; the setup window uses software rendering by default. Linux release binaries are built on Ubuntu 22.04 to keep their glibc requirements compatible with that release and newer distributions. Avoid @@ -119,12 +119,12 @@ This is how CI can exercise the real pipeline on a runner without a display, and how a support case can be reproduced from a terminal. It is not a user CLI — `craftbot.py` is that. -### Rendering fallback +### Rendering -Slint tries the GPU renderer first and falls back to its software renderer -where OpenGL is unavailable (Windows Sandbox, most VMs, some remote -desktops). To force one: `SLINT_BACKEND=winit-software` or -`SLINT_BACKEND=winit-femtovg`. +The setup window uses Slint's software renderer by default, including on +Windows with the basic display driver, virtual machines, and remote desktops. +An OpenGL driver is not required. `SLINT_BACKEND=winit-femtovg` explicitly +selects GPU rendering; `SLINT_BACKEND=winit-software` selects software rendering. ## How it is wired into the repo diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 23d2ec0c..cb8b9128 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -81,6 +81,18 @@ fn main() { std::process::exit(headless(&args[1..])); } + // A basic Windows display driver can create a window but fail later + // when femtovg requests OpenGL symbols. Slint's automatic fallback does + // not catch that event-loop failure. This small setup UI needs no GPU. + let mut backend = slint::BackendSelector::new(); + if std::env::var_os("SLINT_BACKEND").is_none() { + backend = backend.renderer_name("software".into()); + } + if let Err(e) = backend.select() { + logger::log(&format!("could not initialize the window backend: {e}")); + std::process::exit(1); + } + let ui = match InstallerWindow::new() { Ok(ui) => ui, Err(e) => { @@ -179,6 +191,7 @@ fn main() { logger::log("entering event loop"); if let Err(e) = ui.run() { logger::log(&format!("event loop ended with error: {e}")); + std::process::exit(1); } logger::log("window closed"); } From 087c504b9afcfb8ba0c46071dc82fcf31dc12e59 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 21:13:37 +0000 Subject: [PATCH 18/20] Use Windows certificate chain validation for installer downloads --- launcher/Cargo.lock | 122 ++++++++++++++++++++++++++++++++++++--- launcher/Cargo.toml | 8 ++- launcher/src/download.rs | 21 +++++-- 3 files changed, 134 insertions(+), 17 deletions(-) diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 7874c37f..7c550bdb 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -932,6 +932,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -945,9 +955,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -958,7 +968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -985,6 +995,7 @@ dependencies = [ "flate2", "fs4", "libc", + "native-tls", "open", "rfd", "serde", @@ -1516,6 +1527,15 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1523,7 +1543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1537,6 +1557,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2947,6 +2973,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.2", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "natord" version = "1.0.9" @@ -3513,12 +3556,55 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4318,11 +4404,11 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", ] [[package]] @@ -4434,7 +4520,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5363,6 +5462,7 @@ checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", "log", + "native-tls", "once_cell", "rustls", "rustls-native-certs", @@ -5448,6 +5548,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6043,7 +6149,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 1e3f5498..0c0f9db1 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -27,9 +27,9 @@ slint = { version = "1.17", default-features = false, features = [ ] } # HTTP, blocking. All downloads run on a worker thread, so a synchronous -# client is the simplest correct choice. rustls: no OpenSSL to find or ship. -# native-certs: trust the OS certificate store rather than a bundled list, so -# a corporate TLS-inspecting proxy whose CA is installed on the machine works. +# client is the simplest correct choice. Unix uses rustls with the OS trust +# store; Windows selects Schannel below, including its automatic root updates. +# Both honor installed corporate CAs and require no bundled OpenSSL library. ureq = { version = "2.12", default-features = false, features = ["tls", "native-certs", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -46,6 +46,8 @@ dirs = "6" fs4 = "0.13" [target.'cfg(windows)'.dependencies] +native-tls = "0.2" +ureq = { version = "2.12", default-features = false, features = ["native-tls"] } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_System_Threading", diff --git a/launcher/src/download.rs b/launcher/src/download.rs index a997661a..57e3225e 100644 --- a/launcher/src/download.rs +++ b/launcher/src/download.rs @@ -10,20 +10,29 @@ const USER_AGENT: &str = concat!("CraftBotInstaller/", env!("CARGO_PKG_VERSION") pub type Progress<'a> = &'a mut dyn FnMut(u64, Option); -fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() +fn agent() -> Result { + let builder = ureq::AgentBuilder::new() .user_agent(USER_AGENT) .timeout_connect(Duration::from_secs(30)) // Reads are bounded per call, not per download: a 100 MB file on a // slow link legitimately takes minutes. .timeout_read(Duration::from_secs(120)) - .redirects(10) - .build() + .redirects(10); + // A fresh Windows installation populates trusted roots on demand. + // Reading its current store into rustls misses that Windows chain-building + // step and rejects legitimate GitHub downloads with UnknownIssuer. + // Schannel retains the OS certificate and hostname validation policies. + #[cfg(windows)] + let builder = builder.tls_connector(std::sync::Arc::new( + native_tls::TlsConnector::new() + .map_err(|e| format!("cannot initialize Windows TLS: {e}"))?, + )); + Ok(builder.build()) } /// Fetch a small JSON document (a GitHub release listing). pub fn get_json(url: &str) -> Result { - let resp = agent() + let resp = agent()? .get(url) .set("Accept", "application/vnd.github+json") .call() @@ -36,7 +45,7 @@ pub fn get_json(url: &str) -> Result { /// download is never mistaken for a finished one. `progress` is called with /// (bytes so far, total if the server said). pub fn to_file(url: &str, dest: &Path, progress: Progress) -> Result<(), String> { - let resp = agent().get(url).call().map_err(|e| describe(url, e))?; + let resp = agent()?.get(url).call().map_err(|e| describe(url, e))?; let total = resp .header("Content-Length") .and_then(|v| v.trim().parse::().ok()); From a9f3086fba165ec555c7fad17ad5b59aa702bda9 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 21:16:36 +0000 Subject: [PATCH 19/20] Allow enough free Mac lab time for OS installation and reboot validation --- .github/workflows/restart-mac-lab.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/restart-mac-lab.yml b/.github/workflows/restart-mac-lab.yml index d8f82799..3b311200 100644 --- a/.github/workflows/restart-mac-lab.yml +++ b/.github/workflows/restart-mac-lab.yml @@ -12,7 +12,7 @@ permissions: jobs: nested-mac: runs-on: macos-15-intel - timeout-minutes: 180 + timeout-minutes: 360 steps: - uses: actions/checkout@v4 with: @@ -74,7 +74,7 @@ jobs: retention-days: 1 - name: Run bounded nested VM validation session run: | - for i in {1..330}; do + for i in {1..690}; do if test -f "$RUNNER_TEMP/mac-lab/done"; then exit 0; fi sleep 30 done From a6025c34f372715137122e617b7af3242dcb9f2f Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 13 Nov 2026 22:27:48 +0000 Subject: [PATCH 20/20] Remove disposable reboot test infrastructure --- .github/workflows/launcher.yml | 17 -- .github/workflows/restart-mac-lab.yml | 88 ------ .github/workflows/restart-vm-probe.yml | 98 ------- .github/workflows/startup-acceptance.yml | 88 ------ .github/workflows/startup.yml | 6 +- launcher/README.md | 2 +- launcher/packaging/release.yml | 4 +- scripts/macos_reboot_probe.cjs | 28 -- scripts/restart-controller.pub | 1 - scripts/test_startup_acceptance.py | 330 ----------------------- scripts/verify_restart.py | 274 ------------------- scripts/verify_startup_ui.py | 72 ----- tests/test_restart_verifier.py | 35 --- 13 files changed, 5 insertions(+), 1038 deletions(-) delete mode 100644 .github/workflows/restart-mac-lab.yml delete mode 100644 .github/workflows/restart-vm-probe.yml delete mode 100644 .github/workflows/startup-acceptance.yml delete mode 100644 scripts/macos_reboot_probe.cjs delete mode 100644 scripts/restart-controller.pub delete mode 100644 scripts/test_startup_acceptance.py delete mode 100644 scripts/verify_restart.py delete mode 100644 scripts/verify_startup_ui.py delete mode 100644 tests/test_restart_verifier.py diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index a301c7a9..d369ca88 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -46,7 +46,6 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - targets: ${{ matrix.os_label == 'macos' && 'x86_64-apple-darwin,aarch64-apple-darwin' || '' }} - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -75,19 +74,3 @@ jobs: - name: Build run: cargo build --release - - - name: Build universal Mac installer for reboot validation - if: matrix.os_label == 'macos' && (github.head_ref == 'fix/system-restart-auto-launch' || github.ref_name == 'fix/system-restart-auto-launch') - run: | - cargo build --release --locked --target x86_64-apple-darwin - lipo -create target/release/CraftBotInstaller target/x86_64-apple-darwin/release/CraftBotInstaller -output target/release/CraftBotInstaller-universal - mv target/release/CraftBotInstaller-universal target/release/CraftBotInstaller - lipo -info target/release/CraftBotInstaller - - - name: Save installers for disposable reboot tests - if: github.head_ref == 'fix/system-restart-auto-launch' || github.ref_name == 'fix/system-restart-auto-launch' - uses: actions/upload-artifact@v4 - with: - name: reboot-installer-${{ matrix.os_label }} - path: launcher/target/release/CraftBotInstaller${{ runner.os == 'Windows' && '.exe' || '' }} - retention-days: 7 diff --git a/.github/workflows/restart-mac-lab.yml b/.github/workflows/restart-mac-lab.yml deleted file mode 100644 index 3b311200..00000000 --- a/.github/workflows/restart-mac-lab.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Disposable Mac reboot lab - -on: - push: - branches: [fix/system-restart-auto-launch] - paths: ['.github/workflows/restart-mac-lab.yml'] - workflow_dispatch: - -permissions: - contents: read - -jobs: - nested-mac: - runs-on: macos-15-intel - timeout-minutes: 360 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Install VM and temporary controller tools - run: | - brew install qemu cloudflared - mkdir -p "$RUNNER_TEMP/mac-lab" - cd "$RUNNER_TEMP/mac-lab" - npm install --no-audit --no-fund ws@8 - - name: Start key-authenticated disposable controller - run: | - cd "$RUNNER_TEMP/mac-lab" - cp "$GITHUB_WORKSPACE/scripts/restart-controller.pub" authorized_keys - chmod 600 authorized_keys - ssh-keygen -q -t ed25519 -N '' -f host_key - cat > sshd_config < relay.cjs <<'EOF' - const {WebSocketServer} = require('ws'); - const net = require('net'); - new WebSocketServer({host:'127.0.0.1', port:9090}).on('connection', ws => { - const tcp = net.createConnection({host:'127.0.0.1',port:22022}); - ws.on('message', data => tcp.write(data)); - tcp.on('data', data => { if(ws.readyState===1) ws.send(data); }); - tcp.on('error', () => ws.close()); - ws.on('error', () => tcp.destroy()); - ws.on('close', () => tcp.destroy()); - tcp.on('close', () => ws.close()); - }); - EOF - node relay.cjs > relay.log 2>&1 & - cloudflared tunnel --url http://127.0.0.1:9090 --no-autoupdate > tunnel.log 2>&1 & - for i in {1..30}; do - if grep -Eo 'https://[-a-z0-9]+\.trycloudflare\.com' tunnel.log > endpoint.txt; then break; fi - sleep 2 - done - test -s endpoint.txt - cp host_key.pub controller-host-key.pub - - uses: actions/upload-artifact@v4 - with: - name: temporary-mac-controller - path: | - ${{ runner.temp }}/mac-lab/endpoint.txt - ${{ runner.temp }}/mac-lab/controller-host-key.pub - retention-days: 1 - - name: Run bounded nested VM validation session - run: | - for i in {1..690}; do - if test -f "$RUNNER_TEMP/mac-lab/done"; then exit 0; fi - sleep 30 - done - echo 'Validation session timed out' - exit 1 - - uses: actions/upload-artifact@v4 - if: always() - with: - name: mac-nested-reboot-evidence - path: ${{ runner.temp }}/mac-lab/evidence/ - retention-days: 7 diff --git a/.github/workflows/restart-vm-probe.yml b/.github/workflows/restart-vm-probe.yml deleted file mode 100644 index da580b97..00000000 --- a/.github/workflows/restart-vm-probe.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Reboot VM capability probe - -on: - push: - branches: [fix/system-restart-auto-launch] - paths: ['.github/workflows/restart-vm-probe.yml'] - workflow_dispatch: - -permissions: - contents: read - -jobs: - macos-hypervisor: - # Standard runners are free for this public repository. Check an Intel - # runner explicitly: Apple Silicon runners do not expose nested macOS VMs. - runs-on: macos-15-intel - timeout-minutes: 10 - steps: - - name: Inspect available virtualization - run: | - sw_vers - uname -m - sysctl kern.hv_support - df -h / - cat > "$RUNNER_TEMP/hv-probe.c" <<'EOF' - #include - #include - int main(void) { - hv_return_t result = hv_vm_create(HV_VM_DEFAULT); - printf("hv_vm_create: 0x%x\n", result); - if (result == HV_SUCCESS) { - printf("hv_vm_destroy: 0x%x\n", hv_vm_destroy()); - } - return 0; - } - EOF - cat > "$RUNNER_TEMP/hv-entitlements.plist" <<'EOF' - - - com.apple.security.hypervisor - EOF - clang "$RUNNER_TEMP/hv-probe.c" -framework Hypervisor -o "$RUNNER_TEMP/hv-probe" - codesign --force --sign - --entitlements "$RUNNER_TEMP/hv-entitlements.plist" "$RUNNER_TEMP/hv-probe" - "$RUNNER_TEMP/hv-probe" - - macos-reboot: - # A disposable standard runner is itself a VM. Determine whether evidence - # can survive its reboot before attempting the full installation tests. - runs-on: macos-15-intel - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - name: Prepare independent evidence uploader - run: | - mkdir -p "$RUNNER_TEMP/reboot-probe" - cd "$RUNNER_TEMP/reboot-probe" - npm install --no-audit --no-fund @actions/artifact@2 - cp "$GITHUB_WORKSPACE/scripts/macos_reboot_probe.cjs" . - - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const cp = require('child_process'); - const path = require('path'); - const root = path.join(process.env.RUNNER_TEMP, 'reboot-probe'); - const env = {}; - for (const key of ['ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RESULTS_URL', 'ACTIONS_RUNTIME_URL', 'GITHUB_RUN_ID']) { - if (process.env[key]) env[key] = process.env[key]; - } - fs.writeFileSync(path.join(root, 'runtime.json'), JSON.stringify(env), {mode: 0o600}); - fs.writeFileSync(path.join(root, 'before.json'), JSON.stringify({ - boot: cp.execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {encoding:'utf8'}).trim(), - user: cp.execFileSync('/usr/bin/stat', ['-f', '%Su', '/dev/console'], {encoding:'utf8'}).trim() - })); - const plist = ` - Labelcom.craftbot.reboot-probe - UserNamerunner - RunAtLoad - ProgramArguments${process.execPath}${root}/macos_reboot_probe.cjs - StandardOutPath${root}/probe.log - StandardErrorPath${root}/probe.err - `; - fs.writeFileSync(path.join(root, 'probe.plist'), plist); - cp.execFileSync('sudo', ['cp', path.join(root, 'probe.plist'), '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); - cp.execFileSync('sudo', ['chown', 'root:wheel', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); - cp.execFileSync('sudo', ['chmod', '644', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); - cp.execFileSync('sudo', ['launchctl', 'bootstrap', 'system', '/Library/LaunchDaemons/com.craftbot.reboot-probe.plist']); - - name: Reboot disposable Mac VM - run: | - ps -axo pid,ppid,comm - echo 'Rebooting this disposable VM. The independent LaunchDaemon will upload the changed boot identity.' - sudo shutdown -r +1 - # The runner normally reports its shutdown as job cancellation and - # the hosted service immediately deletes the VM. Pause only this - # disposable job's listener/worker so the independent boot probe has - # a chance to report before the bounded job timeout tears it down. - sudo pkill -STOP -f '[Rr]unner[.](Listener|Worker)' || true - sleep 600 diff --git a/.github/workflows/startup-acceptance.yml b/.github/workflows/startup-acceptance.yml deleted file mode 100644 index b8fafba8..00000000 --- a/.github/workflows/startup-acceptance.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Startup acceptance - -on: - push: - branches: [fix/system-restart-auto-launch] - paths: - - 'craftbot.py' - - 'run.py' - - 'installer/**' - - 'app/console.py' - - 'app/python_runtime.py' - - 'app/provision/runtimes.py' - - 'launcher/**' - - 'scripts/test_startup_acceptance.py' - - '.github/workflows/startup-acceptance.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: startup-acceptance-${{ github.ref }} - cancel-in-progress: true - -jobs: - install-and-start: - name: ${{ matrix.method }} (${{ matrix.os }}) - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest, ubuntu-22.04] - method: [cli, installer] - runs-on: ${{ matrix.os }} - env: - PYTHONUNBUFFERED: '1' - PYTHONUTF8: '1' - PIP_NO_CACHE_DIR: '1' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Linux desktop and service prerequisites - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev xvfb dbus-user-session - sudo loginctl enable-linger "$USER" - sudo systemctl start "user@$(id -u).service" - echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" - echo 'DISPLAY=:99' >> "$GITHUB_ENV" - Xvfb :99 -screen 0 1280x720x24 > "$RUNNER_TEMP/xvfb.log" 2>&1 & - - name: Build source payload - shell: bash - env: - VITE_BACKEND_PORT: '7926' - run: | - cd app/ui_layer/browser/frontend - npm ci - npx vite build - cd ../../../.. - python scripts/package_source.py - - uses: dtolnay/rust-toolchain@stable - if: matrix.method == 'installer' - - uses: Swatinem/rust-cache@v2 - if: matrix.method == 'installer' - with: - workspaces: launcher - prefix-key: native-${{ matrix.os }} - cache-on-failure: true - - name: Build native installer - if: matrix.method == 'installer' - working-directory: launcher - run: cargo build --release --locked - # Hosted runners test a real installation and real startup managers. - # They do NOT prove an OS reboot, login trigger or interactive GUI login. - - name: Install, start through OS manager, repair and toggle auto-start - run: python scripts/test_startup_acceptance.py --method ${{ matrix.method }} - - name: Upload acceptance evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: startup-${{ matrix.method }}-${{ matrix.os }} - path: startup-evidence/ diff --git a/.github/workflows/startup.yml b/.github/workflows/startup.yml index 35912446..6e6d1968 100644 --- a/.github/workflows/startup.yml +++ b/.github/workflows/startup.yml @@ -11,8 +11,6 @@ on: - 'app/provision/runtimes.py' - 'app/provision/verify.py' - 'tests/test_native_runtime.py' - - 'tests/test_restart_verifier.py' - - 'scripts/verify_restart.py' - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' @@ -27,8 +25,6 @@ on: - 'app/provision/runtimes.py' - 'app/provision/verify.py' - 'tests/test_native_runtime.py' - - 'tests/test_restart_verifier.py' - - 'scripts/verify_restart.py' - 'tests/test_python_downloads.py' - 'tests/test_autostart.py' - 'tests/test_craftbot_service.py' @@ -48,4 +44,4 @@ jobs: python-version: '3.10' - run: python -m pip install pytest==9.0.2 - name: Check login startup and browser-only shortcuts - run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py tests/test_native_runtime.py tests/test_restart_verifier.py -q + run: python -m pytest tests/test_autostart.py tests/test_craftbot_service.py tests/test_python_downloads.py tests/test_native_runtime.py -q diff --git a/launcher/README.md b/launcher/README.md index 86061a9f..c05a87ea 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -139,7 +139,7 @@ selects GPU rendering; `SLINT_BACKEND=winit-software` selects software rendering shortcut so `uninstall` on a managed install does not pip-uninstall from a sidecar the launcher is about to delete. - `scripts/package_source.py` excludes `launcher/` from `CraftBot-src.zip` - and requires `installer/{helpers,metadata,payload}.py`, which + and requires `installer/{helpers,metadata,payload,startup}.py`, which `craftbot.py` imports at module scope. - The Tk installer (`installer/ui/`, `installer/wizard.py`, `installer/api.py`, `packaging/`) and the PyInstaller hooks (`hooks/`, diff --git a/launcher/packaging/release.yml b/launcher/packaging/release.yml index 758d6fc0..4a56032f 100644 --- a/launcher/packaging/release.yml +++ b/launcher/packaging/release.yml @@ -142,7 +142,8 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + # Keep the installer compatible with glibc 2.35 (Ubuntu 22.04+). + - os: ubuntu-22.04 os_label: linux - os: windows-latest os_label: windows @@ -170,6 +171,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: launcher + prefix-key: native-${{ matrix.os }} # Slint's Linux backend loads these at runtime through dlopen, and its # build has no C dependencies — this is insurance for the few crates diff --git a/scripts/macos_reboot_probe.cjs b/scripts/macos_reboot_probe.cjs deleted file mode 100644 index c32495b7..00000000 --- a/scripts/macos_reboot_probe.cjs +++ /dev/null @@ -1,28 +0,0 @@ -// Runs only in a disposable GitHub-hosted Mac VM. No application is started. -const fs = require('fs'); -const path = require('path'); -const cp = require('child_process'); -const root = __dirname; -const before = JSON.parse(fs.readFileSync(path.join(root, 'before.json'))); -const boot = cp.execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {encoding: 'utf8'}).trim(); -if (boot === before.boot) process.exit(0); -Object.assign(process.env, JSON.parse(fs.readFileSync(path.join(root, 'runtime.json')))); -const {DefaultArtifactClient} = require('@actions/artifact'); -async function main() { - for (let attempt = 0; attempt < 60; attempt++) { - try { - const user = cp.execFileSync('/usr/bin/stat', ['-f', '%Su', '/dev/console'], {encoding: 'utf8'}).trim(); - const result = {before, after: {boot, user}, rebootVerified: boot !== before.boot, recordedAt: new Date().toISOString()}; - const file = path.join(root, 'after.json'); - fs.writeFileSync(file, JSON.stringify(result, null, 2)); - await new DefaultArtifactClient().uploadArtifact('macos-actual-reboot-probe', [file], root, {retentionDays: 7}); - return; - } catch (error) { - // Do not print the runtime credentials or network request headers. - console.log(`Evidence upload attempt ${attempt + 1} failed: ${error.name}`); - await new Promise(resolve => setTimeout(resolve, 5000)); - } - } - process.exitCode = 1; -} -main(); diff --git a/scripts/restart-controller.pub b/scripts/restart-controller.pub deleted file mode 100644 index 151ac113..00000000 --- a/scripts/restart-controller.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCZ6j4AD7QiG7RYPaTv8nQi592+3gDcKJ1b6BFwQ19lyMbZULh7PHsl4vHu3/KxYjaOHuu04BOWa8XiSkpIlwmMF47i8rwBl7s0wYLQXlRv8MbFgTbjVrkeHcUr7DBQagDrlsoSO3imd03zwfePDYNabZMXuG2E1yHhqo0Eh3K67AxPLrBKUVyhLIcoCaDHslLnC8bOG/0ZEV3Q8/0JTNMi99Qu5v0BsZMSJYOUKkpkkK7Sb4yxxEWICMpKTRF/tCfFU5zn9RCUmaDJGHzstGJ/3IChHO0iVBGe6UFFki0jULXFlhjxlfmnusrDG3XaoJ6T/28j9uRfXLyDPpVPG+EPzidqMmOrQC7vlYx6PrPiWid23AE4to3kKSfPZGAlUvO2AZNV+qEtUPyD149gCxtokwfa+fpiYKa18Q5VR/Rj2T5r0YY1jclnYOThaQ3zIshoE+RdQ7UmTfQQFhWQclIcyX/OwkN44NoGZbwl64f82s2MwLp1SifwmWZSBWoHupc= craftbot-disposable-validation diff --git a/scripts/test_startup_acceptance.py b/scripts/test_startup_acceptance.py deleted file mode 100644 index a621c66d..00000000 --- a/scripts/test_startup_acceptance.py +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env python3 -"""Full install and OS startup-manager acceptance on disposable hosted runners. - -This deliberately refuses to run on a developer's machine: it installs real -dependencies, replaces the current user's CraftBot startup registration, and -stops processes on CraftBot's ports. It does NOT reboot the machine or claim -to exercise a real login trigger. See result.json for the exact coverage. -""" - -from __future__ import annotations - -import argparse -import json -import os -from pathlib import Path -import shutil -import socket -import subprocess -import sys -import time -import urllib.request -import zipfile - -ROOT = Path(__file__).resolve().parents[1] -EVIDENCE = ROOT / "startup-evidence" - - -def run(command, *, cwd=None, check=True, timeout=3600): - print(f"$ {subprocess.list2cmdline([str(arg) for arg in command])}", flush=True) - # Save complete output without flooding CI logs with pip/npm progress. - with (EVIDENCE / "commands.log").open("a", encoding="utf-8") as log: - log.write(f"\n$ {command}\n") - log.flush() - result = subprocess.run( - command, - cwd=cwd, - stdin=subprocess.DEVNULL, - stdout=log, - stderr=subprocess.STDOUT, - timeout=timeout, - ) - if check and result.returncode: - raise RuntimeError(f"Command exited {result.returncode}: {command}") - return result.returncode - - -def port_open(port): - try: - with socket.create_connection(("127.0.0.1", port), timeout=2): - return True - except OSError: - return False - - -def user_data(): - if sys.platform == "win32": - return Path(os.environ["LOCALAPPDATA"]) / "CraftBot" - if sys.platform == "darwin": - return Path.home() / "Library/Application Support/CraftBot" - return ( - Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "craftbot" - ) - - -def wait_stopped(timeout=30): - """Allow the OS to finish terminating children after the stop command.""" - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not port_open(7925) and not port_open(7926): - return - time.sleep(0.5) - raise RuntimeError("stop left a server running after 30 seconds") - - -def wait_ready(tree, state, timeout=900): - started = time.monotonic() - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if (state / ".agent-ready").exists() and (tree / "craftbot.pid").exists(): - try: - with urllib.request.urlopen( - "http://127.0.0.1:7925", timeout=5 - ) as response: - html = response.read().decode("utf-8") - with urllib.request.urlopen( - "http://127.0.0.1:7926/api/state", timeout=5 - ) as response: - backend = json.load(response) - if 'id="root"' in html and isinstance(backend, dict) and backend: - return json.loads((tree / "craftbot.pid").read_text()) - except (OSError, ValueError): - pass - if time.monotonic() - started > 60: - # A dead startup process cannot become ready. Fail promptly with - # its logs instead of waiting the full model-download deadline. - probe = subprocess.run( - [ - sys.executable, - "-c", - "import craftbot; raise SystemExit(0 if craftbot._owned_pid() else 1)", - ], - cwd=tree, - capture_output=True, - timeout=30, - ) - if probe.returncode == 1: - raise RuntimeError( - "Startup process exited before CraftBot became ready" - ) - time.sleep(5) - raise RuntimeError( - "CraftBot did not become ready (PID, agent-ready, frontend and backend)" - ) - - -def start_registered(): - if sys.platform == "win32": - run(["schtasks", "/run", "/tn", "CraftBot"], timeout=30) - elif sys.platform == "darwin": - plist = Path.home() / "Library/LaunchAgents/com.craftbot.agent.plist" - run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist)], timeout=30) - else: - # Only the display required by the virtual desktop is supplied. Do - # not import PATH, conda activation, or the install shell environment. - run(["systemctl", "--user", "set-environment", "DISPLAY=:99"], timeout=30) - run(["systemctl", "--user", "start", "craftbot"], timeout=30) - - -def stop_registered(): - if sys.platform == "win32": - run(["schtasks", "/end", "/tn", "CraftBot"], check=False, timeout=30) - elif sys.platform == "darwin": - run( - ["launchctl", "bootout", f"gui/{os.getuid()}/com.craftbot.agent"], - check=False, - timeout=30, - ) - else: - run(["systemctl", "--user", "stop", "craftbot"], check=False, timeout=30) - - -def check_shortcut(): - desktop = Path.home() / "Desktop" - if sys.platform == "win32": - target = str(desktop / "CraftBot.lnk").replace("'", "''") - ps = EVIDENCE / "shortcut.ps1" - ps.write_text( - f"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('{target}')\n" - "@{target=$s.TargetPath; arguments=$s.Arguments} | ConvertTo-Json\n", - encoding="utf-8-sig", - ) - result = subprocess.check_output( - ["powershell", "-NoProfile", "-File", str(ps)], text=True - ) - (EVIDENCE / "shortcut.json").write_text(result, encoding="utf-8") - data = json.loads(result) - assert Path(data["target"]).name.lower() == "cmd.exe", data - assert data["arguments"] == '/c start "" http://localhost:7925', data - else: - path = desktop / ( - "CraftBot.command" if sys.platform == "darwin" else "CraftBot.desktop" - ) - content = path.read_text() - shutil.copy2(path, EVIDENCE / path.name) - if sys.platform == "darwin": - assert content == "#!/bin/sh\nexec open http://localhost:7925\n", content - else: - action = next( - line for line in content.splitlines() if line.startswith("Exec=") - ) - assert action in ( - "Exec=xdg-open http://localhost:7925", - "Exec=sensible-browser http://localhost:7925", - ), action - - -def collect(tree, state): - for name, directory in ( - ("code", tree), - ("state", state), - ("launcher", user_data()), - ): - for filename in ( - "craftbot.log", - "craftbot.pid", - "startup.json", - "launcher.log", - "config.json", - ): - source = directory / filename - if source.is_file(): - shutil.copy2(source, EVIDENCE / f"{name}-{filename}") - if sys.platform == "win32": - run(["schtasks", "/query", "/tn", "CraftBot", "/xml"], check=False, timeout=30) - elif sys.platform == "darwin": - run( - ["launchctl", "print", f"gui/{os.getuid()}/com.craftbot.agent"], - check=False, - timeout=30, - ) - else: - run( - ["journalctl", "--user", "-u", "craftbot", "--no-pager", "-n", "250"], - check=False, - timeout=30, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--method", choices=("cli", "installer"), required=True) - args = parser.parse_args() - if ( - os.environ.get("GITHUB_ACTIONS") != "true" - or os.environ.get("RUNNER_ENVIRONMENT") != "github-hosted" - ): - parser.error( - "This destructive acceptance test requires a disposable GitHub-hosted runner" - ) - EVIDENCE.mkdir(exist_ok=True) - # Startup must use durable registered paths, not a development override. - for key in list(os.environ): - if key.startswith(("CRAFTBOT_", "CONDA_")) or key in ( - "PYTHONPATH", - "VIRTUAL_ENV", - ): - os.environ.pop(key) - (Path.home() / "Desktop").mkdir(exist_ok=True) - tree = Path(os.environ["RUNNER_TEMP"]) / f"CraftBot {args.method} installation" - state = tree if args.method == "cli" else user_data() - python = sys.executable - installer = ( - ROOT - / "launcher/target/release" - / ("CraftBotInstaller.exe" if sys.platform == "win32" else "CraftBotInstaller") - ) - result = { - "method": args.method, - "platform": sys.platform, - "os_reboot_tested": False, - "login_trigger_tested": False, - "passed": [], - } - - def cli(*arguments, check=True): - return run( - [python, str(tree / "craftbot.py"), *arguments], cwd=tree, check=check - ) - - try: - if port_open(7925) or port_open(7926): - raise RuntimeError( - "Fresh runner already has a listener on CraftBot's ports" - ) - if args.method == "cli": - with zipfile.ZipFile(ROOT / "dist/CraftBot-src.zip") as archive: - archive.extractall(tree) - cli("install", "--no-open-browser") - else: - os.environ["CRAFTBOT_SRC_ZIP"] = str(ROOT / "dist/CraftBot-src.zip") - run([str(installer), "--headless", "install", str(tree)]) - record = json.loads((user_data() / "launcher.json").read_text()) - python = record["python"] - assert Path(record["install_dir"]).resolve() == tree.resolve() - initial = wait_ready(tree, state) - result["passed"].append("full_install_and_readiness") - assert json.loads((state / "startup.json").read_text())["enabled"] is True - check_shortcut() - result["passed"].append("desktop_shortcut_contains_only_browser_open") - - cli("stop", check=False) - wait_stopped() - assert not (state / ".agent-ready").exists(), "stop left stale readiness" - start_registered() - restarted = wait_ready(tree, state) - assert initial["pid"] != restarted["pid"], "did not start a new process" - result["passed"].append("real_os_startup_manager_and_readiness") - run( - [ - python, - str(ROOT / "scripts/verify_startup_ui.py"), - "--output", - str(EVIDENCE / "browser"), - ] - ) - result["passed"].append("browser_receives_backend_state_after_startup") - - cli("autostart", "disable") - assert json.loads((state / "startup.json").read_text())["enabled"] is False - assert wait_ready(tree, state, timeout=30)["pid"] == restarted["pid"], ( - "disable stopped the current app" - ) - result["passed"].append("disable_keeps_current_app_running") - stop_registered() - cli("stop", check=False) - - if args.method == "installer": - run([str(installer), "--headless", "repair"]) - else: - cli("install", "--no-open-browser") - wait_ready(tree, state) - assert json.loads((state / "startup.json").read_text())["enabled"] is False - result["passed"].append("repair_preserves_opt_out") - cli("autostart", "enable") - cli("stop", check=False) - start_registered() - wait_ready(tree, state) - result["passed"].append("re_enable_and_startup_manager_readiness") - result["success"] = True - except Exception as exc: - result["success"] = False - result["error"] = str(exc) - print(f"FAILED: {exc}", flush=True) - finally: - (EVIDENCE / "result.json").write_text( - json.dumps(result, indent=2), encoding="utf-8" - ) - try: - collect(tree, state) - finally: - stop_registered() - if (tree / "craftbot.py").exists(): - cli("stop", check=False) - cli("autostart", "disable", check=False) - print(json.dumps(result, indent=2), flush=True) - return 0 if result["success"] else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/verify_restart.py b/scripts/verify_restart.py deleted file mode 100644 index 131d358d..00000000 --- a/scripts/verify_restart.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -"""Record proof of an OS reboot, graphical login and CraftBot startup state. - -Run on a disposable machine from a separate controller account. This verifier -never starts CraftBot, triggers its startup manager, or logs in the test user. -The provisioning controller must perform those actions independently. In -particular, do not SSH into the Linux test account before checking its GUI -login: doing so could start its systemd user service and mask a login failure. -""" - -from __future__ import annotations - -import argparse -import datetime as dt -import importlib.util -import json -import os -from pathlib import Path -import re -import socket -import subprocess -import sys -import time -import urllib.request - - -def output(command): - return subprocess.check_output( - command, - text=True, - encoding="utf-8", - timeout=30, - env={**os.environ, "LC_ALL": "C"}, - ).strip() - - -def powershell(script): - return json.loads( - output( - [ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - "[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); " + script, - ] - ) - ) - - -def boot_state(): - if sys.platform == "win32": - return powershell( - "$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; " - "@{id=$boot.ToUniversalTime().ToString('o'); " - "epoch=([DateTimeOffset]$boot).ToUnixTimeSeconds(); " - "machine=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid} " - "| ConvertTo-Json -Compress" - ) - if sys.platform == "darwin": - boot = output(["sysctl", "-n", "kern.boottime"]) - hardware = output(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"]) - return { - "id": output(["sysctl", "-n", "kern.bootsessionuuid"]), - "epoch": int(re.search(r"sec = (\d+)", boot).group(1)), - "machine": re.search(r'"IOPlatformUUID" = "([^"]+)"', hardware).group(1), - } - return { - "id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), - "epoch": int( - re.search(r"^btime (\d+)$", Path("/proc/stat").read_text(), re.M).group(1) - ), - "machine": Path("/etc/machine-id").read_text().strip(), - } - - -def graphical_login(user, boot): - if sys.platform == "win32": - # An SSH/WinRM service session is not proof of desktop login. Require - # Explorer running as the test user in a nonzero interactive session. - sessions = powershell( - "$sessions = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" " - "| ForEach-Object { $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwner; " - "@{user=$owner.User; session=$_.SessionId; pid=$_.ProcessId; " - "created=([DateTimeOffset]$_.CreationDate).ToUnixTimeSeconds()} }); " - "ConvertTo-Json -InputObject $sessions -Compress" - ) - for session in sessions: - if ( - session["user"].lower() == user.lower() - and session["session"] > 0 - and session["created"] >= boot["epoch"] - ): - return session - elif sys.platform == "darwin": - owner = output(["stat", "-f", "%Su", "/dev/console"]) - if owner == user: - uid = output(["id", "-u", user]) - # print is read-only: bootstrap/kickstart here would invalidate - # the test by creating the very login domain being verified. - output(["launchctl", "print", f"gui/{uid}"]) - return {"user": user, "console_owner": owner, "gui_domain": f"gui/{uid}"} - else: - for row in output( - ["loginctl", "list-sessions", "--no-legend", "--no-pager"] - ).splitlines(): - session_id = row.split()[0] - details = output( - [ - "loginctl", - "show-session", - session_id, - "-p", - "Name", - "-p", - "Type", - "-p", - "Class", - "-p", - "Active", - "-p", - "Service", - "-p", - "Remote", - ] - ) - session = dict(line.split("=", 1) for line in details.splitlines()) - if ( - session.get("Name") == user - and session.get("Type") in ("x11", "wayland") - and session.get("Active") == "yes" - ): - return {"id": session_id, **session} - raise RuntimeError(f"No graphical desktop login found for {user}") - - -def process_state(tree): - spec = importlib.util.spec_from_file_location( - "restart_helpers", tree / "installer/helpers.py" - ) - helpers = importlib.util.module_from_spec(spec) - spec.loader.exec_module(helpers) - try: - record = json.loads((tree / "craftbot.pid").read_text()) - except FileNotFoundError: - return None - actual = helpers.process_start_time(record["pid"]) - if ( - actual is None - or record.get("started") is None - or abs(actual - record["started"]) > 2 - ): - return None - return {"pid": record["pid"], "started": actual} - - -def port_open(port): - try: - with socket.create_connection(("127.0.0.1", port), timeout=2): - return True - except OSError: - return False - - -def observe(tree, state, user, expected, frontend, backend): - boot = boot_state() - login = graphical_login(user, boot) - process = process_state(tree) - if expected == "running": - if process is None or process["started"] < boot["epoch"]: - raise RuntimeError("No live CraftBot process from this boot") - ready = state / ".agent-ready" - if not ready.is_file() or ready.stat().st_mtime < boot["epoch"]: - raise RuntimeError("Missing readiness marker from this boot") - with urllib.request.urlopen( - f"http://127.0.0.1:{frontend}", timeout=5 - ) as response: - assert 'id="root"' in response.read().decode("utf-8"), ( - "Frontend did not serve the app" - ) - with urllib.request.urlopen( - f"http://127.0.0.1:{backend}/api/state", timeout=10 - ) as response: - assert json.load(response), "Backend did not return application state" - elif process is not None or port_open(frontend) or port_open(backend): - raise RuntimeError("CraftBot started despite automatic startup being disabled") - return {"boot": boot, "login": login, "process": process, "expected": expected} - - -def require_reboot(before, after): - if before["boot"]["machine"] != after["boot"]["machine"]: - raise RuntimeError("Evidence comes from a different machine") - if ( - before["boot"]["id"] == after["boot"]["id"] - or after["boot"]["epoch"] <= before["boot"]["epoch"] - ): - raise RuntimeError("OS boot identity did not change: this was not a reboot") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--tree", type=Path, required=True) - parser.add_argument("--state", type=Path, required=True) - parser.add_argument("--user", required=True) - parser.add_argument("--expect", choices=("running", "stopped"), default="running") - parser.add_argument( - "--baseline", type=Path, help="previous observation; require a new boot" - ) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--timeout", type=int, default=900) - parser.add_argument("--frontend-port", type=int, default=7925) - parser.add_argument("--backend-port", type=int, default=7926) - args = parser.parse_args() - previous = json.loads(args.baseline.read_text()) if args.baseline else None - binding = { - "tree": str(args.tree.resolve()), - "state": str(args.state.resolve()), - "user": args.user, - } - if previous and previous["installation"] != binding: - parser.error("The baseline describes a different installation or user") - deadline = time.monotonic() + args.timeout - error = None - stopped_since = None - while time.monotonic() < deadline: - try: - result = observe( - args.tree, - args.state, - args.user, - args.expect, - args.frontend_port, - args.backend_port, - ) - if previous: - require_reboot(previous, result) - # A delayed login task must not turn an immediate absence into a - # false opt-out pass. Require two minutes of continuous absence - # after a graphical login, with no service intervention. - if args.expect == "stopped": - if stopped_since is None: - stopped_since = time.monotonic() - if time.monotonic() - stopped_since < 120: - time.sleep(5) - continue - result["stopped_observation_seconds"] = time.monotonic() - stopped_since - result.update( - { - "installation": binding, - "recorded_at": dt.datetime.now(dt.timezone.utc).isoformat(), - "os_reboot_verified": bool(previous), - "graphical_login_verified": True, - } - ) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(result, indent=2), encoding="utf-8") - print(json.dumps(result, indent=2)) - return 0 - except ( - OSError, - ValueError, - RuntimeError, - AssertionError, - subprocess.SubprocessError, - ) as exc: - error = exc - stopped_since = None - time.sleep(5) - print(f"FAIL: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/verify_startup_ui.py b/scripts/verify_startup_ui.py deleted file mode 100644 index d7f4c115..00000000 --- a/scripts/verify_startup_ui.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Check the installed browser bundle connects and receives backend state. - -Run with the installed environment's Python after verifying OS startup. This -opens a browser only; it never starts CraftBot or its startup registration. -""" - -import argparse -import json -from pathlib import Path - -from playwright.sync_api import sync_playwright - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--url", default="http://localhost:7925") - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--headed", action="store_true") - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - frames = [] - errors = [] - with sync_playwright() as playwright: - browser = playwright.chromium.launch(headless=not args.headed) - page = browser.new_page(viewport={"width": 1280, "height": 800}) - - def record_frame(payload): - try: - message = json.loads(payload) - # Record types only: never save session tokens or app state. - frames.append(message.get("type")) - except (ValueError, AttributeError): - pass - - page.on("websocket", lambda socket: socket.on("framereceived", record_frame)) - page.on("pageerror", lambda error: errors.append(str(error))) - try: - page.goto(args.url, wait_until="domcontentloaded", timeout=60000) - for _ in range(120): - if "init" in frames: - break - page.wait_for_timeout(1000) - assert "init" in frames, ( - f"Browser never received backend init; frames={frames}; errors={errors}" - ) - page.locator(".cb-splash").wait_for(state="detached", timeout=30000) - page.screenshot(path=str(args.output / "browser-ready.png")) - (args.output / "browser-result.json").write_text( - json.dumps( - { - "url": page.url, - "backend_init_received": True, - "startup_splash_removed": True, - "page_errors": errors, - }, - indent=2, - ), - encoding="utf-8", - ) - print( - "PASS: installed frontend received backend state and left the startup splash" - ) - except Exception: - page.screenshot(path=str(args.output / "browser-failure.png")) - raise - finally: - browser.close() - - -if __name__ == "__main__": - main() diff --git a/tests/test_restart_verifier.py b/tests/test_restart_verifier.py deleted file mode 100644 index 7a879af1..00000000 --- a/tests/test_restart_verifier.py +++ /dev/null @@ -1,35 +0,0 @@ -"""A service restart must never be reported as an operating-system reboot.""" - -import pytest - -from scripts.verify_restart import require_reboot - - -@pytest.fixture(autouse=True) -def configured_context_window(): - """The reboot verifier is independent of the model configuration.""" - - -def test_restarting_the_process_without_rebooting_is_rejected(): - before = {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}} - after = { - "boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}, - "process": {"pid": 456}, - } - with pytest.raises(RuntimeError, match="not a reboot"): - require_reboot(before, after) - - -def test_different_machine_is_not_a_reboot(): - with pytest.raises(RuntimeError, match="different machine"): - require_reboot( - {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}}, - {"boot": {"machine": "vm-b", "id": "boot-b", "epoch": 200}}, - ) - - -def test_changed_boot_identity_on_the_same_machine_is_accepted(): - require_reboot( - {"boot": {"machine": "vm-a", "id": "boot-a", "epoch": 100}}, - {"boot": {"machine": "vm-a", "id": "boot-b", "epoch": 200}}, - )