From b502441293595b2ab3bb4d51b183eba24a1b845c Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:42:32 -0700 Subject: [PATCH 01/12] docs: add design spec for init model onboarding and privacy scanner Covers two features based on user feedback: 1. Interactive model download in `jarvis init` with MLX catalog fix 2. New `jarvis scan` privacy environment audit command Co-Authored-By: Claude Opus 4.6 (1M context) --- ...t-onboarding-and-privacy-scanner-design.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md diff --git a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md new file mode 100644 index 00000000..3f93ed5f --- /dev/null +++ b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md @@ -0,0 +1,249 @@ +# Design: Init Model Onboarding & Privacy Environment Scanner + +**Date:** 2026-03-24 +**Status:** Draft +**Author:** Jon Saad-Falcon + Claude + +## Problem Statement + +Two issues reported by a user (Ali Shahkar) after setting up OpenJarvis on macOS with llama.cpp: + +1. **`jarvis init` sets a default model with no way to download it.** The config is written with a recommended model, but there's no download prompt, no validation that it's available, and `jarvis doctor` immediately warns it can't be found. On Apple Silicon with MLX, the bug is worse: `recommend_model()` returns an empty string because no Qwen3.5 model lists MLX as a supported engine. + +2. **No environment privacy auditing.** OpenJarvis positions itself as privacy-first local AI, but `jarvis init` doesn't verify the local environment is actually private. Cloud sync agents, MDM profiles, disk encryption status, and network exposure can all undermine the privacy guarantee without the user knowing. + +## Solution Overview + +### Issue 1: Interactive Model Download in `jarvis init` + +**Bug fix:** Add `"mlx"` to `supported_engines` for Qwen3.5 models (3b, 4b, 8b, 14b) in the model catalog. + +**Feature:** After writing the config and displaying the recommended model, `jarvis init` prompts: + +``` + Recommended model: qwen3.5:8b (~4.4 GB) + Download now? [y/n]: +``` + +Download logic is engine-specific: +- **Ollama:** Reuse existing `model pull` code (HTTP stream to `POST /api/pull`). +- **llama.cpp:** Shell out to `huggingface-cli download` with GGUF filename from catalog metadata. +- **MLX:** Shell out to `huggingface-cli download` for pre-quantized MLX repo from catalog metadata. +- **vLLM/SGLang:** Inform the user the model downloads automatically on first serve. +- **LM Studio/Exo/Nexa:** Show manual download instructions (these have their own UIs). + +**Empty model fallback:** If `recommend_model()` returns `""`, display: + +``` + ! Not enough memory to run any local model. + Consider a cloud engine or a machine with more RAM. +``` + +### Issue 2: Privacy Environment Scanner + +**New CLI command:** `jarvis scan` performs a full environment privacy audit. + +**Init hook:** At the end of `jarvis init`, run a lightweight subset (disk encryption + cloud sync overlap with `~/.openjarvis/`) and show a compact summary pointing to `jarvis scan` for the full audit. + +## Detailed Design + +### Model Catalog Changes + +Add `"mlx"` to `supported_engines` and download metadata for Qwen3.5 models in `src/openjarvis/intelligence/model_catalog.py`. + +**Important:** The catalog has TWO Qwen3.5 sections. The first (lines ~42-121) contains the original entries (`qwen3.5:3b`, `qwen3.5:8b`, `qwen3.5:14b`, `qwen3.5:35b`, etc. with `context_length=131072`). The second (lines ~219-274) contains newer entries (`qwen3.5:4b`, `qwen3.5:35b-a3b`, etc. with `context_length=262144`). Both sections' models are candidates for `recommend_model()`. MLX must be added to entries in BOTH sections. + +Models getting MLX support added to `supported_engines`: + +| model_id | Section | Current context_length | Add MLX | +|----------|---------|----------------------|---------| +| `qwen3.5:3b` | First | 131072 | Yes | +| `qwen3.5:4b` | Second | 262144 | Yes | +| `qwen3.5:8b` | First | 131072 | Yes | +| `qwen3.5:14b` | First | 131072 | Yes | + +Larger models (35b+) are excluded because MLX quantized weights aren't widely available and would exceed typical Apple Silicon memory. + +Download metadata to add to each model's `metadata` dict: + +| model_id | `gguf_file` | `mlx_repo` | +|----------|-------------|------------| +| `qwen3.5:3b` | `qwen3.5-3b-q4_k_m.gguf` | `mlx-community/Qwen3.5-3B-4bit` | +| `qwen3.5:4b` | `qwen3.5-4b-q4_k_m.gguf` | `mlx-community/Qwen3.5-4B-4bit` | +| `qwen3.5:8b` | `qwen3.5-8b-q4_k_m.gguf` | `mlx-community/Qwen3.5-8B-4bit` | +| `qwen3.5:14b` | `qwen3.5-14b-q4_k_m.gguf` | `mlx-community/Qwen3.5-14B-4bit` | + +Example updated entry: + +```python +ModelSpec( + model_id="qwen3.5:8b", + name="Qwen3.5 8B", + parameter_count_b=8.0, + active_parameter_count_b=1.0, + context_length=131072, + supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"), # added mlx + provider="alibaba", + metadata={ + "architecture": "moe", + "hf_repo": "Qwen/Qwen3.5-8B", + "gguf_file": "qwen3.5-8b-q4_k_m.gguf", + "mlx_repo": "mlx-community/Qwen3.5-8B-4bit", + }, +) +``` + +### Init Command Changes (`src/openjarvis/cli/init_cmd.py`) + +After the config is written and the "Getting Started" panel is shown, add: + +1. **Size estimate display:** Compute `parameter_count_b * 0.5 * 1.1` and display alongside the model name. Note: this is an estimate based on Q4_K_M quantization. Actual download size may differ slightly by engine/format. The prompt labels it as "~X GB estimated". + +2. **Download prompt:** `click.confirm(f"Download {model} (~{size:.1f} GB estimated) now?", default=True)`. A new `--no-download` flag on the `init` command skips this prompt (for CI/automated environments). + +3. **Engine-specific download functions:** + - `_download_ollama(model, host, console)` — calls a new shared helper `ollama_pull(host, model_name, console) -> bool` extracted from `cli/model.py`. Both the `model pull` CLI command and `init_cmd.py` import this helper, avoiding duplication. + - `_download_llamacpp(model, spec, console)` — `subprocess.run(["huggingface-cli", "download", repo, gguf_file, "--local-dir", cache_dir])`. If `huggingface-cli` is not found (`FileNotFoundError`), prints: `"Install huggingface-cli: pip install huggingface_hub"` and shows the manual download URL as fallback. + - `_download_mlx(model, spec, console)` — `subprocess.run(["huggingface-cli", "download", mlx_repo, "--local-dir", cache_dir])`. Same `FileNotFoundError` handling as llama.cpp. + - `_download_auto(model, engine, console)` — print info message that the model auto-downloads on first serve (vLLM, SGLang). + - `_download_manual(model, engine, console)` — print engine-specific manual instructions (LM Studio, Exo, Nexa). + +4. **Empty model fallback:** Check `if not model:` and print the "not enough memory" warning before the next-steps panel. + +5. **Privacy hook:** After download (or skip), call `_quick_privacy_check()` that runs disk encryption + cloud sync checks and prints a compact summary. + +6. **Next-steps coverage:** Add entries to `_next_steps_text()` for `exo` and `nexa` engines so the Ollama fallback is eliminated. + +### `jarvis model pull` Enhancement (`src/openjarvis/cli/model.py`) + +**Refactor:** Extract the Ollama streaming pull logic (currently lines 165-196) into a reusable function: + +```python +def ollama_pull(host: str, model_name: str, console: Console) -> bool: + """Pull a model via Ollama API. Returns True on success.""" +``` + +The existing `pull` Click command becomes a thin wrapper around this function. + +**Extend** the `pull` command to support engines beyond Ollama: +- Add an `--engine` flag (optional, defaults to configured engine). +- Look up the model in `BUILTIN_MODELS` or `ModelRegistry` to get metadata. +- Dispatch to engine-specific download based on the engine flag. +- Fallback to Ollama pull if no engine specified and model is in Ollama format (name:tag). +- llama.cpp and MLX paths use `huggingface-cli download` with metadata from the catalog, with `FileNotFoundError` handling. + +### Privacy Scanner (`src/openjarvis/cli/scan_cmd.py` — new file) + +#### Data model + +```python +@dataclass +class ScanResult: + name: str # e.g. "FileVault" + status: str # "ok" | "warn" | "fail" | "skip" + message: str # Human-readable explanation + platform: str # "darwin" | "linux" | "all" +``` + +#### `PrivacyScanner` class + +Contains a list of check methods. Each check: +- Is decorated or tagged with the platform it applies to. +- Calls a subprocess and parses the output. +- Returns a `ScanResult`. +- Never raises — catches all exceptions and returns `status="skip"` with the error. + +#### macOS checks + +| Check | Implementation | Status mapping | +|-------|---------------|----------------| +| `check_filevault()` | `subprocess.run(["fdesetup", "status"])`, parse "FileVault is On/Off" | On = ok, Off = fail | +| `check_mdm()` | `subprocess.run(["profiles", "status", "-type", "enrollment"])`, parse for "MDM enrollment" | Not enrolled = ok, enrolled = warn | +| `check_icloud_sync()` | Read `defaults read MobileMeAccounts` for Desktop/Documents sync; check if `~/.openjarvis/` resolves to a path under `~/Library/Mobile Documents/`. Note: `defaults read` output format varies across macOS versions — parsing should be lenient and return `skip` on unexpected output rather than raising. | Not syncing = ok, syncing = warn | +| `check_cloud_sync_agents()` | Check for Dropbox, OneDrive, Google Drive processes via `pgrep`; check if their known sync dirs overlap with `~/.openjarvis/` | No overlap = ok, overlap = warn | +| `check_network_exposure()` | `lsof -iTCP -sTCP:LISTEN -nP`, filter for known engine ports, check bind address | 127.0.0.1 = ok, 0.0.0.0 = warn | +| `check_screen_recording()` | `pgrep` for TeamViewer, AnyDesk, ScreenConnect, vnc | Not found = ok, found = warn | + +#### Linux checks + +| Check | Implementation | Status mapping | +|-------|---------------|----------------| +| `check_luks()` | `lsblk -o NAME,TYPE,FSTYPE -J`, look for `crypto_LUKS` on root device | Found = ok, not found = fail | +| `check_cloud_sync_agents()` | `pgrep` for rclone, dropbox, insync, onedriver | Not found = ok, found = warn | +| `check_network_exposure()` | `ss -tlnp` or `lsof`, same port-check logic | 127.0.0.1 = ok, 0.0.0.0 = warn | +| `check_remote_access()` | `pgrep` for xrdp, x11vnc, vncserver, AnyDesk | Not found = ok, found = warn | + +#### CLI command + +``` +@click.command() +def scan(): + """Audit your environment for privacy and security risks.""" +``` + +Output format: +``` + Privacy & Environment Audit + ──────────────────────────── + ✓ FileVault: enabled + ✓ Network: inference ports bound to localhost only + ! iCloud Drive: Desktop & Documents sync is active + ✗ MDM: enterprise management profile detected + + 1 warning, 1 issue found. +``` + +Symbols: `✓` for ok, `!` for warn, `✗` for fail. Skipped checks are hidden. + +#### Init integration + +A function `_quick_privacy_check()` in `init_cmd.py` that: +1. Instantiates `PrivacyScanner`. +2. Runs only disk encryption and cloud sync overlap checks. +3. Prints a compact 2-3 line summary. +4. Always prints `Run 'jarvis scan' for a full environment audit.` + +### File changes summary + +| File | Type | Description | +|------|------|-------------| +| `src/openjarvis/intelligence/model_catalog.py` | Edit | Add MLX to supported_engines, add gguf_file/mlx_repo metadata | +| `src/openjarvis/core/config.py` | Edit | Add `estimated_download_gb(spec: ModelSpec) -> float` helper (computes `parameter_count_b * 0.5 * 1.1`) | +| `src/openjarvis/cli/init_cmd.py` | Edit | Add download prompt, engine-specific downloaders, empty-model fallback, privacy hook | +| `src/openjarvis/cli/model.py` | Edit | Extend `pull` for llama.cpp and MLX engines | +| `src/openjarvis/cli/scan_cmd.py` | New | `PrivacyScanner` class, check functions, `jarvis scan` command | +| `src/openjarvis/cli/__init__.py` | Edit | Register `scan` command | + +## Testing + +### Issue 1 — Model onboarding + +| Test | File | What it verifies | +|------|------|-----------------| +| MLX model recommendation | `tests/core/test_recommend_model.py` | Apple Silicon 8/16/32/64GB returns valid model with MLX engine (8GB should get `qwen3.5:4b`, 16GB should get `qwen3.5:14b`) | +| Empty model fallback message | `tests/cli/test_init_guidance.py` | `recommend_model` returning `""` shows "not enough memory" | +| Download prompt shown | `tests/cli/test_init_guidance.py` | Init output includes download prompt when model recommended | +| Engine-specific download dispatch | `tests/cli/test_init_guidance.py` | Ollama triggers pull, llamacpp shows HF download, vllm shows auto-download | +| `model pull` multi-engine | `tests/cli/test_model_pull.py` | llama.cpp and MLX pull paths work (mocked subprocess) | + +### Issue 2 — Privacy scanner + +| Test | File | What it verifies | +|------|------|-----------------| +| FileVault check | `tests/cli/test_scan.py` | Parses `fdesetup status` for on/off | +| MDM check | `tests/cli/test_scan.py` | Parses `profiles status` for enrollment | +| iCloud sync detection | `tests/cli/test_scan.py` | Detects overlap with `~/.openjarvis/` | +| LUKS check | `tests/cli/test_scan.py` | Parses `lsblk` for crypto_LUKS | +| Network exposure | `tests/cli/test_scan.py` | Parses `lsof`/`ss` for 0.0.0.0 binds | +| Platform filtering | `tests/cli/test_scan.py` | macOS checks return skip on Linux, vice versa | +| Init privacy hook | `tests/cli/test_init_guidance.py` | Init output includes privacy summary | + +All tests mock subprocess calls via `monkeypatch`. No real system state required. + +## Out of Scope + +- Windows support (future work). +- Post-download inference validation (trying a test query). +- Rust implementation of scanner checks. +- DNS leak detection (complex, unreliable heuristics). +- Automatic remediation (e.g., moving `~/.openjarvis/` out of iCloud). From b2a88a5514c8908c5b4a85744058b530fc3e82aa Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:56:31 -0700 Subject: [PATCH 02/12] docs: add implementation plan for init onboarding and privacy scanner 6 tasks with TDD steps, covering model catalog fixes, multi-engine pull, interactive download in init, privacy scanner, and CLI registration. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...nit-onboarding-and-privacy-scanner-plan.md | 1413 +++++++++++++++++ 1 file changed, 1413 insertions(+) create mode 100644 docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md diff --git a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md new file mode 100644 index 00000000..acb292da --- /dev/null +++ b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md @@ -0,0 +1,1413 @@ +# Init Model Onboarding & Privacy Scanner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the broken model recommendation for MLX users, add interactive model download to `jarvis init`, and create a `jarvis scan` privacy environment audit command. + +**Architecture:** Two independent features sharing only the init command integration point. Feature 1 modifies the model catalog and init flow. Feature 2 creates a new `PrivacyScanner` class with platform-specific checks exposed via `jarvis scan` CLI command and a lightweight hook in init. + +**Tech Stack:** Python 3.10+, Click (CLI), Rich (terminal UI), httpx (Ollama API), subprocess (system checks) + +**Spec:** `docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md` + +--- + +### Task 1: Fix MLX support in model catalog + +**Files:** +- Modify: `src/openjarvis/intelligence/model_catalog.py:42-54` (qwen3.5:3b) +- Modify: `src/openjarvis/intelligence/model_catalog.py:55-67` (qwen3.5:8b) +- Modify: `src/openjarvis/intelligence/model_catalog.py:68-80` (qwen3.5:14b) +- Modify: `src/openjarvis/intelligence/model_catalog.py:219-232` (qwen3.5:4b) +- Test: `tests/core/test_recommend_model.py` + +- [ ] **Step 1: Write failing tests for MLX model recommendation** + +Add a new test class to `tests/core/test_recommend_model.py`: + +```python +class TestRecommendModelMlx: + """Apple Silicon (MLX) model recommendation.""" + + def test_apple_silicon_8gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=8.0, + gpu=GpuInfo(vendor="apple", name="Apple M1", vram_gb=8.0, count=1), + ) + result = recommend_model(hw, "mlx") + # available = 8 * 0.9 = 7.2 GB + # 8B * 0.5 * 1.1 = 4.4 → fits, but check 14B first: 7.7 → too big + # 4B * 0.5 * 1.1 = 2.2 → fits, but 8B also fits → pick 8B + assert result == "qwen3.5:8b" + + def test_apple_silicon_16gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=16.0, + gpu=GpuInfo(vendor="apple", name="Apple M2", vram_gb=16.0, count=1), + ) + result = recommend_model(hw, "mlx") + # available = 16 * 0.9 = 14.4 GB + # 14B * 0.5 * 1.1 = 7.7 → fits + assert result == "qwen3.5:14b" + + def test_apple_silicon_32gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=32.0, + gpu=GpuInfo(vendor="apple", name="Apple M2 Pro", vram_gb=32.0, count=1), + ) + result = recommend_model(hw, "mlx") + # available = 32 * 0.9 = 28.8 GB + # 14B * 0.5 * 1.1 = 7.7 → fits (14b is the largest with mlx support) + assert result == "qwen3.5:14b" + + def test_apple_silicon_64gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=64.0, + gpu=GpuInfo(vendor="apple", name="Apple M2 Max", vram_gb=64.0, count=1), + ) + result = recommend_model(hw, "mlx") + # available = 64 * 0.9 = 57.6 GB — but 14b is the largest MLX model + assert result == "qwen3.5:14b" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/core/test_recommend_model.py::TestRecommendModelMlx -v` +Expected: FAIL — all 4 tests fail because no Qwen3.5 model has `"mlx"` in `supported_engines`. + +- [ ] **Step 3: Add MLX to supported_engines and download metadata** + +In `src/openjarvis/intelligence/model_catalog.py`, update the four Qwen3.5 model entries: + +For `qwen3.5:3b` (line 48): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-3b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-3B-4bit"` to metadata. + +For `qwen3.5:8b` (line 61): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-8b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-8B-4bit"` to metadata. + +For `qwen3.5:14b` (line 74): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-14b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-14B-4bit"` to metadata. + +For `qwen3.5:4b` (line 226): change `supported_engines` from `("ollama", "vllm", "sglang", "llamacpp")` to `("ollama", "vllm", "sglang", "llamacpp", "mlx")`. Add `"gguf_file": "qwen3.5-4b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-4B-4bit"` to metadata. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/core/test_recommend_model.py -v` +Expected: ALL 15 tests pass (11 existing + 4 new). + +- [ ] **Step 5: Add `estimated_download_gb` helper to config.py** + +In `src/openjarvis/core/config.py`, after the `recommend_model` function (after line 248), add: + +```python +def estimated_download_gb(parameter_count_b: float) -> float: + """Estimate download size in GB for Q4_K_M quantized model.""" + return parameter_count_b * 0.5 * 1.1 +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/intelligence/model_catalog.py src/openjarvis/core/config.py tests/core/test_recommend_model.py +git commit -m "fix: add MLX engine support to Qwen3.5 model catalog entries + +Fixes recommend_model() returning empty string on Apple Silicon +when MLX is the recommended engine. Also adds gguf_file and +mlx_repo download metadata, and estimated_download_gb helper." +``` + +--- + +### Task 2: Extract Ollama pull helper and extend `jarvis model pull` + +**Files:** +- Modify: `src/openjarvis/cli/model.py:152-197` +- Test: `tests/cli/test_model_pull.py` (new) + +- [ ] **Step 1: Write failing tests for multi-engine pull** + +Create `tests/cli/test_model_pull.py`: + +```python +"""Tests for ``jarvis model pull`` multi-engine support.""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from click.testing import CliRunner +from rich.console import Console + +from openjarvis.cli.model import ollama_pull + + +class TestOllamaPull: + """Test the extracted ollama_pull helper.""" + + def test_ollama_pull_success(self) -> None: + import io + console = Console(file=io.StringIO()) + mock_lines = [ + '{"status": "pulling manifest"}', + '{"status": "downloading", "total": 100, "completed": 100}', + '{"status": "success"}', + ] + mock_resp = mock.MagicMock() + mock_resp.raise_for_status = mock.MagicMock() + mock_resp.iter_lines.return_value = iter(mock_lines) + mock_resp.__enter__ = mock.MagicMock(return_value=mock_resp) + mock_resp.__exit__ = mock.MagicMock(return_value=False) + + with mock.patch("httpx.stream", return_value=mock_resp): + result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) + assert result is True + + def test_ollama_pull_connect_error(self) -> None: + import io + import httpx + + console = Console(file=io.StringIO()) + with mock.patch("httpx.stream", side_effect=httpx.ConnectError("refused")): + result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) + assert result is False + + +class TestPullCliMultiEngine: + """Test the pull CLI command dispatches to correct engine.""" + + def test_pull_llamacpp_uses_huggingface_cli(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run") as mock_run, + ): + mock_cfg.return_value.engine.default = "llamacpp" + mock_cfg.return_value.engine.ollama_host = None + mock_run.return_value = mock.MagicMock(returncode=0) + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] + ) + + assert result.exit_code == 0 + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "huggingface-cli" in call_args + assert "qwen3.5-8b-q4_k_m.gguf" in call_args + + def test_pull_mlx_uses_huggingface_cli(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run") as mock_run, + ): + mock_cfg.return_value.engine.default = "mlx" + mock_cfg.return_value.engine.ollama_host = None + mock_run.return_value = mock.MagicMock(returncode=0) + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "mlx"] + ) + + assert result.exit_code == 0 + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "huggingface-cli" in call_args + assert "mlx-community/Qwen3.5-8B-4bit" in call_args + + def test_pull_llamacpp_huggingface_cli_not_found(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run", side_effect=FileNotFoundError), + ): + mock_cfg.return_value.engine.default = "llamacpp" + mock_cfg.return_value.engine.ollama_host = None + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] + ) + + assert result.exit_code != 0 + assert "huggingface_hub" in result.output or "pip install" in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/cli/test_model_pull.py -v` +Expected: FAIL — `ollama_pull` function doesn't exist yet, `--engine` flag not recognized. + +- [ ] **Step 3: Refactor model.py — extract ollama_pull and add multi-engine support** + +In `src/openjarvis/cli/model.py`: + +1. Add `import subprocess` at the top. +2. Add `from openjarvis.intelligence.model_catalog import BUILTIN_MODELS` at the top. +3. Extract the Ollama pull logic into a standalone function: + +```python +def ollama_pull(host: str, model_name: str, console: Console) -> bool: + """Pull a model via Ollama API. Returns True on success.""" + console.print(f"Pulling [cyan]{model_name}[/cyan] via Ollama...") + try: + with httpx.stream( + "POST", + f"{host}/api/pull", + json={"name": model_name, "stream": True}, + timeout=600.0, + ) as resp: + resp.raise_for_status() + import json + + for line in resp.iter_lines(): + if not line.strip(): + continue + try: + data = json.loads(line) + except Exception: + continue + status = data.get("status", "") + if "total" in data and "completed" in data: + total = data["total"] + done = data["completed"] + pct = int(done / total * 100) if total else 0 + console.print(f" {status}: {pct}%", end="\r") + elif status: + console.print(f" {status}") + console.print(f"\n[green]Successfully pulled {model_name}[/green]") + return True + except httpx.ConnectError: + console.print("[red]Cannot connect to Ollama.[/red] Is it running?") + return False + except httpx.HTTPStatusError as exc: + console.print(f"[red]Ollama error:[/red] {exc.response.status_code}") + return False +``` + +4. Add helper to find model spec: + +```python +def find_model_spec(model_name: str): + """Look up a model in the builtin catalog. Returns None if not found.""" + for spec in BUILTIN_MODELS: + if spec.model_id == model_name: + return spec + return None +``` + +5. Add HuggingFace download helper: + +```python +def hf_download(repo: str, filename: str | None, console: Console) -> bool: + """Download from HuggingFace via huggingface-cli. Returns True on success.""" + cmd = ["huggingface-cli", "download", repo] + if filename: + cmd.append(filename) + try: + result = subprocess.run(cmd, check=True) + console.print(f"[green]Download complete.[/green]") + return True + except FileNotFoundError: + console.print( + "[red]huggingface-cli not found.[/red]\n" + "Install it: [cyan]pip install huggingface_hub[/cyan]\n" + f"Or download manually: https://huggingface.co/{repo}" + ) + return False + except subprocess.CalledProcessError: + console.print(f"[red]Download failed.[/red]") + return False +``` + +6. Replace the existing `pull` command with a multi-engine version: + +```python +@model.command() +@click.argument("model_name") +@click.option("--engine", default=None, help="Engine to download for (ollama, llamacpp, mlx).") +def pull(model_name: str, engine: str | None) -> None: + """Download a model.""" + console = Console() + config = load_config() + + engine = engine or config.engine.default or "ollama" + + if engine == "ollama": + host = ( + config.engine.ollama_host + or os.environ.get("OLLAMA_HOST") + or "http://localhost:11434" + ).rstrip("/") + if not ollama_pull(host, model_name, console): + sys.exit(1) + elif engine in ("llamacpp", "mlx"): + spec = find_model_spec(model_name) + if not spec: + console.print(f"[red]Model not in catalog:[/red] {model_name}") + sys.exit(1) + if engine == "llamacpp": + repo = spec.metadata.get("hf_repo", "") + gguf = spec.metadata.get("gguf_file", "") + if not repo or not gguf: + console.print(f"[red]No GGUF download info for {model_name}[/red]") + sys.exit(1) + console.print(f"Downloading [cyan]{gguf}[/cyan] from {repo}...") + if not hf_download(repo, gguf, console): + sys.exit(1) + else: # mlx + mlx_repo = spec.metadata.get("mlx_repo", "") + if not mlx_repo: + console.print(f"[red]No MLX repo info for {model_name}[/red]") + sys.exit(1) + console.print(f"Downloading [cyan]{mlx_repo}[/cyan]...") + if not hf_download(mlx_repo, None, console): + sys.exit(1) + elif engine in ("vllm", "sglang"): + console.print( + f"[cyan]{model_name}[/cyan] will download automatically when " + f"{engine} starts serving it." + ) + else: + console.print( + f"Manual download required for engine [cyan]{engine}[/cyan].\n" + f"Check the engine documentation for instructions." + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/cli/test_model_pull.py -v` +Expected: ALL tests pass. + +- [ ] **Step 5: Run existing tests to verify no regressions** + +Run: `uv run pytest tests/core/test_recommend_model.py tests/cli/test_init_guidance.py -v` +Expected: ALL pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/cli/model.py tests/cli/test_model_pull.py +git commit -m "feat: extract ollama_pull helper and add multi-engine model pull + +Refactors model pull into reusable ollama_pull() function. Adds +--engine flag to support llamacpp (GGUF) and mlx (HuggingFace) +downloads via huggingface-cli, with FileNotFoundError handling." +``` + +--- + +### Task 3: Add interactive download and empty-model fallback to `jarvis init` + +**Files:** +- Modify: `src/openjarvis/cli/init_cmd.py:139-299` +- Test: `tests/cli/test_init_guidance.py` + +- [ ] **Step 1: Write failing tests for download prompt and empty-model fallback** + +Add to `tests/cli/test_init_guidance.py`: + +```python +class TestInitDownloadPrompt: + """Interactive download prompt during init.""" + + def test_init_shows_download_prompt(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama"], input="n\n" + ) + assert result.exit_code == 0 + assert "Download" in result.output and "now?" in result.output + + def test_init_no_download_flag_skips_prompt(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama", "--no-download"] + ) + assert result.exit_code == 0 + assert "Download" not in result.output or "now?" not in result.output + + +class TestInitEmptyModelFallback: + """Empty model recommendation shows helpful message.""" + + def test_init_no_model_shows_warning(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch( + "openjarvis.cli.init_cmd.recommend_model", return_value="" + ), + ): + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) + assert result.exit_code == 0 + assert "Not enough memory" in result.output or "not enough memory" in result.output + + +class TestNextStepsExoNexa: + """Exo and Nexa have their own next-steps text.""" + + def test_next_steps_exo(self) -> None: + text = _next_steps_text("exo") + assert "exo" in text.lower() + assert "jarvis ask" in text + # Should NOT fall back to Ollama instructions + assert "ollama" not in text.lower() + + def test_next_steps_nexa(self) -> None: + text = _next_steps_text("nexa") + assert "nexa" in text.lower() + assert "jarvis ask" in text + assert "ollama" not in text.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/cli/test_init_guidance.py::TestInitDownloadPrompt tests/cli/test_init_guidance.py::TestInitEmptyModelFallback tests/cli/test_init_guidance.py::TestNextStepsExoNexa -v` +Expected: FAIL — no `--no-download` flag, no "Not enough memory" message, no exo/nexa next-steps. + +- [ ] **Step 3: Update init_cmd.py** + +In `src/openjarvis/cli/init_cmd.py`: + +1. Add imports at the top: + +```python +from openjarvis.cli.model import ollama_pull, find_model_spec, hf_download +from openjarvis.core.config import estimated_download_gb +``` + +2. Add `exo` and `nexa` entries to `_next_steps_text()` dict (before the closing `}`): + +```python + "exo": ( + "Next steps:\n" + "\n" + " 1. Install and start Exo:\n" + " pip install exo\n" + " exo\n" + "\n" + " 2. Try it out:\n" + " jarvis ask \"Hello\"\n" + "\n" + " Run `jarvis doctor` to verify your setup." + ), + "nexa": ( + "Next steps:\n" + "\n" + " 1. Install and start Nexa:\n" + " pip install nexaai\n" + " nexa server\n" + "\n" + " 2. Try it out:\n" + " jarvis ask \"Hello\"\n" + "\n" + " Run `jarvis doctor` to verify your setup." + ), +``` + +3. Add `--no-download` flag to the `@click.command()` decorator chain (after `--engine`): + +```python +@click.option( + "--no-download", + is_flag=True, + default=False, + help="Skip the model download prompt.", +) +``` + +4. Update the `init()` function signature to accept `no_download: bool = False`. + +5. Replace the block at lines 287-298 (the model recommendation + next-steps panel) with: + +```python + selected_engine = engine or recommend_engine(hw) + model = recommend_model(hw, selected_engine) + + if not model: + console.print( + "\n [yellow]! Not enough memory to run any local model.[/yellow]\n" + " Consider a cloud engine or a machine with more RAM." + ) + else: + spec = find_model_spec(model) + size_gb = estimated_download_gb(spec.parameter_count_b) if spec else 0 + console.print(f"\n [bold]Recommended model:[/bold] {model} (~{size_gb:.1f} GB estimated)") + + if not no_download and spec: + if click.confirm(f" Download {model} (~{size_gb:.1f} GB estimated) now?", default=True): + _do_download(selected_engine, model, spec, console) + + console.print() + console.print( + Panel( + _next_steps_text(selected_engine, model), + title="Getting Started", + border_style="cyan", + ) + ) +``` + +6. Add the `_do_download` helper function: + +```python +def _do_download(engine: str, model: str, spec, console: Console) -> None: + """Dispatch model download based on engine type.""" + import os + + if engine == "ollama": + host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + ollama_pull(host, model, console) + elif engine == "llamacpp": + repo = spec.metadata.get("hf_repo", "") + gguf = spec.metadata.get("gguf_file", "") + if repo and gguf: + console.print(f" Downloading [cyan]{gguf}[/cyan] from {repo}...") + hf_download(repo, gguf, console) + else: + console.print(f" [yellow]No GGUF download info for {model}[/yellow]") + elif engine == "mlx": + mlx_repo = spec.metadata.get("mlx_repo", "") + if mlx_repo: + console.print(f" Downloading [cyan]{mlx_repo}[/cyan]...") + hf_download(mlx_repo, None, console) + else: + console.print(f" [yellow]No MLX repo info for {model}[/yellow]") + elif engine in ("vllm", "sglang"): + console.print( + f" [cyan]{model}[/cyan] will download automatically when " + f"{engine} starts serving it." + ) + else: + console.print( + f" Download {model} through the {engine} interface." + ) +``` + +- [ ] **Step 4: Update existing init tests to add `--no-download`** + +The download prompt breaks existing tests because `click.confirm` will try to download a model. Update all existing init CLI tests in `tests/cli/test_init_guidance.py` to add `"--no-download"` to their invocation args: + +- `test_init_shows_next_steps`: change `["init", "--engine", "llamacpp"]` to `["init", "--engine", "llamacpp", "--no-download"]` +- `test_init_output_shows_toml_sections_literally`: same change +- `test_init_generates_minimal_by_default`: change `["init", "--engine", "ollama"]` to `["init", "--engine", "ollama", "--no-download"]` +- `test_init_full_generates_verbose_config`: change `["init", "--full", "--engine", "ollama"]` to `["init", "--full", "--engine", "ollama", "--no-download"]` + +- [ ] **Step 5: Add download dispatch tests** + +Add to `tests/cli/test_init_guidance.py`: + +```python +class TestInitDownloadDispatch: + """Verify download dispatches correctly for each engine.""" + + def test_init_ollama_download_calls_ollama_pull(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.ollama_pull", return_value=True) as mock_pull, + ): + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama"], input="y\n" + ) + assert result.exit_code == 0 + mock_pull.assert_called_once() + + def test_init_vllm_shows_auto_download_message(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke( + cli, ["init", "--engine", "vllm"], input="y\n" + ) + assert result.exit_code == 0 + assert "automatically" in result.output +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/cli/test_init_guidance.py -v` +Expected: ALL tests pass (10 existing + 7 new). + +- [ ] **Step 7: Lint** + +Run: `uv run ruff check src/openjarvis/cli/init_cmd.py --fix` +Expected: Clean or auto-fixed. + +- [ ] **Step 8: Commit** + +```bash +git add src/openjarvis/cli/init_cmd.py tests/cli/test_init_guidance.py +git commit -m "feat: add interactive model download and empty-model fallback to init + +Prompts user to download recommended model during jarvis init. +Adds --no-download flag for CI. Shows helpful message when no +model fits available memory. Adds exo/nexa next-steps text." +``` + +--- + +### Task 4: Create privacy scanner — ScanResult and check functions + +**Files:** +- Create: `src/openjarvis/cli/scan_cmd.py` +- Test: `tests/cli/test_scan.py` (new) + +- [ ] **Step 1: Write failing tests for individual scanner checks** + +Create `tests/cli/test_scan.py`: + +```python +"""Tests for ``jarvis scan`` privacy environment audit.""" + +from __future__ import annotations + +import subprocess +import sys +from unittest import mock + +import pytest + +from openjarvis.cli.scan_cmd import PrivacyScanner, ScanResult + + +class TestScanResultDataclass: + def test_scan_result_fields(self) -> None: + r = ScanResult(name="Test", status="ok", message="All good", platform="all") + assert r.name == "Test" + assert r.status == "ok" + assert r.message == "All good" + assert r.platform == "all" + + +class TestFileVault: + def test_filevault_enabled(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout="FileVault is On.", returncode=0 + ) + result = scanner.check_filevault() + assert result.status == "ok" + assert "enabled" in result.message.lower() + + def test_filevault_disabled(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout="FileVault is Off.", returncode=0 + ) + result = scanner.check_filevault() + assert result.status == "fail" + + def test_filevault_command_not_found(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run", side_effect=FileNotFoundError): + result = scanner.check_filevault() + assert result.status == "skip" + + +class TestMDM: + def test_mdm_not_enrolled(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout="This machine is not enrolled", returncode=0 + ) + result = scanner.check_mdm() + assert result.status == "ok" + + def test_mdm_enrolled(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout="Enrolled via DEP: Yes\nMDM server: example.com", + returncode=0, + ) + result = scanner.check_mdm() + assert result.status == "warn" + + +class TestCloudSync: + def test_no_cloud_sync_agents(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + # pgrep returns exit code 1 when no match + mock_run.return_value = mock.MagicMock(stdout="", returncode=1) + result = scanner.check_cloud_sync_agents() + assert result.status == "ok" + + def test_dropbox_running(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + def side_effect(cmd, **kwargs): + m = mock.MagicMock() + # Match on the process name in the pgrep pattern + if any("Dropbox" in str(c) for c in cmd): + m.stdout = "12345" + m.returncode = 0 + else: + m.stdout = "" + m.returncode = 1 + return m + mock_run.side_effect = side_effect + result = scanner.check_cloud_sync_agents() + assert result.status == "warn" + assert "dropbox" in result.message.lower() + + +class TestNetworkExposure: + def test_no_exposed_ports(self) -> None: + scanner = PrivacyScanner() + lsof_output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "ollama 12345 user 5u IPv4 0x1234 0t0 TCP 127.0.0.1:11434 (LISTEN)\n" + ) + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout=lsof_output, returncode=0 + ) + result = scanner.check_network_exposure() + assert result.status == "ok" + + def test_exposed_port(self) -> None: + scanner = PrivacyScanner() + lsof_output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "ollama 12345 user 5u IPv4 0x1234 0t0 TCP *:11434 (LISTEN)\n" + ) + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout=lsof_output, returncode=0 + ) + result = scanner.check_network_exposure() + assert result.status == "warn" + assert "11434" in result.message + + +class TestLUKS: + def test_luks_encrypted(self) -> None: + scanner = PrivacyScanner() + lsblk_json = '{"blockdevices": [{"name": "sda", "type": "disk", "fstype": null, "children": [{"name": "sda1", "type": "part", "fstype": "crypto_LUKS"}]}]}' + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout=lsblk_json, returncode=0 + ) + result = scanner.check_luks() + assert result.status == "ok" + + def test_luks_not_encrypted(self) -> None: + scanner = PrivacyScanner() + lsblk_json = '{"blockdevices": [{"name": "sda", "type": "disk", "fstype": null, "children": [{"name": "sda1", "type": "part", "fstype": "ext4"}]}]}' + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + stdout=lsblk_json, returncode=0 + ) + result = scanner.check_luks() + assert result.status == "fail" + + +class TestScreenRecording: + def test_no_screen_recording(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(stdout="", returncode=1) + result = scanner.check_screen_recording() + assert result.status == "ok" + + def test_teamviewer_running(self) -> None: + scanner = PrivacyScanner() + with mock.patch("subprocess.run") as mock_run: + def side_effect(cmd, **kwargs): + m = mock.MagicMock() + if any("TeamViewer" in str(c) for c in cmd): + m.stdout = "12345" + m.returncode = 0 + else: + m.stdout = "" + m.returncode = 1 + return m + mock_run.side_effect = side_effect + result = scanner.check_screen_recording() + assert result.status == "warn" + + +class TestPlatformFiltering: + def test_run_all_returns_only_current_platform(self) -> None: + scanner = PrivacyScanner() + with mock.patch.object(scanner, "_get_all_checks") as mock_checks: + mock_checks.return_value = [ + lambda: ScanResult("Test1", "ok", "ok", "darwin"), + lambda: ScanResult("Test2", "ok", "ok", "linux"), + lambda: ScanResult("Test3", "ok", "ok", "all"), + ] + results = scanner.run_all() + current = sys.platform + for r in results: + assert r.platform in (current, "all") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/cli/test_scan.py -v` +Expected: FAIL — `scan_cmd` module doesn't exist. + +- [ ] **Step 3: Implement PrivacyScanner class** + +Create `src/openjarvis/cli/scan_cmd.py`: + +```python +"""``jarvis scan`` — privacy and environment audit.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List + +import click +from rich.console import Console + + +@dataclass(slots=True) +class ScanResult: + """Result of a single privacy check.""" + + name: str + status: str # "ok" | "warn" | "fail" | "skip" + message: str + platform: str # "darwin" | "linux" | "all" + + +# Engine ports to check for network exposure +_ENGINE_PORTS = { + 11434: "Ollama", + 8080: "llama.cpp / MLX", + 8000: "vLLM", + 30000: "SGLang", + 1234: "LM Studio", + 52415: "Exo", + 18181: "Nexa", +} + +# Cloud sync process names to check +_CLOUD_SYNC_PROCESSES = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"] + +# Screen recording / remote access process names +_SCREEN_RECORDING_PROCESSES_MACOS = [ + "TeamViewer", "AnyDesk", "ScreenConnect", "VNC", +] +_REMOTE_ACCESS_PROCESSES_LINUX = [ + "xrdp", "x11vnc", "vncserver", "AnyDesk", "TeamViewer", +] + + +class PrivacyScanner: + """Run platform-specific privacy and environment checks.""" + + def _run(self, cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a subprocess, capturing stdout as text.""" + return subprocess.run( + cmd, capture_output=True, text=True, timeout=10, **kwargs + ) + + # ------------------------------------------------------------------ + # macOS checks + # ------------------------------------------------------------------ + + def check_filevault(self) -> ScanResult: + """Check macOS FileVault disk encryption status.""" + try: + proc = self._run(["fdesetup", "status"]) + if "On" in proc.stdout: + return ScanResult("FileVault", "ok", "FileVault enabled", "darwin") + return ScanResult( + "FileVault", "fail", + "FileVault is disabled — disk is not encrypted", "darwin" + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return ScanResult("FileVault", "skip", "fdesetup not available", "darwin") + + def check_mdm(self) -> ScanResult: + """Check for MDM / enterprise management enrollment.""" + try: + proc = self._run(["profiles", "status", "-type", "enrollment"]) + output = proc.stdout + proc.stderr + if "MDM" in output or "Enrolled" in output: + return ScanResult( + "MDM", "warn", + "Enterprise management profile detected — this device may be monitored", + "darwin", + ) + return ScanResult("MDM", "ok", "No MDM enrollment detected", "darwin") + except (FileNotFoundError, subprocess.TimeoutExpired): + return ScanResult("MDM", "skip", "profiles command not available", "darwin") + + def check_icloud_sync(self) -> ScanResult: + """Check if ~/.openjarvis/ could be synced by iCloud Drive.""" + try: + oj_path = Path.home() / ".openjarvis" + # Check if the path is under iCloud's mobile documents + mobile_docs = Path.home() / "Library" / "Mobile Documents" + try: + resolved = oj_path.resolve() + if str(resolved).startswith(str(mobile_docs)): + return ScanResult( + "iCloud Drive", "warn", + "~/.openjarvis/ is inside iCloud Drive sync path", + "darwin", + ) + except OSError: + pass + + # Check if Desktop & Documents sync is enabled + proc = self._run( + ["defaults", "read", "com.apple.bird", "optimize-storage"] + ) + # Also check the broader MobileMeAccounts for sync status + proc2 = self._run( + ["defaults", "read", "MobileMeAccounts"] + ) + output = proc.stdout + proc2.stdout + if "MOBILE_DOCUMENTS" in output or "Desktop" in output: + return ScanResult( + "iCloud Drive", "warn", + "iCloud Desktop & Documents sync may be active — " + "~/.openjarvis/ could be synced to Apple servers", + "darwin", + ) + return ScanResult( + "iCloud Drive", "ok", + "No iCloud sync overlap detected", "darwin" + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return ScanResult( + "iCloud Drive", "skip", + "Could not determine iCloud sync status", "darwin" + ) + except Exception: + return ScanResult( + "iCloud Drive", "skip", + "Could not determine iCloud sync status", "darwin" + ) + + # ------------------------------------------------------------------ + # Linux checks + # ------------------------------------------------------------------ + + def check_luks(self) -> ScanResult: + """Check for LUKS disk encryption on Linux.""" + try: + proc = self._run(["lsblk", "-o", "NAME,TYPE,FSTYPE", "-J"]) + data = json.loads(proc.stdout) + + def _has_luks(devices: list) -> bool: + for dev in devices: + if dev.get("fstype") == "crypto_LUKS": + return True + if _has_luks(dev.get("children", [])): + return True + return False + + if _has_luks(data.get("blockdevices", [])): + return ScanResult( + "Disk Encryption", "ok", + "LUKS encryption detected", "linux" + ) + return ScanResult( + "Disk Encryption", "fail", + "No LUKS encryption detected — disk may not be encrypted", + "linux", + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return ScanResult( + "Disk Encryption", "skip", + "lsblk not available", "linux" + ) + except (json.JSONDecodeError, KeyError): + return ScanResult( + "Disk Encryption", "skip", + "Could not parse lsblk output", "linux" + ) + + def check_remote_access(self) -> ScanResult: + """Check for remote access tools on Linux.""" + return self._check_processes( + _REMOTE_ACCESS_PROCESSES_LINUX, + "Remote Access", + "Remote access tool detected", + "linux", + ) + + # ------------------------------------------------------------------ + # Cross-platform checks + # ------------------------------------------------------------------ + + def check_cloud_sync_agents(self) -> ScanResult: + """Check for running cloud sync agents.""" + platform = "darwin" if sys.platform == "darwin" else "linux" + return self._check_processes( + _CLOUD_SYNC_PROCESSES, + "Cloud Sync", + "Cloud sync agent detected", + platform, + ) + + def check_network_exposure(self) -> ScanResult: + """Check if inference engine ports are bound to 0.0.0.0.""" + platform = "darwin" if sys.platform == "darwin" else "linux" + try: + if sys.platform == "darwin": + proc = self._run(["lsof", "-iTCP", "-sTCP:LISTEN", "-nP"]) + else: + proc = self._run(["ss", "-tlnp"]) + + exposed = [] + for line in proc.stdout.splitlines(): + for port, engine_name in _ENGINE_PORTS.items(): + port_str = str(port) + if port_str in line and ("*:" + port_str in line or "0.0.0.0:" + port_str in line): + exposed.append(f"{engine_name} (port {port})") + + if exposed: + return ScanResult( + "Network Exposure", "warn", + f"Inference ports exposed to network: {', '.join(exposed)}", + platform, + ) + return ScanResult( + "Network Exposure", "ok", + "Inference ports bound to localhost only", platform + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return ScanResult( + "Network Exposure", "skip", + "Could not check listening ports", platform + ) + + def check_screen_recording(self) -> ScanResult: + """Check for screen recording / remote desktop tools (macOS).""" + return self._check_processes( + _SCREEN_RECORDING_PROCESSES_MACOS, + "Screen Recording", + "Screen recording or remote access tool detected", + "darwin", + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _check_processes( + self, + process_names: list[str], + check_name: str, + warn_message: str, + platform: str, + ) -> ScanResult: + """Check if any of the named processes are running.""" + found = [] + for name in process_names: + try: + proc = self._run(["pgrep", "-i", name]) + if proc.returncode == 0 and proc.stdout.strip(): + found.append(name) + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + if found: + return ScanResult( + check_name, "warn", + f"{warn_message}: {', '.join(found)}", platform + ) + return ScanResult( + check_name, "ok", + f"No {check_name.lower()} detected", platform + ) + + def _get_all_checks(self) -> List[Callable[[], ScanResult]]: + """Return all check methods.""" + return [ + self.check_filevault, + self.check_mdm, + self.check_icloud_sync, + self.check_luks, + self.check_cloud_sync_agents, + self.check_network_exposure, + self.check_screen_recording, + self.check_remote_access, + ] + + def run_all(self) -> list[ScanResult]: + """Run all checks, filtering to current platform.""" + results = [] + for check in self._get_all_checks(): + result = check() + if result.platform in (sys.platform, "all"): + if result.status != "skip": + results.append(result) + return results + + def run_quick(self) -> list[ScanResult]: + """Run only critical checks (for init hook).""" + checks = [self.check_filevault, self.check_luks, self.check_icloud_sync, + self.check_cloud_sync_agents] + results = [] + for check in checks: + result = check() + if result.platform in (sys.platform, "all"): + if result.status != "skip": + results.append(result) + return results + + +# ------------------------------------------------------------------ +# CLI command +# ------------------------------------------------------------------ + +_STATUS_SYMBOLS = {"ok": "\u2713", "warn": "!", "fail": "\u2717"} + + +@click.command() +def scan() -> None: + """Audit your environment for privacy and security risks.""" + console = Console() + scanner = PrivacyScanner() + results = scanner.run_all() + + console.print() + console.print(" [bold]Privacy & Environment Audit[/bold]") + console.print(" " + "\u2500" * 28) + + warns = 0 + fails = 0 + for r in results: + symbol = _STATUS_SYMBOLS.get(r.status, "?") + if r.status == "ok": + console.print(f" [green]{symbol}[/green] {r.name}: {r.message}") + elif r.status == "warn": + console.print(f" [yellow]{symbol}[/yellow] {r.name}: {r.message}") + warns += 1 + elif r.status == "fail": + console.print(f" [red]{symbol}[/red] {r.name}: {r.message}") + fails += 1 + + console.print() + if warns == 0 and fails == 0: + console.print(" [green]No issues found.[/green]") + else: + parts = [] + if warns: + parts.append(f"{warns} warning{'s' if warns != 1 else ''}") + if fails: + parts.append(f"{fails} issue{'s' if fails != 1 else ''}") + console.print(f" {', '.join(parts)} found.") + console.print() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/cli/test_scan.py -v` +Expected: ALL tests pass. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/cli/scan_cmd.py --fix` +Expected: Clean or auto-fixed. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/cli/scan_cmd.py tests/cli/test_scan.py +git commit -m "feat: add privacy environment scanner with platform-specific checks + +New PrivacyScanner class with checks for disk encryption (FileVault/LUKS), +MDM profiles, cloud sync agents, network exposure, and screen recording. +Supports macOS and Linux with graceful skip on missing tools." +``` + +--- + +### Task 5: Register `jarvis scan` and integrate privacy hook into init + +**Files:** +- Modify: `src/openjarvis/cli/__init__.py:34` (add import) +- Modify: `src/openjarvis/cli/__init__.py:89` (register command) +- Modify: `src/openjarvis/cli/init_cmd.py` (add privacy hook) +- Test: `tests/cli/test_init_guidance.py` + +- [ ] **Step 1: Write failing test for init privacy hook** + +Add to `tests/cli/test_init_guidance.py`: + +```python +class TestInitPrivacyHook: + """Init shows a lightweight privacy summary.""" + + def test_init_shows_privacy_summary(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner") as MockScanner, + ): + from openjarvis.cli.scan_cmd import ScanResult + instance = MockScanner.return_value + instance.run_quick.return_value = [ + ScanResult("FileVault", "ok", "FileVault enabled", "darwin"), + ] + result = CliRunner().invoke( + cli, ["init", "--engine", "llamacpp", "--no-download"] + ) + assert result.exit_code == 0 + assert "jarvis scan" in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/cli/test_init_guidance.py::TestInitPrivacyHook -v` +Expected: FAIL — no `PrivacyScanner` import in init_cmd, no privacy output. + +- [ ] **Step 3: Update all existing init tests to mock PrivacyScanner** + +After adding the privacy hook to init, all existing init CLI tests will call real system commands (`fdesetup`, `pgrep`, etc.). Add `mock.patch("openjarvis.cli.init_cmd.PrivacyScanner")` to every existing init CLI test's context manager block in `tests/cli/test_init_guidance.py`. This includes: + +- `test_init_shows_next_steps` +- `test_init_output_shows_toml_sections_literally` +- `test_init_generates_minimal_by_default` +- `test_init_full_generates_verbose_config` +- `test_init_shows_download_prompt` +- `test_init_no_download_flag_skips_prompt` +- `test_init_no_model_shows_warning` +- `test_init_ollama_download_calls_ollama_pull` +- `test_init_vllm_shows_auto_download_message` + +For each, add inside the `with (...)` block: +```python +mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), +``` + +- [ ] **Step 4: Register scan command and add init privacy hook** + +In `src/openjarvis/cli/__init__.py`, add after line 33: + +```python +from openjarvis.cli.scan_cmd import scan +``` + +After line 89 (the last `cli.add_command` call), add: + +```python +cli.add_command(scan, "scan") +``` + +In `src/openjarvis/cli/init_cmd.py`, add import: + +```python +from openjarvis.cli.scan_cmd import PrivacyScanner +``` + +Add `_quick_privacy_check` function: + +```python +def _quick_privacy_check(console: Console) -> None: + """Run critical privacy checks and print compact summary.""" + scanner = PrivacyScanner() + results = scanner.run_quick() + + if results: + console.print(" [bold]Privacy check:[/bold]") + for r in results: + if r.status == "ok": + console.print(f" [green]\u2713[/green] {r.message}") + elif r.status == "warn": + console.print(f" [yellow]![/yellow] {r.message}") + elif r.status == "fail": + console.print(f" [red]\u2717[/red] {r.message}") + + console.print() + console.print(" Run [cyan]jarvis scan[/cyan] for a full environment audit.") +``` + +Call `_quick_privacy_check(console)` at the end of the `init()` function, just before the final "Getting Started" panel. + +- [ ] **Step 4: Run all tests to verify everything passes** + +Run: `uv run pytest tests/cli/test_init_guidance.py tests/cli/test_scan.py tests/core/test_recommend_model.py tests/cli/test_model_pull.py -v` +Expected: ALL tests pass. + +- [ ] **Step 5: Lint all changed files** + +Run: `uv run ruff check src/openjarvis/cli/ --fix` +Expected: Clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/cli/__init__.py src/openjarvis/cli/init_cmd.py tests/cli/test_init_guidance.py +git commit -m "feat: register jarvis scan command and add init privacy hook + +Registers the new scan command in the CLI. Adds a lightweight +privacy check at the end of jarvis init that runs disk encryption +and cloud sync checks, with pointer to jarvis scan for full audit." +``` + +--- + +### Task 6: Final integration test and cleanup + +**Files:** +- All modified files from tasks 1-5 + +- [ ] **Step 1: Run full test suite for all touched modules** + +Run: `uv run pytest tests/core/test_recommend_model.py tests/cli/test_init_guidance.py tests/cli/test_model_pull.py tests/cli/test_scan.py -v` +Expected: ALL pass. + +- [ ] **Step 2: Run linter on all source files** + +Run: `uv run ruff check src/openjarvis/cli/ src/openjarvis/intelligence/model_catalog.py src/openjarvis/core/config.py` +Expected: Clean. + +- [ ] **Step 3: Verify the MLX bug is fixed** + +Run: `uv run python3 -c "from openjarvis.core.config import HardwareInfo, GpuInfo, recommend_model; hw = HardwareInfo(platform='darwin', ram_gb=16.0, gpu=GpuInfo(vendor='apple', name='Apple M1', vram_gb=16.0, count=1)); print(f'MLX model: {recommend_model(hw, \"mlx\")}')"` +Expected output: `MLX model: qwen3.5:14b` + +- [ ] **Step 4: Verify the CLI scan command is registered** + +Run: `uv run jarvis scan --help` +Expected: Shows help text for the scan command. + +- [ ] **Step 5: Commit if any cleanup was needed** + +Only if changes were made during cleanup. Otherwise skip. From 7d3b4e5ea3bb893e726e2a89c9726dc7e79b893d Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:04:25 -0700 Subject: [PATCH 03/12] fix: add MLX engine support to Qwen3.5 model catalog entries Fixes recommend_model() returning empty string on Apple Silicon when MLX is the recommended engine. Also adds gguf_file and mlx_repo download metadata, and estimated_download_gb helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 5 +++ src/openjarvis/intelligence/model_catalog.py | 16 ++++++-- tests/core/test_recommend_model.py | 40 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 5ca8fa77..33160232 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -248,6 +248,11 @@ def recommend_model(hw: HardwareInfo, engine: str) -> str: return "" +def estimated_download_gb(parameter_count_b: float) -> float: + """Estimate download size in GB for Q4_K_M quantized model.""" + return parameter_count_b * 0.5 * 1.1 + + # --------------------------------------------------------------------------- # Configuration hierarchy # --------------------------------------------------------------------------- diff --git a/src/openjarvis/intelligence/model_catalog.py b/src/openjarvis/intelligence/model_catalog.py index d77c1740..ee36976f 100644 --- a/src/openjarvis/intelligence/model_catalog.py +++ b/src/openjarvis/intelligence/model_catalog.py @@ -45,11 +45,13 @@ BUILTIN_MODELS: List[ModelSpec] = [ parameter_count_b=3.0, active_parameter_count_b=0.6, context_length=131072, - supported_engines=("ollama", "vllm", "llamacpp", "sglang"), + supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"), provider="alibaba", metadata={ "architecture": "moe", "hf_repo": "Qwen/Qwen3.5-3B", + "gguf_file": "qwen3.5-3b-q4_k_m.gguf", + "mlx_repo": "mlx-community/Qwen3.5-3B-4bit", }, ), ModelSpec( @@ -58,11 +60,13 @@ BUILTIN_MODELS: List[ModelSpec] = [ parameter_count_b=8.0, active_parameter_count_b=1.0, context_length=131072, - supported_engines=("ollama", "vllm", "llamacpp", "sglang"), + supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"), provider="alibaba", metadata={ "architecture": "moe", "hf_repo": "Qwen/Qwen3.5-8B", + "gguf_file": "qwen3.5-8b-q4_k_m.gguf", + "mlx_repo": "mlx-community/Qwen3.5-8B-4bit", }, ), ModelSpec( @@ -71,11 +75,13 @@ BUILTIN_MODELS: List[ModelSpec] = [ parameter_count_b=14.0, active_parameter_count_b=2.0, context_length=131072, - supported_engines=("ollama", "vllm", "llamacpp", "sglang"), + supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"), provider="alibaba", metadata={ "architecture": "moe", "hf_repo": "Qwen/Qwen3.5-14B", + "gguf_file": "qwen3.5-14b-q4_k_m.gguf", + "mlx_repo": "mlx-community/Qwen3.5-14B-4bit", }, ), ModelSpec( @@ -223,11 +229,13 @@ BUILTIN_MODELS: List[ModelSpec] = [ active_parameter_count_b=0.5, context_length=262144, min_vram_gb=3.0, - supported_engines=("ollama", "vllm", "sglang", "llamacpp"), + supported_engines=("ollama", "vllm", "sglang", "llamacpp", "mlx"), provider="alibaba", metadata={ "architecture": "moe", "hf_repo": "Qwen/Qwen3.5-4B", + "gguf_file": "qwen3.5-4b-q4_k_m.gguf", + "mlx_repo": "mlx-community/Qwen3.5-4B-4bit", }, ), ModelSpec( diff --git a/tests/core/test_recommend_model.py b/tests/core/test_recommend_model.py index 8f03d430..61600b69 100644 --- a/tests/core/test_recommend_model.py +++ b/tests/core/test_recommend_model.py @@ -115,3 +115,43 @@ class TestRecommendModelEdgeCases: # With ollama, 397b is excluded (only vllm, sglang) result = recommend_model(hw, "ollama") assert result == "qwen3.5:122b" + + +class TestRecommendModelMlx: + """Apple Silicon (MLX) model recommendation.""" + + def test_apple_silicon_8gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=8.0, + gpu=GpuInfo(vendor="apple", name="Apple M1", vram_gb=8.0, count=1), + ) + result = recommend_model(hw, "mlx") + assert result == "qwen3.5:8b" + + def test_apple_silicon_16gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=16.0, + gpu=GpuInfo(vendor="apple", name="Apple M2", vram_gb=16.0, count=1), + ) + result = recommend_model(hw, "mlx") + assert result == "qwen3.5:14b" + + def test_apple_silicon_32gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=32.0, + gpu=GpuInfo(vendor="apple", name="Apple M2 Pro", vram_gb=32.0, count=1), + ) + result = recommend_model(hw, "mlx") + assert result == "qwen3.5:14b" + + def test_apple_silicon_64gb_mlx(self) -> None: + hw = HardwareInfo( + platform="darwin", + ram_gb=64.0, + gpu=GpuInfo(vendor="apple", name="Apple M2 Max", vram_gb=64.0, count=1), + ) + result = recommend_model(hw, "mlx") + assert result == "qwen3.5:14b" From ced38bcba33514173fbb51a64f1329f4faccac8d Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:07:54 -0700 Subject: [PATCH 04/12] feat: add privacy environment scanner with platform-specific checks New PrivacyScanner class with checks for disk encryption (FileVault/LUKS), MDM profiles, cloud sync agents, network exposure, and screen recording. Supports macOS and Linux with graceful skip on missing tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/scan_cmd.py | 386 +++++++++++++++++++++++++++++++++ tests/cli/test_scan.py | 256 ++++++++++++++++++++++ 2 files changed, 642 insertions(+) create mode 100644 src/openjarvis/cli/scan_cmd.py create mode 100644 tests/cli/test_scan.py diff --git a/src/openjarvis/cli/scan_cmd.py b/src/openjarvis/cli/scan_cmd.py new file mode 100644 index 00000000..e8d83e08 --- /dev/null +++ b/src/openjarvis/cli/scan_cmd.py @@ -0,0 +1,386 @@ +"""``jarvis scan`` — audit your environment for privacy and security risks.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List + +import click + +# Engine ports that should only be listening on localhost. +_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181} + +# Processes associated with cloud-sync agents. +_CLOUD_SYNC_PROCS = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"] + +# Screen-recording / remote-access processes (macOS). +_SCREEN_RECORDING_PROCS = [ + "TeamViewer", "AnyDesk", "ScreenConnect", "vncviewer", "Vine" +] + +# Remote-access processes (Linux). +_REMOTE_ACCESS_PROCS = ["xrdp", "x11vnc", "vncserver", "AnyDesk"] + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass +class ScanResult: + """Result of a single privacy/security check.""" + + name: str + status: str # "ok" | "warn" | "fail" | "skip" + message: str + platform: str # "darwin" | "linux" | "all" + + +# --------------------------------------------------------------------------- +# Scanner +# --------------------------------------------------------------------------- + + +class PrivacyScanner: + """Collection of environment privacy checks.""" + + # -- Subprocess helper --------------------------------------------------- + + def _run(self, cmd: list[str]) -> subprocess.CompletedProcess: # type: ignore[type-arg] + return subprocess.run(cmd, capture_output=True, text=True, timeout=10) + + # -- Individual checks --------------------------------------------------- + + def check_filevault(self) -> ScanResult: + """Check whether FileVault disk encryption is enabled (macOS).""" + try: + proc = self._run(["fdesetup", "status"]) + if "On" in proc.stdout: + return ScanResult( + name="FileVault", + status="ok", + message="FileVault is enabled.", + platform="darwin", + ) + return ScanResult( + name="FileVault", + status="fail", + message="FileVault is NOT enabled. Enable full-disk encryption.", + platform="darwin", + ) + except Exception: + return ScanResult( + name="FileVault", + status="skip", + message="fdesetup not available.", + platform="darwin", + ) + + def check_mdm(self) -> ScanResult: + """Check whether the device is enrolled in an MDM profile (macOS).""" + try: + proc = self._run(["profiles", "status", "-type", "enrollment"]) + output = proc.stdout + proc.stderr + lower = output.lower() + # "not enrolled" is a strong negative signal — check it first. + not_enrolled = "not enrolled" in lower or "no" in lower + # Positive signals: explicit Yes/enrolled without a negation. + enrolled_yes = ( + "mdm enrollment: yes" in lower + or "enrolled via dep: yes" in lower + or ("enrolled" in lower and not not_enrolled) + ) + if enrolled_yes: + return ScanResult( + name="MDM Enrollment", + status="warn", + message="Device appears to be enrolled in an MDM profile.", + platform="darwin", + ) + return ScanResult( + name="MDM Enrollment", + status="ok", + message="Device is not enrolled in an MDM profile.", + platform="darwin", + ) + except Exception: + return ScanResult( + name="MDM Enrollment", + status="skip", + message="profiles command not available.", + platform="darwin", + ) + + def check_icloud_sync(self) -> ScanResult: + """Check whether ~/.openjarvis is inside iCloud Drive sync scope.""" + try: + config_path = Path("~/.openjarvis").expanduser().resolve() + icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve() + if str(config_path).startswith(str(icloud_path)): + return ScanResult( + name="iCloud Sync", + status="warn", + message="~/.openjarvis may be synced to iCloud.", + platform="darwin", + ) + # Also probe defaults for com.apple.bird (iCloud daemon) + try: + proc = self._run( + ["defaults", "read", "com.apple.bird", "optout_preference"] + ) + val = proc.stdout.strip() + if val == "0": + return ScanResult( + name="iCloud Sync", + status="warn", + message="iCloud Desktop/Documents sync may be active.", + platform="darwin", + ) + except Exception: + pass + return ScanResult( + name="iCloud Sync", + status="ok", + message="~/.openjarvis is not inside iCloud Drive.", + platform="darwin", + ) + except Exception: + return ScanResult( + name="iCloud Sync", + status="skip", + message="Could not determine iCloud sync status.", + platform="darwin", + ) + + def check_luks(self) -> ScanResult: + """Check whether any block device uses LUKS encryption (Linux).""" + try: + proc = self._run(["lsblk", "-o", "NAME,TYPE,FSTYPE", "-J"]) + data = json.loads(proc.stdout) + except Exception: + return ScanResult( + name="LUKS Encryption", + status="skip", + message="lsblk not available or returned unexpected output.", + platform="linux", + ) + + def _has_luks(devices: list) -> bool: # type: ignore[type-arg] + for dev in devices: + if dev.get("fstype") == "crypto_LUKS": + return True + children = dev.get("children") or [] + if _has_luks(children): + return True + return False + + try: + devices = data.get("blockdevices", []) + if _has_luks(devices): + return ScanResult( + name="LUKS Encryption", + status="ok", + message="At least one LUKS-encrypted device found.", + platform="linux", + ) + return ScanResult( + name="LUKS Encryption", + status="fail", + message="No LUKS-encrypted block devices found.", + platform="linux", + ) + except Exception: + return ScanResult( + name="LUKS Encryption", + status="skip", + message="Could not parse lsblk output.", + platform="linux", + ) + + def _check_processes( + self, + names: list[str], + check_name: str, + warn_msg: str, + platform: str, + ) -> ScanResult: + """Shared helper: pgrep for any of the given process names.""" + try: + for name in names: + try: + proc = self._run(["pgrep", "-x", name]) + if proc.returncode == 0: + return ScanResult( + name=check_name, + status="warn", + message=warn_msg.format(name=name), + platform=platform, + ) + except Exception: + continue + return ScanResult( + name=check_name, + status="ok", + message=f"No {check_name.lower()} processes detected.", + platform=platform, + ) + except Exception: + return ScanResult( + name=check_name, + status="skip", + message="pgrep not available.", + platform=platform, + ) + + def check_cloud_sync_agents(self) -> ScanResult: + """Check for running cloud-sync agent processes.""" + return self._check_processes( + names=_CLOUD_SYNC_PROCS, + check_name="Cloud Sync Agents", + warn_msg="{name} sync agent is running — weights may be uploaded to cloud.", + platform="all", + ) + + def check_network_exposure(self) -> ScanResult: + """Check if engine ports are exposed on 0.0.0.0 rather than localhost.""" + try: + if sys.platform == "darwin": + proc = self._run(["lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"]) + else: + proc = self._run(["ss", "-tlnp"]) + output = proc.stdout + + exposed: list[int] = [] + for port in _ENGINE_PORTS: + # Look for patterns like *:PORT or 0.0.0.0:PORT + for token in (f"*:{port}", f"0.0.0.0:{port}", f":::{ port}"): + if token.replace(" ", "") in output.replace(" ", ""): + exposed.append(port) + break + + if exposed: + ports_str = ", ".join(str(p) for p in sorted(exposed)) + return ScanResult( + name="Network Exposure", + status="warn", + message=f"Engine port(s) {ports_str} exposed on all interfaces.", + platform="all", + ) + return ScanResult( + name="Network Exposure", + status="ok", + message="All engine ports appear to be bound to localhost only.", + platform="all", + ) + except Exception: + return ScanResult( + name="Network Exposure", + status="skip", + message="Could not determine network exposure (lsof/ss unavailable).", + platform="all", + ) + + def check_screen_recording(self) -> ScanResult: + """Check for running screen-recording / remote-desktop processes (macOS).""" + return self._check_processes( + names=_SCREEN_RECORDING_PROCS, + check_name="Screen Recording", + warn_msg="{name} is running — screen may be accessible remotely.", + platform="darwin", + ) + + def check_remote_access(self) -> ScanResult: + """Check for running remote-access processes (Linux).""" + return self._check_processes( + names=_REMOTE_ACCESS_PROCS, + check_name="Remote Access", + warn_msg="{name} is running — system may be accessible remotely.", + platform="linux", + ) + + # -- Orchestration ------------------------------------------------------- + + def _get_all_checks(self) -> list[Callable[[], ScanResult]]: + return [ + self.check_filevault, + self.check_mdm, + self.check_icloud_sync, + self.check_cloud_sync_agents, + self.check_network_exposure, + self.check_luks, + self.check_screen_recording, + self.check_remote_access, + ] + + def run_all(self) -> list[ScanResult]: + """Run all checks, filter to the current platform, hide 'skip' results.""" + current_plat = "darwin" if sys.platform == "darwin" else "linux" + results: list[ScanResult] = [] + for check_fn in self._get_all_checks(): + result = check_fn() + if result.platform not in (current_plat, "all"): + continue + if result.status == "skip": + continue + results.append(result) + return results + + def run_quick(self) -> list[ScanResult]: + """Run only critical checks: disk encryption + cloud sync agents.""" + current_plat = "darwin" if sys.platform == "darwin" else "linux" + quick_checks: list[Callable[[], ScanResult]] + if current_plat == "darwin": + quick_checks = [self.check_filevault, self.check_cloud_sync_agents] + else: + quick_checks = [self.check_luks, self.check_cloud_sync_agents] + results = [] + for check_fn in quick_checks: + result = check_fn() + if result.status != "skip": + results.append(result) + return results + + +# --------------------------------------------------------------------------- +# CLI command +# --------------------------------------------------------------------------- + +_STATUS_ICONS = {"ok": "✓", "warn": "!", "fail": "✗", "skip": "-"} + + +@click.command() +@click.option("--quick", is_flag=True, default=False, help="Run only critical checks.") +def scan(quick: bool) -> None: + """Audit your environment for privacy and security risks.""" + scanner = PrivacyScanner() + results: List[ScanResult] = scanner.run_quick() if quick else scanner.run_all() + + if not results: + click.echo("No applicable checks for this platform.") + return + + warnings = 0 + failures = 0 + for r in results: + icon = _STATUS_ICONS.get(r.status, "?") + click.echo(f" [{icon}] {r.name}: {r.message}") + if r.status == "warn": + warnings += 1 + elif r.status == "fail": + failures += 1 + + click.echo("") + parts = [] + if warnings: + parts.append(f"{warnings} warning(s)") + if failures: + parts.append(f"{failures} issue(s)") + if parts: + click.echo("Summary: " + ", ".join(parts) + ".") + else: + click.echo("Summary: all checks passed.") diff --git a/tests/cli/test_scan.py b/tests/cli/test_scan.py new file mode 100644 index 00000000..9b8eea7e --- /dev/null +++ b/tests/cli/test_scan.py @@ -0,0 +1,256 @@ +"""Tests for ``jarvis scan`` privacy scanner CLI command.""" + +from __future__ import annotations + +import json +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +import pytest + +from openjarvis.cli.scan_cmd import ScanResult, PrivacyScanner + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_proc(stdout: str = "", stderr: str = "", returncode: int = 0) -> CompletedProcess: + return CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr) + + +# --------------------------------------------------------------------------- +# TestScanResultDataclass +# --------------------------------------------------------------------------- + + +class TestScanResultDataclass: + def test_fields_exist(self) -> None: + r = ScanResult(name="test", status="ok", message="all good", platform="all") + assert r.name == "test" + assert r.status == "ok" + assert r.message == "all good" + assert r.platform == "all" + + def test_status_values(self) -> None: + for status in ("ok", "warn", "fail", "skip"): + r = ScanResult(name="x", status=status, message="", platform="all") + assert r.status == status + + def test_platform_values(self) -> None: + for plat in ("darwin", "linux", "all"): + r = ScanResult(name="x", status="ok", message="", platform=plat) + assert r.platform == plat + + +# --------------------------------------------------------------------------- +# TestFileVault +# --------------------------------------------------------------------------- + + +class TestFileVault: + def test_filevault_enabled(self) -> None: + scanner = PrivacyScanner() + with patch("subprocess.run", return_value=_make_proc(stdout="FileVault is On.")): + result = scanner.check_filevault() + assert result.status == "ok" + + def test_filevault_disabled(self) -> None: + scanner = PrivacyScanner() + with patch("subprocess.run", return_value=_make_proc(stdout="FileVault is Off.")): + result = scanner.check_filevault() + assert result.status == "fail" + + def test_command_not_found(self) -> None: + scanner = PrivacyScanner() + with patch("subprocess.run", side_effect=FileNotFoundError): + result = scanner.check_filevault() + assert result.status == "skip" + + +# --------------------------------------------------------------------------- +# TestMDM +# --------------------------------------------------------------------------- + + +class TestMDM: + def test_not_enrolled(self) -> None: + scanner = PrivacyScanner() + with patch( + "subprocess.run", + return_value=_make_proc(stdout="Enrolled via DEP: No\nMDM enrollment: not enrolled"), + ): + result = scanner.check_mdm() + assert result.status == "ok" + + def test_enrolled(self) -> None: + scanner = PrivacyScanner() + with patch( + "subprocess.run", + return_value=_make_proc(stdout="MDM enrollment: Yes\nEnrolled via DEP: Yes"), + ): + result = scanner.check_mdm() + assert result.status == "warn" + + +# --------------------------------------------------------------------------- +# TestCloudSync +# --------------------------------------------------------------------------- + + +class TestCloudSync: + def test_no_agents(self) -> None: + """pgrep returns non-zero (process not found) → no sync agents running.""" + scanner = PrivacyScanner() + with patch("subprocess.run", return_value=_make_proc(returncode=1, stdout="")): + result = scanner.check_cloud_sync_agents() + assert result.status == "ok" + + def test_dropbox_running(self) -> None: + """pgrep returns 0 when Dropbox is in the command.""" + scanner = PrivacyScanner() + + def _pgrep_side_effect(cmd, **kwargs): + if "Dropbox" in cmd: + return _make_proc(returncode=0, stdout="1234") + return _make_proc(returncode=1, stdout="") + + with patch("subprocess.run", side_effect=_pgrep_side_effect): + result = scanner.check_cloud_sync_agents() + assert result.status == "warn" + + +# --------------------------------------------------------------------------- +# TestNetworkExposure +# --------------------------------------------------------------------------- + + +class TestNetworkExposure: + def test_no_exposed_ports(self) -> None: + """Only localhost bindings → ok.""" + scanner = PrivacyScanner() + # lsof / ss output showing only 127.0.0.1 + lsof_output = "ollama 1234 user IPv4 TCP 127.0.0.1:11434 (LISTEN)\n" + with patch("subprocess.run", return_value=_make_proc(stdout=lsof_output)): + result = scanner.check_network_exposure() + assert result.status == "ok" + + def test_exposed_port(self) -> None: + """A port bound to 0.0.0.0 or * → warn with port in message.""" + scanner = PrivacyScanner() + lsof_output = "ollama 1234 user IPv4 TCP *:11434 (LISTEN)\n" + with patch("subprocess.run", return_value=_make_proc(stdout=lsof_output)): + result = scanner.check_network_exposure() + assert result.status == "warn" + assert "11434" in result.message + + +# --------------------------------------------------------------------------- +# TestLUKS +# --------------------------------------------------------------------------- + + +class TestLUKS: + def test_encrypted(self) -> None: + """lsblk JSON contains crypto_LUKS → ok.""" + scanner = PrivacyScanner() + lsblk_data = { + "blockdevices": [ + { + "name": "sda", + "type": "disk", + "fstype": None, + "children": [ + {"name": "sda1", "type": "part", "fstype": "crypto_LUKS"} + ], + } + ] + } + with patch( + "subprocess.run", + return_value=_make_proc(stdout=json.dumps(lsblk_data)), + ): + result = scanner.check_luks() + assert result.status == "ok" + + def test_not_encrypted(self) -> None: + """lsblk JSON has only ext4 → fail.""" + scanner = PrivacyScanner() + lsblk_data = { + "blockdevices": [ + {"name": "sda", "type": "disk", "fstype": None}, + {"name": "sda1", "type": "part", "fstype": "ext4"}, + ] + } + with patch( + "subprocess.run", + return_value=_make_proc(stdout=json.dumps(lsblk_data)), + ): + result = scanner.check_luks() + assert result.status == "fail" + + +# --------------------------------------------------------------------------- +# TestScreenRecording +# --------------------------------------------------------------------------- + + +class TestScreenRecording: + def test_none_running(self) -> None: + """No screen-recording processes → ok.""" + scanner = PrivacyScanner() + with patch("subprocess.run", return_value=_make_proc(returncode=1, stdout="")): + result = scanner.check_screen_recording() + assert result.status == "ok" + + def test_teamviewer_running(self) -> None: + """TeamViewer found → warn.""" + scanner = PrivacyScanner() + + def _pgrep_side_effect(cmd, **kwargs): + if "TeamViewer" in cmd: + return _make_proc(returncode=0, stdout="5678") + return _make_proc(returncode=1, stdout="") + + with patch("subprocess.run", side_effect=_pgrep_side_effect): + result = scanner.check_screen_recording() + assert result.status == "warn" + + +# --------------------------------------------------------------------------- +# TestPlatformFiltering +# --------------------------------------------------------------------------- + + +class TestPlatformFiltering: + def test_run_all_filters_to_current_platform(self) -> None: + """run_all() should only call checks tagged 'all' or current platform.""" + scanner = PrivacyScanner() + called_platforms: list[str] = [] + + # Patch every check method to record its platform tag instead of running + checks = scanner._get_all_checks() + for check_fn in checks: + # Run the real method but capture which ones get included + pass + + import sys + + current_plat = "darwin" if sys.platform == "darwin" else "linux" + other_plat = "linux" if current_plat == "darwin" else "darwin" + + # Make every check return immediately with a known platform + with patch.object(scanner, "_get_all_checks") as mock_get: + darwin_check = MagicMock(return_value=ScanResult("d", "ok", "msg", "darwin")) + linux_check = MagicMock(return_value=ScanResult("l", "ok", "msg", "linux")) + all_check = MagicMock(return_value=ScanResult("a", "ok", "msg", "all")) + + mock_get.return_value = [darwin_check, linux_check, all_check] + results = scanner.run_all() + + # The check for the other platform should not appear in results + other_result_platform = "linux" if current_plat == "darwin" else "darwin" + result_platforms = {r.platform for r in results} + assert other_result_platform not in result_platforms + assert "all" in result_platforms or current_plat in result_platforms From a05c2b7df641f77b03d299ad8bd8b64cb24b1f95 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:12:55 -0700 Subject: [PATCH 05/12] feat: extract ollama_pull helper and add multi-engine model pull Refactors model pull into reusable ollama_pull() function. Adds --engine flag to support llamacpp (GGUF) and mlx (HuggingFace) downloads via huggingface-cli, with FileNotFoundError handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/model.py | 102 ++++++++++++++++++++++++++++----- tests/cli/test_model_pull.py | 108 +++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 tests/cli/test_model_pull.py diff --git a/src/openjarvis/cli/model.py b/src/openjarvis/cli/model.py index 4a6fc9d1..d3243311 100644 --- a/src/openjarvis/cli/model.py +++ b/src/openjarvis/cli/model.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import subprocess import sys import click @@ -15,6 +16,7 @@ from openjarvis.core.config import load_config from openjarvis.core.registry import ModelRegistry from openjarvis.engine import discover_engines, discover_models from openjarvis.intelligence import merge_discovered_models, register_builtin_models +from openjarvis.intelligence.model_catalog import BUILTIN_MODELS @click.group() @@ -149,18 +151,8 @@ def info(model_name: str) -> None: console.print(Panel("\n".join(lines), title=spec.name, border_style="blue")) -@model.command() -@click.argument("model_name") -def pull(model_name: str) -> None: - """Download a model (Ollama only).""" - console = Console() - config = load_config() - host = ( - config.engine.ollama_host - or os.environ.get("OLLAMA_HOST") - or "http://localhost:11434" - ).rstrip("/") - +def ollama_pull(host: str, model_name: str, console: Console) -> bool: + """Pull a model via Ollama API. Returns True on success.""" console.print(f"Pulling [cyan]{model_name}[/cyan] via Ollama...") try: with httpx.stream( @@ -171,7 +163,6 @@ def pull(model_name: str) -> None: ) as resp: resp.raise_for_status() import json - for line in resp.iter_lines(): if not line.strip(): continue @@ -188,9 +179,90 @@ def pull(model_name: str) -> None: elif status: console.print(f" {status}") console.print(f"\n[green]Successfully pulled {model_name}[/green]") + return True except httpx.ConnectError: console.print("[red]Cannot connect to Ollama.[/red] Is it running?") - sys.exit(1) + return False except httpx.HTTPStatusError as exc: console.print(f"[red]Ollama error:[/red] {exc.response.status_code}") - sys.exit(1) + return False + + +def find_model_spec(model_name: str): + """Look up a model in the builtin catalog. Returns None if not found.""" + for spec in BUILTIN_MODELS: + if spec.model_id == model_name: + return spec + return None + + +def hf_download(repo: str, filename: str | None, console: Console) -> bool: + """Download from HuggingFace via huggingface-cli. Returns True on success.""" + cmd = ["huggingface-cli", "download", repo] + if filename: + cmd.append(filename) + try: + subprocess.run(cmd, check=True) + console.print("[green]Download complete.[/green]") + return True + except FileNotFoundError: + console.print( + "[red]huggingface-cli not found.[/red]\n" + "Install it: [cyan]pip install huggingface_hub[/cyan]\n" + f"Or download manually: https://huggingface.co/{repo}" + ) + return False + except subprocess.CalledProcessError: + console.print("[red]Download failed.[/red]") + return False + + +@model.command() +@click.argument("model_name") +@click.option("--engine", default=None, help="Engine to download for (ollama, llamacpp, mlx).") +def pull(model_name: str, engine: str | None) -> None: + """Download a model.""" + console = Console() + config = load_config() + engine = engine or config.engine.default or "ollama" + + if engine == "ollama": + host = ( + config.engine.ollama_host + or os.environ.get("OLLAMA_HOST") + or "http://localhost:11434" + ).rstrip("/") + if not ollama_pull(host, model_name, console): + sys.exit(1) + elif engine in ("llamacpp", "mlx"): + spec = find_model_spec(model_name) + if not spec: + console.print(f"[red]Model not in catalog:[/red] {model_name}") + sys.exit(1) + if engine == "llamacpp": + repo = spec.metadata.get("hf_repo", "") + gguf = spec.metadata.get("gguf_file", "") + if not repo or not gguf: + console.print(f"[red]No GGUF download info for {model_name}[/red]") + sys.exit(1) + console.print(f"Downloading [cyan]{gguf}[/cyan] from {repo}...") + if not hf_download(repo, gguf, console): + sys.exit(1) + else: # mlx + mlx_repo = spec.metadata.get("mlx_repo", "") + if not mlx_repo: + console.print(f"[red]No MLX repo info for {model_name}[/red]") + sys.exit(1) + console.print(f"Downloading [cyan]{mlx_repo}[/cyan]...") + if not hf_download(mlx_repo, None, console): + sys.exit(1) + elif engine in ("vllm", "sglang"): + console.print( + f"[cyan]{model_name}[/cyan] will download automatically when " + f"{engine} starts serving it." + ) + else: + console.print( + f"Manual download required for engine [cyan]{engine}[/cyan].\n" + f"Check the engine documentation for instructions." + ) diff --git a/tests/cli/test_model_pull.py b/tests/cli/test_model_pull.py new file mode 100644 index 00000000..a87a55ae --- /dev/null +++ b/tests/cli/test_model_pull.py @@ -0,0 +1,108 @@ +"""Tests for ``jarvis model pull`` multi-engine support.""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from click.testing import CliRunner +from rich.console import Console + +from openjarvis.cli.model import ollama_pull + + +class TestOllamaPull: + """Test the extracted ollama_pull helper.""" + + def test_ollama_pull_success(self) -> None: + import io + console = Console(file=io.StringIO()) + mock_lines = [ + '{"status": "pulling manifest"}', + '{"status": "downloading", "total": 100, "completed": 100}', + '{"status": "success"}', + ] + mock_resp = mock.MagicMock() + mock_resp.raise_for_status = mock.MagicMock() + mock_resp.iter_lines.return_value = iter(mock_lines) + mock_resp.__enter__ = mock.MagicMock(return_value=mock_resp) + mock_resp.__exit__ = mock.MagicMock(return_value=False) + + with mock.patch("httpx.stream", return_value=mock_resp): + result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) + assert result is True + + def test_ollama_pull_connect_error(self) -> None: + import io + import httpx + + console = Console(file=io.StringIO()) + with mock.patch("httpx.stream", side_effect=httpx.ConnectError("refused")): + result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) + assert result is False + + +class TestPullCliMultiEngine: + """Test the pull CLI command dispatches to correct engine.""" + + def test_pull_llamacpp_uses_huggingface_cli(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run") as mock_run, + ): + mock_cfg.return_value.engine.default = "llamacpp" + mock_cfg.return_value.engine.ollama_host = None + mock_run.return_value = mock.MagicMock(returncode=0) + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] + ) + + assert result.exit_code == 0 + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "huggingface-cli" in call_args + assert "qwen3.5-8b-q4_k_m.gguf" in call_args + + def test_pull_mlx_uses_huggingface_cli(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run") as mock_run, + ): + mock_cfg.return_value.engine.default = "mlx" + mock_cfg.return_value.engine.ollama_host = None + mock_run.return_value = mock.MagicMock(returncode=0) + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "mlx"] + ) + + assert result.exit_code == 0 + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "huggingface-cli" in call_args + assert "mlx-community/Qwen3.5-8B-4bit" in call_args + + def test_pull_llamacpp_huggingface_cli_not_found(self) -> None: + from openjarvis.cli import cli + + runner = CliRunner() + with ( + mock.patch("openjarvis.cli.model.load_config") as mock_cfg, + mock.patch("subprocess.run", side_effect=FileNotFoundError), + ): + mock_cfg.return_value.engine.default = "llamacpp" + mock_cfg.return_value.engine.ollama_host = None + + result = runner.invoke( + cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] + ) + + assert result.exit_code != 0 + assert "huggingface_hub" in result.output or "pip install" in result.output From e2376d29d12900678dfbe0fc14e5127fbcbf0020 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:16:59 -0700 Subject: [PATCH 06/12] feat: add interactive model download and empty-model fallback to init Prompts user to download recommended model during jarvis init. Adds --no-download flag for CI. Shows helpful message when no model fits available memory. Adds exo/nexa next-steps text. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/init_cmd.py | 74 +++++++++++++++++++++++++++- tests/cli/test_init_guidance.py | 85 +++++++++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index 41fb1864..95c946f3 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -10,10 +10,12 @@ from rich.console import Console from rich.markup import escape from rich.panel import Panel +from openjarvis.cli.model import find_model_spec, hf_download, ollama_pull from openjarvis.core.config import ( DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, detect_hardware, + estimated_download_gb, generate_default_toml, generate_minimal_toml, recommend_engine, @@ -132,10 +134,58 @@ def _next_steps_text(engine: str, model: str = "") -> str: "\n" " Run `jarvis doctor` to verify your setup." ), + "exo": ( + "Next steps:\n\n" + " 1. Install and start Exo:\n" + " pip install exo\n" + " exo\n\n" + " 2. Try it out:\n" + " jarvis ask \"Hello\"\n\n" + " Run `jarvis doctor` to verify your setup." + ), + "nexa": ( + "Next steps:\n\n" + " 1. Install and start Nexa:\n" + " pip install nexaai\n" + " nexa server\n\n" + " 2. Try it out:\n" + " jarvis ask \"Hello\"\n\n" + " Run `jarvis doctor` to verify your setup." + ), } return steps.get(engine, steps["ollama"]) +def _do_download(engine: str, model: str, spec, console: Console) -> None: + """Dispatch model download based on engine type.""" + import os + if engine == "ollama": + host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + ollama_pull(host, model, console) + elif engine == "llamacpp": + repo = spec.metadata.get("hf_repo", "") + gguf = spec.metadata.get("gguf_file", "") + if repo and gguf: + console.print(f" Downloading [cyan]{gguf}[/cyan] from {repo}...") + hf_download(repo, gguf, console) + else: + console.print(f" [yellow]No GGUF download info for {model}[/yellow]") + elif engine == "mlx": + mlx_repo = spec.metadata.get("mlx_repo", "") + if mlx_repo: + console.print(f" Downloading [cyan]{mlx_repo}[/cyan]...") + hf_download(mlx_repo, None, console) + else: + console.print(f" [yellow]No MLX repo info for {model}[/yellow]") + elif engine in ("vllm", "sglang"): + console.print( + f" [cyan]{model}[/cyan] will download automatically when " + f"{engine} starts serving it." + ) + else: + console.print(f" Download {model} through the {engine} interface.") + + @click.command() @click.option( "--force", is_flag=True, help="Overwrite existing config without prompting." @@ -157,11 +207,15 @@ def _next_steps_text(engine: str, model: str = "") -> str: default=None, help="Inference engine to use (skips interactive selection).", ) +@click.option( + "--no-download", is_flag=True, default=False, help="Skip the model download prompt." +) def init( force: bool, config: Optional[Path], full_config: bool = False, engine: Optional[str] = None, + no_download: bool = False, ) -> None: """Detect hardware and generate ~/.openjarvis/config.toml.""" console = Console() @@ -286,8 +340,24 @@ def init( selected_engine = engine or recommend_engine(hw) model = recommend_model(hw, selected_engine) - if model: - console.print(f"\n [bold]Recommended model:[/bold] {model}") + + if not model: + console.print( + "\n [yellow]! Not enough memory to run any local model.[/yellow]\n" + " Consider a cloud engine or a machine with more RAM." + ) + else: + spec = find_model_spec(model) + size_gb = estimated_download_gb(spec.parameter_count_b) if spec else 0 + console.print( + f"\n [bold]Recommended model:[/bold] {model} (~{size_gb:.1f} GB estimated)" + ) + + if not no_download and spec: + prompt = f" Download {model} (~{size_gb:.1f} GB estimated) now?" + if click.confirm(prompt, default=True): + _do_download(selected_engine, model, spec, console) + console.print() console.print( Panel( diff --git a/tests/cli/test_init_guidance.py b/tests/cli/test_init_guidance.py index 263d786d..6e2cd67f 100644 --- a/tests/cli/test_init_guidance.py +++ b/tests/cli/test_init_guidance.py @@ -20,7 +20,7 @@ class TestInitShowsNextSteps: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) assert result.exit_code == 0 assert "Getting Started" in result.output assert "jarvis ask" in result.output @@ -34,7 +34,7 @@ class TestInitShowsNextSteps: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) assert result.exit_code == 0 assert "[engine]" in result.output assert "[intelligence]" in result.output @@ -93,7 +93,7 @@ class TestMinimalConfig: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama"]) + result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) assert result.exit_code == 0 content = config_path.read_text() # Minimal config should be short @@ -110,10 +110,87 @@ class TestMinimalConfig: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), ): - result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama"]) + result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama", "--no-download"]) assert result.exit_code == 0 content = config_path.read_text() # Full config should have many sections assert "[engine.ollama]" in content assert "[server]" in content assert "[security]" in content + + +class TestInitDownloadPrompt: + def test_init_shows_download_prompt(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="n\n") + assert result.exit_code == 0 + assert "Download" in result.output and "now?" in result.output + + def test_init_no_download_flag_skips_prompt(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) + assert result.exit_code == 0 + assert "Download" not in result.output or "now?" not in result.output + + +class TestInitEmptyModelFallback: + def test_init_no_model_shows_warning(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.recommend_model", return_value=""), + ): + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) + assert result.exit_code == 0 + assert "Not enough memory" in result.output or "not enough memory" in result.output + + +class TestNextStepsExoNexa: + def test_next_steps_exo(self) -> None: + text = _next_steps_text("exo") + assert "exo" in text.lower() + assert "jarvis ask" in text + assert "ollama" not in text.lower() + + def test_next_steps_nexa(self) -> None: + text = _next_steps_text("nexa") + assert "nexa" in text.lower() + assert "jarvis ask" in text + assert "ollama" not in text.lower() + + +class TestInitDownloadDispatch: + def test_init_ollama_download_calls_ollama_pull(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.ollama_pull", return_value=True) as mock_pull, + ): + result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="y\n") + assert result.exit_code == 0 + mock_pull.assert_called_once() + + def test_init_vllm_shows_auto_download_message(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + ): + result = CliRunner().invoke(cli, ["init", "--engine", "vllm"], input="y\n") + assert result.exit_code == 0 + assert "automatically" in result.output From 02bc36c1f83bd85c2f1c0434b7c359e4db9160e9 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:22:13 -0700 Subject: [PATCH 07/12] feat: register jarvis scan command and add init privacy hook Registers the new scan command in the CLI. Adds a lightweight privacy check at the end of jarvis init that runs disk encryption and cloud sync checks, with pointer to jarvis scan for full audit. Co-Authored-By: Claude Sonnet 4.6 --- src/openjarvis/cli/__init__.py | 2 ++ src/openjarvis/cli/init_cmd.py | 19 +++++++++++++++++++ tests/cli/test_init_guidance.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 10a70a94..6ff4ab66 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -26,6 +26,7 @@ from openjarvis.cli.operators_cmd import operators from openjarvis.cli.optimize_cmd import optimize_group from openjarvis.cli.quickstart_cmd import quickstart from openjarvis.cli.registry_cmd import registry +from openjarvis.cli.scan_cmd import scan from openjarvis.cli.scheduler_cmd import scheduler from openjarvis.cli.serve import serve from openjarvis.cli.skill_cmd import skill @@ -87,6 +88,7 @@ cli.add_command(gateway, "gateway") cli.add_command(tool, "tool") cli.add_command(registry, "registry") cli.add_command(config, "config") +cli.add_command(scan, "scan") def main() -> None: diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index 95c946f3..8d729b7e 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -11,6 +11,7 @@ from rich.markup import escape from rich.panel import Panel from openjarvis.cli.model import find_model_spec, hf_download, ollama_pull +from openjarvis.cli.scan_cmd import PrivacyScanner from openjarvis.core.config import ( DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, @@ -156,6 +157,23 @@ def _next_steps_text(engine: str, model: str = "") -> str: return steps.get(engine, steps["ollama"]) +def _quick_privacy_check(console: Console) -> None: + """Run critical privacy checks and print compact summary.""" + scanner = PrivacyScanner() + results = scanner.run_quick() + if results: + console.print(" [bold]Privacy check:[/bold]") + for r in results: + if r.status == "ok": + console.print(f" [green]\u2713[/green] {r.message}") + elif r.status == "warn": + console.print(f" [yellow]![/yellow] {r.message}") + elif r.status == "fail": + console.print(f" [red]\u2717[/red] {r.message}") + console.print() + console.print(" Run [cyan]jarvis scan[/cyan] for a full environment audit.") + + def _do_download(engine: str, model: str, spec, console: Console) -> None: """Dispatch model download based on engine type.""" import os @@ -358,6 +376,7 @@ def init( if click.confirm(prompt, default=True): _do_download(selected_engine, model, spec, console) + _quick_privacy_check(console) console.print() console.print( Panel( diff --git a/tests/cli/test_init_guidance.py b/tests/cli/test_init_guidance.py index 6e2cd67f..74924c53 100644 --- a/tests/cli/test_init_guidance.py +++ b/tests/cli/test_init_guidance.py @@ -19,6 +19,7 @@ class TestInitShowsNextSteps: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) assert result.exit_code == 0 @@ -33,6 +34,7 @@ class TestInitShowsNextSteps: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) assert result.exit_code == 0 @@ -92,6 +94,7 @@ class TestMinimalConfig: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) assert result.exit_code == 0 @@ -109,6 +112,7 @@ class TestMinimalConfig: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama", "--no-download"]) assert result.exit_code == 0 @@ -126,6 +130,7 @@ class TestInitDownloadPrompt: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="n\n") assert result.exit_code == 0 @@ -137,6 +142,7 @@ class TestInitDownloadPrompt: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) assert result.exit_code == 0 @@ -151,6 +157,7 @@ class TestInitEmptyModelFallback: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.recommend_model", return_value=""), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) assert result.exit_code == 0 @@ -179,6 +186,7 @@ class TestInitDownloadDispatch: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.ollama_pull", return_value=True) as mock_pull, + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="y\n") assert result.exit_code == 0 @@ -190,7 +198,29 @@ class TestInitDownloadDispatch: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): result = CliRunner().invoke(cli, ["init", "--engine", "vllm"], input="y\n") assert result.exit_code == 0 assert "automatically" in result.output + + +class TestInitPrivacyHook: + def test_init_shows_privacy_summary(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner") as MockScanner, + ): + from openjarvis.cli.scan_cmd import ScanResult + instance = MockScanner.return_value + instance.run_quick.return_value = [ + ScanResult("FileVault", "ok", "FileVault enabled", "darwin"), + ] + result = CliRunner().invoke( + cli, ["init", "--engine", "llamacpp", "--no-download"] + ) + assert result.exit_code == 0 + assert "jarvis scan" in result.output From d9faa4621ebf4d27e170ff3e05b06bbd83048aa5 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:24:07 -0700 Subject: [PATCH 08/12] style: fix E501 line length in model pull --engine option Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/model.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openjarvis/cli/model.py b/src/openjarvis/cli/model.py index d3243311..453bcca2 100644 --- a/src/openjarvis/cli/model.py +++ b/src/openjarvis/cli/model.py @@ -219,7 +219,9 @@ def hf_download(repo: str, filename: str | None, console: Console) -> bool: @model.command() @click.argument("model_name") -@click.option("--engine", default=None, help="Engine to download for (ollama, llamacpp, mlx).") +@click.option( + "--engine", default=None, help="Engine to download for." +) def pull(model_name: str, engine: str | None) -> None: """Download a model.""" console = Console() From a934977ba8202440a64dca20b4de4f320410c8b4 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:26:05 -0700 Subject: [PATCH 09/12] fix: address spec review findings for privacy scanner - Add slots=True to ScanResult dataclass - Fix extra space in IPv6 port f-string - Add missing tests: check_remote_access, check_icloud_sync, run_quick Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/scan_cmd.py | 4 +- tests/cli/test_scan.py | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/openjarvis/cli/scan_cmd.py b/src/openjarvis/cli/scan_cmd.py index e8d83e08..6ff2e210 100644 --- a/src/openjarvis/cli/scan_cmd.py +++ b/src/openjarvis/cli/scan_cmd.py @@ -31,7 +31,7 @@ _REMOTE_ACCESS_PROCS = ["xrdp", "x11vnc", "vncserver", "AnyDesk"] # --------------------------------------------------------------------------- -@dataclass +@dataclass(slots=True) class ScanResult: """Result of a single privacy/security check.""" @@ -258,7 +258,7 @@ class PrivacyScanner: exposed: list[int] = [] for port in _ENGINE_PORTS: # Look for patterns like *:PORT or 0.0.0.0:PORT - for token in (f"*:{port}", f"0.0.0.0:{port}", f":::{ port}"): + for token in (f"*:{port}", f"0.0.0.0:{port}", f":::{port}"): if token.replace(" ", "") in output.replace(" ", ""): exposed.append(port) break diff --git a/tests/cli/test_scan.py b/tests/cli/test_scan.py index 9b8eea7e..c89760f3 100644 --- a/tests/cli/test_scan.py +++ b/tests/cli/test_scan.py @@ -254,3 +254,70 @@ class TestPlatformFiltering: result_platforms = {r.platform for r in results} assert other_result_platform not in result_platforms assert "all" in result_platforms or current_plat in result_platforms + + +class TestRemoteAccess: + """Tests for check_remote_access (Linux).""" + + def test_no_remote_access(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "_run") as mock_run: + mock_run.return_value = CompletedProcess([], 1, stdout="", stderr="") + result = scanner.check_remote_access() + assert result.status == "ok" + + def test_xrdp_running(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "_run") as mock_run: + def side_effect(cmd, **kw): + if any("xrdp" in str(c) for c in cmd): + return CompletedProcess(cmd, 0, stdout="12345", stderr="") + return CompletedProcess(cmd, 1, stdout="", stderr="") + mock_run.side_effect = side_effect + result = scanner.check_remote_access() + assert result.status == "warn" + + +class TestICloudSync: + """Tests for check_icloud_sync (macOS).""" + + def test_no_icloud_sync(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "_run") as mock_run: + mock_run.return_value = CompletedProcess( + [], 0, stdout="no relevant output", stderr="" + ) + result = scanner.check_icloud_sync() + assert result.status in ("ok", "skip") + + def test_icloud_defaults_error_falls_through_to_ok(self) -> None: + scanner = PrivacyScanner() + # When _run raises, the nested try/except catches it and falls to ok + with patch.object(scanner, "_run", side_effect=FileNotFoundError): + result = scanner.check_icloud_sync() + assert result.status == "ok" + + +class TestRunQuick: + """Tests for run_quick subset.""" + + def test_run_quick_returns_subset(self) -> None: + scanner = PrivacyScanner() + with patch.object(scanner, "check_filevault") as fv, \ + patch.object(scanner, "check_luks") as luks, \ + patch.object(scanner, "check_icloud_sync") as ic, \ + patch.object(scanner, "check_cloud_sync_agents") as cs: + import sys + plat = sys.platform + fv.return_value = ScanResult("FV", "ok", "ok", "darwin") + luks.return_value = ScanResult("LUKS", "ok", "ok", "linux") + ic.return_value = ScanResult("iCloud", "ok", "ok", "darwin") + cs.return_value = ScanResult("Cloud", "ok", "ok", plat) + results = scanner.run_quick() + # Should only contain current platform results + for r in results: + assert r.platform in (plat, "all") + # Should not contain network or screen recording + names = {r.name for r in results} + assert "Network Exposure" not in names + assert "Screen Recording" not in names From bc2a7e55958677b8625a709221ee02186c8949dc Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:31:09 -0700 Subject: [PATCH 10/12] chore: remove spec/plan files from tracked tree These are Claude-generated working documents that should not be checked into the repository. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...t-onboarding-and-privacy-scanner-design.md | 249 --- ...nit-onboarding-and-privacy-scanner-plan.md | 1413 ----------------- 2 files changed, 1662 deletions(-) delete mode 100644 docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md delete mode 100644 docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md diff --git a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md deleted file mode 100644 index 3f93ed5f..00000000 --- a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md +++ /dev/null @@ -1,249 +0,0 @@ -# Design: Init Model Onboarding & Privacy Environment Scanner - -**Date:** 2026-03-24 -**Status:** Draft -**Author:** Jon Saad-Falcon + Claude - -## Problem Statement - -Two issues reported by a user (Ali Shahkar) after setting up OpenJarvis on macOS with llama.cpp: - -1. **`jarvis init` sets a default model with no way to download it.** The config is written with a recommended model, but there's no download prompt, no validation that it's available, and `jarvis doctor` immediately warns it can't be found. On Apple Silicon with MLX, the bug is worse: `recommend_model()` returns an empty string because no Qwen3.5 model lists MLX as a supported engine. - -2. **No environment privacy auditing.** OpenJarvis positions itself as privacy-first local AI, but `jarvis init` doesn't verify the local environment is actually private. Cloud sync agents, MDM profiles, disk encryption status, and network exposure can all undermine the privacy guarantee without the user knowing. - -## Solution Overview - -### Issue 1: Interactive Model Download in `jarvis init` - -**Bug fix:** Add `"mlx"` to `supported_engines` for Qwen3.5 models (3b, 4b, 8b, 14b) in the model catalog. - -**Feature:** After writing the config and displaying the recommended model, `jarvis init` prompts: - -``` - Recommended model: qwen3.5:8b (~4.4 GB) - Download now? [y/n]: -``` - -Download logic is engine-specific: -- **Ollama:** Reuse existing `model pull` code (HTTP stream to `POST /api/pull`). -- **llama.cpp:** Shell out to `huggingface-cli download` with GGUF filename from catalog metadata. -- **MLX:** Shell out to `huggingface-cli download` for pre-quantized MLX repo from catalog metadata. -- **vLLM/SGLang:** Inform the user the model downloads automatically on first serve. -- **LM Studio/Exo/Nexa:** Show manual download instructions (these have their own UIs). - -**Empty model fallback:** If `recommend_model()` returns `""`, display: - -``` - ! Not enough memory to run any local model. - Consider a cloud engine or a machine with more RAM. -``` - -### Issue 2: Privacy Environment Scanner - -**New CLI command:** `jarvis scan` performs a full environment privacy audit. - -**Init hook:** At the end of `jarvis init`, run a lightweight subset (disk encryption + cloud sync overlap with `~/.openjarvis/`) and show a compact summary pointing to `jarvis scan` for the full audit. - -## Detailed Design - -### Model Catalog Changes - -Add `"mlx"` to `supported_engines` and download metadata for Qwen3.5 models in `src/openjarvis/intelligence/model_catalog.py`. - -**Important:** The catalog has TWO Qwen3.5 sections. The first (lines ~42-121) contains the original entries (`qwen3.5:3b`, `qwen3.5:8b`, `qwen3.5:14b`, `qwen3.5:35b`, etc. with `context_length=131072`). The second (lines ~219-274) contains newer entries (`qwen3.5:4b`, `qwen3.5:35b-a3b`, etc. with `context_length=262144`). Both sections' models are candidates for `recommend_model()`. MLX must be added to entries in BOTH sections. - -Models getting MLX support added to `supported_engines`: - -| model_id | Section | Current context_length | Add MLX | -|----------|---------|----------------------|---------| -| `qwen3.5:3b` | First | 131072 | Yes | -| `qwen3.5:4b` | Second | 262144 | Yes | -| `qwen3.5:8b` | First | 131072 | Yes | -| `qwen3.5:14b` | First | 131072 | Yes | - -Larger models (35b+) are excluded because MLX quantized weights aren't widely available and would exceed typical Apple Silicon memory. - -Download metadata to add to each model's `metadata` dict: - -| model_id | `gguf_file` | `mlx_repo` | -|----------|-------------|------------| -| `qwen3.5:3b` | `qwen3.5-3b-q4_k_m.gguf` | `mlx-community/Qwen3.5-3B-4bit` | -| `qwen3.5:4b` | `qwen3.5-4b-q4_k_m.gguf` | `mlx-community/Qwen3.5-4B-4bit` | -| `qwen3.5:8b` | `qwen3.5-8b-q4_k_m.gguf` | `mlx-community/Qwen3.5-8B-4bit` | -| `qwen3.5:14b` | `qwen3.5-14b-q4_k_m.gguf` | `mlx-community/Qwen3.5-14B-4bit` | - -Example updated entry: - -```python -ModelSpec( - model_id="qwen3.5:8b", - name="Qwen3.5 8B", - parameter_count_b=8.0, - active_parameter_count_b=1.0, - context_length=131072, - supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"), # added mlx - provider="alibaba", - metadata={ - "architecture": "moe", - "hf_repo": "Qwen/Qwen3.5-8B", - "gguf_file": "qwen3.5-8b-q4_k_m.gguf", - "mlx_repo": "mlx-community/Qwen3.5-8B-4bit", - }, -) -``` - -### Init Command Changes (`src/openjarvis/cli/init_cmd.py`) - -After the config is written and the "Getting Started" panel is shown, add: - -1. **Size estimate display:** Compute `parameter_count_b * 0.5 * 1.1` and display alongside the model name. Note: this is an estimate based on Q4_K_M quantization. Actual download size may differ slightly by engine/format. The prompt labels it as "~X GB estimated". - -2. **Download prompt:** `click.confirm(f"Download {model} (~{size:.1f} GB estimated) now?", default=True)`. A new `--no-download` flag on the `init` command skips this prompt (for CI/automated environments). - -3. **Engine-specific download functions:** - - `_download_ollama(model, host, console)` — calls a new shared helper `ollama_pull(host, model_name, console) -> bool` extracted from `cli/model.py`. Both the `model pull` CLI command and `init_cmd.py` import this helper, avoiding duplication. - - `_download_llamacpp(model, spec, console)` — `subprocess.run(["huggingface-cli", "download", repo, gguf_file, "--local-dir", cache_dir])`. If `huggingface-cli` is not found (`FileNotFoundError`), prints: `"Install huggingface-cli: pip install huggingface_hub"` and shows the manual download URL as fallback. - - `_download_mlx(model, spec, console)` — `subprocess.run(["huggingface-cli", "download", mlx_repo, "--local-dir", cache_dir])`. Same `FileNotFoundError` handling as llama.cpp. - - `_download_auto(model, engine, console)` — print info message that the model auto-downloads on first serve (vLLM, SGLang). - - `_download_manual(model, engine, console)` — print engine-specific manual instructions (LM Studio, Exo, Nexa). - -4. **Empty model fallback:** Check `if not model:` and print the "not enough memory" warning before the next-steps panel. - -5. **Privacy hook:** After download (or skip), call `_quick_privacy_check()` that runs disk encryption + cloud sync checks and prints a compact summary. - -6. **Next-steps coverage:** Add entries to `_next_steps_text()` for `exo` and `nexa` engines so the Ollama fallback is eliminated. - -### `jarvis model pull` Enhancement (`src/openjarvis/cli/model.py`) - -**Refactor:** Extract the Ollama streaming pull logic (currently lines 165-196) into a reusable function: - -```python -def ollama_pull(host: str, model_name: str, console: Console) -> bool: - """Pull a model via Ollama API. Returns True on success.""" -``` - -The existing `pull` Click command becomes a thin wrapper around this function. - -**Extend** the `pull` command to support engines beyond Ollama: -- Add an `--engine` flag (optional, defaults to configured engine). -- Look up the model in `BUILTIN_MODELS` or `ModelRegistry` to get metadata. -- Dispatch to engine-specific download based on the engine flag. -- Fallback to Ollama pull if no engine specified and model is in Ollama format (name:tag). -- llama.cpp and MLX paths use `huggingface-cli download` with metadata from the catalog, with `FileNotFoundError` handling. - -### Privacy Scanner (`src/openjarvis/cli/scan_cmd.py` — new file) - -#### Data model - -```python -@dataclass -class ScanResult: - name: str # e.g. "FileVault" - status: str # "ok" | "warn" | "fail" | "skip" - message: str # Human-readable explanation - platform: str # "darwin" | "linux" | "all" -``` - -#### `PrivacyScanner` class - -Contains a list of check methods. Each check: -- Is decorated or tagged with the platform it applies to. -- Calls a subprocess and parses the output. -- Returns a `ScanResult`. -- Never raises — catches all exceptions and returns `status="skip"` with the error. - -#### macOS checks - -| Check | Implementation | Status mapping | -|-------|---------------|----------------| -| `check_filevault()` | `subprocess.run(["fdesetup", "status"])`, parse "FileVault is On/Off" | On = ok, Off = fail | -| `check_mdm()` | `subprocess.run(["profiles", "status", "-type", "enrollment"])`, parse for "MDM enrollment" | Not enrolled = ok, enrolled = warn | -| `check_icloud_sync()` | Read `defaults read MobileMeAccounts` for Desktop/Documents sync; check if `~/.openjarvis/` resolves to a path under `~/Library/Mobile Documents/`. Note: `defaults read` output format varies across macOS versions — parsing should be lenient and return `skip` on unexpected output rather than raising. | Not syncing = ok, syncing = warn | -| `check_cloud_sync_agents()` | Check for Dropbox, OneDrive, Google Drive processes via `pgrep`; check if their known sync dirs overlap with `~/.openjarvis/` | No overlap = ok, overlap = warn | -| `check_network_exposure()` | `lsof -iTCP -sTCP:LISTEN -nP`, filter for known engine ports, check bind address | 127.0.0.1 = ok, 0.0.0.0 = warn | -| `check_screen_recording()` | `pgrep` for TeamViewer, AnyDesk, ScreenConnect, vnc | Not found = ok, found = warn | - -#### Linux checks - -| Check | Implementation | Status mapping | -|-------|---------------|----------------| -| `check_luks()` | `lsblk -o NAME,TYPE,FSTYPE -J`, look for `crypto_LUKS` on root device | Found = ok, not found = fail | -| `check_cloud_sync_agents()` | `pgrep` for rclone, dropbox, insync, onedriver | Not found = ok, found = warn | -| `check_network_exposure()` | `ss -tlnp` or `lsof`, same port-check logic | 127.0.0.1 = ok, 0.0.0.0 = warn | -| `check_remote_access()` | `pgrep` for xrdp, x11vnc, vncserver, AnyDesk | Not found = ok, found = warn | - -#### CLI command - -``` -@click.command() -def scan(): - """Audit your environment for privacy and security risks.""" -``` - -Output format: -``` - Privacy & Environment Audit - ──────────────────────────── - ✓ FileVault: enabled - ✓ Network: inference ports bound to localhost only - ! iCloud Drive: Desktop & Documents sync is active - ✗ MDM: enterprise management profile detected - - 1 warning, 1 issue found. -``` - -Symbols: `✓` for ok, `!` for warn, `✗` for fail. Skipped checks are hidden. - -#### Init integration - -A function `_quick_privacy_check()` in `init_cmd.py` that: -1. Instantiates `PrivacyScanner`. -2. Runs only disk encryption and cloud sync overlap checks. -3. Prints a compact 2-3 line summary. -4. Always prints `Run 'jarvis scan' for a full environment audit.` - -### File changes summary - -| File | Type | Description | -|------|------|-------------| -| `src/openjarvis/intelligence/model_catalog.py` | Edit | Add MLX to supported_engines, add gguf_file/mlx_repo metadata | -| `src/openjarvis/core/config.py` | Edit | Add `estimated_download_gb(spec: ModelSpec) -> float` helper (computes `parameter_count_b * 0.5 * 1.1`) | -| `src/openjarvis/cli/init_cmd.py` | Edit | Add download prompt, engine-specific downloaders, empty-model fallback, privacy hook | -| `src/openjarvis/cli/model.py` | Edit | Extend `pull` for llama.cpp and MLX engines | -| `src/openjarvis/cli/scan_cmd.py` | New | `PrivacyScanner` class, check functions, `jarvis scan` command | -| `src/openjarvis/cli/__init__.py` | Edit | Register `scan` command | - -## Testing - -### Issue 1 — Model onboarding - -| Test | File | What it verifies | -|------|------|-----------------| -| MLX model recommendation | `tests/core/test_recommend_model.py` | Apple Silicon 8/16/32/64GB returns valid model with MLX engine (8GB should get `qwen3.5:4b`, 16GB should get `qwen3.5:14b`) | -| Empty model fallback message | `tests/cli/test_init_guidance.py` | `recommend_model` returning `""` shows "not enough memory" | -| Download prompt shown | `tests/cli/test_init_guidance.py` | Init output includes download prompt when model recommended | -| Engine-specific download dispatch | `tests/cli/test_init_guidance.py` | Ollama triggers pull, llamacpp shows HF download, vllm shows auto-download | -| `model pull` multi-engine | `tests/cli/test_model_pull.py` | llama.cpp and MLX pull paths work (mocked subprocess) | - -### Issue 2 — Privacy scanner - -| Test | File | What it verifies | -|------|------|-----------------| -| FileVault check | `tests/cli/test_scan.py` | Parses `fdesetup status` for on/off | -| MDM check | `tests/cli/test_scan.py` | Parses `profiles status` for enrollment | -| iCloud sync detection | `tests/cli/test_scan.py` | Detects overlap with `~/.openjarvis/` | -| LUKS check | `tests/cli/test_scan.py` | Parses `lsblk` for crypto_LUKS | -| Network exposure | `tests/cli/test_scan.py` | Parses `lsof`/`ss` for 0.0.0.0 binds | -| Platform filtering | `tests/cli/test_scan.py` | macOS checks return skip on Linux, vice versa | -| Init privacy hook | `tests/cli/test_init_guidance.py` | Init output includes privacy summary | - -All tests mock subprocess calls via `monkeypatch`. No real system state required. - -## Out of Scope - -- Windows support (future work). -- Post-download inference validation (trying a test query). -- Rust implementation of scanner checks. -- DNS leak detection (complex, unreliable heuristics). -- Automatic remediation (e.g., moving `~/.openjarvis/` out of iCloud). diff --git a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md b/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md deleted file mode 100644 index acb292da..00000000 --- a/docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-plan.md +++ /dev/null @@ -1,1413 +0,0 @@ -# Init Model Onboarding & Privacy Scanner Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix the broken model recommendation for MLX users, add interactive model download to `jarvis init`, and create a `jarvis scan` privacy environment audit command. - -**Architecture:** Two independent features sharing only the init command integration point. Feature 1 modifies the model catalog and init flow. Feature 2 creates a new `PrivacyScanner` class with platform-specific checks exposed via `jarvis scan` CLI command and a lightweight hook in init. - -**Tech Stack:** Python 3.10+, Click (CLI), Rich (terminal UI), httpx (Ollama API), subprocess (system checks) - -**Spec:** `docs/development/specs/2026-03-24-init-onboarding-and-privacy-scanner-design.md` - ---- - -### Task 1: Fix MLX support in model catalog - -**Files:** -- Modify: `src/openjarvis/intelligence/model_catalog.py:42-54` (qwen3.5:3b) -- Modify: `src/openjarvis/intelligence/model_catalog.py:55-67` (qwen3.5:8b) -- Modify: `src/openjarvis/intelligence/model_catalog.py:68-80` (qwen3.5:14b) -- Modify: `src/openjarvis/intelligence/model_catalog.py:219-232` (qwen3.5:4b) -- Test: `tests/core/test_recommend_model.py` - -- [ ] **Step 1: Write failing tests for MLX model recommendation** - -Add a new test class to `tests/core/test_recommend_model.py`: - -```python -class TestRecommendModelMlx: - """Apple Silicon (MLX) model recommendation.""" - - def test_apple_silicon_8gb_mlx(self) -> None: - hw = HardwareInfo( - platform="darwin", - ram_gb=8.0, - gpu=GpuInfo(vendor="apple", name="Apple M1", vram_gb=8.0, count=1), - ) - result = recommend_model(hw, "mlx") - # available = 8 * 0.9 = 7.2 GB - # 8B * 0.5 * 1.1 = 4.4 → fits, but check 14B first: 7.7 → too big - # 4B * 0.5 * 1.1 = 2.2 → fits, but 8B also fits → pick 8B - assert result == "qwen3.5:8b" - - def test_apple_silicon_16gb_mlx(self) -> None: - hw = HardwareInfo( - platform="darwin", - ram_gb=16.0, - gpu=GpuInfo(vendor="apple", name="Apple M2", vram_gb=16.0, count=1), - ) - result = recommend_model(hw, "mlx") - # available = 16 * 0.9 = 14.4 GB - # 14B * 0.5 * 1.1 = 7.7 → fits - assert result == "qwen3.5:14b" - - def test_apple_silicon_32gb_mlx(self) -> None: - hw = HardwareInfo( - platform="darwin", - ram_gb=32.0, - gpu=GpuInfo(vendor="apple", name="Apple M2 Pro", vram_gb=32.0, count=1), - ) - result = recommend_model(hw, "mlx") - # available = 32 * 0.9 = 28.8 GB - # 14B * 0.5 * 1.1 = 7.7 → fits (14b is the largest with mlx support) - assert result == "qwen3.5:14b" - - def test_apple_silicon_64gb_mlx(self) -> None: - hw = HardwareInfo( - platform="darwin", - ram_gb=64.0, - gpu=GpuInfo(vendor="apple", name="Apple M2 Max", vram_gb=64.0, count=1), - ) - result = recommend_model(hw, "mlx") - # available = 64 * 0.9 = 57.6 GB — but 14b is the largest MLX model - assert result == "qwen3.5:14b" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/core/test_recommend_model.py::TestRecommendModelMlx -v` -Expected: FAIL — all 4 tests fail because no Qwen3.5 model has `"mlx"` in `supported_engines`. - -- [ ] **Step 3: Add MLX to supported_engines and download metadata** - -In `src/openjarvis/intelligence/model_catalog.py`, update the four Qwen3.5 model entries: - -For `qwen3.5:3b` (line 48): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-3b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-3B-4bit"` to metadata. - -For `qwen3.5:8b` (line 61): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-8b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-8B-4bit"` to metadata. - -For `qwen3.5:14b` (line 74): change `supported_engines` from `("ollama", "vllm", "llamacpp", "sglang")` to `("ollama", "vllm", "llamacpp", "sglang", "mlx")`. Add `"gguf_file": "qwen3.5-14b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-14B-4bit"` to metadata. - -For `qwen3.5:4b` (line 226): change `supported_engines` from `("ollama", "vllm", "sglang", "llamacpp")` to `("ollama", "vllm", "sglang", "llamacpp", "mlx")`. Add `"gguf_file": "qwen3.5-4b-q4_k_m.gguf"` and `"mlx_repo": "mlx-community/Qwen3.5-4B-4bit"` to metadata. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/core/test_recommend_model.py -v` -Expected: ALL 15 tests pass (11 existing + 4 new). - -- [ ] **Step 5: Add `estimated_download_gb` helper to config.py** - -In `src/openjarvis/core/config.py`, after the `recommend_model` function (after line 248), add: - -```python -def estimated_download_gb(parameter_count_b: float) -> float: - """Estimate download size in GB for Q4_K_M quantized model.""" - return parameter_count_b * 0.5 * 1.1 -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/openjarvis/intelligence/model_catalog.py src/openjarvis/core/config.py tests/core/test_recommend_model.py -git commit -m "fix: add MLX engine support to Qwen3.5 model catalog entries - -Fixes recommend_model() returning empty string on Apple Silicon -when MLX is the recommended engine. Also adds gguf_file and -mlx_repo download metadata, and estimated_download_gb helper." -``` - ---- - -### Task 2: Extract Ollama pull helper and extend `jarvis model pull` - -**Files:** -- Modify: `src/openjarvis/cli/model.py:152-197` -- Test: `tests/cli/test_model_pull.py` (new) - -- [ ] **Step 1: Write failing tests for multi-engine pull** - -Create `tests/cli/test_model_pull.py`: - -```python -"""Tests for ``jarvis model pull`` multi-engine support.""" - -from __future__ import annotations - -from unittest import mock - -import pytest -from click.testing import CliRunner -from rich.console import Console - -from openjarvis.cli.model import ollama_pull - - -class TestOllamaPull: - """Test the extracted ollama_pull helper.""" - - def test_ollama_pull_success(self) -> None: - import io - console = Console(file=io.StringIO()) - mock_lines = [ - '{"status": "pulling manifest"}', - '{"status": "downloading", "total": 100, "completed": 100}', - '{"status": "success"}', - ] - mock_resp = mock.MagicMock() - mock_resp.raise_for_status = mock.MagicMock() - mock_resp.iter_lines.return_value = iter(mock_lines) - mock_resp.__enter__ = mock.MagicMock(return_value=mock_resp) - mock_resp.__exit__ = mock.MagicMock(return_value=False) - - with mock.patch("httpx.stream", return_value=mock_resp): - result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) - assert result is True - - def test_ollama_pull_connect_error(self) -> None: - import io - import httpx - - console = Console(file=io.StringIO()) - with mock.patch("httpx.stream", side_effect=httpx.ConnectError("refused")): - result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console) - assert result is False - - -class TestPullCliMultiEngine: - """Test the pull CLI command dispatches to correct engine.""" - - def test_pull_llamacpp_uses_huggingface_cli(self) -> None: - from openjarvis.cli import cli - - runner = CliRunner() - with ( - mock.patch("openjarvis.cli.model.load_config") as mock_cfg, - mock.patch("subprocess.run") as mock_run, - ): - mock_cfg.return_value.engine.default = "llamacpp" - mock_cfg.return_value.engine.ollama_host = None - mock_run.return_value = mock.MagicMock(returncode=0) - - result = runner.invoke( - cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] - ) - - assert result.exit_code == 0 - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert "huggingface-cli" in call_args - assert "qwen3.5-8b-q4_k_m.gguf" in call_args - - def test_pull_mlx_uses_huggingface_cli(self) -> None: - from openjarvis.cli import cli - - runner = CliRunner() - with ( - mock.patch("openjarvis.cli.model.load_config") as mock_cfg, - mock.patch("subprocess.run") as mock_run, - ): - mock_cfg.return_value.engine.default = "mlx" - mock_cfg.return_value.engine.ollama_host = None - mock_run.return_value = mock.MagicMock(returncode=0) - - result = runner.invoke( - cli, ["model", "pull", "qwen3.5:8b", "--engine", "mlx"] - ) - - assert result.exit_code == 0 - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert "huggingface-cli" in call_args - assert "mlx-community/Qwen3.5-8B-4bit" in call_args - - def test_pull_llamacpp_huggingface_cli_not_found(self) -> None: - from openjarvis.cli import cli - - runner = CliRunner() - with ( - mock.patch("openjarvis.cli.model.load_config") as mock_cfg, - mock.patch("subprocess.run", side_effect=FileNotFoundError), - ): - mock_cfg.return_value.engine.default = "llamacpp" - mock_cfg.return_value.engine.ollama_host = None - - result = runner.invoke( - cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"] - ) - - assert result.exit_code != 0 - assert "huggingface_hub" in result.output or "pip install" in result.output -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/cli/test_model_pull.py -v` -Expected: FAIL — `ollama_pull` function doesn't exist yet, `--engine` flag not recognized. - -- [ ] **Step 3: Refactor model.py — extract ollama_pull and add multi-engine support** - -In `src/openjarvis/cli/model.py`: - -1. Add `import subprocess` at the top. -2. Add `from openjarvis.intelligence.model_catalog import BUILTIN_MODELS` at the top. -3. Extract the Ollama pull logic into a standalone function: - -```python -def ollama_pull(host: str, model_name: str, console: Console) -> bool: - """Pull a model via Ollama API. Returns True on success.""" - console.print(f"Pulling [cyan]{model_name}[/cyan] via Ollama...") - try: - with httpx.stream( - "POST", - f"{host}/api/pull", - json={"name": model_name, "stream": True}, - timeout=600.0, - ) as resp: - resp.raise_for_status() - import json - - for line in resp.iter_lines(): - if not line.strip(): - continue - try: - data = json.loads(line) - except Exception: - continue - status = data.get("status", "") - if "total" in data and "completed" in data: - total = data["total"] - done = data["completed"] - pct = int(done / total * 100) if total else 0 - console.print(f" {status}: {pct}%", end="\r") - elif status: - console.print(f" {status}") - console.print(f"\n[green]Successfully pulled {model_name}[/green]") - return True - except httpx.ConnectError: - console.print("[red]Cannot connect to Ollama.[/red] Is it running?") - return False - except httpx.HTTPStatusError as exc: - console.print(f"[red]Ollama error:[/red] {exc.response.status_code}") - return False -``` - -4. Add helper to find model spec: - -```python -def find_model_spec(model_name: str): - """Look up a model in the builtin catalog. Returns None if not found.""" - for spec in BUILTIN_MODELS: - if spec.model_id == model_name: - return spec - return None -``` - -5. Add HuggingFace download helper: - -```python -def hf_download(repo: str, filename: str | None, console: Console) -> bool: - """Download from HuggingFace via huggingface-cli. Returns True on success.""" - cmd = ["huggingface-cli", "download", repo] - if filename: - cmd.append(filename) - try: - result = subprocess.run(cmd, check=True) - console.print(f"[green]Download complete.[/green]") - return True - except FileNotFoundError: - console.print( - "[red]huggingface-cli not found.[/red]\n" - "Install it: [cyan]pip install huggingface_hub[/cyan]\n" - f"Or download manually: https://huggingface.co/{repo}" - ) - return False - except subprocess.CalledProcessError: - console.print(f"[red]Download failed.[/red]") - return False -``` - -6. Replace the existing `pull` command with a multi-engine version: - -```python -@model.command() -@click.argument("model_name") -@click.option("--engine", default=None, help="Engine to download for (ollama, llamacpp, mlx).") -def pull(model_name: str, engine: str | None) -> None: - """Download a model.""" - console = Console() - config = load_config() - - engine = engine or config.engine.default or "ollama" - - if engine == "ollama": - host = ( - config.engine.ollama_host - or os.environ.get("OLLAMA_HOST") - or "http://localhost:11434" - ).rstrip("/") - if not ollama_pull(host, model_name, console): - sys.exit(1) - elif engine in ("llamacpp", "mlx"): - spec = find_model_spec(model_name) - if not spec: - console.print(f"[red]Model not in catalog:[/red] {model_name}") - sys.exit(1) - if engine == "llamacpp": - repo = spec.metadata.get("hf_repo", "") - gguf = spec.metadata.get("gguf_file", "") - if not repo or not gguf: - console.print(f"[red]No GGUF download info for {model_name}[/red]") - sys.exit(1) - console.print(f"Downloading [cyan]{gguf}[/cyan] from {repo}...") - if not hf_download(repo, gguf, console): - sys.exit(1) - else: # mlx - mlx_repo = spec.metadata.get("mlx_repo", "") - if not mlx_repo: - console.print(f"[red]No MLX repo info for {model_name}[/red]") - sys.exit(1) - console.print(f"Downloading [cyan]{mlx_repo}[/cyan]...") - if not hf_download(mlx_repo, None, console): - sys.exit(1) - elif engine in ("vllm", "sglang"): - console.print( - f"[cyan]{model_name}[/cyan] will download automatically when " - f"{engine} starts serving it." - ) - else: - console.print( - f"Manual download required for engine [cyan]{engine}[/cyan].\n" - f"Check the engine documentation for instructions." - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/cli/test_model_pull.py -v` -Expected: ALL tests pass. - -- [ ] **Step 5: Run existing tests to verify no regressions** - -Run: `uv run pytest tests/core/test_recommend_model.py tests/cli/test_init_guidance.py -v` -Expected: ALL pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/openjarvis/cli/model.py tests/cli/test_model_pull.py -git commit -m "feat: extract ollama_pull helper and add multi-engine model pull - -Refactors model pull into reusable ollama_pull() function. Adds ---engine flag to support llamacpp (GGUF) and mlx (HuggingFace) -downloads via huggingface-cli, with FileNotFoundError handling." -``` - ---- - -### Task 3: Add interactive download and empty-model fallback to `jarvis init` - -**Files:** -- Modify: `src/openjarvis/cli/init_cmd.py:139-299` -- Test: `tests/cli/test_init_guidance.py` - -- [ ] **Step 1: Write failing tests for download prompt and empty-model fallback** - -Add to `tests/cli/test_init_guidance.py`: - -```python -class TestInitDownloadPrompt: - """Interactive download prompt during init.""" - - def test_init_shows_download_prompt(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - ): - result = CliRunner().invoke( - cli, ["init", "--engine", "ollama"], input="n\n" - ) - assert result.exit_code == 0 - assert "Download" in result.output and "now?" in result.output - - def test_init_no_download_flag_skips_prompt(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - ): - result = CliRunner().invoke( - cli, ["init", "--engine", "ollama", "--no-download"] - ) - assert result.exit_code == 0 - assert "Download" not in result.output or "now?" not in result.output - - -class TestInitEmptyModelFallback: - """Empty model recommendation shows helpful message.""" - - def test_init_no_model_shows_warning(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - mock.patch( - "openjarvis.cli.init_cmd.recommend_model", return_value="" - ), - ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) - assert result.exit_code == 0 - assert "Not enough memory" in result.output or "not enough memory" in result.output - - -class TestNextStepsExoNexa: - """Exo and Nexa have their own next-steps text.""" - - def test_next_steps_exo(self) -> None: - text = _next_steps_text("exo") - assert "exo" in text.lower() - assert "jarvis ask" in text - # Should NOT fall back to Ollama instructions - assert "ollama" not in text.lower() - - def test_next_steps_nexa(self) -> None: - text = _next_steps_text("nexa") - assert "nexa" in text.lower() - assert "jarvis ask" in text - assert "ollama" not in text.lower() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/cli/test_init_guidance.py::TestInitDownloadPrompt tests/cli/test_init_guidance.py::TestInitEmptyModelFallback tests/cli/test_init_guidance.py::TestNextStepsExoNexa -v` -Expected: FAIL — no `--no-download` flag, no "Not enough memory" message, no exo/nexa next-steps. - -- [ ] **Step 3: Update init_cmd.py** - -In `src/openjarvis/cli/init_cmd.py`: - -1. Add imports at the top: - -```python -from openjarvis.cli.model import ollama_pull, find_model_spec, hf_download -from openjarvis.core.config import estimated_download_gb -``` - -2. Add `exo` and `nexa` entries to `_next_steps_text()` dict (before the closing `}`): - -```python - "exo": ( - "Next steps:\n" - "\n" - " 1. Install and start Exo:\n" - " pip install exo\n" - " exo\n" - "\n" - " 2. Try it out:\n" - " jarvis ask \"Hello\"\n" - "\n" - " Run `jarvis doctor` to verify your setup." - ), - "nexa": ( - "Next steps:\n" - "\n" - " 1. Install and start Nexa:\n" - " pip install nexaai\n" - " nexa server\n" - "\n" - " 2. Try it out:\n" - " jarvis ask \"Hello\"\n" - "\n" - " Run `jarvis doctor` to verify your setup." - ), -``` - -3. Add `--no-download` flag to the `@click.command()` decorator chain (after `--engine`): - -```python -@click.option( - "--no-download", - is_flag=True, - default=False, - help="Skip the model download prompt.", -) -``` - -4. Update the `init()` function signature to accept `no_download: bool = False`. - -5. Replace the block at lines 287-298 (the model recommendation + next-steps panel) with: - -```python - selected_engine = engine or recommend_engine(hw) - model = recommend_model(hw, selected_engine) - - if not model: - console.print( - "\n [yellow]! Not enough memory to run any local model.[/yellow]\n" - " Consider a cloud engine or a machine with more RAM." - ) - else: - spec = find_model_spec(model) - size_gb = estimated_download_gb(spec.parameter_count_b) if spec else 0 - console.print(f"\n [bold]Recommended model:[/bold] {model} (~{size_gb:.1f} GB estimated)") - - if not no_download and spec: - if click.confirm(f" Download {model} (~{size_gb:.1f} GB estimated) now?", default=True): - _do_download(selected_engine, model, spec, console) - - console.print() - console.print( - Panel( - _next_steps_text(selected_engine, model), - title="Getting Started", - border_style="cyan", - ) - ) -``` - -6. Add the `_do_download` helper function: - -```python -def _do_download(engine: str, model: str, spec, console: Console) -> None: - """Dispatch model download based on engine type.""" - import os - - if engine == "ollama": - host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/") - ollama_pull(host, model, console) - elif engine == "llamacpp": - repo = spec.metadata.get("hf_repo", "") - gguf = spec.metadata.get("gguf_file", "") - if repo and gguf: - console.print(f" Downloading [cyan]{gguf}[/cyan] from {repo}...") - hf_download(repo, gguf, console) - else: - console.print(f" [yellow]No GGUF download info for {model}[/yellow]") - elif engine == "mlx": - mlx_repo = spec.metadata.get("mlx_repo", "") - if mlx_repo: - console.print(f" Downloading [cyan]{mlx_repo}[/cyan]...") - hf_download(mlx_repo, None, console) - else: - console.print(f" [yellow]No MLX repo info for {model}[/yellow]") - elif engine in ("vllm", "sglang"): - console.print( - f" [cyan]{model}[/cyan] will download automatically when " - f"{engine} starts serving it." - ) - else: - console.print( - f" Download {model} through the {engine} interface." - ) -``` - -- [ ] **Step 4: Update existing init tests to add `--no-download`** - -The download prompt breaks existing tests because `click.confirm` will try to download a model. Update all existing init CLI tests in `tests/cli/test_init_guidance.py` to add `"--no-download"` to their invocation args: - -- `test_init_shows_next_steps`: change `["init", "--engine", "llamacpp"]` to `["init", "--engine", "llamacpp", "--no-download"]` -- `test_init_output_shows_toml_sections_literally`: same change -- `test_init_generates_minimal_by_default`: change `["init", "--engine", "ollama"]` to `["init", "--engine", "ollama", "--no-download"]` -- `test_init_full_generates_verbose_config`: change `["init", "--full", "--engine", "ollama"]` to `["init", "--full", "--engine", "ollama", "--no-download"]` - -- [ ] **Step 5: Add download dispatch tests** - -Add to `tests/cli/test_init_guidance.py`: - -```python -class TestInitDownloadDispatch: - """Verify download dispatches correctly for each engine.""" - - def test_init_ollama_download_calls_ollama_pull(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - mock.patch("openjarvis.cli.init_cmd.ollama_pull", return_value=True) as mock_pull, - ): - result = CliRunner().invoke( - cli, ["init", "--engine", "ollama"], input="y\n" - ) - assert result.exit_code == 0 - mock_pull.assert_called_once() - - def test_init_vllm_shows_auto_download_message(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - ): - result = CliRunner().invoke( - cli, ["init", "--engine", "vllm"], input="y\n" - ) - assert result.exit_code == 0 - assert "automatically" in result.output -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `uv run pytest tests/cli/test_init_guidance.py -v` -Expected: ALL tests pass (10 existing + 7 new). - -- [ ] **Step 7: Lint** - -Run: `uv run ruff check src/openjarvis/cli/init_cmd.py --fix` -Expected: Clean or auto-fixed. - -- [ ] **Step 8: Commit** - -```bash -git add src/openjarvis/cli/init_cmd.py tests/cli/test_init_guidance.py -git commit -m "feat: add interactive model download and empty-model fallback to init - -Prompts user to download recommended model during jarvis init. -Adds --no-download flag for CI. Shows helpful message when no -model fits available memory. Adds exo/nexa next-steps text." -``` - ---- - -### Task 4: Create privacy scanner — ScanResult and check functions - -**Files:** -- Create: `src/openjarvis/cli/scan_cmd.py` -- Test: `tests/cli/test_scan.py` (new) - -- [ ] **Step 1: Write failing tests for individual scanner checks** - -Create `tests/cli/test_scan.py`: - -```python -"""Tests for ``jarvis scan`` privacy environment audit.""" - -from __future__ import annotations - -import subprocess -import sys -from unittest import mock - -import pytest - -from openjarvis.cli.scan_cmd import PrivacyScanner, ScanResult - - -class TestScanResultDataclass: - def test_scan_result_fields(self) -> None: - r = ScanResult(name="Test", status="ok", message="All good", platform="all") - assert r.name == "Test" - assert r.status == "ok" - assert r.message == "All good" - assert r.platform == "all" - - -class TestFileVault: - def test_filevault_enabled(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout="FileVault is On.", returncode=0 - ) - result = scanner.check_filevault() - assert result.status == "ok" - assert "enabled" in result.message.lower() - - def test_filevault_disabled(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout="FileVault is Off.", returncode=0 - ) - result = scanner.check_filevault() - assert result.status == "fail" - - def test_filevault_command_not_found(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run", side_effect=FileNotFoundError): - result = scanner.check_filevault() - assert result.status == "skip" - - -class TestMDM: - def test_mdm_not_enrolled(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout="This machine is not enrolled", returncode=0 - ) - result = scanner.check_mdm() - assert result.status == "ok" - - def test_mdm_enrolled(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout="Enrolled via DEP: Yes\nMDM server: example.com", - returncode=0, - ) - result = scanner.check_mdm() - assert result.status == "warn" - - -class TestCloudSync: - def test_no_cloud_sync_agents(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - # pgrep returns exit code 1 when no match - mock_run.return_value = mock.MagicMock(stdout="", returncode=1) - result = scanner.check_cloud_sync_agents() - assert result.status == "ok" - - def test_dropbox_running(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - def side_effect(cmd, **kwargs): - m = mock.MagicMock() - # Match on the process name in the pgrep pattern - if any("Dropbox" in str(c) for c in cmd): - m.stdout = "12345" - m.returncode = 0 - else: - m.stdout = "" - m.returncode = 1 - return m - mock_run.side_effect = side_effect - result = scanner.check_cloud_sync_agents() - assert result.status == "warn" - assert "dropbox" in result.message.lower() - - -class TestNetworkExposure: - def test_no_exposed_ports(self) -> None: - scanner = PrivacyScanner() - lsof_output = ( - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" - "ollama 12345 user 5u IPv4 0x1234 0t0 TCP 127.0.0.1:11434 (LISTEN)\n" - ) - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout=lsof_output, returncode=0 - ) - result = scanner.check_network_exposure() - assert result.status == "ok" - - def test_exposed_port(self) -> None: - scanner = PrivacyScanner() - lsof_output = ( - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" - "ollama 12345 user 5u IPv4 0x1234 0t0 TCP *:11434 (LISTEN)\n" - ) - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout=lsof_output, returncode=0 - ) - result = scanner.check_network_exposure() - assert result.status == "warn" - assert "11434" in result.message - - -class TestLUKS: - def test_luks_encrypted(self) -> None: - scanner = PrivacyScanner() - lsblk_json = '{"blockdevices": [{"name": "sda", "type": "disk", "fstype": null, "children": [{"name": "sda1", "type": "part", "fstype": "crypto_LUKS"}]}]}' - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout=lsblk_json, returncode=0 - ) - result = scanner.check_luks() - assert result.status == "ok" - - def test_luks_not_encrypted(self) -> None: - scanner = PrivacyScanner() - lsblk_json = '{"blockdevices": [{"name": "sda", "type": "disk", "fstype": null, "children": [{"name": "sda1", "type": "part", "fstype": "ext4"}]}]}' - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock( - stdout=lsblk_json, returncode=0 - ) - result = scanner.check_luks() - assert result.status == "fail" - - -class TestScreenRecording: - def test_no_screen_recording(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - mock_run.return_value = mock.MagicMock(stdout="", returncode=1) - result = scanner.check_screen_recording() - assert result.status == "ok" - - def test_teamviewer_running(self) -> None: - scanner = PrivacyScanner() - with mock.patch("subprocess.run") as mock_run: - def side_effect(cmd, **kwargs): - m = mock.MagicMock() - if any("TeamViewer" in str(c) for c in cmd): - m.stdout = "12345" - m.returncode = 0 - else: - m.stdout = "" - m.returncode = 1 - return m - mock_run.side_effect = side_effect - result = scanner.check_screen_recording() - assert result.status == "warn" - - -class TestPlatformFiltering: - def test_run_all_returns_only_current_platform(self) -> None: - scanner = PrivacyScanner() - with mock.patch.object(scanner, "_get_all_checks") as mock_checks: - mock_checks.return_value = [ - lambda: ScanResult("Test1", "ok", "ok", "darwin"), - lambda: ScanResult("Test2", "ok", "ok", "linux"), - lambda: ScanResult("Test3", "ok", "ok", "all"), - ] - results = scanner.run_all() - current = sys.platform - for r in results: - assert r.platform in (current, "all") -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/cli/test_scan.py -v` -Expected: FAIL — `scan_cmd` module doesn't exist. - -- [ ] **Step 3: Implement PrivacyScanner class** - -Create `src/openjarvis/cli/scan_cmd.py`: - -```python -"""``jarvis scan`` — privacy and environment audit.""" - -from __future__ import annotations - -import json -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, List - -import click -from rich.console import Console - - -@dataclass(slots=True) -class ScanResult: - """Result of a single privacy check.""" - - name: str - status: str # "ok" | "warn" | "fail" | "skip" - message: str - platform: str # "darwin" | "linux" | "all" - - -# Engine ports to check for network exposure -_ENGINE_PORTS = { - 11434: "Ollama", - 8080: "llama.cpp / MLX", - 8000: "vLLM", - 30000: "SGLang", - 1234: "LM Studio", - 52415: "Exo", - 18181: "Nexa", -} - -# Cloud sync process names to check -_CLOUD_SYNC_PROCESSES = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"] - -# Screen recording / remote access process names -_SCREEN_RECORDING_PROCESSES_MACOS = [ - "TeamViewer", "AnyDesk", "ScreenConnect", "VNC", -] -_REMOTE_ACCESS_PROCESSES_LINUX = [ - "xrdp", "x11vnc", "vncserver", "AnyDesk", "TeamViewer", -] - - -class PrivacyScanner: - """Run platform-specific privacy and environment checks.""" - - def _run(self, cmd: list[str], **kwargs) -> subprocess.CompletedProcess: - """Run a subprocess, capturing stdout as text.""" - return subprocess.run( - cmd, capture_output=True, text=True, timeout=10, **kwargs - ) - - # ------------------------------------------------------------------ - # macOS checks - # ------------------------------------------------------------------ - - def check_filevault(self) -> ScanResult: - """Check macOS FileVault disk encryption status.""" - try: - proc = self._run(["fdesetup", "status"]) - if "On" in proc.stdout: - return ScanResult("FileVault", "ok", "FileVault enabled", "darwin") - return ScanResult( - "FileVault", "fail", - "FileVault is disabled — disk is not encrypted", "darwin" - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return ScanResult("FileVault", "skip", "fdesetup not available", "darwin") - - def check_mdm(self) -> ScanResult: - """Check for MDM / enterprise management enrollment.""" - try: - proc = self._run(["profiles", "status", "-type", "enrollment"]) - output = proc.stdout + proc.stderr - if "MDM" in output or "Enrolled" in output: - return ScanResult( - "MDM", "warn", - "Enterprise management profile detected — this device may be monitored", - "darwin", - ) - return ScanResult("MDM", "ok", "No MDM enrollment detected", "darwin") - except (FileNotFoundError, subprocess.TimeoutExpired): - return ScanResult("MDM", "skip", "profiles command not available", "darwin") - - def check_icloud_sync(self) -> ScanResult: - """Check if ~/.openjarvis/ could be synced by iCloud Drive.""" - try: - oj_path = Path.home() / ".openjarvis" - # Check if the path is under iCloud's mobile documents - mobile_docs = Path.home() / "Library" / "Mobile Documents" - try: - resolved = oj_path.resolve() - if str(resolved).startswith(str(mobile_docs)): - return ScanResult( - "iCloud Drive", "warn", - "~/.openjarvis/ is inside iCloud Drive sync path", - "darwin", - ) - except OSError: - pass - - # Check if Desktop & Documents sync is enabled - proc = self._run( - ["defaults", "read", "com.apple.bird", "optimize-storage"] - ) - # Also check the broader MobileMeAccounts for sync status - proc2 = self._run( - ["defaults", "read", "MobileMeAccounts"] - ) - output = proc.stdout + proc2.stdout - if "MOBILE_DOCUMENTS" in output or "Desktop" in output: - return ScanResult( - "iCloud Drive", "warn", - "iCloud Desktop & Documents sync may be active — " - "~/.openjarvis/ could be synced to Apple servers", - "darwin", - ) - return ScanResult( - "iCloud Drive", "ok", - "No iCloud sync overlap detected", "darwin" - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return ScanResult( - "iCloud Drive", "skip", - "Could not determine iCloud sync status", "darwin" - ) - except Exception: - return ScanResult( - "iCloud Drive", "skip", - "Could not determine iCloud sync status", "darwin" - ) - - # ------------------------------------------------------------------ - # Linux checks - # ------------------------------------------------------------------ - - def check_luks(self) -> ScanResult: - """Check for LUKS disk encryption on Linux.""" - try: - proc = self._run(["lsblk", "-o", "NAME,TYPE,FSTYPE", "-J"]) - data = json.loads(proc.stdout) - - def _has_luks(devices: list) -> bool: - for dev in devices: - if dev.get("fstype") == "crypto_LUKS": - return True - if _has_luks(dev.get("children", [])): - return True - return False - - if _has_luks(data.get("blockdevices", [])): - return ScanResult( - "Disk Encryption", "ok", - "LUKS encryption detected", "linux" - ) - return ScanResult( - "Disk Encryption", "fail", - "No LUKS encryption detected — disk may not be encrypted", - "linux", - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return ScanResult( - "Disk Encryption", "skip", - "lsblk not available", "linux" - ) - except (json.JSONDecodeError, KeyError): - return ScanResult( - "Disk Encryption", "skip", - "Could not parse lsblk output", "linux" - ) - - def check_remote_access(self) -> ScanResult: - """Check for remote access tools on Linux.""" - return self._check_processes( - _REMOTE_ACCESS_PROCESSES_LINUX, - "Remote Access", - "Remote access tool detected", - "linux", - ) - - # ------------------------------------------------------------------ - # Cross-platform checks - # ------------------------------------------------------------------ - - def check_cloud_sync_agents(self) -> ScanResult: - """Check for running cloud sync agents.""" - platform = "darwin" if sys.platform == "darwin" else "linux" - return self._check_processes( - _CLOUD_SYNC_PROCESSES, - "Cloud Sync", - "Cloud sync agent detected", - platform, - ) - - def check_network_exposure(self) -> ScanResult: - """Check if inference engine ports are bound to 0.0.0.0.""" - platform = "darwin" if sys.platform == "darwin" else "linux" - try: - if sys.platform == "darwin": - proc = self._run(["lsof", "-iTCP", "-sTCP:LISTEN", "-nP"]) - else: - proc = self._run(["ss", "-tlnp"]) - - exposed = [] - for line in proc.stdout.splitlines(): - for port, engine_name in _ENGINE_PORTS.items(): - port_str = str(port) - if port_str in line and ("*:" + port_str in line or "0.0.0.0:" + port_str in line): - exposed.append(f"{engine_name} (port {port})") - - if exposed: - return ScanResult( - "Network Exposure", "warn", - f"Inference ports exposed to network: {', '.join(exposed)}", - platform, - ) - return ScanResult( - "Network Exposure", "ok", - "Inference ports bound to localhost only", platform - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return ScanResult( - "Network Exposure", "skip", - "Could not check listening ports", platform - ) - - def check_screen_recording(self) -> ScanResult: - """Check for screen recording / remote desktop tools (macOS).""" - return self._check_processes( - _SCREEN_RECORDING_PROCESSES_MACOS, - "Screen Recording", - "Screen recording or remote access tool detected", - "darwin", - ) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _check_processes( - self, - process_names: list[str], - check_name: str, - warn_message: str, - platform: str, - ) -> ScanResult: - """Check if any of the named processes are running.""" - found = [] - for name in process_names: - try: - proc = self._run(["pgrep", "-i", name]) - if proc.returncode == 0 and proc.stdout.strip(): - found.append(name) - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - if found: - return ScanResult( - check_name, "warn", - f"{warn_message}: {', '.join(found)}", platform - ) - return ScanResult( - check_name, "ok", - f"No {check_name.lower()} detected", platform - ) - - def _get_all_checks(self) -> List[Callable[[], ScanResult]]: - """Return all check methods.""" - return [ - self.check_filevault, - self.check_mdm, - self.check_icloud_sync, - self.check_luks, - self.check_cloud_sync_agents, - self.check_network_exposure, - self.check_screen_recording, - self.check_remote_access, - ] - - def run_all(self) -> list[ScanResult]: - """Run all checks, filtering to current platform.""" - results = [] - for check in self._get_all_checks(): - result = check() - if result.platform in (sys.platform, "all"): - if result.status != "skip": - results.append(result) - return results - - def run_quick(self) -> list[ScanResult]: - """Run only critical checks (for init hook).""" - checks = [self.check_filevault, self.check_luks, self.check_icloud_sync, - self.check_cloud_sync_agents] - results = [] - for check in checks: - result = check() - if result.platform in (sys.platform, "all"): - if result.status != "skip": - results.append(result) - return results - - -# ------------------------------------------------------------------ -# CLI command -# ------------------------------------------------------------------ - -_STATUS_SYMBOLS = {"ok": "\u2713", "warn": "!", "fail": "\u2717"} - - -@click.command() -def scan() -> None: - """Audit your environment for privacy and security risks.""" - console = Console() - scanner = PrivacyScanner() - results = scanner.run_all() - - console.print() - console.print(" [bold]Privacy & Environment Audit[/bold]") - console.print(" " + "\u2500" * 28) - - warns = 0 - fails = 0 - for r in results: - symbol = _STATUS_SYMBOLS.get(r.status, "?") - if r.status == "ok": - console.print(f" [green]{symbol}[/green] {r.name}: {r.message}") - elif r.status == "warn": - console.print(f" [yellow]{symbol}[/yellow] {r.name}: {r.message}") - warns += 1 - elif r.status == "fail": - console.print(f" [red]{symbol}[/red] {r.name}: {r.message}") - fails += 1 - - console.print() - if warns == 0 and fails == 0: - console.print(" [green]No issues found.[/green]") - else: - parts = [] - if warns: - parts.append(f"{warns} warning{'s' if warns != 1 else ''}") - if fails: - parts.append(f"{fails} issue{'s' if fails != 1 else ''}") - console.print(f" {', '.join(parts)} found.") - console.print() -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/cli/test_scan.py -v` -Expected: ALL tests pass. - -- [ ] **Step 5: Lint** - -Run: `uv run ruff check src/openjarvis/cli/scan_cmd.py --fix` -Expected: Clean or auto-fixed. - -- [ ] **Step 6: Commit** - -```bash -git add src/openjarvis/cli/scan_cmd.py tests/cli/test_scan.py -git commit -m "feat: add privacy environment scanner with platform-specific checks - -New PrivacyScanner class with checks for disk encryption (FileVault/LUKS), -MDM profiles, cloud sync agents, network exposure, and screen recording. -Supports macOS and Linux with graceful skip on missing tools." -``` - ---- - -### Task 5: Register `jarvis scan` and integrate privacy hook into init - -**Files:** -- Modify: `src/openjarvis/cli/__init__.py:34` (add import) -- Modify: `src/openjarvis/cli/__init__.py:89` (register command) -- Modify: `src/openjarvis/cli/init_cmd.py` (add privacy hook) -- Test: `tests/cli/test_init_guidance.py` - -- [ ] **Step 1: Write failing test for init privacy hook** - -Add to `tests/cli/test_init_guidance.py`: - -```python -class TestInitPrivacyHook: - """Init shows a lightweight privacy summary.""" - - def test_init_shows_privacy_summary(self, tmp_path: Path) -> None: - config_dir = tmp_path / ".openjarvis" - config_path = config_dir / "config.toml" - with ( - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), - mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - mock.patch("openjarvis.cli.init_cmd.PrivacyScanner") as MockScanner, - ): - from openjarvis.cli.scan_cmd import ScanResult - instance = MockScanner.return_value - instance.run_quick.return_value = [ - ScanResult("FileVault", "ok", "FileVault enabled", "darwin"), - ] - result = CliRunner().invoke( - cli, ["init", "--engine", "llamacpp", "--no-download"] - ) - assert result.exit_code == 0 - assert "jarvis scan" in result.output -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/cli/test_init_guidance.py::TestInitPrivacyHook -v` -Expected: FAIL — no `PrivacyScanner` import in init_cmd, no privacy output. - -- [ ] **Step 3: Update all existing init tests to mock PrivacyScanner** - -After adding the privacy hook to init, all existing init CLI tests will call real system commands (`fdesetup`, `pgrep`, etc.). Add `mock.patch("openjarvis.cli.init_cmd.PrivacyScanner")` to every existing init CLI test's context manager block in `tests/cli/test_init_guidance.py`. This includes: - -- `test_init_shows_next_steps` -- `test_init_output_shows_toml_sections_literally` -- `test_init_generates_minimal_by_default` -- `test_init_full_generates_verbose_config` -- `test_init_shows_download_prompt` -- `test_init_no_download_flag_skips_prompt` -- `test_init_no_model_shows_warning` -- `test_init_ollama_download_calls_ollama_pull` -- `test_init_vllm_shows_auto_download_message` - -For each, add inside the `with (...)` block: -```python -mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), -``` - -- [ ] **Step 4: Register scan command and add init privacy hook** - -In `src/openjarvis/cli/__init__.py`, add after line 33: - -```python -from openjarvis.cli.scan_cmd import scan -``` - -After line 89 (the last `cli.add_command` call), add: - -```python -cli.add_command(scan, "scan") -``` - -In `src/openjarvis/cli/init_cmd.py`, add import: - -```python -from openjarvis.cli.scan_cmd import PrivacyScanner -``` - -Add `_quick_privacy_check` function: - -```python -def _quick_privacy_check(console: Console) -> None: - """Run critical privacy checks and print compact summary.""" - scanner = PrivacyScanner() - results = scanner.run_quick() - - if results: - console.print(" [bold]Privacy check:[/bold]") - for r in results: - if r.status == "ok": - console.print(f" [green]\u2713[/green] {r.message}") - elif r.status == "warn": - console.print(f" [yellow]![/yellow] {r.message}") - elif r.status == "fail": - console.print(f" [red]\u2717[/red] {r.message}") - - console.print() - console.print(" Run [cyan]jarvis scan[/cyan] for a full environment audit.") -``` - -Call `_quick_privacy_check(console)` at the end of the `init()` function, just before the final "Getting Started" panel. - -- [ ] **Step 4: Run all tests to verify everything passes** - -Run: `uv run pytest tests/cli/test_init_guidance.py tests/cli/test_scan.py tests/core/test_recommend_model.py tests/cli/test_model_pull.py -v` -Expected: ALL tests pass. - -- [ ] **Step 5: Lint all changed files** - -Run: `uv run ruff check src/openjarvis/cli/ --fix` -Expected: Clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/openjarvis/cli/__init__.py src/openjarvis/cli/init_cmd.py tests/cli/test_init_guidance.py -git commit -m "feat: register jarvis scan command and add init privacy hook - -Registers the new scan command in the CLI. Adds a lightweight -privacy check at the end of jarvis init that runs disk encryption -and cloud sync checks, with pointer to jarvis scan for full audit." -``` - ---- - -### Task 6: Final integration test and cleanup - -**Files:** -- All modified files from tasks 1-5 - -- [ ] **Step 1: Run full test suite for all touched modules** - -Run: `uv run pytest tests/core/test_recommend_model.py tests/cli/test_init_guidance.py tests/cli/test_model_pull.py tests/cli/test_scan.py -v` -Expected: ALL pass. - -- [ ] **Step 2: Run linter on all source files** - -Run: `uv run ruff check src/openjarvis/cli/ src/openjarvis/intelligence/model_catalog.py src/openjarvis/core/config.py` -Expected: Clean. - -- [ ] **Step 3: Verify the MLX bug is fixed** - -Run: `uv run python3 -c "from openjarvis.core.config import HardwareInfo, GpuInfo, recommend_model; hw = HardwareInfo(platform='darwin', ram_gb=16.0, gpu=GpuInfo(vendor='apple', name='Apple M1', vram_gb=16.0, count=1)); print(f'MLX model: {recommend_model(hw, \"mlx\")}')"` -Expected output: `MLX model: qwen3.5:14b` - -- [ ] **Step 4: Verify the CLI scan command is registered** - -Run: `uv run jarvis scan --help` -Expected: Shows help text for the scan command. - -- [ ] **Step 5: Commit if any cleanup was needed** - -Only if changes were made during cleanup. Otherwise skip. From b2152fb40e0209839b53e1cff3260498aa632086 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:38:14 -0700 Subject: [PATCH 11/12] style: fix E501 line-length and unused imports in test files Wraps long CliRunner.invoke() calls, removes unused pytest imports, fixes import ordering, and removes unused variables in test_scan.py. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/cli/test_init_guidance.py | 60 ++++++++++---- tests/cli/test_model_pull.py | 2 +- tests/cli/test_scan.py | 139 +++++++++++++++++++------------- 3 files changed, 129 insertions(+), 72 deletions(-) diff --git a/tests/cli/test_init_guidance.py b/tests/cli/test_init_guidance.py index 74924c53..acca3c0c 100644 --- a/tests/cli/test_init_guidance.py +++ b/tests/cli/test_init_guidance.py @@ -10,6 +10,8 @@ from click.testing import CliRunner from openjarvis.cli import cli from openjarvis.cli.init_cmd import _next_steps_text +_NO_DL = "--no-download" + class TestInitShowsNextSteps: def test_init_shows_next_steps(self, tmp_path: Path) -> None: @@ -21,7 +23,9 @@ class TestInitShowsNextSteps: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "llamacpp", _NO_DL] + ) assert result.exit_code == 0 assert "Getting Started" in result.output assert "jarvis ask" in result.output @@ -36,7 +40,9 @@ class TestInitShowsNextSteps: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", "--no-download"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "llamacpp", _NO_DL] + ) assert result.exit_code == 0 assert "[engine]" in result.output assert "[intelligence]" in result.output @@ -96,7 +102,9 @@ class TestMinimalConfig: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama", _NO_DL] + ) assert result.exit_code == 0 content = config_path.read_text() # Minimal config should be short @@ -114,7 +122,10 @@ class TestMinimalConfig: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama", "--no-download"]) + result = CliRunner().invoke( + cli, + ["init", "--full", "--engine", "ollama", _NO_DL], + ) assert result.exit_code == 0 content = config_path.read_text() # Full config should have many sections @@ -132,9 +143,12 @@ class TestInitDownloadPrompt: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="n\n") + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama"], input="n\n" + ) assert result.exit_code == 0 - assert "Download" in result.output and "now?" in result.output + assert "Download" in result.output + assert "now?" in result.output def test_init_no_download_flag_skips_prompt(self, tmp_path: Path) -> None: config_dir = tmp_path / ".openjarvis" @@ -144,9 +158,11 @@ class TestInitDownloadPrompt: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama", "--no-download"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama", _NO_DL] + ) assert result.exit_code == 0 - assert "Download" not in result.output or "now?" not in result.output + assert "Download" not in result.output class TestInitEmptyModelFallback: @@ -159,9 +175,14 @@ class TestInitEmptyModelFallback: mock.patch("openjarvis.cli.init_cmd.recommend_model", return_value=""), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "llamacpp"] + ) assert result.exit_code == 0 - assert "Not enough memory" in result.output or "not enough memory" in result.output + assert ( + "Not enough memory" in result.output + or "not enough memory" in result.output + ) class TestNextStepsExoNexa: @@ -179,16 +200,23 @@ class TestNextStepsExoNexa: class TestInitDownloadDispatch: - def test_init_ollama_download_calls_ollama_pull(self, tmp_path: Path) -> None: + def test_init_ollama_download_calls_ollama_pull( + self, tmp_path: Path + ) -> None: config_dir = tmp_path / ".openjarvis" config_path = config_dir / "config.toml" with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), - mock.patch("openjarvis.cli.init_cmd.ollama_pull", return_value=True) as mock_pull, + mock.patch( + "openjarvis.cli.init_cmd.ollama_pull", + return_value=True, + ) as mock_pull, mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama"], input="y\n") + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama"], input="y\n" + ) assert result.exit_code == 0 mock_pull.assert_called_once() @@ -200,7 +228,9 @@ class TestInitDownloadDispatch: mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "vllm"], input="y\n") + result = CliRunner().invoke( + cli, ["init", "--engine", "vllm"], input="y\n" + ) assert result.exit_code == 0 assert "automatically" in result.output @@ -220,7 +250,7 @@ class TestInitPrivacyHook: ScanResult("FileVault", "ok", "FileVault enabled", "darwin"), ] result = CliRunner().invoke( - cli, ["init", "--engine", "llamacpp", "--no-download"] + cli, ["init", "--engine", "llamacpp", _NO_DL] ) assert result.exit_code == 0 assert "jarvis scan" in result.output diff --git a/tests/cli/test_model_pull.py b/tests/cli/test_model_pull.py index a87a55ae..3a724950 100644 --- a/tests/cli/test_model_pull.py +++ b/tests/cli/test_model_pull.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest import mock -import pytest from click.testing import CliRunner from rich.console import Console @@ -34,6 +33,7 @@ class TestOllamaPull: def test_ollama_pull_connect_error(self) -> None: import io + import httpx console = Console(file=io.StringIO()) diff --git a/tests/cli/test_scan.py b/tests/cli/test_scan.py index c89760f3..536439d8 100644 --- a/tests/cli/test_scan.py +++ b/tests/cli/test_scan.py @@ -3,21 +3,25 @@ from __future__ import annotations import json +import sys from subprocess import CompletedProcess from unittest.mock import MagicMock, patch -import pytest - -from openjarvis.cli.scan_cmd import ScanResult, PrivacyScanner - +from openjarvis.cli.scan_cmd import PrivacyScanner, ScanResult # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _make_proc(stdout: str = "", stderr: str = "", returncode: int = 0) -> CompletedProcess: - return CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr) +def _make_proc( + stdout: str = "", + stderr: str = "", + returncode: int = 0, +) -> CompletedProcess: + return CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr=stderr + ) # --------------------------------------------------------------------------- @@ -27,7 +31,9 @@ def _make_proc(stdout: str = "", stderr: str = "", returncode: int = 0) -> Compl class TestScanResultDataclass: def test_fields_exist(self) -> None: - r = ScanResult(name="test", status="ok", message="all good", platform="all") + r = ScanResult( + name="test", status="ok", message="all good", platform="all" + ) assert r.name == "test" assert r.status == "ok" assert r.message == "all good" @@ -52,13 +58,15 @@ class TestScanResultDataclass: class TestFileVault: def test_filevault_enabled(self) -> None: scanner = PrivacyScanner() - with patch("subprocess.run", return_value=_make_proc(stdout="FileVault is On.")): + proc = _make_proc(stdout="FileVault is On.") + with patch("subprocess.run", return_value=proc): result = scanner.check_filevault() assert result.status == "ok" def test_filevault_disabled(self) -> None: scanner = PrivacyScanner() - with patch("subprocess.run", return_value=_make_proc(stdout="FileVault is Off.")): + proc = _make_proc(stdout="FileVault is Off.") + with patch("subprocess.run", return_value=proc): result = scanner.check_filevault() assert result.status == "fail" @@ -77,18 +85,20 @@ class TestFileVault: class TestMDM: def test_not_enrolled(self) -> None: scanner = PrivacyScanner() + stdout = "Enrolled via DEP: No\nMDM enrollment: not enrolled" with patch( "subprocess.run", - return_value=_make_proc(stdout="Enrolled via DEP: No\nMDM enrollment: not enrolled"), + return_value=_make_proc(stdout=stdout), ): result = scanner.check_mdm() assert result.status == "ok" def test_enrolled(self) -> None: scanner = PrivacyScanner() + stdout = "MDM enrollment: Yes\nEnrolled via DEP: Yes" with patch( "subprocess.run", - return_value=_make_proc(stdout="MDM enrollment: Yes\nEnrolled via DEP: Yes"), + return_value=_make_proc(stdout=stdout), ): result = scanner.check_mdm() assert result.status == "warn" @@ -101,9 +111,12 @@ class TestMDM: class TestCloudSync: def test_no_agents(self) -> None: - """pgrep returns non-zero (process not found) → no sync agents running.""" + """pgrep returns non-zero → no sync agents running.""" scanner = PrivacyScanner() - with patch("subprocess.run", return_value=_make_proc(returncode=1, stdout="")): + with patch( + "subprocess.run", + return_value=_make_proc(returncode=1, stdout=""), + ): result = scanner.check_cloud_sync_agents() assert result.status == "ok" @@ -128,19 +141,18 @@ class TestCloudSync: class TestNetworkExposure: def test_no_exposed_ports(self) -> None: - """Only localhost bindings → ok.""" + """Only localhost bindings -> ok.""" scanner = PrivacyScanner() - # lsof / ss output showing only 127.0.0.1 - lsof_output = "ollama 1234 user IPv4 TCP 127.0.0.1:11434 (LISTEN)\n" - with patch("subprocess.run", return_value=_make_proc(stdout=lsof_output)): + lsof_out = "ollama 1234 user IPv4 TCP 127.0.0.1:11434 (LISTEN)\n" + with patch("subprocess.run", return_value=_make_proc(stdout=lsof_out)): result = scanner.check_network_exposure() assert result.status == "ok" def test_exposed_port(self) -> None: - """A port bound to 0.0.0.0 or * → warn with port in message.""" + """A port bound to * -> warn with port in message.""" scanner = PrivacyScanner() - lsof_output = "ollama 1234 user IPv4 TCP *:11434 (LISTEN)\n" - with patch("subprocess.run", return_value=_make_proc(stdout=lsof_output)): + lsof_out = "ollama 1234 user IPv4 TCP *:11434 (LISTEN)\n" + with patch("subprocess.run", return_value=_make_proc(stdout=lsof_out)): result = scanner.check_network_exposure() assert result.status == "warn" assert "11434" in result.message @@ -153,7 +165,7 @@ class TestNetworkExposure: class TestLUKS: def test_encrypted(self) -> None: - """lsblk JSON contains crypto_LUKS → ok.""" + """lsblk JSON contains crypto_LUKS -> ok.""" scanner = PrivacyScanner() lsblk_data = { "blockdevices": [ @@ -175,7 +187,7 @@ class TestLUKS: assert result.status == "ok" def test_not_encrypted(self) -> None: - """lsblk JSON has only ext4 → fail.""" + """lsblk JSON has only ext4 -> fail.""" scanner = PrivacyScanner() lsblk_data = { "blockdevices": [ @@ -198,14 +210,17 @@ class TestLUKS: class TestScreenRecording: def test_none_running(self) -> None: - """No screen-recording processes → ok.""" + """No screen-recording processes -> ok.""" scanner = PrivacyScanner() - with patch("subprocess.run", return_value=_make_proc(returncode=1, stdout="")): + with patch( + "subprocess.run", + return_value=_make_proc(returncode=1, stdout=""), + ): result = scanner.check_screen_recording() assert result.status == "ok" def test_teamviewer_running(self) -> None: - """TeamViewer found → warn.""" + """TeamViewer found -> warn.""" scanner = PrivacyScanner() def _pgrep_side_effect(cmd, **kwargs): @@ -225,44 +240,44 @@ class TestScreenRecording: class TestPlatformFiltering: def test_run_all_filters_to_current_platform(self) -> None: - """run_all() should only call checks tagged 'all' or current platform.""" + """run_all() returns only current-platform + 'all' checks.""" scanner = PrivacyScanner() - called_platforms: list[str] = [] - - # Patch every check method to record its platform tag instead of running - checks = scanner._get_all_checks() - for check_fn in checks: - # Run the real method but capture which ones get included - pass - - import sys - current_plat = "darwin" if sys.platform == "darwin" else "linux" other_plat = "linux" if current_plat == "darwin" else "darwin" - # Make every check return immediately with a known platform with patch.object(scanner, "_get_all_checks") as mock_get: - darwin_check = MagicMock(return_value=ScanResult("d", "ok", "msg", "darwin")) - linux_check = MagicMock(return_value=ScanResult("l", "ok", "msg", "linux")) - all_check = MagicMock(return_value=ScanResult("a", "ok", "msg", "all")) + darwin_ck = MagicMock( + return_value=ScanResult("d", "ok", "msg", "darwin") + ) + linux_ck = MagicMock( + return_value=ScanResult("l", "ok", "msg", "linux") + ) + all_ck = MagicMock( + return_value=ScanResult("a", "ok", "msg", "all") + ) - mock_get.return_value = [darwin_check, linux_check, all_check] + mock_get.return_value = [darwin_ck, linux_ck, all_ck] results = scanner.run_all() - # The check for the other platform should not appear in results - other_result_platform = "linux" if current_plat == "darwin" else "darwin" result_platforms = {r.platform for r in results} - assert other_result_platform not in result_platforms + assert other_plat not in result_platforms assert "all" in result_platforms or current_plat in result_platforms +# --------------------------------------------------------------------------- +# TestRemoteAccess +# --------------------------------------------------------------------------- + + class TestRemoteAccess: """Tests for check_remote_access (Linux).""" def test_no_remote_access(self) -> None: scanner = PrivacyScanner() with patch.object(scanner, "_run") as mock_run: - mock_run.return_value = CompletedProcess([], 1, stdout="", stderr="") + mock_run.return_value = CompletedProcess( + [], 1, stdout="", stderr="" + ) result = scanner.check_remote_access() assert result.status == "ok" @@ -271,13 +286,22 @@ class TestRemoteAccess: with patch.object(scanner, "_run") as mock_run: def side_effect(cmd, **kw): if any("xrdp" in str(c) for c in cmd): - return CompletedProcess(cmd, 0, stdout="12345", stderr="") - return CompletedProcess(cmd, 1, stdout="", stderr="") + return CompletedProcess( + cmd, 0, stdout="12345", stderr="" + ) + return CompletedProcess( + cmd, 1, stdout="", stderr="" + ) mock_run.side_effect = side_effect result = scanner.check_remote_access() assert result.status == "warn" +# --------------------------------------------------------------------------- +# TestICloudSync +# --------------------------------------------------------------------------- + + class TestICloudSync: """Tests for check_icloud_sync (macOS).""" @@ -292,32 +316,35 @@ class TestICloudSync: def test_icloud_defaults_error_falls_through_to_ok(self) -> None: scanner = PrivacyScanner() - # When _run raises, the nested try/except catches it and falls to ok with patch.object(scanner, "_run", side_effect=FileNotFoundError): result = scanner.check_icloud_sync() assert result.status == "ok" +# --------------------------------------------------------------------------- +# TestRunQuick +# --------------------------------------------------------------------------- + + class TestRunQuick: """Tests for run_quick subset.""" def test_run_quick_returns_subset(self) -> None: scanner = PrivacyScanner() - with patch.object(scanner, "check_filevault") as fv, \ - patch.object(scanner, "check_luks") as luks, \ - patch.object(scanner, "check_icloud_sync") as ic, \ - patch.object(scanner, "check_cloud_sync_agents") as cs: - import sys - plat = sys.platform + plat = sys.platform + with ( + patch.object(scanner, "check_filevault") as fv, + patch.object(scanner, "check_luks") as luks, + patch.object(scanner, "check_icloud_sync") as ic, + patch.object(scanner, "check_cloud_sync_agents") as cs, + ): fv.return_value = ScanResult("FV", "ok", "ok", "darwin") luks.return_value = ScanResult("LUKS", "ok", "ok", "linux") ic.return_value = ScanResult("iCloud", "ok", "ok", "darwin") cs.return_value = ScanResult("Cloud", "ok", "ok", plat) results = scanner.run_quick() - # Should only contain current platform results for r in results: assert r.platform in (plat, "all") - # Should not contain network or screen recording names = {r.name for r in results} assert "Network Exposure" not in names assert "Screen Recording" not in names From 68a8f5646c7614837ca9ecbce2aff4b62821f1b9 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:07:35 -0700 Subject: [PATCH 12/12] fix: add --no-download and PrivacyScanner mock to test_cli init test The test_init_creates_config test in test_cli.py was not updated when the download prompt and privacy hook were added to jarvis init. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/cli/test_cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index ce9e7f56..c5d1ac16 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -80,8 +80,11 @@ class TestCLI: with ( mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"), ): - result = CliRunner().invoke(cli, ["init", "--engine", "ollama"]) + result = CliRunner().invoke( + cli, ["init", "--engine", "ollama", "--no-download"] + ) assert result.exit_code == 0 assert config_path.exists() content = config_path.read_text()