Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c93e26
fix: restore automatic startup after system restart
ahmad-ajmal Nov 13, 2026
a97a2f1
test: exercise full installs through native startup managers
ahmad-ajmal Nov 13, 2026
2c8a60a
fix: preserve login state and correct systemd service paths
ahmad-ajmal Nov 13, 2026
5d49cea
fix: recover installer Python downloads after upstream retirement
ahmad-ajmal Nov 13, 2026
7969946
test: verify real reboot identities and graphical logins
ahmad-ajmal Nov 13, 2026
3aaba79
test: provision free reboot probes and save native installers
ahmad-ajmal Nov 13, 2026
9ecf482
test: wait for delayed login tasks and preserve reboot probe window
ahmad-ajmal Nov 13, 2026
c0a57c6
test: diagnose hosted Mac reboot lifecycle
ahmad-ajmal Nov 13, 2026
43c8264
test: prepare bounded free Mac VM validation lab
ahmad-ajmal Nov 13, 2026
5b51193
fix: retain Ubuntu 22.04 compatibility in native installer builds
ahmad-ajmal Nov 13, 2026
b550997
fix: separate native build caches by runner ABI
ahmad-ajmal Nov 13, 2026
00311e7
Validate installed browser startup with the release frontend build
ahmad-ajmal Nov 13, 2026
eaf62dc
Keep Python runtime downloads working when GitHub API is rate limited
ahmad-ajmal Nov 13, 2026
c2a4292
Correct the Windows native runtime registry path
ahmad-ajmal Nov 13, 2026
49822eb
Wait for OS process teardown in startup acceptance checks
ahmad-ajmal Nov 13, 2026
b435e85
Cover native runtime recognition and reboot evidence in CI
ahmad-ajmal Nov 13, 2026
3a94d6e
Open the native installer without requiring an OpenGL driver
ahmad-ajmal Nov 13, 2026
087c504
Use Windows certificate chain validation for installer downloads
ahmad-ajmal Nov 13, 2026
a9f3086
Allow enough free Mac lab time for OS installation and reboot validation
ahmad-ajmal Nov 13, 2026
a6025c3
Remove disposable reboot test infrastructure
ahmad-ajmal Nov 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/launcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/startup.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
19 changes: 18 additions & 1 deletion app/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 36 additions & 22 deletions app/provision/runtimes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion app/provision/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions app/python_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading