From 12a4485d226d6fab385387bd014b2b93dcd3af8e Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 8 Oct 2026 20:39:31 -0400 Subject: [PATCH 1/3] added plan --- docs/directory.md | 1 + docs/system-path-backend-plan.md | 337 +++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 docs/system-path-backend-plan.md diff --git a/docs/directory.md b/docs/directory.md index 62fa536..5b7a4a4 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -1372,6 +1372,7 @@ Prefer `rg` for local search. On Windows/PowerShell, use patterns like `rg -n "p | `CONTRIBUTING.md` | Developer quickstart: setup, run, dev loop, tests, and PR checklist | | `docs/directory.md` | This file — project structure and feature reference | | `docs/tests.md` | Test suite layout, commands, and what each test covers | +| `docs/system-path-backend-plan.md` | Proposed staged implementation for using system-managed llama.cpp tools from PATH (issue #399) | | `docs/gpu-monitoring.md` | User setup guide for NVIDIA SMI, AMD SMI, and the optional cross-vendor all-smi collector | | `docs/maintenance.md` | Release, dependency, compatibility, and repository maintenance guidance | | `docs/security.md` | Security model, trust boundaries, and reporting guidance | diff --git a/docs/system-path-backend-plan.md b/docs/system-path-backend-plan.md new file mode 100644 index 0000000..9ca06cd --- /dev/null +++ b/docs/system-path-backend-plan.md @@ -0,0 +1,337 @@ +# Implementation plan: System (PATH) llama.cpp backend + +Source: [issue #399](https://github.com/thomas9120/LLama-GUI/issues/399) +Prepared: 2026-10-08 +Repository baseline: `6543038` +Status: proposed implementation; no runtime changes have been made. + +## Goal + +Let users activate the llama.cpp tools already available on the GUI process's +`PATH`. Llama GUI will construct launch arguments, start and stop its own child +processes, and provide the existing Chat, API, Benchmarking, and Monitor features. +The user's system package manager will own llama.cpp installation and updates. + +This replaces the issue author's workaround of symlinking system executables into +`llama/custom/bin/`. It is particularly useful for NixOS packages and wrappers +that supply their own runtime dependencies, and also applies to other system +installations. + +## Proposed behavior and scope + +- Add backend ID `system`, displayed as **System (PATH)**, on supported platforms. + Keep it selectable when tools are missing so activation can explain the problem. +- Require an executable `llama-server` for activation. Treat `llama-cli`, + `llama-bench`, `llama-perplexity`, utility tools, and `llama-fit-params` as + independently available features. Existing official and Custom backend + requirements remain unchanged. +- Use the PATH inherited by the Python GUI process. Resolve each tool separately; + tools need not share one directory. Display the paths used for discovery. +- Launch the discovered absolute path with the normal GUI-generated arguments. + Preserve symlink and wrapper entry points rather than replacing them with their + final targets. Use an argument list and the existing subprocess implementation. +- Resolve again before a launch or probe. Package updates can replace a symlink + target while the GUI is open. A running process continues until the normal stop + action; a later launch uses the then-current tool. +- Preserve the inherited environment for System, then merge the already-supported + per-launch `LLAMA_`/`GGML_` overrides. Do not prepend repository binary or library + directories for this backend. +- Disable release selection, GUI binary updates, and Repair for System. A failed + activation leaves the current backend active. Missing System tools never fall + back to official or Custom binaries. +- Keep the existing model root, presets, bundled chat templates, subprocess + working directory, authentication, and process lifecycle. Grammar files remain + user-selected paths; there is no package-data-directory discovery in this scope. + +This feature does not install system packages, modify PATH, source shell startup +files, configure Nix/CUDA, or connect to an already-running server. The existing +external-server feature continues to handle that last case. No new runtime +dependency or general provider framework is needed. + +## Existing foundations and changes needed + +| Area | Current behavior | Required change | +|------|------------------|-----------------| +| `backend/services/llama_manager.py` | Backend specs, folder selection, Custom activation, packaged runtime checks, official-install metadata | System classification, discovery/probes, activation, and correct preservation of the official installation | +| `backend/app.py` and `backend/context.py` | `find_tool_executable()` returns a path inside one selected installation folder | Delegate to shared discovery; represent an unavailable System tool explicitly; register the activation route | +| `backend/services/process_manager.py` | Preflight and launch share validation; environment prepends a selected bin directory; the estimator locates `llama-fit-params` separately | Use System discovery/environment for every subprocess and validate the executable actually being invoked | +| `backend/routes/status.py` | Installation readiness requires CLI and server; tool availability is reported as booleans | Apply System's server requirement and add discovered paths/build information | +| `backend/routes/install.py` | Custom activation has install/process interlocks; other actions assume a downloadable backend | Add System activation and reject inappropriate install/update/release operations | +| `backend/routes/lifecycle.py` | Open Folder creates an application-owned installation directory | Give System a read-only folder-opening policy | +| `ui/js/manager/manager-backends.js`, `ui/js/manager/manager-install.js`, and `ui/js/manager/manager-status.js` | Backend selection, release fetching, activation, and build-tag propagation | System controls, status, switching, and build information | +| `ui/index.html`, `ui/js/quick-launch-ui.js`, and `ui/js/flag-core.js` | Setup markup, launch readiness, and shared argument generation | System guidance, per-tool readiness, and correct build gates | + +Use the existing service/context injection and focused frontend namespaces. +Keep shared flag state authoritative across Configure, Quick Launch, Chat, and +command preview. Touch other consumers only where a demonstrated assumption about +folders, availability, or build tags requires it. + +## Stage 1: Backend classification and executable discovery + +**Outcome:** one resolver finds the selected backend's tools without changing +existing official/Custom behavior. + +1. Add System to `build_backend_specs()` in `backend/services/llama_manager.py`. + Keep `CUSTOM_BACKEND_SPECS` limited to its two real folder slots; startup must + not create a System bin/grammar directory. +2. Add narrow helpers for System detection and for identifying installations + managed outside the GUI. Use them where existing code assumes that every + non-Custom backend is an official installation. Keep Custom folder behavior + separate from System PATH behavior. +3. Introduce shared tool resolution in the owning backend service. For System, + use `shutil.which()` on a known tool filename, make the returned path absolute + without dereferencing its symlinks, and check file/executable availability. + Search the inherited PATH only, including its normal precedence and relative + entries; use platform-aware executable naming. Do not expand the search into + other repository installations or launch a shell to perform discovery. +4. Restrict discovery inputs to known llama.cpp tools. Include `llama-fit-params` + as an internal discovery target without adding it to the public launch-tool + allowlist. Do not permit arbitrary executable names through the API. +5. Make `find_tool_executable()` delegate to that resolver. Use `None` for an + unavailable System tool, update the `BackendServices` callable annotation, + and explicitly guard all consumers before path operations. Preserve the + existing folder-path results for official and Custom installations. +6. Remove System calls into folder-only helpers. System has no single bin + directory: runtime enumeration and setup should not scan or create PATH + directories. A guard in the folder helper can catch accidental misuse. + +**Verification:** add focused discovery cases under `tests/backend/`, covering +PATH precedence, tools in different directories, missing tools despite local +official binaries, spaces in paths, executable permissions, symlinks/wrappers, +Windows `.exe` lookup, and existing official/Custom resolution. Use fixtures for +platform-specific lookup rather than relying on the host's installed llama.cpp. + +**Exit criterion:** a missing System tool is explicit, and choosing System cannot +select a tool from a different backend by fallback. + +## Stage 2: Runtime probes, environment, and process integration + +**Depends on:** Stage 1. +**Outcome:** the existing launch pipeline can run system tools and wrappers. + +1. Add a System branch to `_build_process_env()` that copies the inherited + environment without injecting local binary/library paths. Apply existing + validated per-launch overrides afterward. Keep official/Custom environment + construction unchanged. +2. Validate System executables with a bounded, model-free `--version` subprocess + using the discovered entry point, inherited runtime environment, existing + window-hiding conventions, and a short timeout such as five seconds. Parse + stdout and stderr for recognized build formats; successful execution with an + unknown build format is distinct from a failure to execute. +3. Use that execution check for System runtime health instead of requiring nearby + DLLs, `.so` files, or `@rpath` libraries. Do not run `ldd`/`otool` against a Nix + wrapper and interpret a script-inspection failure as a broken installation. + A successful version probe establishes that the entry point starts; it does + not establish model loading or GPU compatibility. +4. Cache probe results under the existing runtime-health lock. Include the + executable path and file/symlink identity in the System cache key, retain a + bounded TTL, and invalidate on activation or discovery changes. Ordinary + status polls should reuse results rather than spawn probes on every request. + Avoid holding process/config locks during subprocess execution. +5. Integrate missing-tool errors and System probes into preflight and launch. + Resolve and validate the same entry point that is passed to `Popen`; if it + disappears between validation and execution, return the normal logged, + sanitized launch failure. Preserve install/process interlocks, runtime + generations, output capture, health polling, and Windows process groups. +6. Route `_fit_params_executable()` through shared discovery. In memory + estimation, validate `llama-fit-params` itself for System; the current code + locates the estimator separately but validates the requested CLI/server tool. + Missing estimation support must not block server launches. +7. Review `get_buffer_types()`, which currently chooses CLI. For System, prefer + an available supported CLI/server probe target, using server when CLI is + absent. Preserve the existing unavailable/error behavior if the build does + not support the probe rather than implying that GPU capability was verified. + +**Verification:** cover preserved PATH/library variables, wrapper execution, +known/unknown builds, probe failures/timeouts, cache invalidation, missing +estimator, server-only buffer discovery, unchanged argument/environment payloads, +and existing start/stop behavior. Extend +`tests/backend/test_process_environment.py` and relevant service tests. + +**Exit criterion:** server preflight and launch use the system entry point and +environment, with recoverable errors and no packaged-library requirement. + +## Stage 3: Activation, persistence, status, and switching + +**Depends on:** Stages 1-2. +**Outcome:** System can be activated, restored after restart, and switched safely. + +1. Add proposed route `POST /api/activate-system` in `backend/routes/install.py` + and register it in `backend/app.py`. It accepts no client-supplied executable + path. Reuse the Custom activation install-slot claim/release pattern to reject + activation during an install or while a GUI-managed process is running. +2. Discover tools and require the System server execution probe to succeed before + changing configuration. Return discovered paths, found/missing tool lists, + normalized build tags, and actionable failure information. Validate request + shape and log unexpected errors before returning `sanitize_error()` output. +3. Persist the selection under `config_lock`, merging with the latest config so + model-root/external-target settings survive. A simple marker such as + `backend: "system"`, `tag: "system"`, `version: "system"` is sufficient for + selection; discovered paths and build tags come from current discovery/cache, + not persisted Nix-store paths. Configuration is unchanged on failed probing. +4. Preserve `official_install` when leaving an official build. Update both Custom + activation and `get_official_install_status()` so System is never recorded as + the official installation. Switching System -> Custom -> official must retain + the correct official backend/tag. Reuse Activate Existing for the return path. +5. Extend `/api/status` additively. Retain `executables` booleans and existing + fields. Mark the available-backend entry with `system_path: true`, expose + per-tool discovered paths/build tags and probe state, and calculate System + readiness from its server rather than both CLI and server. Keep selected + backend configuration separate from discovered version information. +6. Report a removed/broken System server as unavailable/stale with PATH guidance. + Do not clear the user's selection or silently select another backend. On GUI + restart, rediscover with that process's inherited environment. +7. Explicitly reject System in install, repair-via-install, update, and + activate-existing official requests before downloads or config mutation. + Return an empty release list for System without a release lookup. Verify + official catalog refresh does not turn System into a download target. +8. Keep cleanup limited to the existing application-owned directories. Preserve + the active System selection while clearing removed official-install metadata. + For System Open Folder, open the discovered server's containing directory + without creating it; report unavailable if the server cannot be found. +9. Update the Route Modules table and endpoint count in `docs/directory.md` in + the same implementation change. This plan adds one exact route; the current + baseline's 50 endpoints would become 51 if no other routes change meanwhile. + +**Verification:** successful/failed activation, running/install rejection, +restart restoration, server-only status, direct misuse of install/update routes, +official/System/Custom round trips, config merge preservation, cleanup, and +read-only folder opening. Extend `tests/backend/test_custom_slots.py` and +`tests/backend/test_extracted_routes.py`; put System-specific cases in a focused +`test_system_backend.py` file under the existing backend tests directory. + +**Exit criterion:** a System selection survives restart and cleanup, and users +can return to their previous installation without downloading it again. + +## Stage 4: Frontend controls, readiness, and build compatibility + +**Depends on:** Stage 3. +**Outcome:** the user can discover, activate, and understand System from the GUI. + +1. Render the backend from backend-provided metadata in the existing Manager + package. Selecting it shows **Activate System**, hides release selection, + and explains that the OS package manager owns installation and updates. + Add minimal setup markup in `ui/index.html`; keep the current script order. +2. Display each discovered tool/path and availability using `textContent`. + Show server as required for System and the other tools as feature-specific. + Retain both CLI/server requirements in existing official/Custom views. +3. Distinguish the pending dropdown choice from the active backend. Route System + activation to its endpoint, retain duplicate-action guards, refresh status + afterward, and show errors near the activation controls. Polling must not + reset the user's pending selection. +4. Disable System update/repair and suppress release fetching, including forced + refresh paths. Selecting an official target while System is active should + offer Activate Existing where appropriate. Derive this from accepted status. +5. Adjust Quick Launch and other affected readiness checks to consider the + requested executable. A missing CLI must leave server/Chat usable. Missing + benchmark/perplexity tools or the estimator should produce feature-specific + guidance, with backend launch validation remaining authoritative. +6. Feed detected build tags into the existing shared flag-core compatibility + gates. PATH can resolve CLI and server from different builds; use the tag + for the tool whose arguments are being generated, including tool changes + and benchmark source configurations. Extend the existing tag mechanism + narrowly rather than creating per-tab flag state. Unknown build formats + retain the existing conservative gate behavior and an explicit unknown + version display; do not invent a release tag from a package version. +7. Keep command preview on the existing shared argument builder. The System + details show the actual executable path; a new command builder is unnecessary. + Update folder/remove action labels and explanations to match Stage 3. +8. Explain discovery failures concretely: **llama-server was not found on the + PATH inherited by Llama GUI. Start the GUI from the environment where it is + available, or restart the GUI after changing its launch environment.** + Avoid displaying the entire environment or offering automatic shell edits. + +**Verification:** extend `tests/frontend/manager_releases_unit.cjs`, relevant +flag-core/launch/benchmark tests, and `tests/frontend/flag_sync_smoke.cjs`. +Check activation, pending selection, disabled actions, server-only readiness, +tool-specific build gates, safe path rendering, and official return activation. +Run `node --check` on every changed JS file and `npm run test:frontend` for DOM +wiring/shared-state changes. + +**Exit criterion:** a server-only System installation is usable through the +normal GUI, and unavailable optional features have clear, localized errors. + +## Stage 5: Regression coverage and release checks + +**Depends on:** Stages 1-4; add focused tests alongside their owning stages. +**Outcome:** the whole feature and existing backends pass the release checks. + +- Exercise three representative System layouts: server only, a complete set of + tools, and wrapper/symlink entry points. Include a broken server, different + CLI/server builds, an unknown version format, and a changed package target. +- Keep host llama.cpp installations out of deterministic unit tests. Use + temporary files, mocked subprocess responses, and environment fixtures. +- Update `tests/frontend/llama_flags_supported_unit.cjs` so selecting System + checks PATH tools rather than local downloaded binaries first. Cover selection + in `tests/frontend/llama_flags_runner_unit.cjs`. Preserve explicit binary + directory precedence and the required pinned-build CI checks. The full flag + comparison still needs both CLI and server; server-only runtime support must + not weaken that test's existing contract. +- Recheck official and both Custom activation, launch environment, missing-tool + errors, cleanup, and switching. Reuse the existing suites rather than adding + another process manager or browser harness. +- Run the full backend suite in the project venv, the frontend suite, and docs + link/route-sync checks once the staged work is integrated. + +```powershell +.venv/Scripts/python.exe -m unittest discover tests -v +npm test +.venv/Scripts/python.exe -m unittest tests.backend.test_docs_links tests.backend.test_docs_sync -v +``` + +On Unix, use `.venv/bin/python` for the Python commands. Follow [the test +reference](tests.md) for focused commands during each stage. Keep CI on its +existing Linux and Windows runners; cover macOS path/probe behavior with fixtures. + +**Exit criterion:** automated checks pass without requiring a system llama.cpp +installation, GPU, or native macOS runner, and CI's pinned binary check remains +strict. + +## Stage 6: User documentation and native validation + +**Depends on:** Stage 5. +**Outcome:** the feature is documented and its real environment behavior is known. + +1. Update `docs/directory.md` with System selection/discovery, runtime validation, + status metadata, and the final route contract. Update `docs/tests.md` for new + coverage, and `docs/troubleshooting.md` for inherited PATH, restart, unknown + build tags, and package-manager-owned updates. +2. Document terminal-versus-desktop/Pinokio environment differences. Keep startup + entry points and runtime dependencies unchanged. If implementation requires + launcher/startup changes, inspect the companion launcher documented in the + [project reference](directory.md#companion-repositories) before making them. +3. Validate on NixOS with an actual system package/wrapper: activate, load a model, + send a Chat message, inspect Monitor, stop, restart, and switch back. Test a + package/profile update to confirm later launches rediscover the entry point. +4. Smoke-test a conventional Linux installation, Windows PATH executables, and + macOS package-manager/desktop startup when those environments are available. + Check optional tools only where installed; a missing estimator is an expected + unavailable feature. Record which native checks were actually run. + +**Exit criterion:** docs describe the implemented behavior, and native NixOS +validation establishes that wrappers and real model/GPU loading work. If that +environment is unavailable, explicitly record the remaining native check rather +than claiming fixtures establish NixOS/CUDA compatibility. + +## Effort estimate and completion criteria + +This is a small-to-medium implementation. Discovery is straightforward; runtime +environment assumptions, optional tools, build gates, and switching account for +most of the integration work. + +- Stages 1-2: approximately one developer day. +- Stage 3: approximately half to one day. +- Stage 4: approximately one day. +- Stages 5-6: approximately half to two days, depending on native environments. + +Allow roughly **3-5 developer days** for a reviewed implementation by someone +familiar with this repo. Native NixOS/macOS availability can extend elapsed time. +Expect around **10-14 production files** plus focused tests and documentation; +keep the final diff driven by the concrete consumers listed above. A minimal +server-launch prototype could take 1-2 days but would not complete this plan. + +The feature is complete when System activates without copied/symlinked repository +binaries, normal server/Chat flows work with its inherited environment, missing +optional tools affect only their features, official/Custom switching remains +correct, system files are never modified by maintenance actions, automated checks +pass, and native validation results or remaining limitations are documented. From 0038638b0e3ca03f13ba264122571cadfadd7398 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 10 Oct 2026 20:51:56 -0400 Subject: [PATCH 2/3] Support custom backend executable folders - Allow each custom backend slot to use a saved external executable directory - Validate folders before activation and route runtime checks and launches through them - Add frontend controls, coverage, and documentation for folder selection and reset --- backend/app.py | 4 +- backend/routes/file_picker.py | 20 +- backend/routes/install.py | 4 +- backend/routes/lifecycle.py | 14 +- backend/routes/status.py | 2 +- backend/services/llama_manager.py | 89 ++++++-- backend/services/process_manager.py | 8 +- docs/directory.md | 14 +- docs/tests.md | 9 +- tests/backend/test_custom_slots.py | 202 +++++++++++++++++- tests/backend/test_extracted_routes.py | 4 +- tests/frontend/flag_sync_smoke.cjs | 53 ++++- tests/frontend/llama_flags_runner_unit.cjs | 9 + tests/frontend/llama_flags_supported_unit.cjs | 9 +- tests/frontend/manager_releases_unit.cjs | 89 ++++++++ ui/index.html | 15 +- ui/js/manager/manager-backends.js | 79 ++++++- ui/js/manager/manager-main.js | 1 + 18 files changed, 566 insertions(+), 59 deletions(-) diff --git a/backend/app.py b/backend/app.py index a8d646b..27fd1b4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -194,12 +194,12 @@ def get_tool_filename(tool): def find_tool_executable(tool): cfg = load_config() - return llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend")) / get_tool_filename(tool) + return llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend"), cfg) / get_tool_filename(tool) def get_runtime_files(): cfg = load_config() - search_dir = llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend")) + search_dir = llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend"), cfg) if not search_dir.exists(): return [] runtime_files = [] diff --git a/backend/routes/file_picker.py b/backend/routes/file_picker.py index 5debdb7..1c9a911 100644 --- a/backend/routes/file_picker.py +++ b/backend/routes/file_picker.py @@ -1,9 +1,10 @@ """Routes for native file selection.""" from pathlib import Path +import sys from backend.http import sanitize_error -from backend.services import file_picker, model_dir +from backend.services import file_picker, llama_manager, model_dir def select_file(request, response, ctx): @@ -35,10 +36,20 @@ def select_file(request, response, ctx): def select_folder(request, response, ctx): body = request.body or {} - title = str(body.get("title") or "Select Models Folder").strip() or "Select Models Folder" + default_title = "Select llama.cpp Executable Folder" if body.get("purpose") == "custom-backend" else "Select Models Folder" + title = str(body.get("title") or default_title).strip() or default_title try: - info = model_dir.get_models_dir_info(ctx) - initial_dir = Path(info["models_dir"]) if info["models_dir_available"] else ctx.paths.models.parent + if body.get("purpose") == "custom-backend": + backend = body.get("backend", "custom") + if not llama_manager.is_custom_backend(backend): + response.error("Unsupported custom backend", 400) + return + initial_dir = llama_manager.get_backend_bin_dir(ctx, backend) + if not initial_dir.is_dir(): + initial_dir = ctx.paths.root + else: + info = model_dir.get_models_dir_info(ctx) + initial_dir = Path(info["models_dir"]) if info["models_dir_available"] else ctx.paths.models.parent selected_path = file_picker.select_folder_in_native_dialog( title=title, initial_dir=initial_dir, @@ -47,4 +58,5 @@ def select_folder(request, response, ctx): except file_picker.NativePickerUnavailableError as exc: response.error(str(exc), 503) except Exception as exc: + print(f"[file_picker] folder selection failed: {exc}", file=sys.stderr) response.error(sanitize_error(exc, 500), 500) diff --git a/backend/routes/install.py b/backend/routes/install.py index bf47e5e..8a2ea75 100644 --- a/backend/routes/install.py +++ b/backend/routes/install.py @@ -227,9 +227,11 @@ def activate_custom(request, response, ctx): response.error(*claim_error) return try: - result = llama_manager.activate_custom_backend(ctx, backend) + options = {"bin_dir": request.body["bin_dir"]} if "bin_dir" in (request.body or {}) else {} + result = llama_manager.activate_custom_backend(ctx, backend, **options) response.json(result) finally: process_manager.release_install_slot(ctx) except Exception as e: + print(f"[install] custom activation failed: {e}", file=sys.stderr) response.error(sanitize_error(e, 500), 500) diff --git a/backend/routes/lifecycle.py b/backend/routes/lifecycle.py index 173ea73..b7b228a 100644 --- a/backend/routes/lifecycle.py +++ b/backend/routes/lifecycle.py @@ -26,10 +26,16 @@ def post_open_folder(request, response, ctx): return try: if folder == "llama": - backend = ctx.services.load_config().get("backend") - target = (llama_manager.get_backend_bin_dir(ctx, backend).parent - if llama_manager.is_custom_backend(backend) else ctx.paths.llama) - target.mkdir(parents=True, exist_ok=True) + cfg = ctx.services.load_config() + backend = cfg.get("backend") + if llama_manager.is_custom_backend(backend) and backend in cfg.get("custom_backend_dirs", {}): + target = llama_manager.get_backend_bin_dir(ctx, backend, cfg) + if not target.is_dir(): + raise ValueError(f"Custom backend folder is unavailable: {target}") + else: + target = (llama_manager.get_default_backend_bin_dir(ctx, backend).parent + if llama_manager.is_custom_backend(backend) else ctx.paths.llama) + target.mkdir(parents=True, exist_ok=True) else: target = model_dir.get_models_dir(ctx) lifecycle_service.open_folder_in_file_manager(target) diff --git a/backend/routes/status.py b/backend/routes/status.py index 5400032..479a3f9 100644 --- a/backend/routes/status.py +++ b/backend/routes/status.py @@ -71,7 +71,7 @@ def get_status(request, response, ctx): "available_backends": [ { "id": key, "label": spec["label"], - **({"custom": True, "bin_dir": llama_manager.custom_backend_bin_label(key)} + **({"custom": True, **llama_manager.get_custom_backend_dir_info(ctx, key, cfg)} if llama_manager.is_custom_backend(key) else {}), } for key, spec in backend_specs.items() diff --git a/backend/services/llama_manager.py b/backend/services/llama_manager.py index 780d8d9..e9eb837 100644 --- a/backend/services/llama_manager.py +++ b/backend/services/llama_manager.py @@ -52,7 +52,7 @@ def is_custom_backend(backend: Any) -> bool: return isinstance(backend, str) and backend in CUSTOM_BACKEND_SPECS -def get_backend_bin_dir(ctx: AppContext, backend: Any) -> pathlib.Path: +def get_default_backend_bin_dir(ctx: AppContext, backend: Any) -> pathlib.Path: if backend == "custom": return ctx.paths.llama_custom_bin if is_custom_backend(backend): @@ -60,17 +60,39 @@ def get_backend_bin_dir(ctx: AppContext, backend: Any) -> pathlib.Path: return ctx.paths.llama_bin +def get_backend_bin_dir( + ctx: AppContext, backend: Any, cfg: Optional[Mapping[str, Any]] = None +) -> pathlib.Path: + if is_custom_backend(backend): + cfg = ctx.services.load_config() if cfg is None else cfg + folders = cfg.get("custom_backend_dirs", {}) + if not isinstance(folders, Mapping): + raise ValueError("Configured custom backend folders are invalid.") + if backend in folders: + value = folders[backend] + if not isinstance(value, str) or not value.strip() or "\x00" in value: + raise ValueError("Configured custom backend folder is invalid.") + path = pathlib.Path(value) + if not path.is_absolute(): + raise ValueError("Custom backend folder must be an absolute path.") + return path + return get_default_backend_bin_dir(ctx, backend) + + +def get_custom_backend_dir_info(ctx: AppContext, backend: str, cfg: Mapping[str, Any]) -> dict[str, Any]: + path = get_backend_bin_dir(ctx, backend, cfg) + default = get_default_backend_bin_dir(ctx, backend) + return {"bin_dir": str(path), "default_bin_dir": str(default), "bin_dir_is_default": path == default} + + def get_backend_grammars_dir(ctx: AppContext, backend: Any) -> pathlib.Path: if backend == "custom": return ctx.paths.llama_custom_grammars if is_custom_backend(backend): - return get_backend_bin_dir(ctx, backend).parent / "grammars" + return get_default_backend_bin_dir(ctx, backend).parent / "grammars" return ctx.paths.llama_grammars -def custom_backend_bin_label(backend: str) -> str: - return f"llama/{CUSTOM_BACKEND_SPECS[backend]['folder']}/bin/" - # (gpu_target, family label) for every target upstream publishes. LEMONADE_ROCM_TARGETS = [ ("gfx103X", "AMD RDNA2"), @@ -464,7 +486,7 @@ def validate_runtime_dependencies( print(f"[llama_manager] load_config failed during runtime validation: {e}", file=sys.stderr) cfg = {} - cache_key = (cfg.get("backend"), tool_names) + cache_key = (cfg.get("backend"), str(get_backend_bin_dir(ctx, cfg.get("backend"), cfg)), tool_names) now = time.monotonic() with ctx.state.runtime_health_lock: cached = ctx.state.runtime_health_cache.get(cache_key) @@ -489,7 +511,7 @@ def _validate_runtime_dependencies_uncached( current_platform = ctx.services.current_platform or sys.platform if current_platform == "unknown": current_platform = sys.platform - runtime_dir = get_backend_bin_dir(ctx, cfg.get("backend")) + runtime_dir = get_backend_bin_dir(ctx, cfg.get("backend"), cfg) for tool in tool_names: exe_path = ctx.services.find_tool_executable(tool) @@ -543,13 +565,40 @@ def _validate_runtime_dependencies_uncached( } -def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[str, Any]: +_CUSTOM_DIR_UNSET = object() + + +def activate_custom_backend( + ctx: AppContext, backend: str = "custom", bin_dir: Any = _CUSTOM_DIR_UNSET +) -> dict[str, Any]: if not is_custom_backend(backend): return {"ok": False, "error": "Unsupported custom backend"} try: - bin_dir = get_backend_bin_dir(ctx, backend) - bin_dir.mkdir(parents=True, exist_ok=True) - get_backend_grammars_dir(ctx, backend).mkdir(parents=True, exist_ok=True) + candidate_cfg = dict(ctx.services.load_config()) + folders = candidate_cfg.get("custom_backend_dirs", {}) + if not isinstance(folders, Mapping): + raise ValueError("Configured custom backend folders are invalid.") + folders = dict(folders) + if bin_dir is not _CUSTOM_DIR_UNSET: + if bin_dir is None: + folders.pop(backend, None) + else: + if not isinstance(bin_dir, str) or not bin_dir.strip() or "\x00" in bin_dir: + raise ValueError("Custom backend folder must be an absolute path or null.") + candidate = pathlib.Path(bin_dir.strip()).expanduser() + if not candidate.is_absolute(): + raise ValueError("Custom backend folder must be an absolute path.") + folders[backend] = str(candidate) + candidate_cfg["custom_backend_dirs"] = folders + bin_dir = get_backend_bin_dir(ctx, backend, candidate_cfg) + if backend in folders: + if not bin_dir.is_dir(): + raise ValueError(f"Custom backend folder does not exist or is not a directory: {bin_dir}") + with os.scandir(bin_dir): + pass + else: + bin_dir.mkdir(parents=True, exist_ok=True) + get_backend_grammars_dir(ctx, backend).mkdir(parents=True, exist_ok=True) required = { ctx.services.get_tool_filename("llama-cli"), @@ -580,7 +629,7 @@ def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[st "not_executable": sorted(not_executable), } - runtime_health = _validate_custom_runtime_dependencies(ctx, required, backend) + runtime_health = _validate_custom_runtime_dependencies(ctx, required, backend, bin_dir=bin_dir) if not runtime_health.get("ok", True): return { "ok": False, @@ -603,6 +652,15 @@ def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[st cfg["version"] = "custom" cfg["backend"] = backend cfg["tag"] = "custom" + saved_folders = dict(cfg.get("custom_backend_dirs", {})) + if backend in folders: + saved_folders[backend] = folders[backend] + else: + saved_folders.pop(backend, None) + if saved_folders: + cfg["custom_backend_dirs"] = saved_folders + else: + cfg.pop("custom_backend_dirs", None) ctx.services.save_config(cfg) ctx.state.clear_runtime_health_cache() return { @@ -613,6 +671,8 @@ def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[st "not_executable": [], "runtime_health": runtime_health, } + except ValueError as e: + return {"ok": False, "error": str(e)} except Exception as e: print(f"[llama_manager] activate_custom_backend failed: {e}", file=sys.stderr) return {"ok": False, "error": sanitize_error(e, 500)} @@ -713,9 +773,10 @@ def activate_official_backend(ctx: AppContext, backend: str) -> dict[str, Any]: def _validate_custom_runtime_dependencies( - ctx: AppContext, executable_names: Iterable[str], backend: str = "custom" + ctx: AppContext, executable_names: Iterable[str], backend: str = "custom", + *, bin_dir: Optional[pathlib.Path] = None, ) -> dict[str, Any]: - bin_dir = get_backend_bin_dir(ctx, backend) + bin_dir = get_backend_bin_dir(ctx, backend) if bin_dir is None else bin_dir current_platform = ctx.services.current_platform or sys.platform if current_platform == "unknown": current_platform = sys.platform diff --git a/backend/services/process_manager.py b/backend/services/process_manager.py index 09d650c..1d1b1ed 100644 --- a/backend/services/process_manager.py +++ b/backend/services/process_manager.py @@ -493,7 +493,7 @@ def _load_config_safe(ctx: AppContext) -> dict[str, Any]: def _fit_params_executable(ctx: AppContext) -> Any: suffix = getattr(ctx.services, "binary_suffix", "") or "" cfg = _load_config_safe(ctx) - bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend")) + bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend"), cfg) return bin_dir / f"llama-fit-params{suffix}" @@ -1063,7 +1063,7 @@ def normalize_process_env(value: Any) -> dict[str, str]: def _build_process_env(ctx: AppContext) -> dict[str, str]: env = os.environ.copy() cfg = _load_config_safe(ctx) - bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend")) + bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend"), cfg) runtime_paths = [str(bin_dir)] existing_path = env.get("PATH", "") env["PATH"] = os.pathsep.join(runtime_paths + ([existing_path] if existing_path else [])) @@ -1154,7 +1154,7 @@ def _validate_launch_environment( if current_platform != "win32" and not os.access(exe_path, os.X_OK): backend = _load_config_safe(ctx).get("backend") recovery = ( - f"Run chmod +x on {llama_manager.custom_backend_bin_label(backend)}{exe_name}." + f"Run chmod +x on {exe_path}." if llama_manager.is_custom_backend(backend) else "Use Repair Install to restore executable permissions." ) @@ -1169,7 +1169,7 @@ def _validate_launch_environment( plural = "libraries" if len(missing_runtime_files) != 1 else "library" backend = _load_config_safe(ctx).get("backend") recovery = ( - f"Add the missing files to {llama_manager.custom_backend_bin_label(backend)}." + f"Add the missing files to {exe_path.parent}." if llama_manager.is_custom_backend(backend) else ( "Use Repair Install, then verify the matching Vulkan/ROCm driver runtime is installed." diff --git a/docs/directory.md b/docs/directory.md index 5b7a4a4..b6b4a02 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -108,7 +108,7 @@ | `external_server.py` | `GET /api/chat/target` (read the live and remembered target), `POST /api/chat/target` (register an externally started llama-server as the proxy target; `POST {"restore": true}` re-registers the address saved by an earlier session), `DELETE /api/chat/target` (clear it) | | `benchmarks.py` | `POST /api/benchmark/wikitext2` — ensure WikiText-2 raw test file exists | | `process.py` | `POST /api/launch`, `POST /api/launch/preflight`, `POST /api/presets/fingerprint`, `POST /api/estimate-memory`, generation-bound `POST /api/stop`, `POST /api/send-input`, `POST /api/cleanup-llama`, `GET /api/output`, `GET /api/llama/health`, `GET /api/llama/buffer-types` | -| `install.py` | `GET /api/backends` — refresh the cached official CUDA/ROCm catalog (`?refresh=1` requests a manual refresh; returns a warning on fallback), `GET /api/releases`, `GET /api/download-progress`, `POST /api/install`, `POST /api/update`, `POST /api/activate-custom` (optional `backend`: `custom` or `custom-02`; omitted defaults to `custom`) | +| `install.py` | `GET /api/backends` — refresh the cached official CUDA/ROCm catalog (`?refresh=1` requests a manual refresh; returns a warning on fallback), `GET /api/releases`, `GET /api/download-progress`, `POST /api/install`, `POST /api/update`, `POST /api/activate-custom` (optional `backend`: `custom` or `custom-02`, defaults to `custom`; optional `bin_dir` selects an executable folder, `null` resets it, omitted retains it) | | `metrics.py` | `GET /api/llama/metrics`, `GET /api/llama/slots`, `GET /api/llama/props` — Prometheus proxy and template-capability props | | `models.py` | `GET /api/models` — list GGUF files recursively as names relative to the active model root | | `model_dir.py` | `POST /api/models-dir` — set or reset the active model root | @@ -1039,15 +1039,15 @@ Frontend tunnel controls, status rendering, URL rendering, copy wiring, start/st ## Custom llama.cpp Slots -Install & Update offers two fixed user-provided builds: **Custom** uses `llama/custom/bin/`, and **Custom 02** uses `llama/custom-02/bin/`. Each has a sibling `grammars/` folder. Installers, release packaging, and app startup create both layouts. Existing `backend: "custom"` configurations remain compatible. +Install & Update offers two user-provided builds: **Custom** defaults to `llama/custom/bin/`, and **Custom 02** defaults to `llama/custom-02/bin/`. Each slot can instead use one existing absolute executable directory selected with **Browse…** or entered manually. Choose the directory directly containing `llama-server` and `llama-cli`; there is no recursive build discovery or separate library-folder setting. **Use default folder** clears the pending path. Installers, release packaging, and app startup still create the default layouts and their sibling `grammars/` folders. Existing `backend: "custom"` configurations remain compatible. -`CUSTOM_BACKEND_SPECS` and the path helpers in `backend/services/llama_manager.py` own slot identity and directory routing. `GET /api/status` supplies each slot's `id`, `label`, `custom: true`, and `bin_dir` in `available_backends`; the frontend reads this metadata for controls, paths, and update restrictions. Models and presets remain shared; selecting or loading a preset never activates a different build, and explicit grammar-file paths remain unchanged. +`CUSTOM_BACKEND_SPECS` and the path helpers in `backend/services/llama_manager.py` own slot identity and directory routing. `GET /api/status` supplies each slot's `id`, `label`, `custom: true`, absolute `bin_dir`, `default_bin_dir`, and `bin_dir_is_default` in `available_backends`; the frontend reads this metadata for controls, paths, and update restrictions. Models and presets remain shared; selecting or loading a preset never activates a different build, and explicit grammar-file paths remain unchanged. Default grammar directories remain app-managed; activation never creates a sibling grammar directory in an external installation. -Selecting a dropdown entry is a pending choice. **Activate Custom** sends `POST /api/activate-custom` with `{ "backend": "custom-02" }` (or `custom`). The route validates the slot, holds the existing install/launch interlock, and checks its required `llama-cli` / `llama-server` files and applicable runtime dependencies before saving config. Benchmark tools are optional. Failed validation leaves the active backend unchanged. Runtime inspection retains its existing platform limits, including no Windows dependency inspection; activation is not an inference test. +Selecting a dropdown entry or editing a folder is a pending choice. Folder drafts survive polling and slot changes; cancelling the picker retains the previous draft. **Activate Custom** sends `POST /api/activate-custom` with `{ "backend": "custom-02" }` (or `custom`), adding `bin_dir` when the folder was edited. A string selects an existing absolute directory; `null` resets to the slot default; omission retains the saved directory. The route validates the slot, holds the existing install/launch interlock, and checks the candidate's required `llama-cli` / `llama-server` files and applicable runtime dependencies before saving config. Benchmark tools are optional. Failed validation leaves the active backend and saved folders unchanged. Runtime inspection retains its existing platform limits, including no Windows dependency inspection; activation is not an inference test. -Both slots retain `tag` / `version: "custom"` for existing flag-compatibility behavior; `backend` records the actual slot and survives restarts. Switching does not copy/delete files or borrow tools from another slot. Executable lookup, memory estimation, runtime library search paths, and validation use the selected directory. Activation clears runtime-health caches and preserves the remembered official installation so **Activate Existing** can switch back without downloading. +Both slots retain `tag` / `version: "custom"` for existing flag-compatibility behavior; `backend` records the actual slot and survives restarts. `config.json` stores independent folder overrides under `custom_backend_dirs`, keyed by slot ID. Switching does not copy/delete files or borrow tools from another slot. An unavailable saved folder stays unavailable rather than falling back or being recreated. Executable lookup, memory estimation, runtime library search paths, validation, and the local flag-compatibility test runner use the selected directory. Runtime-health caches include the directory; activation clears them and preserves the remembered official installation so **Activate Existing** can switch back without downloading. -**Open llama.cpp** opens the active Custom slot's root (containing `bin` and `grammars`), or the main `llama/` directory for official builds. Official update and repair paths exclude both slots. **Remove llama.cpp Files** only removes official runtime directories and metadata; it preserves both custom directories and an active custom selection. The GitHub release importer remains deferred. +**Open llama.cpp** opens the selected executable directory for an overridden Custom slot, the slot's root (containing `bin` and `grammars`) for a default Custom layout, or the main `llama/` directory for official builds. Official update and repair paths exclude both slots. **Remove llama.cpp Files** only removes official runtime directories and metadata; it preserves custom folder overrides, external files, both default custom directories, and an active custom selection. The GitHub release importer remains deferred. ## Auto-Update System @@ -1349,7 +1349,7 @@ Path-type flags (model, mmproj, draft model, etc.) have a "Browse" button that o Returns `{"selected": bool, "path": string}`. -`POST /api/select-folder` accepts an optional `title` and returns the same shape. Cancellation returns an empty path and never changes `models_dir`; the caller must persist a selected folder through `POST /api/models-dir`. +`POST /api/select-folder` accepts an optional `title` and returns the same shape. With `purpose: "custom-backend"` and `backend: "custom"` or `"custom-02"`, it starts at that slot's saved executable directory (or the app root if unavailable). Otherwise it starts at the active model root. The dialog opens on the GUI server's computer; typed custom-backend paths also support headless use. Cancellation returns an empty path and never changes configuration; callers persist a selection through `POST /api/models-dir` or successful `POST /api/activate-custom`. ### File Type Filters diff --git a/docs/tests.md b/docs/tests.md index aef205d..ecd1f58 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -13,6 +13,13 @@ workflow retains its documentation exclusions and Linux/Windows matrix. ## Common Commands +Custom executable-folder coverage in `manager_releases_unit.cjs` checks per-slot +drafts, typed paths, picker cancellation and duplicate guards, polling during +selection/activation, failure retention, and staged reset payloads. The custom +backend browser scenario exercises Browse, typed paths, failed activation, +reload persistence, and reset while preserving shared flags. Binary-runner +fixtures verify configured external folders and no fallback to another build. + `python -m unittest tests.backend.test_official_backends -v` (using the project venv) covers official CUDA/ROCm version discovery, platform/architecture filtering, tagged and untagged runtime pairs, cache persistence/corruption, bounded refreshes, @@ -298,7 +305,7 @@ Backend tests use Python `unittest` and mostly exercise route/service logic with - `test_server_baseline.py`: compatibility wrapper behavior, API dispatch, CORS, static asset versioning, and baseline server helpers. - `test_diagnostics.py`: automatic startup capture for both entrypoints, main/worker exception tracebacks, native crash capture in an isolated child process, console preservation and unavailable-console support, idempotent setup, session retention, import side-effect isolation, and log-directory/disk failure isolation. - `test_services.py`: service-level helpers for install specs, runtime validation, process/auth and active-runtime lifecycle, generation-bound health/stop behavior, downloads, file picker behavior, chat/search helpers, external-server registration (local-only validation, header-safe API keys, key never published or persisted, llama.cpp-aware probe identification, remembered-address round-tripping, unattended restore rules, runtime precedence), and HF validation. Linux `ldd` opt-out fixtures cover enabled/disabled values, tool/plugin probe skipping, cache behavior, and unchanged macOS validation. -- `test_custom_slots.py`: both fixed custom slots, persisted activation and official-build round trips, missing-tool/permission/runtime failures, Linux/macOS dependency fixtures, slot-specific executable and library paths, invalid/busy/running activation guards, official-download exclusions, status metadata, folder opening, and cleanup preservation. Linux `ldd` opt-out fixtures exercise status, activation, preflight, and launch for official and both custom installs while retaining executable guards and subprocess errors. NixOS launch and macOS library loading still need manual checks; fixtures run on the existing Linux/Windows runners. +- `test_custom_slots.py`: both custom slots, persisted activation and official-build round trips, independent external executable folders and resets, invalid/unreadable/missing folders without fallback or creation, failed activation retaining saved paths, missing-tool/permission/runtime failures, Linux/macOS dependency fixtures, external-directory preflight/launch and library environments, directory-specific health caching, invalid/busy/running activation guards, official-download exclusions, status metadata, native-picker initial folders, folder opening, and external-file cleanup preservation. Linux `ldd` opt-out fixtures exercise status, activation, preflight, and launch for official and both custom installs while retaining executable guards and subprocess errors. NixOS launch and macOS library loading still need manual checks; fixtures run on the existing Linux/Windows runners. - `test_review_regressions.py`: HF/API credential sanitization and quoted-argument rejection, live `/v1` fallback, external reconnect generations, installation rollback after grammar/permission/config failures, interrupted WikiText cleanup, and complete split-GGUF discovery/download/cancellation/overwrite handling. Frontend regression cases live in the existing launch-args, presets, Chat, and benchmark unit suites. - `test_extracted_routes.py`: extracted route handlers and larger service flows, including preset secret scrubbing, launch preflight, active-runtime status, health/readiness, process launch/auth parsing, authoritative metrics/slots/chat targets, external chat-target registration and restore, HF download, tunnel, app update, and lifecycle routes. - `test_docs_links.py`: documentation drift. Reads every git-tracked Markdown file and asserts its file references resolve — relative markdown links against the linking doc's directory, plus backtick-quoted repo-rooted paths (`docs/`, `ui/`, `backend/`, `tests/`, `scripts/`) against the repo root. Fenced code blocks are skipped, untracked `docs/design-docs/` is ignored, archived plan docs are exempt (existence-checked), and llama.cpp upstream paths colliding with the `tests/` prefix are allowlisted. diff --git a/tests/backend/test_custom_slots.py b/tests/backend/test_custom_slots.py index f2346c7..61ea8bd 100644 --- a/tests/backend/test_custom_slots.py +++ b/tests/backend/test_custom_slots.py @@ -8,7 +8,7 @@ from backend import app from backend.http import Request -from backend.routes import install, lifecycle, status +from backend.routes import file_picker, install, lifecycle, status from backend.services import llama_manager, process_manager from tests.backend.test_extracted_routes import DummyResponse from tests.backend.test_services import make_service_context @@ -54,11 +54,191 @@ def write_build(self, backend): (grammars / "local.gbnf").write_text(backend) return directory - def activate(self, backend="custom"): + def activate(self, backend="custom", **options): response = DummyResponse() - install.activate_custom(Request("POST", "/api/activate-custom", "", {}, body={"backend": backend}), response, self.ctx) + install.activate_custom(Request("POST", "/api/activate-custom", "", {}, body={"backend": backend, **options}), response, self.ctx) return response + def external_build(self, name="external build", suffix=None): + directory = self.ctx.paths.root / name + directory.mkdir() + for tool in ("llama-cli", "llama-server"): + (directory / (tool + (self.ctx.services.binary_suffix if suffix is None else suffix))).write_text(name) + (directory / "ggml.dll").write_text(name) + return directory + + def test_external_folders_persist_per_slot_and_reset_independently(self): + first = self.external_build() + second = self.external_build("second build") + for backend, directory in (("custom", first), ("custom-02", second)): + self.assertTrue(self.activate(backend, bin_dir=str(directory)).payload["ok"]) + with mock.patch.object(app, "APP_CONTEXT", self.ctx), mock.patch.object(app, "CONFIG_FILE", self.ctx.paths.config_file), mock.patch.object(app, "BINARY_SUFFIX", ".exe"): + self.assertEqual(app.find_tool_executable("llama-server"), directory / "llama-server.exe") + self.assertEqual(app.get_runtime_files(), [directory / "ggml.dll"]) + self.assertFalse((directory.parent / "grammars").exists()) + cfg = self.ctx.services.load_config() + self.assertEqual(cfg["custom_backend_dirs"], {"custom": str(first), "custom-02": str(second)}) + self.assertEqual(cfg["models_dir"], "saved-model-root") + self.assertEqual(cfg["official_install"]["backend"], "cpu") + with mock.patch.object(llama_manager, "_probe_official_build", return_value=(True, "b123")): + self.assertTrue(llama_manager.activate_official_backend(self.ctx, "cpu")["ok"]) + self.assertEqual(self.ctx.services.load_config()["custom_backend_dirs"], cfg["custom_backend_dirs"]) + self.assertTrue(self.activate("custom").payload["ok"]) + self.assertEqual(llama_manager.get_backend_bin_dir(self.ctx, "custom"), first) + self.assertTrue(self.activate("custom", bin_dir=None).payload["ok"]) + self.assertEqual(self.ctx.services.load_config()["custom_backend_dirs"], {"custom-02": str(second)}) + self.assertEqual(llama_manager.get_backend_bin_dir(self.ctx, "custom"), self.ctx.paths.llama_custom_bin) + self.assertTrue(self.activate("custom-02", bin_dir=None).payload["ok"]) + self.assertNotIn("custom_backend_dirs", self.ctx.services.load_config()) + + def test_invalid_external_folders_do_not_change_config_or_create_directories(self): + first = self.external_build() + self.assertTrue(self.activate(bin_dir=str(first)).payload["ok"]) + previous = self.ctx.paths.config_file.read_bytes() + missing = self.ctx.paths.root / "absent" / "bin" + for value in (str(missing), str(first / "llama-cli.exe"), "relative/bin", "", "\x00", 12, [], {}): + with self.subTest(value=value): + result = self.activate("custom-02", bin_dir=value) + self.assertFalse(result.payload["ok"]) + self.assertTrue(result.payload["error"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + self.assertFalse(self.ctx.state.install_in_progress) + self.assertFalse(missing.parent.exists()) + with mock.patch.object(llama_manager.os, "scandir", side_effect=PermissionError("unreadable")): + self.assertFalse(self.activate("custom-02", bin_dir=str(first)).payload["ok"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + + def test_missing_external_tool_and_failed_reset_keep_previous_selection(self): + directory = self.external_build() + self.assertTrue(self.activate(bin_dir=str(directory)).payload["ok"]) + previous = self.ctx.paths.config_file.read_bytes() + (self.ctx.paths.llama_custom_bin / "llama-server.exe").unlink() + self.assertFalse(self.activate(bin_dir=None).payload["ok"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + (directory / "llama-server.exe").unlink() + result = self.activate() + self.assertEqual(result.payload["missing_required"], ["llama-server.exe"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + executable, error = process_manager._validate_launch_environment(self.ctx, "llama-server") + self.assertIsNone(executable) + self.assertIn("not found", error) + + def test_unavailable_saved_external_folder_does_not_fallback_or_get_recreated(self): + directory = self.external_build() + self.activate(bin_dir=str(directory)) + for path in directory.iterdir(): + path.unlink() + directory.rmdir() + self.assertFalse(self.activate().payload["ok"]) + response = DummyResponse() + status.get_status(Request("GET", "/api/status", "", {}), response, self.ctx) + self.assertFalse(response.payload["installed"]) + self.assertTrue(response.payload["config_stale"]) + self.assertEqual(llama_manager.get_backend_bin_dir(self.ctx, "custom"), directory) + response = DummyResponse() + lifecycle.post_open_folder(Request("POST", "/api/open-folder", "", {}, body={"folder": "llama"}), response, self.ctx) + self.assertEqual(response.status, 409) + self.assertFalse(directory.exists()) + + def test_external_runtime_checks_and_launch_use_the_candidate_directory(self): + self.ctx.services.current_platform = "linux" + self.ctx.services.binary_suffix = "" + self.ctx.services.normalize_llama_api_target = lambda host, port: {"host": host, "port": int(port)} + directory = self.external_build() + (directory / "libggml-cuda.so").write_text("plugin") + previous = self.ctx.paths.config_file.read_bytes() + with mock.patch.object(llama_manager.os, "access", return_value=False): + self.assertEqual(len(self.activate(bin_dir=str(directory)).payload["not_executable"]), 2) + with mock.patch.object(llama_manager.os, "access", return_value=True), mock.patch.object( + llama_manager, "get_linux_missing_libraries", return_value=["libmissing.so"] + ) as probe: + result = self.activate(bin_dir=str(directory)) + self.assertFalse(result.payload["ok"]) + self.assertTrue(all(call.args[1] == directory for call in probe.call_args_list)) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + probe.return_value = [] + self.assertTrue(self.activate(bin_dir=str(directory)).payload["ok"]) + fake_process = mock.Mock(pid=1234) + fake_process.poll.return_value = None + model = self.ctx.paths.root / "model.gguf" + model.write_text("model") + args = ["-m", str(model)] + with mock.patch.object(process_manager.subprocess, "Popen", return_value=fake_process) as popen, mock.patch.object(process_manager.threading, "Thread"): + self.assertTrue(process_manager.preflight_launch(self.ctx, "llama-server", args, {})["ok"]) + result = process_manager.launch_process(self.ctx, "llama-server", args) + self.assertEqual(result["pid"], 1234) + self.assertEqual(popen.call_args.args[0], [str(directory / "llama-server"), *args]) + self.assertEqual(popen.call_args.kwargs["env"]["PATH"].split(os.pathsep)[0], str(directory)) + self.assertEqual(popen.call_args.kwargs["env"]["LD_LIBRARY_PATH"].split(os.pathsep)[0], str(directory)) + fake_process.poll.return_value = 0 + self.assertEqual(process_manager._fit_params_executable(self.ctx), directory / "llama-fit-params") + with mock.patch.object(llama_manager.os, "access", return_value=False): + self.assertIn(str(directory), process_manager._validate_launch_environment(self.ctx, "llama-server")[1]) + + def test_runtime_cache_is_specific_to_the_selected_external_folder(self): + first = self.external_build() + second = self.external_build("second build") + self.ctx.services.current_platform = "linux" + cfg = self.ctx.services.load_config() + cfg.update(backend="custom", custom_backend_dirs={"custom": str(first)}) + self.ctx.services.save_config(cfg) + with mock.patch.object(llama_manager, "_validate_runtime_dependencies_uncached", return_value={"ok": True}) as probe: + llama_manager.validate_runtime_dependencies(self.ctx) + cfg["custom_backend_dirs"]["custom"] = str(second) + self.ctx.services.save_config(cfg) + llama_manager.validate_runtime_dependencies(self.ctx) + self.assertEqual(probe.call_count, 2) + + def test_macos_external_runtime_validation_and_environment(self): + self.ctx.services.current_platform = "darwin" + self.ctx.services.binary_suffix = "" + directory = self.external_build() + with mock.patch.object(llama_manager.os, "access", return_value=True), mock.patch.object( + llama_manager, "get_macos_rpath_libraries", return_value=["libllama.dylib"] + ): + previous = self.ctx.paths.config_file.read_bytes() + self.assertFalse(self.activate(bin_dir=str(directory)).payload["ok"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + (directory / "libllama.dylib").write_text("library") + self.assertTrue(self.activate(bin_dir=str(directory)).payload["ok"]) + self.assertIsNone(process_manager._validate_launch_environment(self.ctx, "llama-server")[1]) + env = process_manager._build_process_env(self.ctx) + self.assertEqual(env["DYLD_LIBRARY_PATH"].split(os.pathsep)[0], str(directory)) + + def test_invalid_saved_overrides_never_resolve_to_default_binaries(self): + cfg = self.ctx.services.load_config() + for folders in (None, [], {"custom": None}, {"custom": ""}, {"custom": "relative"}, {"custom": "\x00"}): + with self.subTest(folders=folders): + cfg["custom_backend_dirs"] = folders + self.ctx.services.save_config(cfg) + with self.assertRaises(ValueError): + llama_manager.get_backend_bin_dir(self.ctx, "custom") + self.assertFalse(self.activate().payload["ok"]) + + def test_external_folder_status_picker_open_and_cleanup_preserve_files(self): + directory = self.external_build() + self.activate("custom-02", bin_dir=str(directory)) + previous = {p: p.read_bytes() for p in directory.iterdir()} + response = DummyResponse() + status.get_status(Request("GET", "/api/status", "", {}), response, self.ctx) + slot = next(item for item in response.payload["available_backends"] if item["id"] == "custom-02") + self.assertEqual(slot["bin_dir"], str(directory)) + self.assertFalse(slot["bin_dir_is_default"]) + with mock.patch.object(file_picker.file_picker, "select_folder_in_native_dialog", return_value="") as picker: + response = DummyResponse() + file_picker.select_folder(Request("POST", "/api/select-folder", "", {}, body={"purpose": "custom-backend", "backend": "custom-02"}), response, self.ctx) + self.assertFalse(response.payload["selected"]) + self.assertEqual(picker.call_args.kwargs["initial_dir"], directory) + response = DummyResponse() + file_picker.select_folder(Request("POST", "/api/select-folder", "", {}, body={"purpose": "custom-backend", "backend": "cpu"}), response, self.ctx) + self.assertEqual(response.status, 400) + with mock.patch.object(lifecycle.lifecycle_service, "open_folder_in_file_manager") as open_folder: + lifecycle.post_open_folder(Request("POST", "/api/open-folder", "", {}, body={"folder": "llama"}), DummyResponse(), self.ctx) + open_folder.assert_called_once_with(directory) + process_manager.remove_llama_files(self.ctx) + self.assertEqual(previous, {p: p.read_bytes() for p in directory.iterdir()}) + self.assertEqual(self.ctx.services.load_config()["custom_backend_dirs"], {"custom-02": str(directory)}) + def test_switch_round_trip_persists_identity_and_keeps_both_builds(self): original = {p: p.read_bytes() for p in self.ctx.paths.llama.rglob("*") if p.is_file()} for backend in ("custom", "custom-02"): @@ -237,13 +417,16 @@ def test_routes_reject_invalid_slots_busy_installs_and_running_process(self): self.assertEqual(self.activate(invalid).status, 400) self.assertFalse(self.ctx.state.install_in_progress) self.ctx.state.install_in_progress = True - self.assertEqual(self.activate("custom-02").status, 409) + candidate = str(self.external_build()) + previous = self.ctx.paths.config_file.read_bytes() + self.assertEqual(self.activate("custom-02", bin_dir=candidate).status, 409) self.ctx.state.install_in_progress = False with mock.patch.object(process_manager, "is_process_running", return_value=True): - result = self.activate("custom-02") + result = self.activate("custom-02", bin_dir=candidate) self.assertEqual(result.status, 400) self.assertIn("Stop running process", result.payload["error"]) self.assertEqual(self.ctx.services.load_config()["backend"], "cpu") + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) def test_second_slot_is_excluded_from_official_download_and_update_routes(self): self.activate("custom-02") @@ -268,10 +451,11 @@ def test_status_folder_opening_and_cleanup_preserve_custom_selection(self): self.assertTrue(response.payload["installed"]) self.assertEqual(response.payload["backend"], "custom-02") slots = [item for item in response.payload["available_backends"] if item.get("custom")] - self.assertEqual(slots, [ - {"id": "custom", "label": "Custom", "custom": True, "bin_dir": "llama/custom/bin/"}, - {"id": "custom-02", "label": "Custom 02", "custom": True, "bin_dir": "llama/custom-02/bin/"}, - ]) + self.assertEqual([slot["id"] for slot in slots], ["custom", "custom-02"]) + for slot in slots: + self.assertEqual(slot["bin_dir"], str(llama_manager.get_default_backend_bin_dir(self.ctx, slot["id"]))) + self.assertEqual(slot["default_bin_dir"], slot["bin_dir"]) + self.assertTrue(slot["bin_dir_is_default"]) with mock.patch.object(lifecycle.lifecycle_service, "open_folder_in_file_manager") as open_folder: lifecycle.post_open_folder(Request("POST", "/api/open-folder", "", {}, body={"folder": "llama"}), DummyResponse(), self.ctx) open_folder.assert_called_once_with(self.ctx.paths.llama / "custom-02") diff --git a/tests/backend/test_extracted_routes.py b/tests/backend/test_extracted_routes.py index 35ec29c..05c09b3 100644 --- a/tests/backend/test_extracted_routes.py +++ b/tests/backend/test_extracted_routes.py @@ -2024,7 +2024,7 @@ def test_process_manager_launch_reports_missing_runtime_before_popen(self): def test_process_manager_launch_rejects_non_executable_unix_tools(self): cases = [ ("cpu", "Repair Install"), - ("custom", "chmod +x on llama/custom/bin/llama-server"), + ("custom", "chmod +x on"), ] for backend, expected_recovery in cases: with self.subTest(backend=backend), tempfile.TemporaryDirectory() as tmp: @@ -2046,6 +2046,8 @@ def test_process_manager_launch_rejects_non_executable_unix_tools(self): self.assertIn("llama-server is not executable", result["error"]) self.assertIn(expected_recovery, result["error"]) + if backend == "custom": + self.assertIn(str(executable), result["error"]) mock_popen.assert_not_called() def test_process_manager_redacts_api_keys_from_display_commands(self): diff --git a/tests/frontend/flag_sync_smoke.cjs b/tests/frontend/flag_sync_smoke.cjs index 70b1438..657326f 100644 --- a/tests/frontend/flag_sync_smoke.cjs +++ b/tests/frontend/flag_sync_smoke.cjs @@ -1647,6 +1647,9 @@ async function runScenario(browser, port, verify) { let rememberedTarget = null; const externalTargetRequests = []; let installedBackend = "cpu"; + const customBackendDirs = {}; + let customFolderSelection = { selected: true, path: "D:\\Custom & Build" }; + const customFolderRequests = []; let modelsDirInfo = { models_dir: "models", models_arg_root: "models", @@ -1815,6 +1818,12 @@ async function runScenario(browser, port, verify) { return; } if (pathName === "/api/select-folder") { + const request = JSON.parse(route.request().postData() || "{}"); + if (request.purpose === "custom-backend") { + customFolderRequests.push(request); + await route.fulfill({ json: customFolderSelection }); + return; + } await route.fulfill({ status: 200, contentType: "application/json", @@ -1937,8 +1946,11 @@ async function runScenario(browser, port, verify) { official_install: { backend: "cpu", tag: "smoke", version: "smoke", files_present: true }, available_backends: [ { id: "cpu", label: "CPU" }, - { id: "custom", label: "Custom", custom: true, bin_dir: "llama/custom/bin/" }, - { id: "custom-02", label: "Custom 02", custom: true, bin_dir: "llama/custom-02/bin/" }, + ...["custom", "custom-02"].map(id => ({ + id, label: id === "custom" ? "Custom" : "Custom 02", custom: true, + bin_dir: customBackendDirs[id] || `llama/${id}/bin/`, + default_bin_dir: `llama/${id}/bin/`, bin_dir_is_default: !customBackendDirs[id], + })), ], executables: { "llama-cli": true, @@ -2005,6 +2017,10 @@ async function runScenario(browser, port, verify) { return; } installedBackend = requested.backend || "custom"; + if (Object.hasOwn(requested, "bin_dir")) { + if (requested.bin_dir === null) delete customBackendDirs[installedBackend]; + else customBackendDirs[installedBackend] = requested.bin_dir; + } await route.fulfill({ status: 200, contentType: "application/json", @@ -3765,35 +3781,64 @@ async function runScenario(browser, port, verify) { await page.waitForFunction(() => document.querySelector("#btn-install")?.textContent === "Activate Custom"); await page.waitForTimeout(250); assert.equal(countCustomReleaseRequests(), customReleaseCountBefore); + await page.click("#btn-browse-custom-backend"); + await page.waitForFunction(() => document.querySelector("#custom-backend-dir")?.value === "D:\\Custom & Build"); + assert.equal(customFolderRequests.at(-1).backend, "custom"); + assert.equal(installedBackend, "cpu", "browsing does not activate a backend"); + await page.evaluate(() => window.LlamaGui.manager.checkStatus()); + assert.equal(await page.inputValue("#custom-backend-dir"), "D:\\Custom & Build"); await page.click("#btn-install"); await page.waitForFunction(() => document.querySelector("#install-status")?.textContent.includes("Custom backend activated")); assert.equal(activateCustomRequests.length, 1); + assert.deepEqual(activateCustomRequests[0], { backend: "custom", bin_dir: "D:\\Custom & Build" }); + assert.equal(await page.textContent("#custom-backend-folder"), "D:\\Custom & Build"); assert.equal(pageErrors.length, 0, pageErrors.join("\n")); const flagsBeforeSwitch = await page.evaluate(() => JSON.stringify(window.LlamaGui.flagCore.getFlagValues())); await page.selectOption("#backend-select", "custom-02"); await page.waitForFunction(() => document.querySelector("#custom-backend-folder")?.textContent === "llama/custom-02/bin/"); + await page.fill("#custom-backend-dir", "D:\\Second Build"); assert.equal(await page.locator("#installed-backend-summary").textContent(), "Installed backend: Custom"); await page.click("#btn-install"); await page.waitForFunction(() => document.querySelector("#install-status")?.textContent.includes("Custom 02 needs llama-cli and llama-server")); assert.equal(installedBackend, "custom"); + assert.equal(customBackendDirs["custom-02"], undefined); + assert.equal(await page.inputValue("#custom-backend-dir"), "D:\\Second Build"); assert.equal(await page.locator("#btn-update").isDisabled(), true); custom02Ready = true; await page.click("#btn-install"); await page.waitForFunction(() => document.querySelector("#installed-backend-summary")?.textContent === "Installed backend: Custom 02"); assert.equal(await page.locator("#version-badge").textContent(), "Custom 02"); - assert.match(await page.locator("#installed-info").textContent(), /llama\/custom-02\/bin\//); - assert.deepEqual(activateCustomRequests.at(-1), { backend: "custom-02" }); + assert.ok((await page.locator("#installed-info").textContent()).includes("D:\\Second Build")); + assert.deepEqual(activateCustomRequests.at(-1), { backend: "custom-02", bin_dir: "D:\\Second Build" }); assert.equal(countCustomReleaseRequests(), customReleaseCountBefore); await page.selectOption("#backend-select", "cpu"); await page.waitForFunction(() => document.querySelector("#btn-install")?.textContent === "Activate Existing"); await page.click("#btn-install"); await page.waitForFunction(() => document.querySelector("#installed-backend-summary")?.textContent === "Installed backend: CPU"); await page.selectOption("#backend-select", "custom"); + assert.equal(await page.inputValue("#custom-backend-dir"), "D:\\Custom & Build"); + customFolderSelection = { selected: false, path: "" }; + await page.click("#btn-browse-custom-backend"); + await page.waitForFunction(() => !document.querySelector("#btn-browse-custom-backend")?.disabled); + assert.equal(await page.inputValue("#custom-backend-dir"), "D:\\Custom & Build"); await page.click("#btn-install"); await page.waitForFunction(() => document.querySelector("#installed-backend-summary")?.textContent === "Installed backend: Custom"); assert.equal(await page.evaluate(() => JSON.stringify(window.LlamaGui.flagCore.getFlagValues())), flagsBeforeSwitch, "switching builds must not change shared model/preset flags"); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.querySelector("#custom-backend-dir")?.value === "D:\\Custom & Build"); + await selectSection(page, "install"); + const flagsAfterReload = await page.evaluate(() => JSON.stringify(window.LlamaGui.flagCore.getFlagValues())); + await page.click("#btn-default-custom-backend"); + assert.equal(await page.inputValue("#custom-backend-dir"), ""); + assert.equal(customBackendDirs.custom, "D:\\Custom & Build", "reset is staged until activation"); + await page.click("#btn-install"); + await page.waitForFunction(() => document.querySelector("#custom-backend-folder")?.textContent === "llama/custom/bin/"); + assert.deepEqual(activateCustomRequests.at(-1), { backend: "custom", bin_dir: null }); + assert.equal(customBackendDirs["custom-02"], "D:\\Second Build"); + assert.equal(await page.evaluate(() => JSON.stringify(window.LlamaGui.flagCore.getFlagValues())), flagsAfterReload, + "resetting the executable folder must not change shared flags"); }, async "model switcher refresh drag and keyboard controls"() { await selectSection(page, "quick-launch"); diff --git a/tests/frontend/llama_flags_runner_unit.cjs b/tests/frontend/llama_flags_runner_unit.cjs index 7e97705..450767a 100644 --- a/tests/frontend/llama_flags_runner_unit.cjs +++ b/tests/frontend/llama_flags_runner_unit.cjs @@ -98,6 +98,15 @@ const custom02 = ["/repo/llama/custom-02/bin/llama-server", "/repo/llama/custom- const selected02 = run({ config: { backend: "custom-02" }, files: [...custom02, ...custom, ...both], env: { PATH: "/pin" } }); assert.equal(selected02.exitCode, 0); assert.deepEqual(selected02.calls, custom02, "the second slot must take precedence over the first and PATH"); +const external = ["/external build/llama-server", "/external build/llama-cli"]; +const externalConfig = { backend: "custom-02", custom_backend_dirs: { custom: "/other", "custom-02": "/external build" } }; +const selectedExternal = run({ config: externalConfig, files: [...external, ...custom02, ...both], env: { PATH: "/pin" } }); +assert.equal(selectedExternal.exitCode, 0); +assert.deepEqual(selectedExternal.calls, external); +const missingExternal = run({ config: externalConfig, files: [...custom02, ...both], env: { PATH: "/pin" } }); +assert.deepEqual(missingExternal.calls, [], "a missing external build must not check another installation"); +assert.match(missingExternal.output, /check skipped/); +assert.equal(run({ config: { backend: "custom", custom_backend_dirs: { custom: "relative" } }, files: both }).exitCode, 1); assert.equal(run({ files: both, env: { LLAMA_CPP_BIN_DIR: "/pin" } }).exitCode, 0); // removed_in exemption: builds at or above the removal tag may lack the flag; diff --git a/tests/frontend/llama_flags_supported_unit.cjs b/tests/frontend/llama_flags_supported_unit.cjs index 00c24b2..a579d9a 100644 --- a/tests/frontend/llama_flags_supported_unit.cjs +++ b/tests/frontend/llama_flags_supported_unit.cjs @@ -30,14 +30,21 @@ function candidateExecutables(toolName) { // An explicitly selected build must never fall back to another installation. if (EXPLICIT_BIN_DIR) return [path.resolve(EXPLICIT_BIN_DIR, exeName)]; let backend; + let customDirectory; const configPath = path.join(ROOT, "config.json"); if (fs.existsSync(configPath)) { try { - backend = JSON.parse(fs.readFileSync(configPath, "utf8"))?.backend; + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + backend = config?.backend; + customDirectory = config?.custom_backend_dirs?.[backend]; } catch (error) { console.warn(`Could not read installed backend selection: ${error.message}`); } } + if (["custom", "custom-02"].includes(backend) && customDirectory !== undefined) { + assert.ok(typeof customDirectory === "string" && path.isAbsolute(customDirectory), "configured custom backend folder must be absolute"); + return [path.join(customDirectory, exeName)]; + } const installedDirectory = ["custom", "custom-02"].includes(backend) ? path.join(ROOT, "llama", backend, "bin") : path.join(ROOT, "llama", "bin"); const dirs = [ diff --git a/tests/frontend/manager_releases_unit.cjs b/tests/frontend/manager_releases_unit.cjs index cd6686f..ba58891 100644 --- a/tests/frontend/manager_releases_unit.cjs +++ b/tests/frontend/manager_releases_unit.cjs @@ -90,6 +90,9 @@ const elements = new Map(); "custom-backend-info", "custom-backend-folder", "custom-backend-title", + "custom-backend-dir", + "btn-browse-custom-backend", + "btn-default-custom-backend", "install-status", "app-update-status", "app-update-channel", @@ -587,6 +590,92 @@ for (const src of scripts) { unloadHandlers[0](); assert.deepEqual(clearedTimers, [17, 17], "unload cleans up active install polling"); + // Folder changes belong to a slot and remain pending until activation succeeds. + const input = elements.get("custom-backend-dir"); + const browse = elements.get("btn-browse-custom-backend"); + const reset = elements.get("btn-default-custom-backend"); + assert.equal(input.listeners.input.length, 1); + assert.equal(browse.listeners.click.length, 1); + const folderRequests = []; + let folderStatus = { ...cpuStatus, available_backends: availableBackends.map(option => option.custom + ? { ...option, default_bin_dir: option.bin_dir, bin_dir_is_default: true } : option) }; + let selection = { selected: false, path: "" }; + let finishPicker; + let finishFolderActivation; + manager.configure({ fetchJson: async (url, options) => { + if (url === "/api/status") return folderStatus; + if (url === "/api/select-folder") { + folderRequests.push(JSON.parse(options.body)); + if (selection === null) return new Promise(resolve => { finishPicker = resolve; }); + return selection; + } + if (url === "/api/activate-custom") { + folderRequests.push(JSON.parse(options.body)); + return new Promise(resolve => { finishFolderActivation = resolve; }); + } + if (url.startsWith("/api/releases")) return fetchPayload; + assert.fail("Unexpected folder request: " + url); + } }); + await manager.checkStatus(); + backendSelect.value = "custom"; + manager._test.onBackendChange(); + input.value = "/first build"; + input.listeners.input[0]({ target: input }); + await manager.checkStatus(); + assert.equal(input.value, "/first build", "polling preserves a typed draft"); + backendSelect.value = "custom-02"; + manager._test.onBackendChange(); + assert.equal(input.value, ""); + input.value = "/second build"; + input.listeners.input[0]({ target: input }); + backendSelect.value = "custom"; + manager._test.onBackendChange(); + assert.equal(input.value, "/first build", "switching slots restores the slot's draft"); + await browse.listeners.click[0](); + assert.equal(input.value, "/first build", "picker cancellation keeps the draft"); + assert.equal(folderRequests.at(-1).purpose, "custom-backend"); + assert.equal(folderRequests.at(-1).backend, "custom"); + selection = null; + const picking = browse.listeners.click[0](); + await browse.listeners.click[0](); + assert.equal(folderRequests.filter(request => request.purpose).length, 2, "duplicate pickers are ignored"); + await manager.checkStatus(); + assert.equal(backendSelect.disabled, true); + assert.equal(input.disabled, true); + assert.equal(elements.get("btn-install").disabled, true); + finishPicker({ selected: true, path: "/picked & build" }); + await picking; + assert.equal(input.value, "/picked & build"); + assert.equal(backendSelect.disabled, false); + let applying = manager._test.installRelease(); + assert.deepEqual(folderRequests.at(-1), { backend: "custom", bin_dir: "/picked & build" }); + await manager.checkStatus(); + assert.equal(input.disabled, true); + assert.equal(reset.disabled, true); + finishFolderActivation({ ok: false, missing_required: ["llama-server"] }); + await applying; + assert.equal(input.value, "/picked & build"); + assert.equal(manager.getLatestStatus().backend, "cpu"); + applying = manager._test.installRelease(); + folderStatus = { ...folderStatus, backend: "custom", available_backends: folderStatus.available_backends.map(option => + option.id === "custom" ? { ...option, bin_dir: "/picked & build", bin_dir_is_default: false } : option) }; + finishFolderActivation({ ok: true, found: ["llama-server", "llama-cli"] }); + await applying; + assert.equal(input.value, "/picked & build", "successful activation renders the saved folder"); + assert.equal(elements.get("custom-backend-folder").textContent, "/picked & build"); + reset.listeners.click[0](); + await manager.checkStatus(); + assert.equal(input.value, "", "a pending reset survives polling"); + assert.equal(elements.get("custom-backend-folder").textContent, "/picked & build", "reset does not change the saved path before activation"); + applying = manager._test.installRelease(); + assert.deepEqual(folderRequests.at(-1), { backend: "custom", bin_dir: null }); + finishFolderActivation({ ok: false, missing_required: ["llama-server"] }); + await applying; + assert.match(elements.get("install-status").textContent, /llama\/custom\/bin/); + backendSelect.value = "custom-02"; + manager._test.onBackendChange(); + assert.equal(input.value, "/second build", "other slot's pending folder remains independent"); + console.log("manager releases unit tests passed"); })().catch((err) => { console.error(err); diff --git a/ui/index.html b/ui/index.html index c487d50..68c303b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -252,13 +252,24 @@

Install

diff --git a/ui/js/manager/manager-backends.js b/ui/js/manager/manager-backends.js index 7195f26..09e7ad8 100644 --- a/ui/js/manager/manager-backends.js +++ b/ui/js/manager/manager-backends.js @@ -5,6 +5,8 @@ let lastInstalledInfoRenderKey = ""; let pendingInstallBackendId = null; let customActivationInProgress = false; + let customFolderPickerInProgress = false; + const customFolderDrafts = new Map(); function normalizeBackendId(value) { return String(value || "").trim(); @@ -36,6 +38,60 @@ return backend && backend.bin_dir ? backend.bin_dir : "llama/custom/bin/"; } + function renderCustomFolderControls(backend, status = I.status.getLatestStatus()) { + const option = backendOptionsFromStatus(status).find(entry => entry.id === backend); + const input = document.getElementById("custom-backend-dir"); + const draft = customFolderDrafts.has(backend) + ? customFolderDrafts.get(backend) + : option && option.bin_dir_is_default === false ? option.bin_dir : ""; + const busy = customActivationInProgress || customFolderPickerInProgress; + if (input) { + if (input.value !== draft) input.value = draft; + input.placeholder = option && option.default_bin_dir || customBackendFolder(backend, status); + input.disabled = busy; + } + for (const id of ["btn-browse-custom-backend", "btn-default-custom-backend"]) { + const button = document.getElementById(id); + if (button) button.disabled = busy; + } + const select = document.getElementById("backend-select"); + if (select) select.disabled = busy; + } + + async function chooseCustomBackendFolder() { + if (customActivationInProgress || customFolderPickerInProgress) return; + const backend = selectedBackendId(); + customFolderPickerInProgress = true; + renderCustomFolderControls(backend); + I.install.setInstallButtonsDisabled(true); + try { + const selection = await I.dependencies.fetchJson("/api/select-folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ purpose: "custom-backend", backend, title: "Select llama.cpp Executable Folder" }), + }); + if (selection.selected) customFolderDrafts.set(backend, selection.path); + } catch (error) { + I.install.showStatus("error", "Could not select the custom backend folder: " + error.message); + } finally { + customFolderPickerInProgress = false; + renderCustomFolderControls(selectedBackendId()); + I.install.setInstallButtonsDisabled(false); + syncInstallActionButtons(I.status.getLatestStatus(), selectedBackendId()); + } + } + + function initCustomFolderControls() { + document.getElementById("custom-backend-dir")?.addEventListener("input", event => { + customFolderDrafts.set(selectedBackendId(), event.target.value); + }); + document.getElementById("btn-browse-custom-backend")?.addEventListener("click", chooseCustomBackendFolder); + document.getElementById("btn-default-custom-backend")?.addEventListener("click", () => { + customFolderDrafts.set(selectedBackendId(), ""); + renderCustomFolderControls(selectedBackendId()); + }); + } + function installedBackendIdFromStatus(status) { return normalizeBackendId(status && status.backend); } @@ -182,6 +238,7 @@ if (customInfo) customInfo.style.display = ""; const folder = document.getElementById("custom-backend-folder"); if (folder) folder.textContent = customBackendFolder(backend, status); + renderCustomFolderControls(backend, status); const title = document.getElementById("custom-backend-title"); if (title) title.textContent = backendLabelFromStatus(status, backend) + " Setup:"; const installBtn = document.getElementById("btn-install"); @@ -226,20 +283,29 @@ } async function activateCustomBackend() { - if (customActivationInProgress) return; + if (customActivationInProgress || customFolderPickerInProgress) return; customActivationInProgress = true; const backend = selectedBackendId(); const label = backendLabelFromStatus(I.status.getLatestStatus(), backend); - const folder = customBackendFolder(backend); + const hasDraft = customFolderDrafts.has(backend); + const draft = customFolderDrafts.get(backend); + const option = backendOptionsFromStatus(I.status.getLatestStatus()).find(entry => entry.id === backend); + const folder = hasDraft + ? draft.trim() || option && option.default_bin_dir || customBackendFolder(backend) + : customBackendFolder(backend); + const body = { backend }; + if (hasDraft) body.bin_dir = draft.trim() || null; + renderCustomFolderControls(backend); I.install.setInstallButtonsDisabled(true); I.install.showStatus("info", `Checking ${label} binaries...`); try { const result = await I.dependencies.fetchJson("/api/activate-custom", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ backend }), + body: JSON.stringify(body), }); if (result.ok) { + customFolderDrafts.delete(backend); const foundList = (result.found || []).join(", "); const missingList = (result.missing || []).join(", "); let msg = label + " backend activated. Found: " + (foundList || "none") + "."; @@ -265,6 +331,7 @@ I.install.showStatus("error", `Failed to activate ${label}: ${e.message}`); } finally { customActivationInProgress = false; + renderCustomFolderControls(selectedBackendId()); I.install.setInstallButtonsDisabled(false); syncInstallActionButtons(I.status.getLatestStatus(), selectedBackendId()); } @@ -318,7 +385,10 @@ showOfficialBackendControls(); } syncInstallActionButtons(status, activeBackend); - if (customActivationInProgress) I.install.setInstallButtonsDisabled(true); + if (customActivationInProgress || customFolderPickerInProgress) { + I.install.setInstallButtonsDisabled(true); + if (backendSelect) backendSelect.disabled = true; + } if (backendSelect) { const targetBackend = activeBackend; @@ -471,6 +541,7 @@ canActivateOfficialBackend, selectedBackendId, onBackendChange, + initCustomFolderControls, activateCustomBackend, activateOfficialBackend, updateStatusUI, diff --git a/ui/js/manager/manager-main.js b/ui/js/manager/manager-main.js index ff6f44c..6c3d348 100644 --- a/ui/js/manager/manager-main.js +++ b/ui/js/manager/manager-main.js @@ -28,6 +28,7 @@ document.getElementById("btn-sidebar-stop-app")?.addEventListener("click", I.lifecycle.stopPythonServer); document.getElementById("btn-refresh-models")?.addEventListener("click", () => I.models.refreshModels()); I.modelDir.initModelDirControls(); + I.backends.initCustomFolderControls(); window.addEventListener("beforeunload", I.install.stopInstallProgressPolling); I.appUpdate.checkAppUpdateStatus(); I.install.refreshBackends(); From df4f8391058c14c782a99ea3d7b279b1ea129994 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 10 Oct 2026 21:08:23 -0400 Subject: [PATCH 3/3] Custom activation now rejects folders overlapping managed runtime Custom activation now rejects folders overlapping managed runtime, staging, or backup directories, using resolved paths to catch aliases. Added regression tests and updated the docs. Backend checks pass: 861 tests --- backend/services/llama_manager.py | 14 ++++++++++ docs/directory.md | 2 +- tests/backend/test_custom_slots.py | 42 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/services/llama_manager.py b/backend/services/llama_manager.py index e9eb837..b10f59e 100644 --- a/backend/services/llama_manager.py +++ b/backend/services/llama_manager.py @@ -591,6 +591,20 @@ def activate_custom_backend( folders[backend] = str(candidate) candidate_cfg["custom_backend_dirs"] = folders bin_dir = get_backend_bin_dir(ctx, backend, candidate_cfg) + # Official installs replace these trees; cleanup also removes the DLL tree. + managed_dirs = [ctx.paths.llama_bin, ctx.paths.llama_grammars, ctx.paths.llama / "dll"] + for directory in (ctx.paths.llama_bin, ctx.paths.llama_grammars): + managed_dirs.extend(directory.with_name(directory.name + suffix) for suffix in (".new", ".old")) + resolved_bin_dir = bin_dir.resolve() + for directory in managed_dirs: + resolved_directory = directory.resolve() + if (resolved_bin_dir == resolved_directory + or resolved_directory in resolved_bin_dir.parents + or resolved_bin_dir in resolved_directory.parents): + raise ValueError( + f"Custom backend folder overlaps an app-managed runtime folder: {directory}. " + "Choose a separate executable folder." + ) if backend in folders: if not bin_dir.is_dir(): raise ValueError(f"Custom backend folder does not exist or is not a directory: {bin_dir}") diff --git a/docs/directory.md b/docs/directory.md index b6b4a02..1fd0401 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -1043,7 +1043,7 @@ Install & Update offers two user-provided builds: **Custom** defaults to `llama/ `CUSTOM_BACKEND_SPECS` and the path helpers in `backend/services/llama_manager.py` own slot identity and directory routing. `GET /api/status` supplies each slot's `id`, `label`, `custom: true`, absolute `bin_dir`, `default_bin_dir`, and `bin_dir_is_default` in `available_backends`; the frontend reads this metadata for controls, paths, and update restrictions. Models and presets remain shared; selecting or loading a preset never activates a different build, and explicit grammar-file paths remain unchanged. Default grammar directories remain app-managed; activation never creates a sibling grammar directory in an external installation. -Selecting a dropdown entry or editing a folder is a pending choice. Folder drafts survive polling and slot changes; cancelling the picker retains the previous draft. **Activate Custom** sends `POST /api/activate-custom` with `{ "backend": "custom-02" }` (or `custom`), adding `bin_dir` when the folder was edited. A string selects an existing absolute directory; `null` resets to the slot default; omission retains the saved directory. The route validates the slot, holds the existing install/launch interlock, and checks the candidate's required `llama-cli` / `llama-server` files and applicable runtime dependencies before saving config. Benchmark tools are optional. Failed validation leaves the active backend and saved folders unchanged. Runtime inspection retains its existing platform limits, including no Windows dependency inspection; activation is not an inference test. +Selecting a dropdown entry or editing a folder is a pending choice. Folder drafts survive polling and slot changes; cancelling the picker retains the previous draft. **Activate Custom** sends `POST /api/activate-custom` with `{ "backend": "custom-02" }` (or `custom`), adding `bin_dir` when the folder was edited. A string selects an existing absolute directory; `null` resets to the slot default; omission retains the saved directory. The route validates the slot, holds the existing install/launch interlock, and checks the candidate's required `llama-cli` / `llama-server` files and applicable runtime dependencies before saving config. Resolved paths must not overlap the official `bin`, `grammars`, or `dll` directories, or the installer's `bin.new`, `bin.old`, `grammars.new`, and `grammars.old` directories; this also rejects symlink aliases, parents, and subfolders. Benchmark tools are optional. Failed validation leaves the active backend and saved folders unchanged. Runtime inspection retains its existing platform limits, including no Windows dependency inspection; activation is not an inference test. Both slots retain `tag` / `version: "custom"` for existing flag-compatibility behavior; `backend` records the actual slot and survives restarts. `config.json` stores independent folder overrides under `custom_backend_dirs`, keyed by slot ID. Switching does not copy/delete files or borrow tools from another slot. An unavailable saved folder stays unavailable rather than falling back or being recreated. Executable lookup, memory estimation, runtime library search paths, validation, and the local flag-compatibility test runner use the selected directory. Runtime-health caches include the directory; activation clears them and preserves the remembered official installation so **Activate Existing** can switch back without downloading. diff --git a/tests/backend/test_custom_slots.py b/tests/backend/test_custom_slots.py index 61ea8bd..a6fd64b 100644 --- a/tests/backend/test_custom_slots.py +++ b/tests/backend/test_custom_slots.py @@ -108,6 +108,48 @@ def test_invalid_external_folders_do_not_change_config_or_create_directories(sel self.assertFalse(self.activate("custom-02", bin_dir=str(first)).payload["ok"]) self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + def test_managed_runtime_folders_cannot_be_selected_as_custom_builds(self): + managed = [self.ctx.paths.llama_bin, self.ctx.paths.llama_grammars, self.ctx.paths.llama / "dll"] + for directory in (self.ctx.paths.llama_bin, self.ctx.paths.llama_grammars): + managed.extend(directory.with_name(directory.name + suffix) for suffix in (".new", ".old")) + candidates = managed + [directory / "nested" for directory in managed] + candidates.extend([self.ctx.paths.llama, self.ctx.paths.root, + self.ctx.paths.llama_custom_bin / ".." / ".." / "bin"]) + previous = self.ctx.paths.config_file.read_bytes() + for directory in candidates: + with self.subTest(directory=directory): + directory.mkdir(parents=True, exist_ok=True) + for tool in ("llama-cli.exe", "llama-server.exe"): + (directory / tool).write_text("custom build") + result = self.activate("custom-02", bin_dir=str(directory)) + self.assertFalse(result.payload["ok"]) + self.assertIn("overlaps", result.payload["error"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + self.assertFalse(self.ctx.state.install_in_progress) + self.assertEqual((directory / "llama-server.exe").read_text(), "custom build") + + def test_saved_managed_folder_is_rejected_on_reactivation(self): + cfg = self.ctx.services.load_config() + cfg["custom_backend_dirs"] = {"custom": str(self.ctx.paths.llama_bin)} + self.ctx.services.save_config(cfg) + previous = self.ctx.paths.config_file.read_bytes() + result = self.activate() + self.assertFalse(result.payload["ok"]) + self.assertIn("overlaps", result.payload["error"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + + def test_symlink_to_managed_folder_is_rejected(self): + alias = self.ctx.paths.root / "runtime alias" + try: + alias.symlink_to(self.ctx.paths.llama_bin, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + self.skipTest(f"Directory symlinks unavailable: {exc}") + previous = self.ctx.paths.config_file.read_bytes() + result = self.activate(bin_dir=str(alias)) + self.assertFalse(result.payload["ok"]) + self.assertIn("overlaps", result.payload["error"]) + self.assertEqual(self.ctx.paths.config_file.read_bytes(), previous) + def test_missing_external_tool_and_failed_reset_keep_previous_selection(self): directory = self.external_build() self.assertTrue(self.activate(bin_dir=str(directory)).payload["ok"])