diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 8de425706..2fcb7a736 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -45,15 +45,16 @@ jobs: uv sync --frozen uv run pyinstaller strix.spec --noconfirm + # Onedir build: dist/strix/ is an app directory (strix + _internal/). if [[ "${{ runner.os }}" == "Windows" ]]; then - dist/strix.exe --version + dist/strix/strix.exe --version else - dist/strix --version + dist/strix/strix --version fi if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then - file dist/strix - file dist/strix | grep -q "ARM aarch64" || { + file dist/strix/strix + file dist/strix/strix | grep -q "ARM aarch64" || { echo "::error::linux-arm64 artifact is not an ARM aarch64 binary" exit 1 } @@ -62,13 +63,15 @@ jobs: VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') mkdir -p dist/release + cp -R dist/strix "dist/release/strix-${VERSION}-${{ matrix.target }}" + if [[ "${{ runner.os }}" == "Windows" ]]; then - cp dist/strix.exe "dist/release/strix-${VERSION}-${{ matrix.target }}.exe" - (cd dist/release && 7z a "strix-${VERSION}-${{ matrix.target }}.zip" "strix-${VERSION}-${{ matrix.target }}.exe") + (cd dist/release && 7z a "strix-${VERSION}-${{ matrix.target }}.zip" "strix-${VERSION}-${{ matrix.target }}") + rm -rf "dist/release/strix-${VERSION}-${{ matrix.target }}" else - cp dist/strix "dist/release/strix-${VERSION}-${{ matrix.target }}" - chmod +x "dist/release/strix-${VERSION}-${{ matrix.target }}" + chmod +x "dist/release/strix-${VERSION}-${{ matrix.target }}/strix" tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}" + rm -rf "dist/release/strix-${VERSION}-${{ matrix.target }}" fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/scripts/build.sh b/scripts/build.sh index 66fd066fd..5f62a39dc 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -54,45 +54,48 @@ uv run pyinstaller strix.spec --noconfirm RELEASE_DIR="dist/release" mkdir -p "$RELEASE_DIR" -BINARY_NAME="strix-${VERSION}-${OS_NAME}-${ARCH_NAME}" +# Onedir build: dist/strix/ is an app directory (strix + _internal/). +BUNDLE_NAME="strix-${VERSION}-${OS_NAME}-${ARCH_NAME}" + +if [ "$OS_NAME" = "windows" ]; then + EXE_NAME="strix.exe" +else + EXE_NAME="strix" +fi + +if [ ! -f "dist/strix/$EXE_NAME" ]; then + echo -e "${RED}Build failed: Binary not found${NC}" + exit 1 +fi + +cp -R "dist/strix" "$RELEASE_DIR/$BUNDLE_NAME" +chmod +x "$RELEASE_DIR/$BUNDLE_NAME/$EXE_NAME" if [ "$OS_NAME" = "windows" ]; then - if [ ! -f "dist/strix.exe" ]; then - echo -e "${RED}Build failed: Binary not found${NC}" - exit 1 - fi - BINARY_NAME="${BINARY_NAME}.exe" - cp "dist/strix.exe" "$RELEASE_DIR/$BINARY_NAME" echo -e "\n${BLUE}Creating zip...${NC}" - ARCHIVE_NAME="${BINARY_NAME%.exe}.zip" + ARCHIVE_NAME="${BUNDLE_NAME}.zip" if command -v 7z &> /dev/null; then - 7z a "$RELEASE_DIR/$ARCHIVE_NAME" "$RELEASE_DIR/$BINARY_NAME" + (cd "$RELEASE_DIR" && 7z a "$ARCHIVE_NAME" "$BUNDLE_NAME") else - powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BINARY_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'" + powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BUNDLE_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'" fi echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME" else - if [ ! -f "dist/strix" ]; then - echo -e "${RED}Build failed: Binary not found${NC}" - exit 1 - fi - cp "dist/strix" "$RELEASE_DIR/$BINARY_NAME" - chmod +x "$RELEASE_DIR/$BINARY_NAME" echo -e "\n${BLUE}Creating tarball...${NC}" - ARCHIVE_NAME="${BINARY_NAME}.tar.gz" - tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BINARY_NAME" + ARCHIVE_NAME="${BUNDLE_NAME}.tar.gz" + tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BUNDLE_NAME" echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME" fi echo -e "\n${GREEN}Build successful!${NC}" echo "================================" -echo -e "${YELLOW}Binary:${NC} $RELEASE_DIR/$BINARY_NAME" +echo -e "${YELLOW}App directory:${NC} $RELEASE_DIR/$BUNDLE_NAME" -SIZE=$(ls -lh "$RELEASE_DIR/$BINARY_NAME" | awk '{print $5}') +SIZE=$(du -sh "$RELEASE_DIR/$BUNDLE_NAME" | awk '{print $1}') echo -e "${YELLOW}Size:${NC} $SIZE" echo -e "\n${BLUE}Testing binary...${NC}" -"$RELEASE_DIR/$BINARY_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}" +"$RELEASE_DIR/$BUNDLE_NAME/$EXE_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}" echo -e "\n${GREEN}Done!${NC}" diff --git a/scripts/install.sh b/scripts/install.sh index cacbd9cbe..d381eab8e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -159,12 +159,28 @@ download_and_install() { fi echo -e "${MUTED}Extracting...${NC}" + local extracted="strix-${specific_version}-${target}" if [ "$os" = "windows" ]; then unzip -q "$filename" - mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe" + if [ -d "$extracted" ]; then + # Onedir app bundle (strix.exe + _internal/). + rm -rf "$INSTALL_DIR/_internal" "$INSTALL_DIR/strix.exe" + cp -R "$extracted/." "$INSTALL_DIR/" + else + # Legacy single-file binary. + mv "${extracted}.exe" "$INSTALL_DIR/strix.exe" + fi else tar -xzf "$filename" - mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix" + if [ -d "$extracted" ]; then + # Onedir app bundle (strix + _internal/). + rm -rf "$INSTALL_DIR/_internal" "$INSTALL_DIR/strix" + cp -R "$extracted/." "$INSTALL_DIR/" + else + # Legacy single-file binary. + rm -rf "$INSTALL_DIR/strix" + mv "$extracted" "$INSTALL_DIR/strix" + fi chmod 755 "$INSTALL_DIR/strix" fi diff --git a/strix.spec b/strix.spec index 19e39b375..26750a469 100644 --- a/strix.spec +++ b/strix.spec @@ -276,19 +276,19 @@ a = Analysis( pyz = PYZ(a.pure) +# Onedir build: no self-extraction at startup, so the CLI starts fast on +# every run (onefile unpacks the full bundle to a temp dir each invocation). exe = EXE( pyz, a.scripts, - a.binaries, - a.datas, [], + exclude_binaries=True, name='strix', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, upx_exclude=[], - runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, @@ -296,3 +296,13 @@ exe = EXE( codesign_identity=None, entitlements_file=None, ) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='strix', +) diff --git a/strix/core/constants.py b/strix/core/constants.py new file mode 100644 index 000000000..9504d950f --- /dev/null +++ b/strix/core/constants.py @@ -0,0 +1,3 @@ +"""Lightweight constants importable without pulling in the agent runtime.""" + +DEFAULT_MAX_TURNS = 500 diff --git a/strix/core/inputs.py b/strix/core/inputs.py index aef1fe137..4baf0ac96 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -24,9 +24,6 @@ if TYPE_CHECKING: from strix.config.settings import ReasoningEffort -DEFAULT_MAX_TURNS = 500 - - def _accepts_required_tool_choice(model_name: str | None) -> bool: name = (model_name or "").strip().lower() for prefix in ("litellm/", "any-llm/"): diff --git a/strix/core/runner.py b/strix/core/runner.py index fdb432d0c..18140d94f 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -24,6 +24,7 @@ from strix.config.models import ( uses_chat_completions_tool_schema, ) from strix.core.agents import AgentCoordinator +from strix.core.constants import DEFAULT_MAX_TURNS from strix.core.execution import ( respawn_subagents, run_agent_loop, @@ -33,7 +34,6 @@ from strix.core.execution import ( ) from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags from strix.core.inputs import ( - DEFAULT_MAX_TURNS, build_root_task, build_scope_context, make_model_settings, diff --git a/strix/interface/__init__.py b/strix/interface/__init__.py index b0f97407c..75abbf835 100644 --- a/strix/interface/__init__.py +++ b/strix/interface/__init__.py @@ -1,4 +1,16 @@ -from .main import main +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .main import main __all__ = ["main"] + + +def __getattr__(name: str) -> object: + if name == "main": + from .main import main + + return main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/strix/interface/cli.py b/strix/interface/cli.py index c03e8c86c..1b6b00da8 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -13,7 +13,7 @@ from rich.panel import Panel from rich.text import Text from strix.config import load_settings -from strix.core.inputs import DEFAULT_MAX_TURNS +from strix.core.constants import DEFAULT_MAX_TURNS from strix.core.runner import run_strix_scan from strix.report.state import ReportState, set_global_report_state from strix.runtime import session_manager diff --git a/strix/interface/main.py b/strix/interface/main.py index 0fbcbf486..740304675 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -11,9 +11,6 @@ import sys from datetime import UTC, datetime from pathlib import Path -from agents.model_settings import ModelSettings -from agents.models.interface import ModelTracing -from docker.errors import DockerException from rich.console import Console from rich.panel import Panel from rich.text import Text @@ -24,17 +21,8 @@ from strix.config import ( load_settings, persist_current, ) -from strix.config.models import ( - RECOMMENDED_MODEL_NAMES, - StrixProvider, - configure_sdk_model_defaults, - is_known_openai_bare_model, - is_recommended_or_frontier_model, -) -from strix.core.inputs import DEFAULT_MAX_TURNS +from strix.core.constants import DEFAULT_MAX_TURNS from strix.core.paths import run_dir_for, runtime_state_dir -from strix.interface.cli import run_cli -from strix.interface.tui import run_tui from strix.interface.update_check import ( is_binary_install, notify_update, @@ -61,8 +49,6 @@ from strix.interface.utils import ( rewrite_localhost_targets, validate_config_file, ) -from strix.report.state import get_global_report_state -from strix.report.writer import read_run_record, write_run_record from strix.telemetry import posthog, scarf from strix.telemetry.logging import configure_dependency_logging @@ -313,6 +299,17 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: console = Console() logger.info("Warming up LLM connection") + from agents.model_settings import ModelSettings + from agents.models.interface import ModelTracing + + from strix.config.models import ( + RECOMMENDED_MODEL_NAMES, + StrixProvider, + configure_sdk_model_defaults, + is_known_openai_bare_model, + is_recommended_or_frontier_model, + ) + raw_model = "" try: settings = load_settings() @@ -776,6 +773,8 @@ Examples: def _persist_run_record(args: argparse.Namespace) -> None: + from strix.report.writer import write_run_record + run_dir = run_dir_for(args.run_name) run_dir.mkdir(parents=True, exist_ok=True) run_record = { @@ -799,6 +798,8 @@ def _persist_run_record(args: argparse.Namespace) -> None: def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: """Populate ``args.targets_info`` and friends from a prior run's run.json.""" + from strix.report.writer import read_run_record + run_dir = run_dir_for(args.resume) state_path = run_dir / "run.json" if not state_path.exists(): @@ -843,6 +844,8 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser def display_completion_message(args: argparse.Namespace, results_path: Path) -> None: + from strix.report.state import get_global_report_state + console = Console() report_state = get_global_report_state() @@ -922,6 +925,8 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) -> def pull_docker_image() -> None: + from docker.errors import DockerException + console = Console() client = check_docker_connection() @@ -1071,8 +1076,12 @@ def main() -> None: exit_reason = "user_exit" try: if args.non_interactive: + from strix.interface.cli import run_cli + asyncio.run(run_cli(args)) else: + from strix.interface.tui import run_tui + asyncio.run(run_tui(args)) except KeyboardInterrupt: exit_reason = "interrupted" @@ -1082,6 +1091,8 @@ def main() -> None: scarf.error("unhandled_exception") raise finally: + from strix.report.state import get_global_report_state + report_state = get_global_report_state() if report_state: status = {"interrupted": "interrupted", "error": "failed"}.get( @@ -1097,6 +1108,8 @@ def main() -> None: display_completion_message(args, results_path) if args.non_interactive: + from strix.report.state import get_global_report_state + report_state = get_global_report_state() if report_state and report_state.vulnerability_reports: sys.exit(2) diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index cc5a36c4f..3a14c5f07 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -33,8 +33,8 @@ from textual.widgets.tree import TreeNode from strix.config import load_settings from strix.config.models import is_recommended_or_frontier_model +from strix.core.constants import DEFAULT_MAX_TURNS from strix.core.hooks import BudgetExceededError -from strix.core.inputs import DEFAULT_MAX_TURNS from strix.core.runner import run_strix_scan from strix.interface.tui.live_view import TuiLiveView from strix.interface.tui.messages import send_user_message_to_agent diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py index 711592675..01bd4ece8 100644 --- a/strix/interface/update_check.py +++ b/strix/interface/update_check.py @@ -316,35 +316,99 @@ def _download_and_replace(version: str, target: str, console: Console) -> bool: if is_windows: with zipfile.ZipFile(archive_path) as zf: - zf.extract(binary_name, tmp_dir) + zf.extractall(tmp_dir) # noqa: S202 # nosec B202 checksum-verified above else: with tarfile.open(archive_path, "r:gz") as tf: - tf.extract(binary_name, tmp_dir, filter="data") + tf.extractall(tmp_dir, filter="data") # nosec B202 + + bundle_dir = tmp_dir / f"strix-{version}-{target}" + if bundle_dir.is_dir(): + _swap_app_bundle(bundle_dir, current_exe, is_windows=is_windows) + return True new_binary = tmp_dir / binary_name - new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - staged = current_exe.with_name(current_exe.name + ".new") - try: - shutil.copy2(new_binary, staged) - if is_windows: - # Windows can't replace a running executable in place; move it aside first. - old = current_exe.with_name(current_exe.name + ".old") - old.unlink(missing_ok=True) - current_exe.rename(old) - try: - staged.replace(current_exe) - except Exception: - old.rename(current_exe) - raise - else: - staged.replace(current_exe) - except Exception: - staged.unlink(missing_ok=True) - raise + if not new_binary.is_file(): + raise RuntimeError(f"unexpected archive layout in {filename}") + _swap_single_binary(new_binary, current_exe, is_windows=is_windows) return True +def _swap_single_binary(new_binary: Path, current_exe: Path, *, is_windows: bool) -> None: + """Replace a legacy single-file binary in place.""" + new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + staged = current_exe.with_name(current_exe.name + ".new") + try: + shutil.copy2(new_binary, staged) + if is_windows: + # Windows can't replace a running executable in place; move it aside first. + old = current_exe.with_name(current_exe.name + ".old") + old.unlink(missing_ok=True) + current_exe.rename(old) + try: + staged.replace(current_exe) + except Exception: + old.rename(current_exe) + raise + else: + staged.replace(current_exe) + except Exception: + staged.unlink(missing_ok=True) + raise + + +def _swap_app_bundle(bundle_dir: Path, current_exe: Path, *, is_windows: bool) -> None: + """Swap an onedir app bundle (exe + ``_internal/``) into place.""" + exe_name = "strix.exe" if is_windows else "strix" + new_exe = bundle_dir / exe_name + new_internal = bundle_dir / "_internal" + if not new_exe.is_file() or not new_internal.is_dir(): + raise RuntimeError("unexpected app bundle layout (missing strix or _internal)") + new_exe.chmod(new_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + app_dir = current_exe.parent + internal = app_dir / "_internal" + old_internal = app_dir / "_internal.old" + staged_internal = app_dir / "_internal.new" + staged_exe = current_exe.with_name(current_exe.name + ".new") + old_exe = current_exe.with_name(current_exe.name + ".old") + + shutil.rmtree(staged_internal, ignore_errors=True) + shutil.rmtree(old_internal, ignore_errors=True) + staged_exe.unlink(missing_ok=True) + old_exe.unlink(missing_ok=True) + + shutil.copytree(new_internal, staged_internal) + shutil.copy2(new_exe, staged_exe) + + internal_moved = False + try: + internal.rename(old_internal) + internal_moved = True + staged_internal.rename(internal) + if is_windows: + # Windows can't replace a running executable in place; move it aside first. + current_exe.rename(old_exe) + try: + staged_exe.replace(current_exe) + except Exception: + old_exe.rename(current_exe) + raise + else: + staged_exe.replace(current_exe) + except Exception: + if internal_moved and not internal.exists() and old_internal.exists(): + old_internal.rename(internal) + shutil.rmtree(staged_internal, ignore_errors=True) + staged_exe.unlink(missing_ok=True) + raise + + # Best effort: on Windows loaded DLLs/exe may resist deletion until restart. + shutil.rmtree(old_internal, ignore_errors=True) + if not is_windows: + old_exe.unlink(missing_ok=True) + + def self_update(console: Console | None = None, version: str | None = None) -> bool: """Replace the running standalone binary with the latest release. diff --git a/strix/telemetry/logging.py b/strix/telemetry/logging.py index 53945f12c..6364938fa 100644 --- a/strix/telemetry/logging.py +++ b/strix/telemetry/logging.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextlib import logging import os +import sys import warnings from contextvars import ContextVar from pathlib import Path # noqa: TC003 used at runtime by ``setup_scan_logging`` @@ -78,11 +79,12 @@ class _StdoutQuietFilter(logging.Filter): def configure_dependency_logging() -> None: """Quiet dependency logging/warnings that obscure Strix scan logs.""" - with contextlib.suppress(Exception): - import litellm - - litellm_logging = litellm._logging - litellm_logging._disable_debugging() # type: ignore[no-untyped-call] + # Only quiet litellm when it is already loaded: importing it here would + # pull the whole package in (~1s+) even for paths that never use it. + litellm = sys.modules.get("litellm") + if litellm is not None: + with contextlib.suppress(Exception): + litellm._logging._disable_debugging() logging.getLogger("asyncio").setLevel(logging.CRITICAL) logging.getLogger("asyncio").propagate = False