diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml
index cff2f4ab..d369ca88 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
@@ -49,6 +51,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 13929b5c..0b7310be 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
@@ -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/.github/workflows/startup.yml b/.github/workflows/startup.yml
new file mode 100644
index 00000000..6e6d1968
--- /dev/null
+++ b/.github/workflows/startup.yml
@@ -0,0 +1,47 @@
+name: Startup regressions
+
+on:
+ push:
+ paths:
+ - 'craftbot.py'
+ - 'run.py'
+ - 'installer/**'
+ - 'app/console.py'
+ - 'app/python_runtime.py'
+ - 'app/provision/runtimes.py'
+ - 'app/provision/verify.py'
+ - 'tests/test_native_runtime.py'
+ - 'tests/test_python_downloads.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'
+ - 'app/provision/runtimes.py'
+ - 'app/provision/verify.py'
+ - 'tests/test_native_runtime.py'
+ - 'tests/test_python_downloads.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 tests/test_python_downloads.py tests/test_native_runtime.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/provision/runtimes.py b/app/provision/runtimes.py
index 458e79e7..a8b2e72c 100644
--- a/app/provision/runtimes.py
+++ b/app/provision/runtimes.py
@@ -23,16 +23,21 @@
# 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"
+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
@@ -175,8 +180,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 +200,30 @@ 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]}")
+ # 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 ""
+ 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/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/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..25962300 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)
@@ -808,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}")
@@ -993,29 +1007,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 +1056,8 @@ def _create_desktop_shortcut_windows() -> None:
],
capture_output=True,
timeout=15,
+ check=True,
+ **_quiet_windows_flags(),
)
finally:
try:
@@ -1063,16 +1084,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 +1096,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 +1129,51 @@ 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":
+ 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
+
+
+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 +1186,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 +1309,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 +1337,7 @@ def _install_linux(run_args: List[str]) -> None:
[Service]
Type=simple
ExecStart={exec_start}
-WorkingDirectory={BASE_DIR}
+WorkingDirectory={_startup.systemd_path(directory)}
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
@@ -1296,9 +1357,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 +1369,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 +1382,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 +1403,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 +1481,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 +1517,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 +1634,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 +1752,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 +1774,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 +1865,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 +2191,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..04f84f57
--- /dev/null
+++ b/installer/startup.py
@@ -0,0 +1,135 @@
+"""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)
+
+
+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/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 31f4b6fb..0c0f9db1 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",
@@ -29,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"
@@ -48,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/README.md b/launcher/README.md
index 4dd6209a..c05a87ea 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 |
@@ -71,7 +79,13 @@ 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
+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"
@@ -105,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
@@ -125,7 +139,7 @@ desktops). To force one: `SLINT_BACKEND=winit-software` or
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/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());
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");
}
diff --git a/launcher/src/python.rs b/launcher/src/python.rs
index 33511602..1fe308ee 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,26 @@ 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";
+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(
@@ -130,29 +150,117 @@ 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 = 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("");
+ 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 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 [
+ "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/run.py b/run.py
index 3ffdc2ca..5991b287 100644
--- a/run.py
+++ b/run.py
@@ -33,18 +33,28 @@
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()
+configure_console(
+ log_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "craftbot.log")
+)
multiprocessing.freeze_support()
@@ -79,6 +89,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 +1496,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..2fdfea41
--- /dev/null
+++ b/tests/test_autostart.py
@@ -0,0 +1,432 @@
+"""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",
+ f"--state-root={tmp_path.resolve()}",
+ ]
+ 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 != "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"
+)
+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")
+@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"
+ 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}')
+ 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() == state
+ print("SERVICE ENTRY OK", flush=True)
+ (tree / "preflight-reached").touch()
+ raise SystemExit(0)
+ runtime_preflight.ensure_runtime_dependencies = reached_preflight
+ sys.argv = command
+ 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), str(state), *command[1:]],
+ 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..0426d2c4 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
@@ -15,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
@@ -67,106 +89,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(
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)
diff --git a/tests/test_python_downloads.py b/tests/test_python_downloads.py
new file mode 100644
index 00000000..4c11689c
--- /dev/null
+++ b/tests/test_python_downloads.py
@@ -0,0 +1,94 @@
+"""A newer upstream release must not strand installs pinned to Python 3.10."""
+
+import io
+import json
+import urllib.request
+import urllib.error
+
+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", 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",
+ [
+ "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