From c0be383f9906e005de43f3a368e8dd191da3d621 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 26 Jun 2026 04:57:20 -0700 Subject: [PATCH] refactor(doctor): replace turnstone-bootstrap with turnstone-doctor (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(doctor): replace turnstone-bootstrap with turnstone-doctor turnstone-bootstrap was an LLM setup wizard for Day-0; run.sh now owns install. Repurpose its LLM/conversation plumbing into turnstone-doctor — a diagnose-only tool for a running cluster. - Preflight detects the install kind (docker-compose/systemd/pip/source) from config.toml + TURNSTONE_* env, with secret redaction. - Self-configuring brain resolves the cluster's own model from config/env/storage read-only (no migrations, no create_all), falling back to interactive selection; the attempt itself is the LLM-backend health check. - Deterministic version check: installed version, cluster drift via the console's authoritative /health, and latest upstream stable/experimental (offline-safe). - Read-only diagnostic tools (read_file, compose/systemd/journal, http_health, check_llm_backend, node_health, finish) behind one secret-scrubbing chokepoint; no generic shell, so read-only is structural. - node_health reaches a node the right way for the detected install kind (exec-into-container for compose, direct HTTP otherwise), overridable per node for mixed clusters. - mTLS-aware: forwards [database] SSL params and reports node-mesh mTLS instead of mislabelling healthy nodes "unreachable". init_storage gains a backward-compatible create_tables override for read-only opens. Entry point turnstone-bootstrap -> turnstone-doctor; README/QUICKSTART/ architecture/docker docs, the bundled compose header, run.sh, and the CI smoke updated. CHANGELOG deferred. * fix(doctor): address Copilot + CodeQL review findings on #718 Validated all seven review findings (none false positives) and fixed: - check_llm_backend now applies the same scheme / metadata-host guard as http_health (extracted to _assert_safe_http_url), so a model-supplied base_url can't be steered at the cloud metadata endpoint or a file:// URL. - node_health no longer double-appends the default port when the operator passes host:port (regression: 10.0.0.5:8081 -> http://10.0.0.5:8081:8080). - node_health install_type enum uses "git-source" to match the label the rest of the module and the prompt/report show the model (a schema-strict provider would otherwise reject the value the model is told to use). - _read_api_creds takes base_url + api_key as a unit from the first config source that defines either field, then env-fills, instead of splicing the two across different config files into a pair that exists in no real config. - _mask_secrets masks assignment-shaped content inside comment lines, so a commented-out real secret can't leak through read_file / the report; prose comments (no KEY=value shape) still pass through untouched. - drop the mixed import styles CodeQL flagged in doctor.py and test_doctor.py. Adds 5 tests; ruff + mypy clean; full doctor suite passes (129). --- .github/workflows/ci.yml | 2 +- QUICKSTART.md | 147 +- README.md | 4 +- docs/architecture.md | 2 +- docs/docker.md | 4 +- pyproject.toml | 2 +- run.sh | 3 + tests/test_bootstrap.py | 683 --------- tests/test_doctor.py | 1333 ++++++++++++++++ turnstone/bootstrap.py | 1240 --------------- turnstone/core/storage/_registry.py | 16 +- turnstone/deploy/__init__.py | 5 +- turnstone/deploy/compose.yaml | 4 +- turnstone/doctor.py | 2195 +++++++++++++++++++++++++++ 14 files changed, 3636 insertions(+), 2004 deletions(-) delete mode 100644 tests/test_bootstrap.py create mode 100644 tests/test_doctor.py delete mode 100644 turnstone/bootstrap.py create mode 100644 turnstone/doctor.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 537c7230..70a81f29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,7 +146,7 @@ jobs: /tmp/smoke/bin/turnstone-console --help /tmp/smoke/bin/turnstone-admin --help /tmp/smoke/bin/turnstone-channel --help - /tmp/smoke/bin/turnstone-bootstrap --help + /tmp/smoke/bin/turnstone-doctor --help lock-check: runs-on: ubuntu-latest diff --git a/QUICKSTART.md b/QUICKSTART.md index 4d489490..702e3b08 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,92 +1,107 @@ -# Bootstrap Wizard +# Quickstart -Interactive, AI-guided setup for Turnstone deployments. Instead of manually -editing `.env` files and reading deployment docs, the wizard walks you through -every decision conversationally and generates all the config files for you. +Install Turnstone, then diagnose it with `turnstone-doctor` if anything looks off. -## Quick Start +## Install + +The one-line installer autodetects your distro (Ubuntu/Debian, Fedora/RHEL, +Arch, and WSL), installs git + Docker if missing, generates secrets, picks free +ports, and starts the stack: ```bash -turnstone-bootstrap +curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash ``` -That's it — no flags, no arguments. The wizard prompts for everything. +Re-running is safe — it updates the checkout and keeps your existing `.env`. +When it finishes it prints the dashboard URL and how to create the first admin +user. -## How It Works +**Other ways to install** -1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to - power the wizard. Local endpoints auto-detect available models. -2. **Answer questions** — The AI walks you through deployment mode, LLM - provider, database, authentication, ports, and optional features. -3. **Review generated files** — Each file is previewed before writing. You - confirm or reject every write. -4. **Start the stack** — The wizard prints the exact `docker compose` command - and a `setup.sh` script to create your first admin user, roles, and policies. +- **Already have Docker?** Clone the repo and `docker compose up` for the full + local cluster, or `docker compose -f turnstone/deploy/compose.yaml up` for the + released single-node stack. See [docs/docker.md](docs/docker.md). +- **Python package:** `pip install turnstone` (add `--pre` for the experimental + track), then run `turnstone-server` / `turnstone-console` directly. See the + [README](README.md#quickstart). -## What Gets Generated +## Diagnose: `turnstone-doctor` -| File | Purpose | +`turnstone-doctor` is an LLM-backed assistant that inspects a **running** +Turnstone install and helps you troubleshoot it. It is **read-only** — it +investigates and tells you the exact commands to fix things, but never changes +your system. (Installation is the installer's job, not the doctor's.) + +```bash +# From a host that has the turnstone package installed: +turnstone-doctor + +# For a Docker install from run.sh (no package on the host), run it with pipx: +pipx run --spec turnstone turnstone-doctor --dir ~/turnstone +``` + +### What it does + +1. **Preflight** — detects how Turnstone is installed here (docker-compose, + systemd/bare-metal, pip, or a source checkout) by probing for `config.toml` + files, `TURNSTONE_*` environment variables, compose files, and systemd units. +2. **Self-configures its LLM** — it powers its own brain from your cluster's + *own* model configuration (env / `config.toml` / the database). Whether that + works is the first diagnostic: success means your LLM backend is healthy; if + it can't, that's surfaced as finding #1 and it falls back to asking you for a + provider and key so it can still help. +3. **Version check** — reports the installed version, version drift across your + cluster's nodes, and the latest upstream stable/experimental releases. +4. **Interactive diagnosis** — it reads logs, `/health`, `docker compose ps`, + `systemctl`, config, and ports to pin down problems like a node not joining + the console, an unreachable database, a down model backend, port conflicts, + or a JWT-secret mismatch — then hands you the precise remediation commands. + +### Flags + +| Flag | Purpose | |------|---------| -| `.env` | All environment variables for `compose.yaml` | -| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API | -| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed | +| `--dir PATH` | Install directory to inspect (default: current directory) | +| `--report` | Print the deterministic preflight report and exit — no LLM key needed | +| `--offline` | Skip the upstream GitHub version check | -## Requirements +`--report` is the fastest way to get a health snapshot (and to share one when +asking for help) — it never needs an API key: -- **Python 3.11+** with turnstone installed (`pip install turnstone`) -- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local - model). This can differ from the LLM your deployment will use. -- **Docker & Docker Compose** — needed to run the stack. The wizard detects - whether Docker is installed and gives platform-specific install instructions - if it's missing. You can still generate config files without Docker. - -## Deployment Modes - -- **Single-node production** — `docker compose up` against the bundled - `turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL, - pulled from ghcr.io. Good for most deployments. -- **Local multi-node cluster** — clone the repo and run `docker compose up` at - the root for a 10-node fleet + console + Caddy + channel, built locally. - -See [docs/docker.md](docs/docker.md) for both. - -## Example Session +```bash +turnstone-doctor --report --dir ~/turnstone +``` ``` -$ turnstone-bootstrap +## Install profile +- Detected kind(s): docker-compose (primary: docker-compose) +- Docker daemon reachable: yes +- Compose files: + /home/you/turnstone/compose.yaml +- Database: backend=postgresql, url=postgresql+psycopg://turnstone:****@postgres:5432/turnstone +- Candidate health URLs: http://localhost:8080/health, http://localhost:8090/health - Turnstone Bootstrap Wizard v1.5.0 - ──────────────────────────────────────────────── +## Versions +- Installed (this tool): 1.7.0a2 +- Cluster nodes: 10 reporting; versions ['1.7.0a2'] +- Version drift across nodes: no +- Upstream: stable 1.6.9, experimental 1.7.0a2 - Which provider for this wizard? - [1] OpenAI - [2] Anthropic - [3] OpenAI-compatible (local/vLLM) - - > 3 - - Base URL [http://localhost:8000/v1]: - API key (press Enter for 'none'): - - Querying http://localhost:8000/v1 for available models... - Found model: Qwen/Qwen3-32B - - Connected to Qwen/Qwen3-32B. Handing off to AI assistant... - -> (AI walks you through the rest interactively) +## LLM backend (ok) +- resolved Qwen/Qwen3-32B via openai-compatible @ http://host.docker.internal:8000/v1 ``` +Secrets (JWT secret, database password, API keys) are always redacted in the +report and in anything the doctor reads. + ## Tips -- **Re-run safely** — running the wizard again detects your existing `.env` - and offers to update it rather than overwriting. -- **Duplicate writes are skipped** — if the LLM tries to write the same file - twice with identical content, it's silently ignored. -- **Type `quit` to exit** at any time during the conversation. -- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit. +- **Type `quit`** to exit the conversation; **Ctrl+C** interrupts (twice to quit). +- **Point it at the right install** with `--dir` when you run it from elsewhere. +- **(Re)installing or adding nodes?** Use the installer (`run.sh`), not the doctor. ## See Also -- [Docker Deployment](docs/docker.md) — manual compose setup and profiles +- [Docker Deployment](docs/docker.md) — compose stacks, ports, and bare-metal nodes - [Security](docs/security.md) — auth architecture and token types - [Governance](docs/governance.md) — roles, policies, and templates diff --git a/README.md b/README.md index f2eaf66a..7f0f9692 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ LLM; add model backends from the console UI. For production (released images from ghcr.io, real secrets required), use the bundled stack: `docker compose -f turnstone/deploy/compose.yaml up`. -See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration. +See [QUICKSTART.md](QUICKSTART.md) for the install + troubleshooting walkthrough and [docs/docker.md](docs/docker.md) for Docker configuration. ### Programmatic (SDK) @@ -125,7 +125,7 @@ Built-in tools for shell, files, search, web, memory, notifications, and autonom | `turnstone-channel` | Channel gateway (Discord and Slack adapters) | | `turnstone-admin` | User/token management CLI | | `turnstone-eval` | Eval harness for prompt/tool optimization | -| `turnstone-bootstrap` | LLM-guided setup wizard | +| `turnstone-doctor` | LLM-backed cluster diagnostics | ### Diagrams diff --git a/docs/architecture.md b/docs/architecture.md index 1531d6ac..0b3f651b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ plugs in. | `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization | | `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) | | `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management | -| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard | +| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics | --- diff --git a/docs/docker.md b/docs/docker.md index fcf42ac7..a3429c9a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -125,7 +125,7 @@ docker compose -f turnstone/deploy/compose.yaml up It's the same shape as the dev stack — Caddy-fronted console, channel, and a PostgreSQL all share one database so the console discovers the node — but it pulls released images, runs a single server node, and has **no baked-in -secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them): +secrets**. Set these in `.env` first (generate with `openssl rand -hex 32`): ```bash TURNSTONE_JWT_SECRET= @@ -260,7 +260,7 @@ interface, or anyone who can reach it can search through your instance. Both stacks install all entry points into a single image (`turnstone`, `turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, -`turnstone-eval`, `turnstone-bootstrap`): +`turnstone-eval`, `turnstone-doctor`): ```bash docker compose build # build the dev image diff --git a/pyproject.toml b/pyproject.toml index d7881733..86ac399a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ turnstone-server = "turnstone.server:main" turnstone-console = "turnstone.console.server:main" turnstone-admin = "turnstone.admin:main" turnstone-channel = "turnstone.channels.cli:main" -turnstone-bootstrap = "turnstone.bootstrap:main" +turnstone-doctor = "turnstone.doctor:main" [tool.hatch.build.targets.wheel] include = [ diff --git a/run.sh b/run.sh index 2def37c1..0060992a 100755 --- a/run.sh +++ b/run.sh @@ -380,6 +380,9 @@ ${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT" ${DIM}$DOCKER compose down${RESET} stop (add -v to wipe data) Config $INSTALL_DIR/.env (generated secrets + ports) + + Troubleshoot ${DIM}pipx run --spec turnstone turnstone-doctor --dir $INSTALL_DIR${RESET} + LLM-backed diagnostics for this install (read-only; needs Python) EOF } diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py deleted file mode 100644 index 6d8a3bc9..00000000 --- a/tests/test_bootstrap.py +++ /dev/null @@ -1,683 +0,0 @@ -"""Tests for the bootstrap wizard module.""" - -from __future__ import annotations - -import os -import socket -from pathlib import Path -from unittest.mock import MagicMock, patch - -from turnstone.bootstrap import ( - SYSTEM_PROMPT, - TOOLS, - _BootstrapLLM, - _FinishError, - _mask_secrets, - _tool_check_docker, - _tool_check_port, - _tool_finish, - _tool_generate_secret, - _tool_read_file, - _tool_validate_api_key, - _tool_write_compose, - _tool_write_file, - execute_tool, -) - -# --------------------------------------------------------------------------- -# Tool function tests -# --------------------------------------------------------------------------- - - -class TestReadFile: - def test_existing_file(self, tmp_path: Path) -> None: - f = tmp_path / "test.txt" - f.write_text("hello world") - result = _tool_read_file(tmp_path, {"path": "test.txt"}) - assert result == "hello world" - - def test_missing_file(self, tmp_path: Path) -> None: - result = _tool_read_file(tmp_path, {"path": "nope.txt"}) - assert "Error: file not found" in result - - def test_nested_path(self, tmp_path: Path) -> None: - sub = tmp_path / "sub" - sub.mkdir() - f = sub / "nested.txt" - f.write_text("nested content") - result = _tool_read_file(tmp_path, {"path": "sub/nested.txt"}) - assert result == "nested content" - - def test_path_traversal_blocked(self, tmp_path: Path) -> None: - result = _tool_read_file(tmp_path, {"path": "../../etc/passwd"}) - assert "escapes project directory" in result - - def test_absolute_path_blocked(self, tmp_path: Path) -> None: - result = _tool_read_file(tmp_path, {"path": "/etc/passwd"}) - assert "escapes project directory" in result - - -class TestWriteFile: - def test_write_confirmed(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"}) - assert "written successfully" in result - assert (tmp_path / "out.txt").read_text() == "data\n" - - def test_write_declined(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="n"): - result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"}) - assert "declined" in result - assert not (tmp_path / "out.txt").exists() - - def test_write_creates_parent_dirs(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - result = _tool_write_file(tmp_path, {"path": "a/b/c.txt", "content": "deep\n"}) - assert "written successfully" in result - assert (tmp_path / "a" / "b" / "c.txt").read_text() == "deep\n" - - def test_sh_files_are_executable(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - _tool_write_file(tmp_path, {"path": "setup.sh", "content": "#!/bin/bash\n"}) - mode = (tmp_path / "setup.sh").stat().st_mode - assert mode & 0o110 # user + group executable, not world - - def test_path_traversal_blocked(self, tmp_path: Path) -> None: - result = _tool_write_file(tmp_path, {"path": "../../escape.txt", "content": "bad\n"}) - assert "escapes project directory" in result - - def test_default_enter_confirms(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value=""): - result = _tool_write_file(tmp_path, {"path": "ok.txt", "content": "ok\n"}) - assert "written successfully" in result - - def test_duplicate_write_skipped(self, tmp_path: Path) -> None: - (tmp_path / "dup.txt").write_text("same\n") - result = _tool_write_file(tmp_path, {"path": "dup.txt", "content": "same\n"}) - assert "already exists" in result - - def test_different_content_still_prompts(self, tmp_path: Path) -> None: - (tmp_path / "changed.txt").write_text("old\n") - with patch("builtins.input", return_value="y"): - result = _tool_write_file(tmp_path, {"path": "changed.txt", "content": "new\n"}) - assert "written successfully" in result - assert (tmp_path / "changed.txt").read_text() == "new\n" - - -class TestWriteCompose: - def test_writes_compose_file(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - result = _tool_write_compose(tmp_path, {}) - assert "written successfully" in result - assert "ghcr.io" in result - content = (tmp_path / "compose.yaml").read_text() - assert "ghcr.io/turnstonelabs/turnstone" in content - assert "TURNSTONE_IMAGE_TAG" in content - # The compose mounts ./Caddyfile and ./searxng, so the wizard must write - # both alongside — guards the extra writes and the pyproject wheel-include. - caddyfile = (tmp_path / "Caddyfile").read_text() - assert "reverse_proxy console:8090" in caddyfile - searxng_cfg = (tmp_path / "searxng" / "settings.yml").read_text() - assert "json" in searxng_cfg # the bundled config enables the JSON API - - def test_user_declines(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="n"): - result = _tool_write_compose(tmp_path, {}) - assert "declined" in result - assert not (tmp_path / "compose.yaml").exists() - - def test_identical_content_skipped(self, tmp_path: Path) -> None: - # Write it once - with patch("builtins.input", return_value="y"): - _tool_write_compose(tmp_path, {}) - # Second call should skip - result = _tool_write_compose(tmp_path, {}) - assert "identical content" in result - - def test_no_build_blocks(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - _tool_write_compose(tmp_path, {}) - content = (tmp_path / "compose.yaml").read_text() - assert "build:" not in content - assert "dockerfile:" not in content.lower() - - def test_overwrites_different_content(self, tmp_path: Path) -> None: - (tmp_path / "compose.yaml").write_text("old content\n") - with patch("builtins.input", return_value="y"): - result = _tool_write_compose(tmp_path, {}) - assert "written successfully" in result - content = (tmp_path / "compose.yaml").read_text() - assert "ghcr.io" in content - - def test_no_local_image_references(self, tmp_path: Path) -> None: - with patch("builtins.input", return_value="y"): - _tool_write_compose(tmp_path, {}) - content = (tmp_path / "compose.yaml").read_text() - assert "turnstone:local" not in content - - -class TestGenerateSecret: - def test_default_length(self) -> None: - secret = _tool_generate_secret({}) - assert len(secret) == 64 # 32 bytes -> 64 hex chars - - def test_custom_length(self) -> None: - secret = _tool_generate_secret({"length": 16}) - assert len(secret) == 32 - - def test_uniqueness(self) -> None: - s1 = _tool_generate_secret({}) - s2 = _tool_generate_secret({}) - assert s1 != s2 - - def test_invalid_length_fallback(self) -> None: - secret = _tool_generate_secret({"length": -1}) - assert len(secret) == 64 # falls back to 32 bytes - - def test_excessive_length_capped(self) -> None: - secret = _tool_generate_secret({"length": 99999}) - assert len(secret) == 64 # falls back to 32 bytes - - -class TestCheckPort: - def test_available_port(self) -> None: - # Pick a random high port that's likely free - result = _tool_check_port({"port": 59123}) - assert "AVAILABLE" in result or "IN USE" in result - - def test_in_use_port(self) -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - sock.listen(1) - result = _tool_check_port({"port": port}) - assert "IN USE" in result - - def test_invalid_port(self) -> None: - result = _tool_check_port({"port": -1}) - assert "Error" in result - - def test_port_zero(self) -> None: - result = _tool_check_port({"port": 0}) - assert "Error" in result - - -class TestCheckDocker: - def test_docker_installed(self) -> None: - mock_docker = MagicMock() - mock_docker.returncode = 0 - mock_docker.stdout = "24.0.7" - - mock_compose = MagicMock() - mock_compose.returncode = 0 - mock_compose.stdout = "2.24.5" - - with patch("subprocess.run", side_effect=[mock_docker, mock_compose]): - result = _tool_check_docker({}) - assert "Docker: installed" in result - assert "Docker Compose: installed" in result - - def test_docker_not_installed(self) -> None: - with patch("subprocess.run", side_effect=FileNotFoundError): - result = _tool_check_docker({}) - assert "NOT installed" in result or "NOT available" in result - - def test_docker_daemon_not_running(self) -> None: - mock_docker = MagicMock() - mock_docker.returncode = 1 - mock_docker.stderr = "Cannot connect to the Docker daemon" - - mock_compose = MagicMock() - mock_compose.returncode = 1 - - with patch("subprocess.run", side_effect=[mock_docker, mock_compose]): - result = _tool_check_docker({}) - assert "NOT running" in result - - -class TestValidateApiKey: - def test_openai_success(self) -> None: - mock_client = MagicMock() - mock_client.models.list.return_value = [] - with patch("openai.OpenAI", return_value=mock_client): - result = _tool_validate_api_key({"provider": "openai", "api_key": "sk-test"}) - assert "Success" in result - - def test_openai_failure(self) -> None: - with patch("openai.OpenAI") as mock_cls: - mock_cls.return_value.models.list.side_effect = Exception("Invalid key") - result = _tool_validate_api_key({"provider": "openai", "api_key": "bad"}) - assert "Failed" in result - - def test_unknown_provider(self) -> None: - result = _tool_validate_api_key({"provider": "unknown", "api_key": "x"}) - assert "unknown" in result - - -class TestExecuteTool: - def test_unknown_tool(self, tmp_path: Path) -> None: - result = execute_tool("nonexistent", {}, tmp_path) - assert "unknown tool" in result - - def test_dispatches_correctly(self, tmp_path: Path) -> None: - f = tmp_path / "hello.txt" - f.write_text("hi") - result = execute_tool("read_file", {"path": "hello.txt"}, tmp_path) - assert result == "hi" - - def test_finish_raises(self, tmp_path: Path) -> None: - import pytest - - with pytest.raises(_FinishError, match="All done"): - execute_tool("finish", {"summary": "All done"}, tmp_path) - - -class TestFinishTool: - def test_raises_with_summary(self) -> None: - import pytest - - with pytest.raises(_FinishError) as exc_info: - _tool_finish({"summary": "Configured production deployment."}) - assert exc_info.value.summary == "Configured production deployment." - - def test_default_summary(self) -> None: - import pytest - - with pytest.raises(_FinishError) as exc_info: - _tool_finish({}) - assert exc_info.value.summary == "Setup complete." - - -# --------------------------------------------------------------------------- -# Secret masking tests -# --------------------------------------------------------------------------- - - -class TestMaskSecrets: - def test_masks_api_key(self) -> None: - text = "OPENAI_API_KEY=sk-1234567890abcdef" - result = _mask_secrets(text) - assert "sk-1" in result - assert "cdef" in result - assert "1234567890abcde" not in result - - def test_preserves_comments(self) -> None: - text = "# OPENAI_API_KEY=sk-1234567890abcdef" - result = _mask_secrets(text) - assert result == text - - def test_preserves_short_values(self) -> None: - text = "TOKEN=short" - result = _mask_secrets(text) - assert result == text - - def test_preserves_non_sensitive(self) -> None: - text = "MODEL=gpt-5.4" - result = _mask_secrets(text) - assert result == text - - -# --------------------------------------------------------------------------- -# Message conversion tests (Anthropic) -# --------------------------------------------------------------------------- - - -class TestAnthropicConversion: - """Test the Anthropic message/tool conversion inside _BootstrapLLM.""" - - def _make_llm(self) -> _BootstrapLLM: - return _BootstrapLLM("anthropic", MagicMock(), "test-model") - - def test_tool_format_conversion(self) -> None: - """OpenAI tool format should convert to Anthropic format.""" - llm = self._make_llm() - # The conversion happens inside _complete_anthropic; we test indirectly - # by checking the tools passed to the mock client - mock_response = MagicMock() - mock_response.content = [MagicMock(type="text", text="hello")] - mock_response.stop_reason = "end_turn" - llm.client.messages.create.return_value = mock_response - - llm.complete( - [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}], - TOOLS[:1], # Just read_file - ) - - call_kwargs = llm.client.messages.create.call_args[1] - api_tools = call_kwargs["tools"] - assert len(api_tools) == 1 - assert api_tools[0]["name"] == "read_file" - assert "input_schema" in api_tools[0] - assert "description" in api_tools[0] - - def test_system_message_extraction(self) -> None: - """System message should be extracted to system parameter.""" - llm = self._make_llm() - mock_response = MagicMock() - mock_response.content = [MagicMock(type="text", text="ok")] - mock_response.stop_reason = "end_turn" - llm.client.messages.create.return_value = mock_response - - llm.complete( - [{"role": "system", "content": "test system"}, {"role": "user", "content": "hi"}], - [], - ) - - call_kwargs = llm.client.messages.create.call_args[1] - assert call_kwargs["system"] == "test system" - # System should NOT appear in messages - for msg in call_kwargs["messages"]: - assert msg["role"] != "system" - - def test_tool_result_conversion(self) -> None: - """OpenAI tool result messages should convert to Anthropic format.""" - llm = self._make_llm() - mock_response = MagicMock() - mock_response.content = [MagicMock(type="text", text="got it")] - mock_response.stop_reason = "end_turn" - llm.client.messages.create.return_value = mock_response - - messages = [ - {"role": "system", "content": "sys"}, - {"role": "user", "content": "hi"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "tc_1", - "type": "function", - "function": {"name": "check_docker", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "tool_call_id": "tc_1", - "content": "Docker: installed", - }, - ] - llm.complete(messages, TOOLS) - - call_kwargs = llm.client.messages.create.call_args[1] - api_messages = call_kwargs["messages"] - - # Find the tool_result message - tool_result_found = False - for msg in api_messages: - if msg["role"] == "user" and isinstance(msg.get("content"), list): - for block in msg["content"]: - if isinstance(block, dict) and block.get("type") == "tool_result": - assert block["tool_use_id"] == "tc_1" - assert block["content"] == "Docker: installed" - tool_result_found = True - assert tool_result_found - - def test_tool_use_blocks_in_assistant(self) -> None: - """Assistant messages with tool_calls should convert to content blocks.""" - llm = self._make_llm() - mock_response = MagicMock() - mock_response.content = [MagicMock(type="text", text="ok")] - mock_response.stop_reason = "end_turn" - llm.client.messages.create.return_value = mock_response - - messages = [ - {"role": "system", "content": "sys"}, - {"role": "user", "content": "hi"}, - { - "role": "assistant", - "content": "Let me check", - "tool_calls": [ - { - "id": "tc_1", - "type": "function", - "function": {"name": "check_docker", "arguments": "{}"}, - } - ], - }, - {"role": "tool", "tool_call_id": "tc_1", "content": "ok"}, - ] - llm.complete(messages, TOOLS) - - call_kwargs = llm.client.messages.create.call_args[1] - api_messages = call_kwargs["messages"] - - # First message should be user "hi" - assert api_messages[0]["role"] == "user" - # Second should be assistant with content blocks - assistant_msg = api_messages[1] - assert assistant_msg["role"] == "assistant" - assert isinstance(assistant_msg["content"], list) - # Should have text block + tool_use block - types = [b["type"] for b in assistant_msg["content"]] - assert "text" in types - assert "tool_use" in types - - -class TestOpenAICompletion: - """Test the OpenAI path of _BootstrapLLM.""" - - def test_text_response(self) -> None: - llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") - mock_choice = MagicMock() - mock_choice.message.content = "Hello!" - mock_choice.message.tool_calls = None - mock_choice.finish_reason = "stop" - llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) - - content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], TOOLS) - assert content == "Hello!" - assert tool_calls is None - assert reason == "stop" - - def test_tool_call_response(self) -> None: - llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") - - mock_tc = MagicMock() - mock_tc.id = "call_123" - mock_tc.function.name = "check_docker" - mock_tc.function.arguments = "{}" - - mock_choice = MagicMock() - mock_choice.message.content = "" - mock_choice.message.tool_calls = [mock_tc] - mock_choice.finish_reason = "tool_calls" - llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) - - content, tool_calls, reason = llm.complete( - [{"role": "user", "content": "check docker"}], TOOLS - ) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0]["function"]["name"] == "check_docker" - assert tool_calls[0]["id"] == "call_123" - - def test_no_content(self) -> None: - llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") - mock_choice = MagicMock() - mock_choice.message.content = None - mock_choice.message.tool_calls = None - mock_choice.finish_reason = "stop" - llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) - - content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], []) - assert content == "" - assert tool_calls is None - - -# --------------------------------------------------------------------------- -# Conversation loop tests -# --------------------------------------------------------------------------- - - -class TestConversationLoop: - def test_quit_exits(self) -> None: - """User typing 'quit' should exit the loop.""" - llm = MagicMock(spec=_BootstrapLLM) - llm.complete.return_value = ("What would you like?", None, "stop") - - with patch("builtins.input", return_value="quit"): - from turnstone.bootstrap import _run_conversation - - _run_conversation(llm, Path("/tmp")) - - def test_tool_calls_executed(self, tmp_path: Path) -> None: - """Tool calls should be executed and results fed back.""" - llm = MagicMock(spec=_BootstrapLLM) - # First call: LLM returns a tool call - llm.complete.side_effect = [ - ( - "", - [ - { - "id": "tc_1", - "type": "function", - "function": {"name": "generate_secret", "arguments": "{}"}, - } - ], - "tool_calls", - ), - # Second call: LLM responds with text after seeing tool result - ("Here's your secret!", None, "stop"), - ] - - with patch("builtins.input", return_value="quit"): - from turnstone.bootstrap import _run_conversation - - _run_conversation(llm, tmp_path) - - # Verify two calls were made - assert llm.complete.call_count == 2 - # Verify tool result was fed back in second call's messages - second_call_messages = llm.complete.call_args_list[1][0][0] - tool_results = [m for m in second_call_messages if m.get("role") == "tool"] - assert len(tool_results) == 1 - assert tool_results[0]["tool_call_id"] == "tc_1" - # Result should be a 64-char hex string - assert len(tool_results[0]["content"]) == 64 - - def test_empty_input_skipped(self) -> None: - """Empty user input should be skipped.""" - llm = MagicMock(spec=_BootstrapLLM) - llm.complete.return_value = ("Ask me something.", None, "stop") - - call_count = 0 - - def mock_input(prompt: str = "") -> str: - nonlocal call_count - call_count += 1 - if call_count <= 2: - return "" # Empty inputs - return "quit" - - with patch("builtins.input", side_effect=mock_input): - from turnstone.bootstrap import _run_conversation - - _run_conversation(llm, Path("/tmp")) - - def test_finish_tool_exits_loop(self, tmp_path: Path) -> None: - """LLM calling finish tool should exit the conversation cleanly.""" - llm = MagicMock(spec=_BootstrapLLM) - llm.complete.return_value = ( - "", - [ - { - "id": "tc_fin", - "type": "function", - "function": { - "name": "finish", - "arguments": '{"summary": "All configured."}', - }, - } - ], - "tool_calls", - ) - - from turnstone.bootstrap import _run_conversation - - # Should return without needing user input - _run_conversation(llm, tmp_path) - assert llm.complete.call_count == 1 - - -# --------------------------------------------------------------------------- -# Interactive startup tests -# --------------------------------------------------------------------------- - - -class TestProviderDefaults: - def test_openai_default_model(self) -> None: - from turnstone.bootstrap import _DEFAULT_MODELS - - assert _DEFAULT_MODELS["openai"] == "gpt-5.4" - - def test_anthropic_default_model(self) -> None: - from turnstone.bootstrap import _DEFAULT_MODELS - - assert _DEFAULT_MODELS["anthropic"] == "claude-sonnet-4-6" - - -class TestSelectProvider: - def test_openai_selection(self) -> None: - """Selecting '1' should set up OpenAI.""" - mock_client = MagicMock() - with ( - patch("builtins.input", side_effect=["1", ""]), - patch("getpass.getpass", return_value="sk-test"), - patch("openai.OpenAI", return_value=mock_client), - ): - from turnstone.bootstrap import _select_provider - - provider, client, model = _select_provider() - assert provider == "openai" - assert model == "gpt-5.4" - - def test_local_selection(self) -> None: - """Selecting '3' should set up local/vLLM.""" - mock_client = MagicMock() - # Ensure OPENAI_API_KEY is not in env so we hit the getpass path - env = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"} - with ( - patch.dict("os.environ", env, clear=True), - patch("builtins.input", side_effect=["3", "http://localhost:8000/v1", "my-model"]), - patch("getpass.getpass", return_value="none"), - patch("openai.OpenAI", return_value=mock_client), - ): - from turnstone.bootstrap import _select_provider - - provider, client, model = _select_provider() - assert provider == "openai" - assert model == "my-model" - - -# --------------------------------------------------------------------------- -# System prompt and tools sanity checks -# --------------------------------------------------------------------------- - - -class TestConstants: - def test_system_prompt_not_empty(self) -> None: - assert len(SYSTEM_PROMPT) > 500 - - def test_system_prompt_mentions_turnstone(self) -> None: - assert "Turnstone" in SYSTEM_PROMPT - - def test_all_tools_have_required_fields(self) -> None: - for tool in TOOLS: - assert tool["type"] == "function" - func = tool["function"] - assert "name" in func - assert "description" in func - assert "parameters" in func - assert func["parameters"]["type"] == "object" - - def test_tool_count(self) -> None: - assert len(TOOLS) == 8 - - def test_all_tools_have_implementations(self) -> None: - from turnstone.bootstrap import TOOL_FUNCTIONS - - for tool in TOOLS: - name = tool["function"]["name"] - assert name in TOOL_FUNCTIONS, f"Missing implementation for tool: {name}" diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 00000000..ac25f7df --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,1333 @@ +"""Tests for the turnstone-doctor diagnostic tool.""" + +from __future__ import annotations + +import socket +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from turnstone import __version__ +from turnstone.doctor import ( + SYSTEM_PROMPT, + TOOLS, + BackendVerdict, + ConfigFileInfo, + InstallProfile, + VersionReport, + _compose_image_tag, + _derive_health_urls, + _discover_config_files, + _DoctorLLM, + _fetch_upstream_versions, + _find_compose_files, + _find_repo_root, + _FinishError, + _http_get_json, + _mask_secrets, + _parse_config_sections, + _primary_kind, + _read_api_creds, + _redact_url_credentials, + _relevant_env, + _resolve_db_config, + _run_conversation, + _scrub_tool_output, + _select_provider, + _tool_check_docker, + _tool_check_port, + _tool_compose_logs, + _tool_compose_status, + _tool_finish, + _tool_journal_tail, + _tool_node_health, + _tool_read_file, + _tool_systemd_status, + _version_behind, + check_versions, + detect_install_profile, + execute_tool, + family_of, + open_storage, + render_full_report, + render_profile_report, + render_version_report, + resolve_doctor_brain, +) + +MUTATING_TOKENS = frozenset( + {"up", "down", "restart", "stop", "start", "rm", "exec", "create", "delete", "kill", "build"} +) + + +def _make_profile(tmp_path: Path, **overrides: object) -> InstallProfile: + """Build an InstallProfile with sane defaults for tests.""" + base: dict[str, object] = { + "project_dir": tmp_path, + "kinds": [], + "primary_kind": "unknown", + "install_source": "unknown", + "repo_root": None, + "docker_available": False, + "compose_files": [], + "compose_ps": "", + "systemd_units": [], + "config_files": [], + "env_present": {}, + "db_config": {"backend": "sqlite", "url": "", "path": ""}, + "health_urls": [], + } + base.update(overrides) + return InstallProfile(**base) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Secret masking +# --------------------------------------------------------------------------- + + +class TestMaskSecrets: + def test_masks_env_api_key(self) -> None: + out = _mask_secrets("OPENAI_API_KEY=sk-1234567890abcdef") + assert "sk-1" in out and "cdef" in out + assert "1234567890abcde" not in out + + def test_masks_toml_api_key(self) -> None: + out = _mask_secrets('api_key = "sk-1234567890abcdef"') + assert "sk-1234567890abcdef" not in out + assert "****" in out + + def test_masks_toml_jwt_secret(self) -> None: + out = _mask_secrets('jwt_secret = "deadbeefdeadbeefdeadbeef"') + assert "deadbeefdeadbeefdeadbeef" not in out + + def test_redacts_db_url_password(self) -> None: + line = 'url = "postgresql+psycopg://turnstone:supersecret@db:5432/turnstone"' + out = _mask_secrets(line) + assert "supersecret" not in out + assert "turnstone:****@db" in out + # host and db name stay visible for diagnosis + assert "db:5432/turnstone" in out + + def test_base_url_not_masked(self) -> None: + out = _mask_secrets("LLM_BASE_URL=http://localhost:8000/v1") + assert "http://localhost:8000/v1" in out + + def test_commented_secret_still_masked(self) -> None: + # A commented-out assignment can still hold a real secret, so its value is + # masked even in a comment — the '#' marker and key stay for readability. + out = _mask_secrets("# OPENAI_API_KEY=sk-1234567890abcdef") + assert out.startswith("# OPENAI_API_KEY=") + assert "sk-1234567890abcdef" not in out + assert "1234567890abcde" not in out + + def test_prose_comment_preserved(self) -> None: + # A prose comment has no KEY=value shape, so it passes through untouched. + text = "# Set your API key in the environment before starting." + assert _mask_secrets(text) == text + + def test_non_secret_preserved(self) -> None: + assert _mask_secrets("MODEL=gpt-5.4") == "MODEL=gpt-5.4" + + def test_redact_url_credentials_direct(self) -> None: + out = _redact_url_credentials("postgresql://u:p@h/db") + assert out == "postgresql://u:****@h/db" + + def test_hash_in_secret_value_not_leaked(self) -> None: + # '#' must NOT be treated as a comment inside a value (bug-2). + out = _mask_secrets('password = "p#ssw0rd1234"') + assert "p#ssw0rd1234" not in out + assert "ssw0rd" not in out + + def test_hash_in_db_url_password_not_leaked(self) -> None: + out = _mask_secrets('url = "postgresql://turnstone:p#ss@h:5432/db"') + assert "p#ss" not in out + assert "turnstone:****@h" in out + + def test_yaml_style_secret_masked(self) -> None: + out = _mask_secrets("POSTGRES_PASSWORD: hunter2s=long") + assert "hunter2s=long" not in out + + def test_timestamp_line_untouched(self) -> None: + # A log line with ':' but no config key must pass through verbatim. + line = "2026-06-25 22:42:53 [warning] something happened" + assert _mask_secrets(line) == line + + +class TestScrubToolOutput: + def test_redacts_dsn_in_log(self) -> None: + # A DSN in a connection-failure log line (no assignment key) is redacted. + out = _scrub_tool_output("FATAL: connect to postgresql://u:hunter2pw@h/db failed") + assert "hunter2pw" not in out + assert "u:****@h" in out + + def test_drops_pem_block(self) -> None: + text = "before\n-----BEGIN PRIVATE KEY-----\nSEKRIT\n-----END PRIVATE KEY-----\nafter" + out = _scrub_tool_output(text) + assert "SEKRIT" not in out + assert "before" in out and "after" in out + + def test_masks_env_echo(self) -> None: + # Env echo prints one VAR=value per line — the secret line is masked. + out = _scrub_tool_output("startup env:\nTURNSTONE_JWT_SECRET=deadbeefdeadbeef\nready") + assert "deadbeefdeadbeef" not in out + + +# --------------------------------------------------------------------------- +# read_file (scoped + masked) +# --------------------------------------------------------------------------- + + +class TestReadFile: + def test_existing_file(self, tmp_path: Path) -> None: + (tmp_path / "a.txt").write_text("hello") + assert _tool_read_file(tmp_path, {"path": "a.txt"}) == "hello" + + def test_missing_file(self, tmp_path: Path) -> None: + assert "file not found" in _tool_read_file(tmp_path, {"path": "nope.txt"}) + + def test_traversal_blocked(self, tmp_path: Path) -> None: + assert "escapes install directory" in _tool_read_file( + tmp_path, {"path": "../../etc/passwd"} + ) + + def test_absolute_blocked(self, tmp_path: Path) -> None: + assert "escapes install directory" in _tool_read_file(tmp_path, {"path": "/etc/passwd"}) + + def test_secrets_masked_via_execute_tool(self, tmp_path: Path) -> None: + # Masking is centralized at the execute_tool chokepoint. + (tmp_path / ".env").write_text("TURNSTONE_JWT_SECRET=abcdef0123456789\n") + out = execute_tool("read_file", {"path": ".env"}, tmp_path) + assert "abcdef0123456789" not in out + + def test_refuses_key_file(self, tmp_path: Path) -> None: + (tmp_path / "ca.key").write_text( + "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n" + ) + out = _tool_read_file(tmp_path, {"path": "ca.key"}) + assert "Refused" in out and "x" not in out + + def test_refuses_pem_content(self, tmp_path: Path) -> None: + (tmp_path / "creds.txt").write_text( + "-----BEGIN RSA PRIVATE KEY-----\nSEKRIT\n-----END RSA PRIVATE KEY-----\n" + ) + out = _tool_read_file(tmp_path, {"path": "creds.txt"}) + assert "SEKRIT" not in out + + def test_caps_large_read(self, tmp_path: Path) -> None: + (tmp_path / "big.log").write_text("A" * 200_000) + out = _tool_read_file(tmp_path, {"path": "big.log"}) + assert "truncated" in out and len(out) < 100_000 + + +# --------------------------------------------------------------------------- +# check_port / check_docker +# --------------------------------------------------------------------------- + + +class TestCheckPort: + def test_in_use(self) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + assert "IN USE" in _tool_check_port({"port": port}) + + def test_free(self) -> None: + result = _tool_check_port({"port": 59321}) + assert "FREE" in result or "IN USE" in result + + def test_invalid(self) -> None: + assert "Error" in _tool_check_port({"port": -1}) + + +class TestCheckDocker: + def test_installed(self) -> None: + d = MagicMock(returncode=0, stdout="24.0.7") + c = MagicMock(returncode=0, stdout="2.24.5") + with patch("subprocess.run", side_effect=[d, c]): + out = _tool_check_docker({}) + assert "Docker: installed" in out and "Docker Compose: installed" in out + + def test_not_installed(self) -> None: + with patch("subprocess.run", side_effect=FileNotFoundError): + assert "NOT installed" in _tool_check_docker({}) + + def test_daemon_down(self) -> None: + d = MagicMock(returncode=1, stderr="Cannot connect to the Docker daemon") + c = MagicMock(returncode=1) + with patch("subprocess.run", side_effect=[d, c]): + assert "NOT running" in _tool_check_docker({}) + + +# --------------------------------------------------------------------------- +# Diagnostic tools build READ-ONLY command lines (no mutating verbs) +# --------------------------------------------------------------------------- + + +class TestDiagnosticToolsReadOnly: + def _capture(self, fn, *call_args) -> list[str]: + captured: dict[str, list[str]] = {} + + def fake_run(cmd, *a, **k): # noqa: ANN001 + captured["cmd"] = cmd + return MagicMock(returncode=0, stdout="ok", stderr="") + + with patch("turnstone.doctor.subprocess.run", side_effect=fake_run): + fn(*call_args) + return captured["cmd"] + + def test_compose_status_readonly(self, tmp_path: Path) -> None: + cmd = self._capture(_tool_compose_status, tmp_path, {}) + assert cmd[:2] == ["docker", "compose"] + assert "ps" in cmd + assert not (set(cmd) & MUTATING_TOKENS) + + def test_compose_logs_readonly(self, tmp_path: Path) -> None: + cmd = self._capture(_tool_compose_logs, tmp_path, {"service": "node-1", "tail": 50}) + assert "logs" in cmd and "node-1" in cmd + assert not (set(cmd) & MUTATING_TOKENS) + + def test_systemd_status_readonly(self) -> None: + cmd = self._capture(_tool_systemd_status, {"unit": "turnstone-server.service"}) + assert cmd[0] == "systemctl" and "status" in cmd + assert not (set(cmd) & MUTATING_TOKENS) + + def test_journal_tail_readonly(self) -> None: + cmd = self._capture(_tool_journal_tail, {"unit": "turnstone-server.service", "lines": 10}) + assert cmd[0] == "journalctl" + assert not (set(cmd) & MUTATING_TOKENS) + + def test_compose_logs_clamps_tail(self, tmp_path: Path) -> None: + cmd = self._capture(_tool_compose_logs, tmp_path, {"tail": 999999}) + # absurd tail falls back to the default of 100 + assert "100" in cmd + + def test_systemd_status_rejects_option_injection(self) -> None: + # A model-supplied unit that looks like a systemctl global option is refused. + out = _tool_systemd_status({"unit": "-Hattacker.example"}) + assert "invalid unit" in out.lower() + + def test_systemd_status_uses_option_terminator(self, tmp_path: Path) -> None: + cmd = self._capture(_tool_systemd_status, {"unit": "turnstone-server.service"}) + # '--' must precede the unit so it can't be parsed as an option. + assert "--" in cmd + assert cmd.index("--") < cmd.index("turnstone-server.service") + + def test_compose_logs_rejects_dashed_service(self, tmp_path: Path) -> None: + out = _tool_compose_logs(tmp_path, {"service": "--privileged"}) + assert "invalid service" in out.lower() + + +class TestHttpGetJsonSafety: + def test_rejects_file_scheme(self) -> None: + with pytest.raises(ValueError, match="non-http"): + _http_get_json("file:///etc/passwd") + + def test_rejects_link_local_metadata(self) -> None: + with pytest.raises(ValueError, match="link-local|metadata"): + _http_get_json("http://169.254.169.254/latest/meta-data/") + + def test_allows_loopback(self, monkeypatch: pytest.MonkeyPatch) -> None: + # loopback must stay allowed (probing localhost /health is the job) + class _FakeResp: + def __enter__(self) -> _FakeResp: + return self + + def __exit__(self, *a: object) -> bool: + return False + + def read(self) -> bytes: + return b'{"ok": true}' + + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResp()) + assert _http_get_json("http://localhost:8080/health") == {"ok": True} + + +class TestHttpHealthTool: + def test_ok(self) -> None: + from turnstone.doctor import _tool_http_health + + with patch( + "turnstone.doctor._http_get_json", return_value={"status": "ok", "version": "1.7.0a2"} + ): + out = _tool_http_health({"url": "http://localhost:8080"}) + assert "ok" in out and "1.7.0a2" in out + + def test_unreachable(self) -> None: + import urllib.error + + from turnstone.doctor import _tool_http_health + + with patch("turnstone.doctor._http_get_json", side_effect=urllib.error.URLError("refused")): + out = _tool_http_health({"url": "http://localhost:8080"}) + assert "unreachable" in out + + +class TestCheckLlmBackendTool: + def test_probe_summarized(self) -> None: + from turnstone.doctor import _tool_check_llm_backend + + fake = {"reachable": True, "available_models": ["m"], "error": None} + with patch("turnstone.core.model_registry.probe_model_endpoint", return_value=fake): + out = _tool_check_llm_backend({"provider": "openai", "base_url": "http://x/v1"}) + assert "reachable" in out + + def test_rejects_metadata_base_url(self) -> None: + # base_url is model-supplied, so it gets the same guard as http_health: + # the cloud metadata endpoint is refused before any probe is attempted. + from turnstone.doctor import _tool_check_llm_backend + + with patch("turnstone.core.model_registry.probe_model_endpoint") as probe: + out = _tool_check_llm_backend( + {"provider": "openai", "base_url": "http://169.254.169.254/v1"} + ) + assert "Refused" in out + probe.assert_not_called() + + def test_rejects_non_http_base_url(self) -> None: + from turnstone.doctor import _tool_check_llm_backend + + with patch("turnstone.core.model_registry.probe_model_endpoint") as probe: + out = _tool_check_llm_backend({"provider": "openai", "base_url": "file:///etc/passwd"}) + assert "Refused" in out + probe.assert_not_called() + + +class TestNodeHealth: + def _capture_cmd(self, tmp_path: Path, kind: str, args: dict[str, object]) -> list[str]: + captured: dict[str, list[str]] = {} + + def fake_run(cmd, *a, **k): # noqa: ANN001 + captured["cmd"] = cmd + return MagicMock(returncode=0, stdout='{"status": "ok"}', stderr="") + + with patch("turnstone.doctor.subprocess.run", side_effect=fake_run): + _tool_node_health(tmp_path, kind, args) + return captured["cmd"] + + def test_compose_execs_fixed_readonly_snippet(self, tmp_path: Path) -> None: + cmd = self._capture_cmd(tmp_path, "docker-compose", {"node": "node-1"}) + assert cmd[:2] == ["docker", "compose"] + assert "exec" in cmd and "-T" in cmd and "node-1" in cmd + # the executed command is the fixed read-only health fetch — not arbitrary + assert cmd[-3] == "python" and cmd[-2] == "-c" + snippet = cmd[-1] + assert "urlopen" in snippet and "/health" in snippet + assert "system" not in snippet and "Popen" not in snippet + + def test_install_type_override_uses_http( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured: dict[str, str] = {} + + def fake_get(url: str, timeout: float = 5.0) -> dict[str, str]: + captured["url"] = url + return {"status": "ok", "version": "1.7.0a2"} + + monkeypatch.setattr("turnstone.doctor._http_get_json", fake_get) + # primary_kind is compose, but the per-node override forces the http path + out = _tool_node_health( + tmp_path, "docker-compose", {"node": "10.0.0.5", "install_type": "systemd"} + ) + assert "10.0.0.5:8080/health" in captured["url"] + assert "1.7.0a2" in out + + def test_host_with_explicit_port_not_double_suffixed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A host:port from the model must not get the default port appended again + # (regression: "10.0.0.5:8081" → "http://10.0.0.5:8081:8080"). + captured: dict[str, str] = {} + + def fake_get(url: str, timeout: float = 5.0) -> dict[str, str]: + captured["url"] = url + return {"status": "ok"} + + monkeypatch.setattr("turnstone.doctor._http_get_json", fake_get) + _tool_node_health(tmp_path, "systemd", {"node": "10.0.0.5:8081"}) + assert "10.0.0.5:8081/health" in captured["url"] + assert ":8080" not in captured["url"] + + def test_rejects_option_node(self, tmp_path: Path) -> None: + assert "Error" in _tool_node_health(tmp_path, "docker-compose", {"node": "--rm"}) + + def test_execute_tool_threads_primary_kind(self, tmp_path: Path) -> None: + captured: dict[str, list[str]] = {} + + def fake_run(cmd, *a, **k): # noqa: ANN001 + captured["cmd"] = cmd + return MagicMock(returncode=0, stdout="{}", stderr="") + + with patch("turnstone.doctor.subprocess.run", side_effect=fake_run): + execute_tool("node_health", {"node": "node-2"}, tmp_path, primary_kind="docker-compose") + assert "exec" in captured["cmd"] and "node-2" in captured["cmd"] + + +class TestConsoleReframe: + def test_healthy_nodes_not_called_down(self) -> None: + vr = VersionReport( + installed=__version__, + image_tag="", + node_versions={}, + cluster_versions=["1.7.0a2"], + unreachable_nodes=["node-1", "node-2"], + drift=False, + upstream_stable="", + upstream_experimental="", + upstream_error="skipped", + behind_stable=False, + behind_experimental=False, + console_reachable=True, + console_nodes=10, + ) + out = render_version_report(vr) + assert "10 node(s) live" in out + assert "node_health" in out + assert "down" not in out.lower() # never frame healthy-per-console nodes as down + + +# --------------------------------------------------------------------------- +# finish + execute_tool dispatch +# --------------------------------------------------------------------------- + + +class TestFinishAndDispatch: + def test_finish_raises(self) -> None: + with pytest.raises(_FinishError, match="All done"): + _tool_finish({"summary": "All done"}) + + def test_finish_default_summary(self) -> None: + with pytest.raises(_FinishError) as exc: + _tool_finish({}) + assert exc.value.summary == "Diagnosis complete." + + def test_unknown_tool(self, tmp_path: Path) -> None: + assert "unknown tool" in execute_tool("nonexistent", {}, tmp_path) + + def test_dispatch_read_file(self, tmp_path: Path) -> None: + (tmp_path / "x.txt").write_text("hi") + assert execute_tool("read_file", {"path": "x.txt"}, tmp_path) == "hi" + + def test_finish_propagates_through_execute(self, tmp_path: Path) -> None: + with pytest.raises(_FinishError): + execute_tool("finish", {"summary": "x"}, tmp_path) + + +# --------------------------------------------------------------------------- +# family_of +# --------------------------------------------------------------------------- + + +class TestFamilyOf: + @pytest.mark.parametrize( + ("provider", "expected"), + [ + ("openai", "openai"), + ("openai-compatible", "openai"), + ("xai", "openai"), + ("anthropic", "anthropic"), + ("anthropic-compatible", "anthropic"), + ("Anthropic", "anthropic"), + ("google", None), + ("", None), + ], + ) + def test_mapping(self, provider: str, expected: str | None) -> None: + assert family_of(provider) == expected + + +# --------------------------------------------------------------------------- +# Preflight helpers +# --------------------------------------------------------------------------- + + +class TestPreflightHelpers: + def test_find_repo_root(self, tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + (tmp_path / "pyproject.toml").write_text('[project]\nname = "turnstone"\n') + sub = tmp_path / "turnstone" / "core" + sub.mkdir(parents=True) + assert _find_repo_root(sub) == tmp_path + + def test_find_repo_root_none(self, tmp_path: Path) -> None: + assert _find_repo_root(tmp_path) is None + + def test_find_compose_files(self, tmp_path: Path) -> None: + (tmp_path / "compose.yaml").write_text("services: {}\n") + found = _find_compose_files(tmp_path, {"TURNSTONE_DIR": str(tmp_path / "none")}, None) + assert (tmp_path / "compose.yaml").resolve() in found + + def test_parse_config_sections(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text( + '[auth]\njwt_secret = "x"\n' + '[database]\nbackend = "postgresql"\n' + 'url = "postgresql://u:p@h/db"\n' + '[api]\nbase_url = "http://localhost:8000/v1"\napi_key = "sk-test"\n' + ) + sections, db, api = _parse_config_sections(cfg) + assert "auth" in sections and "database" in sections and "api" in sections + assert db["backend"] == "postgresql" + assert db["url"] == "postgresql://u:p@h/db" + assert api["base_url"] == "http://localhost:8000/v1" + assert api["api_key"] == "sk-test" + + def test_discover_config_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path / "fakehome") + cfg = tmp_path / "config.toml" + cfg.write_text('[database]\nbackend = "sqlite"\n') + found = _discover_config_files(tmp_path, {}) + assert any(cf.path == cfg.resolve() for cf in found) + + def test_resolve_db_config_config_wins_over_env(self) -> None: + cf = ConfigFileInfo( + path=Path("/x"), sections=["database"], db={"backend": "postgresql", "url": "u"}, api={} + ) + out = _resolve_db_config([cf], {"TURNSTONE_DB_BACKEND": "sqlite"}) + assert out["backend"] == "postgresql" + + def test_resolve_db_config_env_fallback(self) -> None: + out = _resolve_db_config( + [], {"TURNSTONE_DB_BACKEND": "postgresql", "TURNSTONE_DB_URL": "u"} + ) + assert out["backend"] == "postgresql" and out["url"] == "u" + + def test_parse_config_extracts_db_ssl(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text( + '[database]\nbackend = "postgresql"\nsslmode = "verify-full"\n' + 'sslrootcert = "/c/ca.crt"\nsslcert = "/c/client.crt"\nsslkey = "/c/client.key"\n' + ) + _sections, db, _api = _parse_config_sections(cfg) + assert db["sslmode"] == "verify-full" + assert db["sslrootcert"] == "/c/ca.crt" + + def test_resolve_db_config_carries_ssl(self) -> None: + cf = ConfigFileInfo( + path=Path("/x"), + sections=["database"], + db={"backend": "postgresql", "url": "u", "sslmode": "verify-full"}, + api={}, + ) + out = _resolve_db_config([cf], {"TURNSTONE_DB_SSLCERT": "/env/client.crt"}) + assert out["sslmode"] == "verify-full" # config + assert out["sslcert"] == "/env/client.crt" # env fallback + + def test_relevant_env_redacts_secrets(self) -> None: + out = _relevant_env( + {"TURNSTONE_JWT_SECRET": "supersecret", "TURNSTONE_HOST_IP": "10.0.0.1"} + ) + assert out["TURNSTONE_JWT_SECRET"] == "set (hidden)" + assert out["TURNSTONE_HOST_IP"] == "10.0.0.1" + + def test_relevant_env_redacts_db_url_creds(self) -> None: + out = _relevant_env({"TURNSTONE_DB_URL": "postgresql://u:pw@h/db"}) + # TURNSTONE_DB_URL is in the explicit scrub set → fully hidden + assert out["TURNSTONE_DB_URL"] == "set (hidden)" + + def test_primary_kind_prefers_running_compose(self) -> None: + kinds = ["git-source", "docker-compose"] + assert _primary_kind(kinds, "node-1 running") == "docker-compose" + + def test_primary_kind_systemd_when_not_running_compose(self) -> None: + kinds = ["docker-compose", "systemd"] + assert _primary_kind(kinds, "") == "systemd" + + def test_derive_health_urls(self) -> None: + urls = _derive_health_urls({}) + assert "http://localhost:8080/health" in urls + assert "http://localhost:8090/health" in urls + + def test_read_api_creds_reads_parsed_api_field(self, tmp_path: Path) -> None: + # perf-5: creds come from the already-parsed ConfigFileInfo.api (first + # non-empty wins) — the file is never re-opened (it doesn't even exist here). + cf = ConfigFileInfo( + path=tmp_path / "config.toml", + sections=["api"], + db={}, + api={"base_url": "http://localhost:8000/v1", "api_key": "sk-cfg"}, + ) + profile = _make_profile(tmp_path, config_files=[cf]) + base_url, api_key = _read_api_creds(profile, {}) + assert base_url == "http://localhost:8000/v1" + assert api_key == "sk-cfg" + + def test_read_api_creds_env_fallback(self, tmp_path: Path) -> None: + profile = _make_profile(tmp_path, config_files=[]) + base_url, api_key = _read_api_creds( + profile, {"LLM_BASE_URL": "http://env/v1", "OPENAI_API_KEY": "sk-env"} + ) + assert base_url == "http://env/v1" + assert api_key == "sk-env" + + def test_read_api_creds_pair_from_single_source(self, tmp_path: Path) -> None: + # Two config files: the first defines only base_url, the second only + # api_key. The pair must come from ONE source (the first that defines + # either field) — never a base_url+api_key spliced across both files. + cf1 = ConfigFileInfo( + path=tmp_path / "a.toml", + sections=["api"], + db={}, + api={"base_url": "http://endpoint-a/v1"}, + ) + cf2 = ConfigFileInfo( + path=tmp_path / "b.toml", + sections=["api"], + db={}, + api={"api_key": "sk-from-b"}, + ) + profile = _make_profile(tmp_path, config_files=[cf1, cf2]) + base_url, api_key = _read_api_creds(profile, {}) + assert base_url == "http://endpoint-a/v1" + assert api_key == "" # not spliced from the unrelated second file + + def test_detect_install_profile_classifies_compose( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Direct classification: a compose file present, nothing else probing true. + (tmp_path / "compose.yaml").write_text("services: {}\n") + monkeypatch.setattr("turnstone.doctor._docker_available", lambda: False) + monkeypatch.setattr("turnstone.doctor._systemd_units", lambda: []) + monkeypatch.setattr("turnstone.doctor._find_repo_root", lambda p: None) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome") + profile = detect_install_profile(tmp_path, {}) + assert "docker-compose" in profile.kinds + assert isinstance(profile, InstallProfile) + + +# --------------------------------------------------------------------------- +# Reports never leak secrets +# --------------------------------------------------------------------------- + + +class TestReportNoSecretLeak: + def test_profile_report_masks_db_password(self, tmp_path: Path) -> None: + profile = _make_profile( + tmp_path, + db_config={"backend": "postgresql", "url": "postgresql://u:TOPSECRET@h/db", "path": ""}, + env_present={"TURNSTONE_JWT_SECRET": "set (hidden)"}, + ) + report = render_profile_report(profile) + assert "TOPSECRET" not in report + assert "u:****@h" in report + + def test_full_report_has_three_sections(self, tmp_path: Path) -> None: + profile = _make_profile(tmp_path) + vr = VersionReport(__version__, "", {}, [], [], False, "", "", "skipped", False, False) + verdict = BackendVerdict(False, "no db") + report = render_full_report(profile, vr, verdict) + assert "## Install profile" in report + assert "## Versions" in report + assert "## LLM backend" in report + + +# --------------------------------------------------------------------------- +# open_storage (read-only; never creates a SQLite file) +# --------------------------------------------------------------------------- + + +class TestOpenStorage: + def test_missing_sqlite_returns_error(self, tmp_path: Path) -> None: + profile = _make_profile( + tmp_path, db_config={"backend": "sqlite", "url": "", "path": str(tmp_path / "nope.db")} + ) + storage, err = open_storage(profile) + assert storage is None + assert "no SQLite database file" in err + # the message names the actual paths searched (not a hard-coded default) + assert "nope.db" in err + # crucially, it did not create the file + assert not (tmp_path / "nope.db").exists() + + def test_read_only_no_migrations_no_create_tables(self, tmp_path: Path) -> None: + # Diagnose-only: must neither migrate NOR create_all() against the live DB. + profile = _make_profile( + tmp_path, + db_config={"backend": "postgresql", "url": "postgresql://u:p@h/db", "path": ""}, + ) + fake_init = MagicMock(return_value=MagicMock()) + with patch("turnstone.core.storage.init_storage", fake_init): + open_storage(profile) + assert fake_init.call_args.kwargs["run_migrations"] is False + assert fake_init.call_args.kwargs["create_tables"] is False + + def test_forwards_db_ssl_params(self, tmp_path: Path) -> None: + # An SSL/mTLS-required Postgres needs the [database] ssl* params forwarded. + profile = _make_profile( + tmp_path, + db_config={ + "backend": "postgresql", + "url": "postgresql://u:p@h/db", + "path": "", + "sslmode": "verify-full", + "sslrootcert": "/c/ca.crt", + "sslcert": "/c/client.crt", + "sslkey": "/c/client.key", + }, + ) + fake_init = MagicMock(return_value=MagicMock()) + with patch("turnstone.core.storage.init_storage", fake_init): + open_storage(profile) + kwargs = fake_init.call_args.kwargs + assert kwargs["sslmode"] == "verify-full" + assert kwargs["sslrootcert"] == "/c/ca.crt" + assert kwargs["sslcert"] == "/c/client.crt" + assert kwargs["sslkey"] == "/c/client.key" + + +# --------------------------------------------------------------------------- +# Self-configuring brain (§3) +# --------------------------------------------------------------------------- + + +class TestResolveDoctorBrain: + def test_storage_none_falls_back(self, tmp_path: Path) -> None: + brain, verdict = resolve_doctor_brain(_make_profile(tmp_path), None, "boom") + assert brain is None + assert verdict.ok is False + assert "boom" in verdict.detail + + def _patch_resolution(self, monkeypatch: pytest.MonkeyPatch, provider: str) -> None: + from turnstone.core.model_registry import ModelConfig + + cfg = ModelConfig( + alias="default", + base_url="http://localhost:9/v1", + api_key="dummy", + model="test-model", + context_window=8192, + provider=provider, + ) + registry = MagicMock() + registry.resolve.return_value = (MagicMock(), "test-model", cfg) + monkeypatch.setattr( + "turnstone.core.config_store.ConfigStore", + lambda **kw: MagicMock(get=lambda *a, **k: ""), + ) + monkeypatch.setattr( + "turnstone.core.model_registry.load_model_registry", lambda **kw: registry + ) + + def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + self._patch_resolution(monkeypatch, "openai-compatible") + monkeypatch.setattr("turnstone.doctor._validate_connection", lambda llm: (True, "")) + brain, verdict = resolve_doctor_brain(_make_profile(tmp_path), MagicMock(), "") + assert brain is not None + assert brain.model == "test-model" + assert verdict.ok is True + + def test_google_provider_falls_back( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._patch_resolution(monkeypatch, "google") + brain, verdict = resolve_doctor_brain(_make_profile(tmp_path), MagicMock(), "") + assert brain is None + assert verdict.ok is False + assert "google" in verdict.detail.lower() + + def test_connection_failure_falls_back( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._patch_resolution(monkeypatch, "openai") + monkeypatch.setattr("turnstone.doctor._validate_connection", lambda llm: (False, "refused")) + brain, verdict = resolve_doctor_brain(_make_profile(tmp_path), MagicMock(), "") + assert brain is None + assert "refused" in verdict.detail + + def test_no_model_configured_falls_back( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + "turnstone.core.config_store.ConfigStore", + lambda **kw: MagicMock(get=lambda *a, **k: ""), + ) + + def boom(**kw: object) -> object: + raise RuntimeError("no models") + + monkeypatch.setattr("turnstone.core.model_registry.load_model_registry", boom) + brain, verdict = resolve_doctor_brain(_make_profile(tmp_path), MagicMock(), "") + assert brain is None + assert "no usable model" in verdict.detail + + +class TestResolveBrainIntegration: + """End-to-end against an ephemeral SQLite DB seeded with one model.""" + + def test_resolves_seeded_model(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from turnstone.core.storage import init_storage, reset_storage + + db_path = str(tmp_path / "doctor.db") + reset_storage() + st = init_storage("sqlite", path=db_path, run_migrations=True) + try: + st.create_model_definition( + "def1", + "default", + "test-model", + provider="openai-compatible", + base_url="http://localhost:9/v1", + api_key="dummy", + context_window=8192, + enabled=True, + ) + monkeypatch.setattr("turnstone.doctor._validate_connection", lambda llm: (True, "")) + profile = _make_profile( + tmp_path, db_config={"backend": "sqlite", "url": "", "path": db_path} + ) + brain, verdict = resolve_doctor_brain(profile, st, "") + assert brain is not None + assert brain.model == "test-model" + assert verdict.ok is True + finally: + reset_storage() + + +# --------------------------------------------------------------------------- +# Version check (§4) +# --------------------------------------------------------------------------- + + +class TestCheckVersions: + def test_offline_skips_upstream(self, tmp_path: Path) -> None: + vr = check_versions(_make_profile(tmp_path), None, offline=True) + assert vr.upstream_error == "skipped (--offline)" + assert vr.installed == __version__ + + def test_drift_detected(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "n1", "url": "http://a"}, {"service_id": "n2", "url": "http://b"}] + if t == "server" + else [] + ) + versions = {"http://a": "1.6.9", "http://b": "1.7.0a2"} + monkeypatch.setattr( + "turnstone.doctor._fetch_health_version", lambda url: versions.get(url, "") + ) + vr = check_versions(_make_profile(tmp_path), storage, offline=True) + assert vr.drift is True + assert set(vr.node_versions.values()) == {"1.6.9", "1.7.0a2"} + + def test_no_drift_when_uniform(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "n1", "url": "http://a"}] if t == "server" else [] + ) + monkeypatch.setattr("turnstone.doctor._fetch_health_version", lambda url: "1.7.0a2") + vr = check_versions(_make_profile(tmp_path), storage, offline=True) + assert vr.drift is False + + def test_unreachable_node_flagged( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "down", "url": "http://x"}] if t == "server" else [] + ) + monkeypatch.setattr("turnstone.doctor._fetch_health_version", lambda url: "") + monkeypatch.setattr("turnstone.doctor._tls_cert_dir_present", lambda: False) + vr = check_versions(_make_profile(tmp_path), storage, offline=True) + assert "down" in vr.unreachable_nodes + assert vr.mtls is False + + def test_mtls_detected_from_https_urls( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # https advertise URLs ⇒ the node mesh runs mTLS; per-node probes can't auth. + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "node-1", "url": "https://node-1:8080"}] if t == "server" else [] + ) + monkeypatch.setattr("turnstone.doctor._fetch_health_version", lambda url: "") + monkeypatch.setattr("turnstone.doctor._tls_cert_dir_present", lambda: False) + vr = check_versions(_make_profile(tmp_path), storage, offline=True) + assert vr.mtls is True + assert "node-1" in vr.unreachable_nodes + # the report reframes "unreachable" as an mTLS limitation, not "down" + rendered = render_version_report(vr) + assert "mTLS" in rendered + + def test_cert_dir_only_signals_mtls_when_storage_down( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A stray cert dir must NOT false-positive when storage is reachable + http. + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "n1", "url": "http://a"}] if t == "server" else [] + ) + monkeypatch.setattr("turnstone.doctor._fetch_health_version", lambda url: "1.7.0a2") + monkeypatch.setattr("turnstone.doctor._tls_cert_dir_present", lambda: True) + vr = check_versions(_make_profile(tmp_path), storage, offline=True) + assert vr.mtls is False # http URLs are authoritative; cert dir ignored + # but with storage unreachable, the cert dir is the fallback signal + vr2 = check_versions(_make_profile(tmp_path), None, offline=True) + assert vr2.mtls is True + + def test_drift_via_console_health( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Nodes are registered but unreachable (container-internal URLs); the + # console /health reports cluster-wide versions + drift. + storage = MagicMock() + storage.list_services.side_effect = lambda t, **k: ( + [{"service_id": "node-1", "url": "http://internal:8080"}] if t == "server" else [] + ) + monkeypatch.setattr("turnstone.doctor._fetch_health_version", lambda url: "") + monkeypatch.setattr( + "turnstone.doctor._probe_health", + lambda url: {"versions": ["1.6.9", "1.7.0a2"], "version_drift": True}, + ) + profile = _make_profile(tmp_path, health_urls=["http://localhost:8090"]) + vr = check_versions(profile, storage, offline=True) + assert vr.drift is True + assert vr.cluster_versions == ["1.6.9", "1.7.0a2"] + assert "node-1" in vr.unreachable_nodes + + def test_upstream_parse(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tags = [{"name": "v1.6.8"}, {"name": "v1.7.0a2"}, {"name": "v1.6.9"}] + monkeypatch.setattr("turnstone.doctor._http_get_json", lambda url, timeout=6.0: tags) + vr = check_versions(_make_profile(tmp_path), None, offline=False) + assert vr.upstream_stable == "1.6.9" + assert vr.upstream_experimental == "1.7.0a2" + + def test_upstream_unreachable_degrades( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + import urllib.error + + def boom(url: str, timeout: float = 6.0) -> object: + raise urllib.error.URLError("no net") + + monkeypatch.setattr("turnstone.doctor._http_get_json", boom) + vr = check_versions(_make_profile(tmp_path), None, offline=False) + assert vr.upstream_error # non-empty, but no exception raised + + +class TestComposeImageTag: + def test_from_env_present(self, tmp_path: Path) -> None: + profile = _make_profile(tmp_path, env_present={"TURNSTONE_IMAGE_TAG": "v1.7.0a2"}) + assert _compose_image_tag(profile) == "v1.7.0a2" + + def test_falls_back_to_dotenv_file(self, tmp_path: Path) -> None: + # No env_present override → read TURNSTONE_IMAGE_TAG from the .env next to + # the compose file. + (tmp_path / "compose.yaml").write_text("services: {}\n") + (tmp_path / ".env").write_text("TURNSTONE_IMAGE_TAG=v1.6.9\n") + profile = _make_profile(tmp_path, compose_files=[tmp_path / "compose.yaml"]) + assert _compose_image_tag(profile) == "v1.6.9" + + +class TestRenderVersionReport: + def _vr(self, **overrides: object) -> VersionReport: + base: dict[str, object] = { + "installed": __version__, + "image_tag": "", + "node_versions": {}, + "cluster_versions": [], + "unreachable_nodes": [], + "drift": False, + "upstream_stable": "", + "upstream_experimental": "", + "upstream_error": "skipped", + "behind_stable": False, + "behind_experimental": False, + } + base.update(overrides) + return VersionReport(**base) # type: ignore[arg-type] + + def test_renders_per_node_versions(self) -> None: + vr = self._vr( + node_versions={"n1": "1.6.9", "n2": "1.7.0a2"}, + cluster_versions=["1.6.9", "1.7.0a2"], + drift=True, + ) + out = render_version_report(vr) + assert "Per-node versions:" in out + assert "n1=1.6.9" in out and "n2=1.7.0a2" in out + + def test_omits_per_node_line_when_empty(self) -> None: + out = render_version_report(self._vr()) + assert "Per-node versions:" not in out + + +class TestVersionBehind: + def test_behind(self) -> None: + assert _version_behind("1.6.5", "1.6.9") is True + + def test_not_behind(self) -> None: + assert _version_behind("1.7.0a2", "1.6.9") is False + + def test_missing_inputs(self) -> None: + assert _version_behind("", "1.6.9") is False + assert _version_behind("1.6.9", "") is False + + def test_unparseable(self) -> None: + assert _version_behind("not-a-version", "1.6.9") is False + + +class TestUpstreamFetchParsing: + def test_classifies_stable_vs_prerelease(self, monkeypatch: pytest.MonkeyPatch) -> None: + tags = [{"name": "v1.5.18"}, {"name": "v1.6.9"}, {"name": "v1.7.0a1"}, {"name": "v1.7.0a2"}] + monkeypatch.setattr("turnstone.doctor._http_get_json", lambda url, timeout=6.0: tags) + stable, experimental, err = _fetch_upstream_versions() + assert stable == "1.6.9" + assert experimental == "1.7.0a2" + assert err == "" + + +# --------------------------------------------------------------------------- +# _DoctorLLM provider conversion +# --------------------------------------------------------------------------- + + +class TestDoctorLLMOpenAI: + def test_text_response(self) -> None: + llm = _DoctorLLM("openai", MagicMock(), "gpt-5.4") + choice = MagicMock() + choice.message.content = "Hello!" + choice.message.tool_calls = None + choice.finish_reason = "stop" + llm.client.chat.completions.create.return_value = MagicMock(choices=[choice]) + content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], TOOLS) + assert content == "Hello!" and tool_calls is None and reason == "stop" + + def test_tool_call_response(self) -> None: + llm = _DoctorLLM("openai", MagicMock(), "gpt-5.4") + tc = MagicMock() + tc.id = "call_123" + tc.function.name = "check_docker" + tc.function.arguments = "{}" + choice = MagicMock() + choice.message.content = "" + choice.message.tool_calls = [tc] + choice.finish_reason = "tool_calls" + llm.client.chat.completions.create.return_value = MagicMock(choices=[choice]) + _content, tool_calls, _reason = llm.complete([{"role": "user", "content": "x"}], TOOLS) + assert tool_calls is not None and tool_calls[0]["function"]["name"] == "check_docker" + + def test_null_response_raises(self) -> None: + llm = _DoctorLLM("openai", MagicMock(), "gpt-5.4") + llm.client.chat.completions.create.return_value = None + with pytest.raises(RuntimeError): + llm.complete([{"role": "user", "content": "x"}], []) + + +class TestDoctorLLMAnthropic: + def _make(self) -> _DoctorLLM: + return _DoctorLLM("anthropic", MagicMock(), "claude-sonnet-4-6") + + def test_system_extracted(self) -> None: + llm = self._make() + resp = MagicMock() + resp.content = [MagicMock(type="text", text="ok")] + resp.stop_reason = "end_turn" + llm.client.messages.create.return_value = resp + llm.complete([{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}], []) + kwargs = llm.client.messages.create.call_args[1] + assert kwargs["system"] == "sys" + assert all(m["role"] != "system" for m in kwargs["messages"]) + + def test_tool_result_converted(self) -> None: + llm = self._make() + resp = MagicMock() + resp.content = [MagicMock(type="text", text="done")] + resp.stop_reason = "end_turn" + llm.client.messages.create.return_value = resp + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "tc1", + "type": "function", + "function": {"name": "check_docker", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "tc1", "content": "Docker: installed"}, + ] + llm.complete(messages, TOOLS) + api_messages = llm.client.messages.create.call_args[1]["messages"] + found = any( + isinstance(m.get("content"), list) + and any(isinstance(b, dict) and b.get("type") == "tool_result" for b in m["content"]) + for m in api_messages + ) + assert found + + def test_tool_format_conversion(self) -> None: + llm = self._make() + resp = MagicMock() + resp.content = [MagicMock(type="text", text="ok")] + resp.stop_reason = "end_turn" + llm.client.messages.create.return_value = resp + llm.complete( + [{"role": "system", "content": "s"}, {"role": "user", "content": "hi"}], TOOLS[:1] + ) + api_tools = llm.client.messages.create.call_args[1]["tools"] + assert api_tools[0]["name"] == "read_file" and "input_schema" in api_tools[0] + + +# --------------------------------------------------------------------------- +# Conversation loop +# --------------------------------------------------------------------------- + + +class TestConversationLoop: + def test_quit_exits(self) -> None: + llm = MagicMock(spec=_DoctorLLM) + llm.complete.return_value = ("What's wrong?", None, "stop") + with patch("builtins.input", return_value="quit"): + _run_conversation(llm, Path("/tmp"), "ctx") + + def test_tool_calls_executed(self, tmp_path: Path) -> None: + llm = MagicMock(spec=_DoctorLLM) + llm.complete.side_effect = [ + ( + "", + [ + { + "id": "tc1", + "type": "function", + "function": {"name": "check_port", "arguments": '{"port": 8080}'}, + } + ], + "tool_calls", + ), + ("Looks fine.", None, "stop"), + ] + with patch("builtins.input", return_value="quit"): + _run_conversation(llm, tmp_path, "ctx") + assert llm.complete.call_count == 2 + second = llm.complete.call_args_list[1][0][0] + tool_msgs = [m for m in second if m.get("role") == "tool"] + assert len(tool_msgs) == 1 and tool_msgs[0]["tool_call_id"] == "tc1" + + def test_finish_exits(self, tmp_path: Path) -> None: + llm = MagicMock(spec=_DoctorLLM) + llm.complete.return_value = ( + "", + [ + { + "id": "f", + "type": "function", + "function": {"name": "finish", "arguments": '{"summary": "done"}'}, + } + ], + "tool_calls", + ) + _run_conversation(llm, tmp_path, "ctx") + assert llm.complete.call_count == 1 + + def test_empty_input_skipped(self) -> None: + llm = MagicMock(spec=_DoctorLLM) + llm.complete.return_value = ("Ask me.", None, "stop") + calls = {"n": 0} + + def fake_input(prompt: str = "") -> str: + calls["n"] += 1 + return "" if calls["n"] <= 2 else "quit" + + with patch("builtins.input", side_effect=fake_input): + _run_conversation(llm, Path("/tmp"), "ctx") + + +# --------------------------------------------------------------------------- +# Interactive provider selection (fallback) +# --------------------------------------------------------------------------- + + +class TestSelectProvider: + def test_openai(self) -> None: + with ( + patch("builtins.input", side_effect=["1", ""]), + patch("getpass.getpass", return_value="sk-test"), + patch("openai.OpenAI", return_value=MagicMock()), + ): + provider, _client, model = _select_provider() + assert provider == "openai" and model == "gpt-5.4" + + def test_local(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with ( + patch("builtins.input", side_effect=["3", "http://localhost:8000/v1", "my-model"]), + patch("getpass.getpass", return_value="none"), + patch("openai.OpenAI", return_value=MagicMock()), + ): + provider, _client, model = _select_provider() + assert provider == "openai" and model == "my-model" + + +# --------------------------------------------------------------------------- +# CLI report smoke +# --------------------------------------------------------------------------- + + +class TestReportCLI: + def test_report_prints_sections( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + from turnstone import doctor + + # Keep the smoke hermetic: no DB, no docker/systemd probing. + monkeypatch.setattr(doctor, "open_storage", lambda profile: (None, "no db (test)")) + monkeypatch.setattr(doctor, "_docker_available", lambda: False) + monkeypatch.setattr(doctor, "_systemd_units", lambda: []) + monkeypatch.setattr( + sys, "argv", ["turnstone-doctor", "--report", "--offline", "--dir", str(tmp_path)] + ) + doctor.main() + out = capsys.readouterr().out + assert "## Install profile" in out + assert "## Versions" in out + assert "## LLM backend" in out + + +# --------------------------------------------------------------------------- +# Constants / tool sanity +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_system_prompt_is_diagnose_only(self) -> None: + assert "DIAGNOSE-ONLY" in SYSTEM_PROMPT + assert "Turnstone" in SYSTEM_PROMPT + assert len(SYSTEM_PROMPT) > 500 + + def test_all_tools_well_formed(self) -> None: + for tool in TOOLS: + assert tool["type"] == "function" + fn = tool["function"] + assert "name" in fn and "description" in fn + assert fn["parameters"]["type"] == "object" + + def test_all_tools_have_implementations(self) -> None: + from turnstone.doctor import TOOL_FUNCTIONS + + for tool in TOOLS: + assert tool["function"]["name"] in TOOL_FUNCTIONS + + def test_expected_diagnostic_tools_present(self) -> None: + names = {t["function"]["name"] for t in TOOLS} + assert { + "read_file", + "compose_status", + "compose_logs", + "http_health", + "check_llm_backend", + "finish", + } <= names + # setup-only tools must be gone + assert ( + "write_file" not in names + and "write_compose" not in names + and "generate_secret" not in names + ) diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py deleted file mode 100644 index 8b5e0547..00000000 --- a/turnstone/bootstrap.py +++ /dev/null @@ -1,1240 +0,0 @@ -"""LLM-guided interactive setup wizard for Turnstone deployments. - -Entry point: turnstone-bootstrap - -Walks users through configuring a Turnstone deployment via a conversational -AI assistant. Generates compose.yaml, .env files, and post-start setup -scripts. -""" - -from __future__ import annotations - -import getpass -import importlib.resources -import json -import os -import secrets -import socket -import stat -import subprocess -import sys -from pathlib import Path -from typing import Any - -from turnstone import __version__ -from turnstone.ui.colors import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW -from turnstone.ui.markdown import MarkdownRenderer -from turnstone.ui.spinner import Spinner - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_DEFAULT_MODELS: dict[str, str] = { - "openai": "gpt-5.4", - "anthropic": "claude-sonnet-4-6", -} - -_SENSITIVE_PATTERNS = ( - "API_KEY", - "PASSWORD", - "SECRET", - "TOKEN", - "DISCORD_TOKEN", -) - -# --------------------------------------------------------------------------- -# System prompt — encodes Turnstone architecture knowledge for the LLM -# --------------------------------------------------------------------------- - -SYSTEM_PROMPT = """\ -You are the Turnstone setup wizard, an expert assistant that helps users \ -configure a Turnstone deployment interactively. - -## About Turnstone -Turnstone is a multi-node AI orchestration platform. A deployment consists of: -- **Server** (turnstone-server): Web UI + chat workstreams + LLM interaction -- **Console** (turnstone-console): Cluster dashboard + admin panel -- **Caddy**: fronts the console over HTTPS at https://localhost:8443 — the only - published web entry point (the console's plain-HTTP port is not exposed, which - avoids the browser's 6-connection cap on the dashboard's SSE streams) -- **PostgreSQL**: persistent shared database — required so the console discovers nodes -- **Channel** (optional): Discord/Slack gateway - -## Compose stack (compose.yaml) -`docker compose up` starts the whole single-node stack — server + console + Caddy + -PostgreSQL + channel — using pre-built ghcr.io images. There are no profiles. The -server boots even with no model configured yet and registers in the console; real -model backends are added afterwards in the admin UI. - -## Environment Variables (.env) -The compose.yaml reads these from a `.env` file: - -### LLM Provider (required) -- `LLM_BASE_URL` — OpenAI-compatible API endpoint (default: http://host.docker.internal:8000/v1). \ -For local models (vLLM, llama.cpp), this points to the local server. \ -From inside Docker, use `http://host.docker.internal:/v1` to reach the host machine. -- `OPENAI_API_KEY` — API key for the LLM provider. For local models that don't require \ -authentication, set this to `dummy` (the compose.yaml defaults to `dummy` if unset). \ -For commercial providers (OpenAI, Anthropic-via-proxy), use the real key. -- `MODEL` — Model name (optional, auto-detected if blank) -- `TURNSTONE_SEARXNG_URL` — Web-search backend URL (optional; defaults to the \ -bundled `searxng` service, so it can usually be left unset. Set it only to point \ -at an external SearxNG instance.) - -### Database -- `TURNSTONE_DB_BACKEND` — `sqlite` (default) or `postgresql` -- `TURNSTONE_DB_URL` — PostgreSQL connection URL (production only), \ -e.g. `postgresql+psycopg://turnstone:@postgres:5432/turnstone` -- `POSTGRES_USER` — PostgreSQL username (default: turnstone) -- `POSTGRES_PASSWORD` — PostgreSQL password (required for production) - -### Authentication (always enabled) -- `TURNSTONE_JWT_SECRET` — JWT signing secret (required). All services must share the same secret. \ -Generate with: `python -c "import secrets; print(secrets.token_hex(32))"` - -### OIDC SSO (optional) -- `TURNSTONE_OIDC_ISSUER` — OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO. -- `TURNSTONE_OIDC_CLIENT_ID` — Client ID from the identity provider -- `TURNSTONE_OIDC_CLIENT_SECRET` — Client secret (confidential client) -- `TURNSTONE_OIDC_PROVIDER_NAME` — Display name for the SSO button (default: "SSO") -- `TURNSTONE_OIDC_SCOPES` — OIDC scopes (default: "openid email profile") -- `TURNSTONE_OIDC_ROLE_CLAIM` — Claim name for role mapping (e.g., "groups") -- `TURNSTONE_OIDC_ROLE_MAP` — Comma-separated claim_value:role_id pairs (e.g., "admin:builtin-admin,eng:builtin-operator") -- `TURNSTONE_OIDC_PASSWORD_ENABLED` — Set to "false" to hide password login and force SSO-only - -### Ports / networking -- `CONSOLE_HTTPS_PORT` — Caddy HTTPS port for the dashboard (default: 8443) -- `POSTGRES_PORT` — PostgreSQL host port, for joining a bare-metal server (default: 5432) -- `TURNSTONE_HOST_IP` — interface the published bare-metal ports (Postgres, console - ACME, SearxNG) bind on (default: 127.0.0.1; set your host's LAN IP to join from - another machine). The legacy `POSTGRES_BIND` is still honored for Postgres. -- `SEARXNG_API_PORT` — host port a bare-metal node's web_search dials SearxNG on (default: 8081) - -### Channel Gateway (optional) -- `TURNSTONE_DISCORD_TOKEN` — Discord bot token -- `TURNSTONE_DISCORD_GUILD` — Restrict to single guild ID - -### Docker Image -- `TURNSTONE_IMAGE_TAG` — Docker image tag (default: `latest`). \ -Set this to pin the image version (e.g., `1.1.0`, `stable`, `experimental`). - -### MCP Integration (optional) -- `MCP_CONFIG` — Path to MCP server config inside the container. \ -When set, servers connect to configured MCP servers on startup. - -### Other -- `APPROVAL_TIMEOUT` — Tool approval timeout in seconds (default: 3600) - -## Auth Setup Flow -After the stack starts, the first admin user is created via: -`POST /v1/api/auth/setup` with `{"username", "display_name", "password"}` -This is a one-time endpoint that only works when zero users exist. - -Subsequent governance setup (roles, policies, skills) uses the console admin API \ -with the JWT returned from setup. - -If OIDC is configured, users can also log in via the "Continue with [Provider]" button on the login page. -The first admin user must still be created via the setup wizard (OIDC login requires at least one user to exist). -OIDC users are auto-provisioned on first login with a default viewer role unless role mapping is configured. - -## Runtime Settings (ConfigStore) -After the stack is running, ~40 runtime settings (model, temperature, max_tokens, \ -reasoning_effort, tool timeout, rate limiting, health probes, judge config, memory \ -config, etc.) are configurable via the admin Settings tab in the console — no \ -config.toml edits or restarts needed for most changes. These settings are stored in \ -the database and apply cluster-wide. The `.env` file only needs bootstrap-critical \ -settings (database, auth, ports, API keys). Tell users they can fine-tune \ -model and behavioral settings after deployment through the admin panel. - -## Built-in Roles -- **Admin** (`builtin-admin`): Full access — read, write, approve, all admin.* permissions -- **Operator** (`builtin-operator`): create / close workstreams, approve tools, modify conversations (read, write, workstreams.create, workstreams.close, tools.approve, conversation.modify) -- **Viewer** (`builtin-viewer`): read only - -## Tool Policies -Glob-pattern rules for tool execution. Actions: `allow`, `deny`, `ask`. \ -First match by priority wins. Example: `{"name": "Block bash", "tool_pattern": "bash*", \ -"action": "deny", "priority": 100}` - -## Skills -Reusable system message content with `{{variable}}` placeholders. \ -Categories like "engineering", "analysis", etc. - -## Your Task -Walk the user through setting up their deployment step by step: - -1. **First**: Call `check_docker`, `read_file` on `.env`, and `read_file` on `compose.yaml` \ -to detect existing state. If `compose.yaml` does not exist, call `write_compose` to \ -extract the bundled compose.yaml and its companion files (the Caddyfile and the \ -SearxNG web-search config). This is essential — without it, `docker compose` will fail. -2. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \ -(may differ from this wizard's model). Ask for base URL, API key, model name. -3. **Database**: SQLite (dev/simple) vs PostgreSQL (production). \ -PostgreSQL is recommended for production use. -4. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \ -Use `generate_secret` for JWT secret and Postgres password. \ -Always set `TURNSTONE_JWT_SECRET` in the .env. \ -Ask for initial admin username and password. \ -If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \ -offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \ -Optionally configure role mapping and OIDC-only mode. -5. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts. -6. **Optional features**: Discord integration. Web search works out of the box \ -via the bundled `searxng` service (no API key) — only ask about it if the user \ -wants to point at an existing external SearxNG instance instead. -7. **Generate .env**: Call `write_file` with the complete `.env` content. \ -Include `TURNSTONE_IMAGE_TAG` set to the version matching the installed package. -8. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \ -user and any roles/policies/skills the user wants. -9. **Finish**: Call the `finish` tool with a summary of what was configured and the \ -exact commands to run next (e.g., `docker compose up -d` then `./setup.sh`) and the \ -dashboard URL, https://localhost:8443. - -## Rules -- Be concise. Ask 1-2 questions at a time, not a wall of options. -- NEVER echo API keys or passwords back to the user in your text responses. -- ALWAYS use `generate_secret` for passwords and secrets — never invent them. -- When writing files, use `write_file` — the user will see a preview and confirm. -- If `compose.yaml` is missing, call `write_compose` before anything else (it also \ -writes the Caddyfile and SearxNG config the compose mounts). The compose uses pre-built \ -ghcr.io images — no local Docker build is needed. -- If an existing .env is detected, summarize what's configured and ask what to change. -- The `TURNSTONE_DB_URL` for docker compose internal networking uses the hostname `postgres` \ -(e.g., `postgresql+psycopg://turnstone:@postgres:5432/turnstone`). -- For local LLM backends (vLLM, llama.cpp, etc.), set `OPENAI_API_KEY=dummy` in the \ -.env file — local servers typically don't require authentication. The `LLM_BASE_URL` should \ -use `host.docker.internal` to reach the host machine from inside Docker \ -(e.g., `http://host.docker.internal:8000/v1`). -- If Docker is NOT installed, tell the user they need to install it before proceeding. \ -Give them the install command for their platform: \ -Linux: `curl -fsSL https://get.docker.com | sh`, \ -macOS: "Install Docker Desktop from https://docs.docker.com/desktop/install/mac-install/", \ -Windows: "Install Docker Desktop from https://docs.docker.com/desktop/install/windows-install/". \ -You can still generate the config files — they just can't start the stack until Docker is installed. - -""" - -# --------------------------------------------------------------------------- -# Tool schemas (OpenAI function-calling format) -# --------------------------------------------------------------------------- - -TOOLS: list[dict[str, Any]] = [ - { - "type": "function", - "function": { - "name": "read_file", - "description": ( - "Read the contents of a file relative to the project directory. " - "Returns the file content or an error if the file doesn't exist." - ), - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path relative to the project root.", - }, - }, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "write_file", - "description": ( - "Write content to a file. The user will be shown a preview and " - "asked to confirm before the write happens. The file is created " - "if it doesn't exist." - ), - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path relative to the project root.", - }, - "content": { - "type": "string", - "description": "Full file content to write.", - }, - }, - "required": ["path", "content"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "generate_secret", - "description": ( - "Generate a cryptographically secure random hex string for use " - "as JWT secrets, passwords, auth tokens, etc." - ), - "parameters": { - "type": "object", - "properties": { - "length": { - "type": "integer", - "description": ( - "Number of random bytes. Output will be 2x this in " - "hex characters. Default: 32." - ), - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "check_port", - "description": "Check if a TCP port is available (not in use) on localhost.", - "parameters": { - "type": "object", - "properties": { - "port": { - "type": "integer", - "description": "Port number to check.", - }, - }, - "required": ["port"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "validate_api_key", - "description": ( - "Test an API key by making a lightweight request to the provider. " - "Returns success/failure and any error message." - ), - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["openai", "anthropic"], - "description": "Provider name.", - }, - "api_key": { - "type": "string", - "description": "API key to validate.", - }, - "base_url": { - "type": "string", - "description": ( - "API base URL. Only needed for OpenAI-compatible endpoints." - ), - }, - }, - "required": ["provider", "api_key"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "check_docker", - "description": ( - "Check if Docker and Docker Compose are installed and the Docker " - "daemon is running. Returns version info or error details." - ), - "parameters": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "write_compose", - "description": ( - "Write the production Docker Compose file (and its companion " - "Caddyfile and SearxNG web-search config) to the project directory. " - "This extracts the compose.yaml bundled with Turnstone, which uses " - "pre-built images from ghcr.io (no local Docker build required), a Caddy " - "reverse proxy that fronts the console over HTTPS, and a bundled SearxNG " - "service for web search. The user will see a preview and confirm." - ), - "parameters": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "finish", - "description": ( - "Call this tool when the bootstrap setup is complete and all files " - "have been written. Displays a final summary and exits the wizard. " - "You MUST call this after writing all config files and printing " - "the next-steps summary." - ), - "parameters": { - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": ( - "A short summary of what was configured (deployment mode, " - "files written, next commands to run)." - ), - }, - }, - "required": ["summary"], - }, - }, - }, -] - -# --------------------------------------------------------------------------- -# Tool implementations -# --------------------------------------------------------------------------- - - -def _mask_secrets(text: str) -> str: - """Mask sensitive values in text for display preview.""" - lines = text.split("\n") - masked: list[str] = [] - for line in lines: - if "=" in line and not line.lstrip().startswith("#"): - key, _, value = line.partition("=") - key_upper = key.strip().upper() - if any(pat in key_upper for pat in _SENSITIVE_PATTERNS) and len(value) > 8: - masked.append(f"{key}={value[:4]}****{value[-4:]}") - continue - masked.append(line) - return "\n".join(masked) - - -def _resolve_safe(project_dir: Path, raw_path: str) -> Path | None: - """Resolve a path and verify it stays within project_dir. Returns None if unsafe.""" - resolved = (project_dir / raw_path).resolve() - if not resolved.is_relative_to(project_dir.resolve()): - return None - return resolved - - -def _tool_read_file(project_dir: Path, args: dict[str, Any]) -> str: - """Read a file relative to the project directory.""" - raw = str(args["path"]) - path = _resolve_safe(project_dir, raw) - if path is None: - return f"Error: path escapes project directory: {raw}" - try: - content: str = path.read_text(encoding="utf-8") - return content - except FileNotFoundError: - return f"Error: file not found: {args['path']}" - except (OSError, UnicodeDecodeError) as exc: - return f"Error reading {args['path']}: {exc}" - - -def _tool_write_file(project_dir: Path, args: dict[str, Any]) -> str: - """Write a file with user confirmation.""" - raw = str(args["path"]) - path = _resolve_safe(project_dir, raw) - if path is None: - return f"Error: path escapes project directory: {raw}" - content = args["content"] - - # Skip if file already exists with identical content - if path.exists(): - try: - existing = path.read_text(encoding="utf-8") - if existing == content: - return f"File already exists with identical content: {args['path']}" - except (OSError, UnicodeDecodeError): - pass # best-effort duplicate check - - line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0) - - # Show preview - print(f"\n{YELLOW} Writing {args['path']} ({line_count} lines){RESET}") - print(f"{DIM}{'─' * 50}{RESET}") - preview = _mask_secrets(content) - for line in preview.split("\n")[:50]: - print(f" {DIM}{line}{RESET}") - if line_count > 50: - print(f" {DIM}... ({line_count - 50} more lines){RESET}") - print(f"{DIM}{'─' * 50}{RESET}") - - try: - choice = input(f"{BOLD}Write this file? [Y/n]{RESET} ").strip().lower() - except (EOFError, KeyboardInterrupt): - return "User cancelled the write." - if choice in ("n", "no"): - return "User declined to write file." - - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - # Make .sh files executable - if path.suffix == ".sh": - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) - - return f"File written successfully: {args['path']}" - - -def _tool_generate_secret(args: dict[str, Any]) -> str: - """Generate a cryptographically secure random hex string.""" - length = args.get("length", 32) - if not isinstance(length, int) or length < 1 or length > 128: - length = 32 - return secrets.token_hex(length) - - -def _tool_check_port(args: dict[str, Any]) -> str: - """Check if a TCP port is available on localhost.""" - port = args["port"] - if not isinstance(port, int) or port < 1 or port > 65535: - return f"Error: invalid port number: {port}" - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(1) - result = sock.connect_ex(("127.0.0.1", port)) - if result == 0: - return f"Port {port} is IN USE (something is already listening)." - return f"Port {port} is AVAILABLE." - except OSError as exc: - return f"Error checking port {port}: {exc}" - - -def _tool_validate_api_key(args: dict[str, Any]) -> str: - """Validate an API key with a lightweight request.""" - provider = args["provider"] - api_key = args["api_key"] - base_url = args.get("base_url") - - if provider == "openai": - try: - from openai import OpenAI - - kwargs: dict[str, Any] = {"api_key": api_key} - if base_url: - kwargs["base_url"] = base_url - oai_client = OpenAI(**kwargs) - oai_client.models.list() - return "Success: API key is valid." - except Exception as exc: - return f"Failed: {exc}" - - elif provider == "anthropic": - try: - import anthropic - - ant_client = anthropic.Anthropic(api_key=api_key) - ant_client.messages.create( - model="claude-sonnet-4-6", - max_tokens=1, - messages=[{"role": "user", "content": "hi"}], - ) - return "Success: API key is valid." - except Exception as exc: - return f"Failed: {exc}" - - return f"Error: unknown provider '{provider}'" - - -def _tool_check_docker(args: dict[str, Any]) -> str: - """Check Docker and Docker Compose availability.""" - results: list[str] = [] - - # Check Docker - try: - proc = subprocess.run( - ["docker", "version", "--format", "{{.Server.Version}}"], - capture_output=True, - text=True, - timeout=10, - ) - if proc.returncode == 0: - results.append(f"Docker: installed (version {proc.stdout.strip()})") - else: - stderr = proc.stderr.strip() - if "Cannot connect" in stderr or "Is the docker daemon running" in stderr: - results.append("Docker: installed but daemon is NOT running") - else: - results.append(f"Docker: error — {stderr}") - except FileNotFoundError: - results.append("Docker: NOT installed") - except subprocess.TimeoutExpired: - results.append("Docker: timed out (daemon may be unresponsive)") - - # Check Docker Compose - try: - proc = subprocess.run( - ["docker", "compose", "version", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - if proc.returncode == 0: - results.append(f"Docker Compose: installed (version {proc.stdout.strip()})") - else: - results.append("Docker Compose: NOT available") - except (FileNotFoundError, subprocess.TimeoutExpired): - results.append("Docker Compose: NOT available") - - return "\n".join(results) - - -def _tool_write_compose(project_dir: Path, args: dict[str, Any]) -> str: - """Extract the bundled compose.yaml + Caddyfile + SearxNG settings. - - The compose file bind-mounts ``./Caddyfile`` (Caddy) and ``./searxng`` - (the SearxNG web-search service), so all three must land together — - otherwise ``docker compose up`` fails to start those services. - """ - dest = project_dir / "compose.yaml" - caddy_dest = project_dir / "Caddyfile" - searxng_dest = project_dir / "searxng" / "settings.yml" - - # Read the bundled templates - try: - deploy = importlib.resources.files("turnstone.deploy") - content = deploy.joinpath("compose.yaml").read_text(encoding="utf-8") - caddy_content = deploy.joinpath("Caddyfile").read_text(encoding="utf-8") - searxng_content = deploy.joinpath("searxng/settings.yml").read_text(encoding="utf-8") - except Exception as exc: - return f"Error: could not read bundled compose templates: {exc}" - - # Skip if all already identical - if dest.exists() and caddy_dest.exists() and searxng_dest.exists(): - try: - if ( - dest.read_text(encoding="utf-8") == content - and caddy_dest.read_text(encoding="utf-8") == caddy_content - and searxng_dest.read_text(encoding="utf-8") == searxng_content - ): - return ( - "compose.yaml, Caddyfile and searxng/settings.yml already exist " - "with identical content." - ) - except (OSError, UnicodeDecodeError): - pass # best-effort duplicate check - - line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0) - - # Show preview - print( - f"\n{YELLOW} Writing compose.yaml ({line_count} lines) + Caddyfile + searxng/settings.yml{RESET}" - ) - print(f"{DIM}{'─' * 50}{RESET}") - for line in content.split("\n")[:30]: - print(f" {DIM}{line}{RESET}") - if line_count > 30: - print(f" {DIM}... ({line_count - 30} more lines){RESET}") - print(f"{DIM}{'─' * 50}{RESET}") - - try: - choice = input(f"{BOLD}Write these files? [Y/n]{RESET} ").strip().lower() - except (EOFError, KeyboardInterrupt): - return "User cancelled the write." - if choice in ("n", "no"): - return "User declined to write compose.yaml." - - dest.write_text(content, encoding="utf-8") - caddy_dest.write_text(caddy_content, encoding="utf-8") - searxng_dest.parent.mkdir(parents=True, exist_ok=True) - searxng_dest.write_text(searxng_content, encoding="utf-8") - - return ( - f"compose.yaml + Caddyfile + searxng/settings.yml written successfully. " - f"The compose uses ghcr.io/turnstonelabs/turnstone images; Caddy fronts the " - f"console dashboard over HTTPS at https://localhost:8443, and the bundled " - f"SearxNG service provides web search. " - f"Add TURNSTONE_IMAGE_TAG={__version__} to .env to pin the image " - f"to the currently installed version, or omit it to use 'latest'." - ) - - -class _FinishError(Exception): - """Raised by the finish tool to signal the wizard is done.""" - - def __init__(self, summary: str) -> None: - self.summary = summary - - -def _tool_finish(args: dict[str, Any]) -> str: - """Signal that the bootstrap wizard is complete.""" - raise _FinishError(args.get("summary", "Setup complete.")) - - -# Tool dispatch table -TOOL_FUNCTIONS: dict[str, Any] = { - "read_file": _tool_read_file, - "write_file": _tool_write_file, - "generate_secret": _tool_generate_secret, - "check_port": _tool_check_port, - "validate_api_key": _tool_validate_api_key, - "check_docker": _tool_check_docker, - "write_compose": _tool_write_compose, - "finish": _tool_finish, -} - -# Tools that need the project_dir argument -_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file", "write_compose"}) - - -def execute_tool(name: str, args: dict[str, Any], project_dir: Path) -> str: - """Execute a tool and return the result string. - - Raises _FinishError when the finish tool is called. - """ - fn = TOOL_FUNCTIONS.get(name) - if fn is None: - return f"Error: unknown tool '{name}'" - try: - if name in _PROJECT_DIR_TOOLS: - result: str = fn(project_dir, args) - else: - result = fn(args) - return result - except _FinishError: - raise - except Exception as exc: - return f"Error executing {name}: {exc}" - - -# --------------------------------------------------------------------------- -# _BootstrapLLM — thin wrapper over OpenAI / Anthropic SDKs -# --------------------------------------------------------------------------- - - -class _BootstrapLLM: - """Provider-agnostic wrapper for non-streaming tool-calling completions.""" - - def __init__(self, provider: str, client: Any, model: str) -> None: - self.provider = provider - self.client = client - self.model = model - - def complete( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - ) -> tuple[str, list[dict[str, Any]] | None, str]: - """Run a completion and return (content, tool_calls, stop_reason).""" - if self.provider == "anthropic": - return self._complete_anthropic(messages, tools) - return self._complete_openai(messages, tools) - - # -- OpenAI path -------------------------------------------------------- - - def _complete_openai( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - ) -> tuple[str, list[dict[str, Any]] | None, str]: - resp = self.client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools if tools else None, - ) - # Guard against non-spec responses from proxies (Open WebUI, LiteLLM, etc.) - if resp is None: - raise RuntimeError( - "Server returned null — your OpenAI-compatible endpoint may not " - "support tool calling. Try a direct connection to the model server." - ) - choices = getattr(resp, "choices", None) - if not choices: - raise RuntimeError( - "Server returned an empty choices array. " - "The model may have hit its context limit, or the proxy " - "dropped the response." - ) - choice = choices[0] - message = getattr(choice, "message", None) - if message is None: - raise RuntimeError( - "Server returned a choice with no message. " - "Your OpenAI-compatible endpoint may not fully implement " - "the chat completions API." - ) - content = message.content or "" - tool_calls = None - if getattr(message, "tool_calls", None): - tool_calls = [ - { - "id": getattr(tc, "id", None) or f"call_{secrets.token_hex(4)}", - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - for i, tc in enumerate(message.tool_calls) - ] - return content, tool_calls, getattr(choice, "finish_reason", None) or "stop" - - # -- Anthropic path ----------------------------------------------------- - - def _complete_anthropic( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - ) -> tuple[str, list[dict[str, Any]] | None, str]: - # Extract system message - system_text = "" - api_messages: list[dict[str, Any]] = [] - for msg in messages: - if msg["role"] == "system": - system_text = msg["content"] - elif msg["role"] == "tool": - # Convert OpenAI tool result to Anthropic format - api_messages.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": msg["tool_call_id"], - "content": msg["content"], - } - ], - } - ) - elif msg["role"] == "assistant" and msg.get("tool_calls"): - # Convert assistant tool_calls to Anthropic content blocks - blocks: list[dict[str, Any]] = [] - if msg.get("content"): - blocks.append({"type": "text", "text": msg["content"]}) - for tc in msg["tool_calls"]: - blocks.append( - { - "type": "tool_use", - "id": tc["id"], - "name": tc["function"]["name"], - "input": json.loads(tc["function"]["arguments"]), - } - ) - api_messages.append({"role": "assistant", "content": blocks}) - else: - api_messages.append(msg) - - # Merge consecutive same-role messages (Anthropic requires alternation) - merged: list[dict[str, Any]] = [] - for msg in api_messages: - if merged and merged[-1]["role"] == msg["role"]: - # Merge content - prev = merged[-1] - prev_content = prev["content"] - new_content = msg["content"] - if isinstance(prev_content, str) and isinstance(new_content, str): - prev["content"] = prev_content + "\n" + new_content - elif isinstance(prev_content, str): - prev["content"] = [{"type": "text", "text": prev_content}] + ( - new_content if isinstance(new_content, list) else [new_content] - ) - elif isinstance(new_content, str): - prev["content"] = prev_content + [{"type": "text", "text": new_content}] - else: - prev["content"] = prev_content + new_content - else: - merged.append(msg) - api_messages = merged - - # Convert tools - api_tools = [ - { - "name": t["function"]["name"], - "description": t["function"]["description"], - "input_schema": t["function"]["parameters"], - } - for t in tools - ] - - resp = self.client.messages.create( - model=self.model, - max_tokens=4096, - system=system_text, - messages=api_messages, - tools=api_tools if api_tools else [], - ) - - # Parse response - content_parts: list[str] = [] - tool_calls: list[dict[str, Any]] = [] - for block in resp.content: - if block.type == "text": - content_parts.append(block.text) - elif block.type == "tool_use": - tool_calls.append( - { - "id": block.id, - "type": "function", - "function": { - "name": block.name, - "arguments": json.dumps(block.input), - }, - } - ) - - content = "\n".join(content_parts) - return ( - content, - tool_calls if tool_calls else None, - resp.stop_reason or "end_turn", - ) - - -# --------------------------------------------------------------------------- -# Interactive startup (Phase 1: before LLM) -# --------------------------------------------------------------------------- - - -def _print_banner() -> None: - print(f"\n{BOLD}{CYAN} Turnstone Bootstrap Wizard{RESET} {DIM}v{__version__}{RESET}") - print(f" {DIM}{'─' * 48}{RESET}") - print() - print(" This wizard uses an AI model to walk you through") - print(" setting up a Turnstone deployment. You'll need an") - print(" API key for one of the supported providers.") - print() - - -def _select_provider() -> tuple[str, Any, str]: - """Interactive provider/model/key selection. Returns (provider, client, model).""" - print(f" {BOLD}Which provider for this wizard?{RESET}") - print(f" {CYAN}[1]{RESET} OpenAI") - print(f" {CYAN}[2]{RESET} Anthropic") - print(f" {CYAN}[3]{RESET} OpenAI-compatible (local/vLLM)") - print() - - while True: - try: - choice = input(f" {BOLD}>{RESET} ").strip() - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - if choice in ("1", "2", "3"): - break - print(f" {RED}Please enter 1, 2, or 3.{RESET}") - - if choice == "1": - return _setup_openai() - elif choice == "2": - return _setup_anthropic() - else: - return _setup_local() - - -def _prompt_api_key(env_var: str, label: str) -> str: - """Prompt for an API key, checking env var first.""" - env_val = os.environ.get(env_var, "") - if env_val: - prefix = env_val[:4] + "..." if len(env_val) > 4 else env_val - print(f"\n Found {CYAN}${env_var}{RESET} in environment ({DIM}{prefix}{RESET})") - try: - use_env = input(f" Use it? {BOLD}[Y/n]{RESET} ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - if use_env not in ("n", "no"): - return env_val - - print(f"\n {label}") - try: - key = getpass.getpass(" API key: ") - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - if not key.strip(): - print(f" {RED}API key cannot be empty.{RESET}") - sys.exit(1) - return key.strip() - - -def _prompt_model(provider: str) -> str: - """Prompt for model name with a sensible default.""" - default = _DEFAULT_MODELS.get(provider, "") - prompt = f" Model {DIM}[{default}]{RESET}: " if default else " Model name: " - try: - model = input(prompt).strip() - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - return model or default - - -def _setup_openai() -> tuple[str, Any, str]: - from openai import OpenAI - - api_key = _prompt_api_key("OPENAI_API_KEY", "Enter your OpenAI API key:") - model = _prompt_model("openai") - client = OpenAI(api_key=api_key) - return "openai", client, model - - -def _setup_anthropic() -> tuple[str, Any, str]: - import anthropic - - api_key = _prompt_api_key("ANTHROPIC_API_KEY", "Enter your Anthropic API key:") - model = _prompt_model("anthropic") - client = anthropic.Anthropic(api_key=api_key) - return "anthropic", client, model - - -def _detect_models(client: Any) -> list[str]: - """Query /v1/models and return a sorted list of model IDs.""" - try: - resp = client.models.list() - models = sorted(m.id for m in resp.data) - return models - except Exception: - return [] - - -def _setup_local() -> tuple[str, Any, str]: - from openai import OpenAI - - print("\n Enter the base URL of your OpenAI-compatible endpoint.") - default_url = "http://localhost:8000/v1" - try: - url = input(f" Base URL {DIM}[{default_url}]{RESET}: ").strip() or default_url - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - - # Local endpoints often don't need a real key - env_key = os.environ.get("OPENAI_API_KEY", "") - if env_key: - api_key = env_key - print(f" Using {CYAN}$OPENAI_API_KEY{RESET} from environment.") - else: - print(" API key (press Enter for 'none'):") - try: - api_key = getpass.getpass(" API key: ") or "none" - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - - client = OpenAI(api_key=api_key, base_url=url) - - # Try to auto-detect available models - print(f"\n {DIM}Querying {url} for available models...{RESET}") - available = _detect_models(client) - - if len(available) == 1: - model = available[0] - print(f" Found model: {CYAN}{model}{RESET}") - elif available: - print(f" Found {len(available)} model(s):") - for i, m in enumerate(available, 1): - print(f" {CYAN}[{i}]{RESET} {m}") - print() - try: - choice = input(f" Select model {DIM}[1]{RESET}: ").strip() or "1" - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - try: - idx = int(choice) - 1 - model = available[idx] if 0 <= idx < len(available) else choice - except ValueError: - model = choice # Treat as literal model name - else: - print(f" {YELLOW}Could not auto-detect models.{RESET}") - try: - model = input(" Model name: ").strip() - except (EOFError, KeyboardInterrupt): - print("\nCancelled.") - sys.exit(0) - if not model: - print(f" {RED}Model name is required for local endpoints.{RESET}") - sys.exit(1) - - return "openai", client, model - - -def _validate_connection(llm: _BootstrapLLM) -> bool: - """Validate the LLM connection with a minimal request.""" - try: - content, _, _ = llm.complete( - [ - {"role": "system", "content": "Reply with exactly: ok"}, - {"role": "user", "content": "ping"}, - ], - [], - ) - return True - except Exception as exc: - print(f"\n {RED}Connection failed: {exc}{RESET}") - return False - - -# --------------------------------------------------------------------------- -# Conversation loop -# --------------------------------------------------------------------------- - - -def _run_conversation( - llm: _BootstrapLLM, - project_dir: Path, -) -> None: - """Main LLM-driven conversation loop.""" - renderer = MarkdownRenderer() - - messages: list[dict[str, Any]] = [ - {"role": "system", "content": SYSTEM_PROMPT}, - { - "role": "user", - "content": ( - "I'd like to set up Turnstone. Please start by checking if " - "Docker is available and if there's an existing .env configuration." - ), - }, - ] - - _max_retries = 3 - retries = 0 - - while True: - # Get LLM response - with Spinner("Thinking"): - try: - content, tool_calls, reason = llm.complete(messages, TOOLS) - except KeyboardInterrupt: - print(f"\n{DIM}(Interrupted. Type 'quit' to exit.){RESET}") - messages.append( - {"role": "user", "content": "The user interrupted. Ask what they need."} - ) - continue - except Exception as exc: - retries += 1 - if retries >= _max_retries: - print(f"\n{RED}LLM error after {_max_retries} attempts: {exc}{RESET}") - print() - print("Troubleshooting:") - print( - f" {DIM}• If using a proxy (Open WebUI, LiteLLM), try connecting directly{RESET}" - ) - print(f" {DIM}• Verify the endpoint supports tool/function calling{RESET}") - print(f" {DIM}• Check that the model context window isn't exceeded{RESET}") - print( - f" {DIM}• Try a different model — not all models handle tool calls reliably{RESET}" - ) - return - print(f"\n{RED}LLM error: {exc}{RESET}") - print(f"{DIM}Retrying ({retries}/{_max_retries})...{RESET}") - continue - - retries = 0 # Reset on success - - # Build assistant message - assistant_msg: dict[str, Any] = {"role": "assistant", "content": content or ""} - if tool_calls: - assistant_msg["tool_calls"] = tool_calls - messages.append(assistant_msg) - - # Print text content - if content: - rendered = renderer.feed(content + "\n") - flushed = renderer.flush() - print(rendered + flushed, end="") - - # Execute tool calls - if tool_calls: - for tc in tool_calls: - name = tc["function"]["name"] - try: - args = json.loads(tc["function"]["arguments"]) - except json.JSONDecodeError as exc: - result = f"Error: invalid JSON arguments: {exc}" - args = {} - else: - print(f" {DIM}[{name}]{RESET}", end="") - if name in ("read_file", "write_file") and "path" in args: - print(f" {DIM}{args['path']}{RESET}") - elif name == "check_port" and "port" in args: - print(f" {DIM}:{args['port']}{RESET}") - else: - print() - try: - result = execute_tool(name, args, project_dir) - except _FinishError as fin: - print(f"\n{GREEN}{BOLD} Setup complete!{RESET}\n") - rendered = renderer.feed(fin.summary + "\n") - flushed = renderer.flush() - print(rendered + flushed, end="") - return - - messages.append( - { - "role": "tool", - "tool_call_id": tc["id"], - "content": result, - } - ) - continue # Let LLM process tool results - - # No tool calls — prompt user - print() - try: - user_input = input(f"{BOLD}>{RESET} ").strip() - except EOFError: - print("\nGoodbye!") - return - except KeyboardInterrupt: - print(f"\n{DIM}(Press Ctrl+C again to quit, or type your response.){RESET}") - try: - user_input = input(f"{BOLD}>{RESET} ").strip() - except (EOFError, KeyboardInterrupt): - print("\nGoodbye!") - return - - if user_input.lower() in ("quit", "exit", "q"): - print("Goodbye!") - return - - if not user_input: - continue - - messages.append({"role": "user", "content": user_input}) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def main() -> None: - """Entry point for turnstone-bootstrap CLI.""" - _print_banner() - - # Phase 1: Interactive provider selection - provider, client, model = _select_provider() - llm = _BootstrapLLM(provider, client, model) - - # Validate connection - print(f"\n {DIM}Validating connection...{RESET}") - if not _validate_connection(llm): - print(f" {RED}Could not connect to the model. Please check your settings.{RESET}") - sys.exit(1) - - print(f"\n {GREEN}Connected to {BOLD}{model}{RESET}{GREEN}.{RESET}") - print(f" {DIM}Handing off to AI assistant...{RESET}\n") - - # Phase 2: LLM-driven conversation - project_dir = Path.cwd() - try: - _run_conversation(llm, project_dir) - except KeyboardInterrupt: - print("\nGoodbye!") - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/turnstone/core/storage/_registry.py b/turnstone/core/storage/_registry.py index c2d636d6..b1b5045e 100644 --- a/turnstone/core/storage/_registry.py +++ b/turnstone/core/storage/_registry.py @@ -30,6 +30,7 @@ def init_storage( url: str = "", pool_size: int = 2, run_migrations: bool = True, + create_tables: bool | None = None, sslmode: str = "", sslrootcert: str = "", sslcert: str = "", @@ -44,6 +45,11 @@ def init_storage( url: PostgreSQL connection URL (e.g. postgresql+psycopg://user:pass@host/db) pool_size: Connection pool size (PostgreSQL only) run_migrations: Whether to run Alembic migrations on init + create_tables: Whether to emit ``CREATE TABLE`` DDL (``create_all``) on + init. ``None`` (default) keeps the historical behaviour — create + tables only when migrations are NOT run. Pass ``False`` for a + strictly read-only open that must never touch the schema (e.g. + ``turnstone-doctor`` inspecting a live DB); pass ``True`` to force it. listen_url: Optional dedicated PostgreSQL URL for the dispatcher's ``LISTEN`` connection. Required only when ``url`` points at a ``pgbouncer`` running in transaction pooling mode (LISTEN @@ -54,10 +60,12 @@ def init_storage( """ global _storage - # When Alembic migrations will run, skip create_all() to avoid - # bypassing migration-managed DDL. Tests pass run_migrations=False - # and rely on create_all() instead. - create_tables = not run_migrations + # When Alembic migrations will run, skip create_all() to avoid bypassing + # migration-managed DDL. Tests pass run_migrations=False and rely on + # create_all() instead. An explicit create_tables overrides this — a + # diagnose-only caller passes create_tables=False to never emit DDL. + if create_tables is None: + create_tables = not run_migrations if backend == "sqlite": from turnstone.core.storage._sqlite import SQLiteBackend diff --git a/turnstone/deploy/__init__.py b/turnstone/deploy/__init__.py index 67ed0c04..bd3d9bac 100644 --- a/turnstone/deploy/__init__.py +++ b/turnstone/deploy/__init__.py @@ -1,5 +1,6 @@ """Bundled deployment templates (compose files, overlays). -These files are included in the wheel so that ``turnstone-bootstrap`` can -extract them for users who install via pip/pipx and don't have a git clone. +These files ship in the wheel so a pip/pipx install can run the released +single-node stack (``docker compose -f turnstone/deploy/compose.yaml up``) +without a git clone. """ diff --git a/turnstone/deploy/compose.yaml b/turnstone/deploy/compose.yaml index 5e51fbcf..59169d80 100644 --- a/turnstone/deploy/compose.yaml +++ b/turnstone/deploy/compose.yaml @@ -4,7 +4,7 @@ # Same shape as the dev stack at the repo root (Caddy-fronted console, shared # Postgres, channel gateway) but it pulls released images from ghcr.io instead # of building, runs a single server node, and requires real secrets. Bundled -# with the turnstone wheel and written by turnstone-bootstrap. +# with the turnstone wheel. # # docker compose -f turnstone/deploy/compose.yaml up # @@ -16,7 +16,7 @@ # dashboard's SSE streams). # # No baked-in secrets: set TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD in .env -# (turnstone-bootstrap generates them). Pin images with TURNSTONE_IMAGE_TAG +# (set them yourself, e.g. `openssl rand -hex 32`). Pin images with TURNSTONE_IMAGE_TAG # (default: latest). # # Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a diff --git a/turnstone/doctor.py b/turnstone/doctor.py new file mode 100644 index 00000000..efa159b5 --- /dev/null +++ b/turnstone/doctor.py @@ -0,0 +1,2195 @@ +"""LLM-backed diagnostic tool for a running Turnstone install. + +Entry point: turnstone-doctor + +``turnstone-doctor`` inspects a *running* Turnstone deployment and helps the +operator troubleshoot it conversationally. It is **diagnose-only**: it reads +state (config files, env, ``docker compose ps``, ``systemctl``, ``/health``, +the database) and explains what it finds, recommending the exact commands to +run — it never mutates the system. + +Day-0 installation is owned by ``run.sh`` (the one-line installer), not by this +tool. + +Startup sequence: + +1. **Preflight** — :func:`detect_install_profile` deterministically detects how + Turnstone is installed here (docker-compose / systemd / pip / git-source) by + probing for ``config.toml`` files, ``TURNSTONE_*`` env vars, compose files, + and systemd units. +2. **Self-configuring brain** — :func:`resolve_doctor_brain` powers doctor's own + LLM from the cluster's *own* configuration (config/env/storage), exactly the + way a node does. Whether that succeeds is itself the first diagnostic (the + LLM-backend verdict). On failure it falls back to interactive provider + selection so doctor still runs. +3. **Version check** — :func:`check_versions` reports the installed version, + cluster version drift (via storage + ``/health``), and the latest upstream + stable/experimental releases. +4. **Diagnose loop** — the LLM drives read-only diagnostic tools. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import getpass +import json +import os +import re +import socket +import subprocess +import sys +import tempfile +import tomllib +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from packaging.version import InvalidVersion, Version + +from turnstone import __version__ +from turnstone.core.env import _is_secret +from turnstone.ui.colors import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW +from turnstone.ui.markdown import MarkdownRenderer +from turnstone.ui.spinner import Spinner + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_MODELS: dict[str, str] = { + "openai": "gpt-5.4", + "anthropic": "claude-sonnet-4-6", +} + +# Default HTTP ports the server node and console bind (overridable per install). +DEFAULT_SERVER_PORT = 8080 +DEFAULT_CONSOLE_PORT = 8090 + +# Public release tags for the upstream version check (no auth, no user data sent). +GITHUB_TAGS_URL = "https://api.github.com/repos/turnstonelabs/turnstone/tags" + +# Env vars worth surfacing in the preflight report. Secret-named ones (per +# turnstone.core.env._is_secret) are shown as present-but-hidden; the rest have +# any embedded URL credentials redacted. +_RELEVANT_ENV_VARS: tuple[str, ...] = ( + "TURNSTONE_DB_BACKEND", + "TURNSTONE_DB_URL", + "TURNSTONE_DB_PATH", + "TURNSTONE_JWT_SECRET", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "LLM_BASE_URL", + "MODEL", + "TURNSTONE_HOST_IP", + "TURNSTONE_CONSOLE_URL", + "TURNSTONE_SERVER_URL", + "TURNSTONE_ADVERTISE_URL", + "TURNSTONE_NODE_ID", + "TURNSTONE_DISCORD_TOKEN", + "TURNSTONE_SLACK_TOKEN", + "TURNSTONE_IMAGE_TAG", + "MCP_CONFIG", + "TURNSTONE_CONFIG", +) + +# Lowercased config-file keys that hold secrets even though they don't match the +# _KEY/_SECRET/_TOKEN/_PASSWORD suffix rule in turnstone.core.env._is_secret. +_SECRET_CONFIG_KEYS: frozenset[str] = frozenset( + {"password", "secret", "api_key", "jwt_secret", "token"} +) + +# Model providers doctor's built-in _DoctorLLM can drive, grouped by wire family. +_ANTHROPIC_PROVIDERS: frozenset[str] = frozenset({"anthropic", "anthropic-compatible"}) +_OPENAI_PROVIDERS: frozenset[str] = frozenset({"openai", "openai-compatible", "xai"}) + +# read_file refuses to dump raw key/cert material outright, and caps large reads +# so a big log can't flood the model's context. +_SECRET_FILE_SUFFIXES: tuple[str, ...] = (".pem", ".key", ".crt", ".cer", ".p12", ".pfx") +_READ_MAX_CHARS = 64_000 + + +# --------------------------------------------------------------------------- +# Secret masking (display-only) +# --------------------------------------------------------------------------- + +# scheme://user:password@host -> scheme://user:****@host +_URL_CRED_RE = re.compile(r"(://[^:/@\s]+:)([^@/\s]+)(@)") +# A config-key token (left of '='/':' in env/TOML/YAML/JSON), optionally quoted. +_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]*$") +# A PEM private-key block (any flavour), redacted whole. +_PEM_RE = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", + re.DOTALL, +) + + +def _redact_url_credentials(value: str) -> str: + """Mask the password in any ``scheme://user:password@host`` URL in *value*.""" + return _URL_CRED_RE.sub(r"\1****\3", value) + + +def _mask_value(value: str) -> str: + """Show only the first/last few characters of a secret value.""" + if len(value) > 8: + return f"{value[:4]}****{value[-4:]}" + return "****" if value else value + + +def _mask_line(line: str) -> str: + """Mask a single ``KEY=value`` / ``key: value`` assignment if it's a secret. + + Uses the earliest of ``=`` / ``:`` as the separator and only treats the line + as an assignment when the left side is a bare config key (so timestamps, + URLs, and prose lines pass through untouched). The WHOLE value is masked — + ``#`` is never treated as a comment, since a ``#`` can sit inside a secret. + """ + seps = [i for i in (line.find("="), line.find(":")) if i != -1] + if not seps: + return line + idx = min(seps) + sep = line[idx] + key_part, val_part = line[:idx], line[idx + 1 :] + key = key_part.strip().strip("\"'") + if not _KEY_RE.match(key): + return line + raw = val_part.strip() + if not raw: + return line + if _is_secret(key) or key.lower() in _SECRET_CONFIG_KEYS: + masked = _mask_value(raw.strip("\"',")) + else: + masked = _redact_url_credentials(raw) + lead = val_part[: len(val_part) - len(val_part.lstrip())] + return f"{key_part}{sep}{lead}{masked}" + + +def _mask_secrets(text: str) -> str: + """Redact secrets in ``.env`` / TOML / YAML / JSON-ish text for display. + + Masks values whose key looks like a secret (reusing ``turnstone.core.env._is_secret`` + plus a few bare config key names) and redacts embedded URL credentials on every + other value, so a database DSN's password never leaks under a non-secret-looking + key like ``url``. + """ + out: list[str] = [] + for line in text.split("\n"): + stripped = line.lstrip() + if stripped.startswith("#"): + # A commented-out assignment can still hold a real secret + # (e.g. "# OPENAI_API_KEY=..."), so mask the comment body too, + # keeping the indent and leading '#' marker intact. Prose comments + # have no KEY=value shape and pass through _mask_line unchanged. + indent = line[: len(line) - len(stripped)] + hashes = len(stripped) - len(stripped.lstrip("#")) + out.append(f"{indent}{stripped[:hashes]}{_mask_line(stripped[hashes:])}") + else: + out.append(_mask_line(line)) + return "\n".join(out) + + +def _scrub_tool_output(text: str) -> str: + """Defang any diagnostic tool result before it reaches the model/console. + + A single chokepoint (applied in :func:`execute_tool`) so every tool — not + just ``read_file`` — is covered: redacts URL credentials anywhere (DSNs in + logs/stack traces), drops PEM private-key blocks, and masks secret-keyed + ``KEY=value`` / ``key: value`` lines (env echoes in logs, config dumps). + """ + text = _redact_url_credentials(text) + text = _PEM_RE.sub("[REDACTED PRIVATE KEY]", text) + return _mask_secrets(text) + + +def _resolve_safe(project_dir: Path, raw_path: str) -> Path | None: + """Resolve a path and verify it stays within project_dir. Returns None if unsafe.""" + resolved = (project_dir / raw_path).resolve() + if not resolved.is_relative_to(project_dir.resolve()): + return None + return resolved + + +# A plain systemd unit name. Model-supplied; must not start with '-' (which +# systemctl would parse as a global option like -H / -M). +_UNIT_RE = re.compile(r"^[A-Za-z0-9@._-]+$") + + +def _safe_unit(unit: str) -> str | None: + """Return *unit* if it's a plain unit name, else None (rejects option injection).""" + unit = unit.strip() + if unit.startswith("-") or not _UNIT_RE.match(unit): + return None + return unit + + +def _reject_option(value: str) -> str | None: + """Return *value* unless it would be parsed as a CLI option (leading '-').""" + value = str(value).strip() + return None if value.startswith("-") else value + + +# --------------------------------------------------------------------------- +# Small HTTP / subprocess helpers (read-only) +# --------------------------------------------------------------------------- + + +def _assert_safe_http_url(url: str) -> None: + """Reject a model-supplied URL that isn't safe to fetch from this host. + + Restricts the scheme to http/https (no ``file://``/``ftp://``) and blocks the + cloud link-local metadata range (``169.254.0.0/16`` / ``metadata.google.internal``) + so an LLM-driven probe can't be steered at the instance-metadata service. + Loopback and private cluster IPs stay allowed — probing + ``http://localhost:PORT/health`` and private node URLs is the job. Raises + ``ValueError`` when the URL is unsafe. Shared by every tool that fetches a + model-supplied URL (``http_health``, ``node_health``, ``check_llm_backend``). + """ + parts = urllib.parse.urlsplit(url) + if parts.scheme not in ("http", "https"): + raise ValueError(f"refusing non-http(s) URL: {url!r}") + host = (parts.hostname or "").lower() + if host.startswith("169.254.") or host == "metadata.google.internal": + raise ValueError(f"refusing link-local/metadata host: {host!r}") + + +def _http_get_json(url: str, timeout: float = 5.0) -> Any: + """GET *url* and parse JSON. Raises on network/parse failure or unsafe URL.""" + _assert_safe_http_url(url) + req = urllib.request.Request( + url, + headers={"Accept": "application/json", "User-Agent": f"turnstone-doctor/{__version__}"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - scheme restricted above + return json.loads(resp.read().decode("utf-8")) + + +def _run_readonly( + cmd: list[str], + *, + cwd: Path | None = None, + timeout: int = 20, + max_chars: int = 8000, +) -> str: + """Run a read-only command and return its combined output (truncated).""" + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd else None, + ) + except FileNotFoundError: + return f"Error: '{cmd[0]}' not found on PATH." + except subprocess.TimeoutExpired: + return f"Error: '{' '.join(cmd)}' timed out after {timeout}s." + except OSError as exc: + return f"Error running {cmd[0]}: {exc}" + out = ((proc.stdout or "") + (proc.stderr or "")).strip() + if not out: + out = f"(no output; exit code {proc.returncode})" + if len(out) > max_chars: + out = out[:max_chars] + f"\n... (truncated, {len(out) - max_chars} more chars)" + return out + + +# --------------------------------------------------------------------------- +# Preflight: install detection +# --------------------------------------------------------------------------- + + +@dataclass +class ConfigFileInfo: + """A discovered ``config.toml`` and the diagnostics-relevant bits of it.""" + + path: Path + sections: list[str] + db: dict[str, str] # backend/url/path from [database], if present (real values) + api: dict[str, str] # base_url/api_key from [api], if present (real values) + + +@dataclass +class InstallProfile: + """Deterministic snapshot of how Turnstone is installed on this machine.""" + + project_dir: Path + kinds: list[str] + primary_kind: str + install_source: str # "source" | "site-packages" | "unknown" + repo_root: Path | None + docker_available: bool + compose_files: list[Path] + compose_ps: str + systemd_units: list[str] + config_files: list[ConfigFileInfo] + env_present: dict[str, str] + db_config: dict[str, str] + health_urls: list[str] + notes: list[str] = field(default_factory=list) + + +def _find_repo_root(start: Path) -> Path | None: + """Walk up from *start* looking for a Turnstone source checkout.""" + cur = start + for _ in range(6): + if (cur / ".git").exists() and (cur / "pyproject.toml").is_file(): + try: + txt = (cur / "pyproject.toml").read_text(encoding="utf-8") + except OSError: + txt = "" + if 'name = "turnstone"' in txt: + return cur + if cur.parent == cur: + break + cur = cur.parent + return None + + +def _find_compose_files( + project_dir: Path, env: dict[str, str], repo_root: Path | None +) -> list[Path]: + """Find compose files in the project dir, run.sh's checkout, and the repo root.""" + names = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml") + td = env.get("TURNSTONE_DIR") + dirs = [project_dir, Path(td) if td else Path.home() / "turnstone"] + if repo_root is not None: + dirs.append(repo_root) + found: list[Path] = [] + seen: set[Path] = set() + for d in dirs: + for n in names: + try: + p = d / n + if p.is_file(): + rp = p.resolve() + if rp not in seen: + seen.add(rp) + found.append(rp) + except OSError: + continue + return found + + +def _docker_available() -> bool: + """True if the Docker daemon is reachable.""" + try: + proc = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=10) + return proc.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return False + + +def _systemd_units() -> list[str]: + """Return Turnstone systemd service unit names, or [] if none / no systemd.""" + try: + proc = subprocess.run( + [ + "systemctl", + "list-units", + "--all", + "--type=service", + "--no-legend", + "--plain", + "turnstone-*", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return [] + if proc.returncode != 0: + return [] + units: list[str] = [] + for line in proc.stdout.splitlines(): + parts = line.split() + if parts and parts[0].endswith(".service"): + units.append(parts[0]) + return units + + +def _parse_config_sections(path: Path) -> tuple[list[str], dict[str, str], dict[str, str]]: + """Return (section names, [database] subset, [api] subset) for a config.toml.""" + try: + with open(path, "rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError): + return [], {}, {} + sections = sorted(k for k, v in data.items() if isinstance(v, dict)) + db: dict[str, str] = {} + dbsec = data.get("database") + if isinstance(dbsec, dict): + for k in ("backend", "url", "path", "sslmode", "sslrootcert", "sslcert", "sslkey"): + v = dbsec.get(k) + if v: + db[k] = str(v) + api: dict[str, str] = {} + apisec = data.get("api") + if isinstance(apisec, dict): + for k in ("base_url", "api_key"): + v = apisec.get(k) + if v: + api[k] = str(v) + return sections, db, api + + +def _discover_config_files(project_dir: Path, env: dict[str, str]) -> list[ConfigFileInfo]: + """Discover config.toml files the way Turnstone resolves them, plus systemd/cwd.""" + candidates: list[Path] = [] + tc = env.get("TURNSTONE_CONFIG") + if tc: + candidates.append(Path(tc)) + candidates += [ + Path.home() / ".config" / "turnstone" / "config.toml", + Path("/etc/turnstone/config.toml"), + project_dir / "config.toml", + project_dir / "turnstone.toml", + ] + out: list[ConfigFileInfo] = [] + seen: set[Path] = set() + for p in candidates: + try: + if not p.is_file(): + continue + rp = p.resolve() + except OSError: + continue + if rp in seen: + continue + seen.add(rp) + sections, db, api = _parse_config_sections(rp) + out.append(ConfigFileInfo(path=rp, sections=sections, db=db, api=api)) + return out + + +def _resolve_db_config(config_files: list[ConfigFileInfo], env: dict[str, str]) -> dict[str, str]: + """Resolve DB settings: config.toml [database] > TURNSTONE_DB_* env > defaults.""" + cfg: dict[str, str] = {} + for cf in config_files: + if cf.db: + cfg = dict(cf.db) + break + return { + "backend": cfg.get("backend") or env.get("TURNSTONE_DB_BACKEND") or "sqlite", + "url": cfg.get("url") or env.get("TURNSTONE_DB_URL") or "", + "path": cfg.get("path") or env.get("TURNSTONE_DB_PATH") or "", + # Postgres TLS — needed to reach an SSL/mTLS-required database (mirrors + # turnstone-admin's _get_storage). Values are file paths, not secrets. + "sslmode": cfg.get("sslmode") or env.get("TURNSTONE_DB_SSLMODE") or "", + "sslrootcert": cfg.get("sslrootcert") or env.get("TURNSTONE_DB_SSLROOTCERT") or "", + "sslcert": cfg.get("sslcert") or env.get("TURNSTONE_DB_SSLCERT") or "", + "sslkey": cfg.get("sslkey") or env.get("TURNSTONE_DB_SSLKEY") or "", + } + + +def _relevant_env(env: dict[str, str]) -> dict[str, str]: + """Map present, relevant env vars to a redacted display value.""" + out: dict[str, str] = {} + for name in _RELEVANT_ENV_VARS: + val = env.get(name) + if not val: + continue + out[name] = "set (hidden)" if _is_secret(name) else _redact_url_credentials(val) + return out + + +def _derive_health_urls(env: dict[str, str]) -> list[str]: + """Best-guess local /health URLs for the server node and console.""" + sp = env.get("TURNSTONE_SERVER_PORT") or str(DEFAULT_SERVER_PORT) + cp = env.get("TURNSTONE_CONSOLE_PORT") or str(DEFAULT_CONSOLE_PORT) + return [f"http://localhost:{sp}/health", f"http://localhost:{cp}/health"] + + +def _primary_kind(kinds: list[str], compose_ps: str) -> str: + """Pick the most likely primary install kind, favouring what's actually running.""" + running_compose = bool(compose_ps) and bool( + re.search(r"\b(running|up)\b", compose_ps, re.IGNORECASE) + ) + if "docker-compose" in kinds and running_compose: + return "docker-compose" + for k in ("systemd", "docker-compose", "git-source", "pip"): + if k in kinds: + return k + return "unknown" + + +def detect_install_profile(project_dir: Path, env: dict[str, str] | None = None) -> InstallProfile: + """Deterministically detect how Turnstone is installed here. Read-only, no LLM.""" + env = dict(env if env is not None else os.environ) + kinds: list[str] = [] + + from turnstone import __file__ as _turnstone_file + + pkg_dir = Path(_turnstone_file).resolve().parent + repo_root = _find_repo_root(pkg_dir.parent) + if {"site-packages", "dist-packages"} & set(pkg_dir.parts): + install_source = "site-packages" + elif repo_root is not None: + install_source = "source" + else: + install_source = "unknown" + if install_source == "source": + kinds.append("git-source") + + compose_files = _find_compose_files(project_dir, env, repo_root) + # The systemd probe is independent of the docker chain, so run it concurrently + # while we walk the (dependent) docker daemon → `compose ps` steps inline. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + units_future = ex.submit(_systemd_units) + docker_available = _docker_available() + compose_ps = "" + if compose_files: + kinds.append("docker-compose") + if docker_available: + compose_ps = _run_readonly( + ["docker", "compose", "-f", str(compose_files[0]), "ps", "-a"], + cwd=project_dir, + timeout=8, + ) + systemd_units = units_future.result() + if systemd_units: + kinds.append("systemd") + + if install_source == "site-packages": + kinds.append("pip") + + config_files = _discover_config_files(project_dir, env) + db_config = _resolve_db_config(config_files, env) + env_present = _relevant_env(env) + health_urls = _derive_health_urls(env) + primary = _primary_kind(kinds, compose_ps) + + return InstallProfile( + project_dir=project_dir, + kinds=kinds, + primary_kind=primary, + install_source=install_source, + repo_root=repo_root, + docker_available=docker_available, + compose_files=compose_files, + compose_ps=compose_ps, + systemd_units=systemd_units, + config_files=config_files, + env_present=env_present, + db_config=db_config, + health_urls=health_urls, + ) + + +def render_profile_report(profile: InstallProfile) -> str: + """Render an InstallProfile as a human/LLM-readable block (secrets redacted).""" + lines: list[str] = ["## Install profile"] + kinds = ", ".join(profile.kinds) if profile.kinds else "unknown" + lines.append(f"- Detected kind(s): {kinds} (primary: {profile.primary_kind})") + lines.append(f"- Package install source: {profile.install_source}") + if profile.repo_root: + lines.append(f"- Source checkout: {profile.repo_root}") + lines.append(f"- Docker daemon reachable: {'yes' if profile.docker_available else 'no'}") + if profile.compose_files: + lines.append("- Compose files:") + lines += [f" {p}" for p in profile.compose_files] + if profile.compose_ps: + lines.append("- `docker compose ps`:") + lines += [f" {ln}" for ln in profile.compose_ps.splitlines()] + if profile.systemd_units: + lines.append(f"- systemd units: {', '.join(profile.systemd_units)}") + + if profile.config_files: + lines.append("- Config files:") + for cf in profile.config_files: + secs = f" [{', '.join(cf.sections)}]" if cf.sections else "" + lines.append(f" {cf.path}{secs}") + else: + lines.append("- Config files: none found") + + db = profile.db_config + db_url = _redact_url_credentials(db.get("url", "")) if db.get("url") else "" + db_desc = f"backend={db.get('backend', '?')}" + if db_url: + db_desc += f", url={db_url}" + if db.get("path"): + db_desc += f", path={db['path']}" + if db.get("sslmode"): + db_desc += f", sslmode={db['sslmode']}" + lines.append(f"- Database: {db_desc}") + + if profile.env_present: + lines.append("- Relevant env vars:") + lines += [f" {k}={v}" for k, v in profile.env_present.items()] + else: + lines.append("- Relevant env vars: none set") + + lines.append(f"- Candidate health URLs: {', '.join(profile.health_urls)}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Storage (read-only) — shared by the brain (§3) and the version check (§4) +# --------------------------------------------------------------------------- + + +def open_storage(profile: InstallProfile) -> tuple[Any, str]: + """Open the cluster's storage read-only (no migrations). Returns (storage, error). + + For SQLite, only an *existing* database file is opened — connecting to a + missing path would create an empty file, which a diagnose-only tool must + never do. A missing database is reported as the error (itself a finding). + """ + db = profile.db_config + backend = db.get("backend", "sqlite") + path = db.get("path", "") + if backend == "sqlite": + # Only the configured path or the install dir's default — never a stray + # .turnstone.db from an unrelated cwd. + candidates = [Path(path)] if path else [] + candidates.append(profile.project_dir / ".turnstone.db") + existing = next((p for p in candidates if p.is_file()), None) + if existing is None: + looked = ", ".join(str(c) for c in candidates) + return None, f"no SQLite database file found (looked for: {looked})" + path = str(existing) + try: + from turnstone.core.storage import init_storage + + # create_tables=False keeps this strictly read-only: no migrations AND no + # create_all() DDL against the operator's live database. SSL params are + # forwarded so an SSL/mTLS-required Postgres is reachable. + storage = init_storage( + backend, + path=path, + url=db.get("url", ""), + pool_size=1, + run_migrations=False, + create_tables=False, + sslmode=db.get("sslmode", ""), + sslrootcert=db.get("sslrootcert", ""), + sslcert=db.get("sslcert", ""), + sslkey=db.get("sslkey", ""), + ) + return storage, "" + except Exception as exc: # noqa: BLE001 - any failure is a diagnostic, not fatal + return None, str(exc) + + +# --------------------------------------------------------------------------- +# Deterministic version check (§4) +# --------------------------------------------------------------------------- + + +@dataclass +class VersionReport: + """Installed/running version, cluster drift, and upstream releases.""" + + installed: str + image_tag: str + node_versions: dict[str, str] + cluster_versions: list[str] + unreachable_nodes: list[str] + drift: bool + upstream_stable: str + upstream_experimental: str + upstream_error: str + behind_stable: bool + behind_experimental: bool + mtls: bool = False + console_reachable: bool = False + console_nodes: int = 0 + + +def _compose_image_tag(profile: InstallProfile) -> str: + """Read TURNSTONE_IMAGE_TAG from the compose checkout's .env, if present.""" + raw = profile.env_present.get("TURNSTONE_IMAGE_TAG", "") + if raw: + return raw + for d in {p.parent for p in profile.compose_files}: + envf = d / ".env" + try: + if envf.is_file(): + for line in envf.read_text(encoding="utf-8").splitlines(): + if line.startswith("TURNSTONE_IMAGE_TAG="): + return line.partition("=")[2].strip() + except OSError: + continue + return "" + + +def _probe_health(url: str) -> dict[str, Any] | None: + """GET ``/health`` and return the parsed JSON dict, or None on failure.""" + target = url.rstrip("/") + if not target.endswith("/health"): + target += "/health" + try: + data = _http_get_json(target, timeout=4) + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + return data if isinstance(data, dict) else None + + +def _fetch_health_version(url: str) -> str: + """GET ``/health`` and return its reported version, or '' on failure.""" + data = _probe_health(url) + return str(data.get("version", "")) if isinstance(data, dict) else "" + + +def _fetch_upstream_versions(timeout: float = 6.0) -> tuple[str, str, str]: + """Return (latest_stable, latest_experimental, error) from the upstream tags.""" + try: + data = _http_get_json(GITHUB_TAGS_URL + "?per_page=100", timeout=timeout) + except (urllib.error.URLError, OSError, ValueError, TimeoutError) as exc: + return "", "", str(exc) + if not isinstance(data, list): + return "", "", "unexpected response from GitHub tags API" + stable: Version | None = None + experimental: Version | None = None + for tag in data: + name = str(tag.get("name", "")).lstrip("v") if isinstance(tag, dict) else "" + try: + v = Version(name) + except InvalidVersion: + continue + if experimental is None or v > experimental: + experimental = v + if not v.is_prerelease and (stable is None or v > stable): + stable = v + return (str(stable) if stable else ""), (str(experimental) if experimental else ""), "" + + +def _version_behind(current: str, latest: str) -> bool: + """True if *current* parses below *latest*. False on missing/unparseable input.""" + if not current or not latest: + return False + try: + return Version(current.lstrip("v")) < Version(latest) + except InvalidVersion: + return False + + +def _tls_cert_dir_present() -> bool: + """True if a local Turnstone TLS cert dir exists — a node-host mTLS signal.""" + pem_dir = os.environ.get("TURNSTONE_TLS_PEM_DIR") or os.path.join( + tempfile.gettempdir(), "turnstone-tls" + ) + try: + d = Path(pem_dir) + return d.is_dir() and any(d.glob("lacme-pem-*")) + except OSError: + return False + + +def check_versions( + profile: InstallProfile, storage: Any, *, offline: bool = False +) -> VersionReport: + """Deterministic version check: installed, cluster drift, and upstream releases.""" + installed = __version__ + image_tag = _compose_image_tag(profile) + + node_versions: dict[str, str] = {} + unreachable: list[str] = [] + versions_seen: set[str] = set() + console_drift = False + node_urls_https = False + + # Per-node: storage gives the node inventory; each node's /health gives its + # version (reachable only when node URLs aren't container-internal). + if storage is not None: + services: list[dict[str, Any]] = [] + for svc_type in ("server", "console"): + try: + services += storage.list_services(svc_type, max_age_seconds=3600) + except Exception: # noqa: BLE001 - storage hiccup is itself diagnostic + continue + + # https advertise URLs ⇒ the cluster runs TLS/mTLS on the node mesh. + node_urls_https = any(str(s.get("url", "")).startswith("https://") for s in services) + + # Probe nodes concurrently — container-internal URLs each block the full + # 4s timeout, so serial probing would stall ~N×4s on a large cluster. + def _probe_node(svc: dict[str, Any]) -> tuple[str, str]: + url = str(svc.get("url", "")) + label = str(svc.get("service_id", "")) or url or "?" + return label, (_fetch_health_version(url) if url else "") + + if services: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(16, len(services))) as ex: + probed = list(ex.map(_probe_node, services)) + for label, ver in probed: + if ver: + node_versions[label] = ver + versions_seen.add(ver) + else: + unreachable.append(label) + + # The console (and any reachable candidate /health) reports cluster-wide + # versions + drift + a live-node count — the authoritative source when node + # URLs are only reachable inside the container network (the docker-compose case). + console_reachable = False + console_nodes = 0 + for url in profile.health_urls: + data = _probe_health(url) + if data is None: + continue + listed = data.get("versions") + if isinstance(listed, list): + versions_seen.update(str(v) for v in listed if v) + if data.get("version_drift"): + console_drift = True + elif data.get("version"): + versions_seen.add(str(data["version"])) + if data.get("service") == "turnstone-console" or "versions" in data: + console_reachable = True + if isinstance(data.get("nodes"), int): + console_nodes = max(console_nodes, data["nodes"]) + + drift = console_drift or len(versions_seen) > 1 + cluster_versions = sorted(versions_seen) + # https advertise URLs are authoritative; the local cert dir is only a fallback + # signal when storage is unreachable (so a stray dir can't false-positive a + # plain http compose cluster). + mtls = node_urls_https or (storage is None and _tls_cert_dir_present()) + + upstream_stable = upstream_experimental = upstream_error = "" + if offline: + upstream_error = "skipped (--offline)" + else: + upstream_stable, upstream_experimental, upstream_error = _fetch_upstream_versions() + + return VersionReport( + installed=installed, + image_tag=image_tag, + node_versions=node_versions, + cluster_versions=cluster_versions, + unreachable_nodes=unreachable, + drift=drift, + upstream_stable=upstream_stable, + upstream_experimental=upstream_experimental, + upstream_error=upstream_error, + behind_stable=_version_behind(installed, upstream_stable), + behind_experimental=_version_behind(installed, upstream_experimental), + mtls=mtls, + console_reachable=console_reachable, + console_nodes=console_nodes, + ) + + +def render_version_report(vr: VersionReport) -> str: + """Render a VersionReport as a human/LLM-readable block.""" + lines: list[str] = ["## Versions"] + inst = f"- Installed (this tool): {vr.installed}" + if vr.image_tag: + inst += f" (compose image tag: {vr.image_tag})" + lines.append(inst) + + if vr.cluster_versions: + lines.append(f"- Cluster versions: {vr.cluster_versions}") + lines.append(f"- Version drift across nodes: {'YES' if vr.drift else 'no'}") + else: + lines.append("- Cluster versions: none reported (no node or console /health reachable)") + if vr.node_versions: + lines.append( + "- Per-node versions: " + + ", ".join(f"{k}={v}" for k, v in sorted(vr.node_versions.items())) + ) + if vr.unreachable_nodes: + reason = ( + "per-node /health requires a client cert (mTLS), which doctor doesn't present" + if vr.mtls + else "their advertise URLs are cluster-internal (docker-compose), not host-routable" + ) + nodes_csv = ", ".join(vr.unreachable_nodes) + if vr.console_reachable: + live = f"{vr.console_nodes} node(s) live" if vr.console_nodes else "the cluster live" + lines.append( + f"- Per-node /health not reached from the host — {reason}. The console " + f"reports {live}, so the cluster is healthy (cluster versions above are " + f"authoritative). Use `node_health` for a specific node: {nodes_csv}." + ) + else: + lines.append( + f"- Registered nodes not reachable, and no console /health to confirm " + f"health: {nodes_csv} ({reason})." + ) + elif vr.mtls: + lines.append("- TLS: cluster appears to run mTLS on the node mesh") + + if vr.upstream_error: + lines.append(f"- Upstream check: {vr.upstream_error}") + else: + parts: list[str] = [] + if vr.upstream_stable: + tag = " — UPDATE AVAILABLE" if vr.behind_stable else "" + parts.append(f"stable {vr.upstream_stable}{tag}") + if vr.upstream_experimental: + tag = " — newer" if vr.behind_experimental else "" + parts.append(f"experimental {vr.upstream_experimental}{tag}") + lines.append("- Upstream: " + (", ".join(parts) if parts else "no tags found")) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Self-configuring LLM brain (§3) +# --------------------------------------------------------------------------- + + +@dataclass +class BackendVerdict: + """Outcome of resolving doctor's brain from the cluster's own config.""" + + ok: bool + detail: str + + +def family_of(provider: str) -> str | None: + """Map a model provider to the _DoctorLLM wire family. + + Returns ``"anthropic"`` or ``"openai"``, or ``None`` for providers + _DoctorLLM cannot drive (e.g. ``"google"``) — the caller then falls back + to interactive selection. + """ + p = (provider or "").lower() + if p in _ANTHROPIC_PROVIDERS: + return "anthropic" + if p in _OPENAI_PROVIDERS: + return "openai" + return None + + +def _read_api_creds(profile: InstallProfile, env: dict[str, str]) -> tuple[str, str]: + """Resolve (base_url, api_key) from config.toml [api] then env, for brain seeding. + + Reads the ``[api]`` block already parsed into each ``ConfigFileInfo`` at + discovery time (the config files aren't re-opened). The pair is taken from the + *first* config file that defines either field, as a unit — so base_url and + api_key never get mixed across two different config sources (which would form + an endpoint/key pair that exists in no real config). Any field still missing + is then filled from the environment, matching Turnstone's config→env order. + """ + base_url = api_key = "" + for cf in profile.config_files: + if cf.api.get("base_url") or cf.api.get("api_key"): + base_url = cf.api.get("base_url", "") + api_key = cf.api.get("api_key", "") + break + base_url = base_url or env.get("LLM_BASE_URL", "") + api_key = api_key or env.get("OPENAI_API_KEY", "") or env.get("ANTHROPIC_API_KEY", "") + return base_url, api_key + + +def resolve_doctor_brain( + profile: InstallProfile, + storage: Any, + storage_err: str, + *, + env: dict[str, str] | None = None, + validate: bool = True, +) -> tuple[_DoctorLLM | None, BackendVerdict]: + """Build doctor's LLM from the cluster's config (the first diagnostic). + + Returns (brain, verdict). ``brain`` is None when resolution fails, in which + case ``verdict.detail`` explains why and the caller should fall back to + interactive provider selection. + """ + env = dict(env if env is not None else os.environ) + if storage is None: + return None, BackendVerdict(False, f"storage unreachable: {storage_err}") + + try: + from turnstone.core.config_store import ConfigStore + from turnstone.core.model_registry import load_model_registry + + cs = ConfigStore(storage=storage, node_id="") + alias = str(cs.get("model.default_alias", "") or "") + base_url, api_key = _read_api_creds(profile, env) + registry = load_model_registry( + base_url=base_url, + api_key=api_key, + model=env.get("MODEL", ""), + storage=storage, + allow_empty=True, + ) + client, model_name, cfg = registry.resolve(alias or None) + except Exception as exc: # noqa: BLE001 - any failure means "no usable model" + return None, BackendVerdict(False, f"no usable model configured: {exc}") + + fam = family_of(cfg.provider) + if fam is None: + return None, BackendVerdict( + False, + f"auto-config found a {cfg.provider} model ({model_name}); doctor's " + "built-in brain supports OpenAI/Anthropic-compatible backends — " + "falling back to interactive selection", + ) + + brain = _DoctorLLM(fam, client, model_name) + # Redact any embedded credentials in base_url — this string is printed and + # also fed to the model as context. + safe_base = _redact_url_credentials(cfg.base_url) or "default endpoint" + where = f"{model_name} via {cfg.provider} @ {safe_base}" + if validate: + ok, err = _validate_connection(brain) + if not ok: + return None, BackendVerdict(False, f"resolved {where} but connection failed: {err}") + return brain, BackendVerdict(True, f"resolved {where}") + + +def render_backend_verdict(verdict: BackendVerdict) -> str: + """Render the LLM-backend verdict as a human/LLM-readable block.""" + status = "ok" if verdict.ok else "PROBLEM" + return f"## LLM backend ({status})\n- {verdict.detail}" + + +def render_full_report( + profile: InstallProfile, versions: VersionReport, verdict: BackendVerdict +) -> str: + """Combine the preflight, version, and backend blocks into one report.""" + return "\n\n".join( + [ + render_profile_report(profile), + render_version_report(versions), + render_backend_verdict(verdict), + ] + ) + + +# --------------------------------------------------------------------------- +# Diagnostic tools (read-only) the LLM calls +# --------------------------------------------------------------------------- + +TOOLS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "read_file", + "description": ( + "Read a file (config.toml, .env, compose.yaml, etc.) relative to the " + "install directory. Secret values are masked in the result." + ), + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to the install dir."} + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_port", + "description": "Check whether a TCP port on localhost has something listening.", + "parameters": { + "type": "object", + "properties": {"port": {"type": "integer", "description": "Port number."}}, + "required": ["port"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_docker", + "description": "Check whether Docker and the Compose plugin are installed and running.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "compose_status", + "description": ( + "Run `docker compose ps -a` to list the stack's containers and their state. " + "Optionally pass an explicit compose file path." + ), + "parameters": { + "type": "object", + "properties": { + "compose_file": { + "type": "string", + "description": "Optional path to a compose file (-f).", + } + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "compose_logs", + "description": ( + "Show recent `docker compose logs` for a service (no follow). Use this to " + "see why a container is crashing or unhealthy." + ), + "parameters": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name (e.g. node-1, console).", + }, + "tail": {"type": "integer", "description": "Lines to show (default 100)."}, + "compose_file": { + "type": "string", + "description": "Optional compose file path (-f).", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "systemd_status", + "description": "Show `systemctl status` for a Turnstone systemd unit (bare-metal installs).", + "parameters": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "Unit name, e.g. turnstone-server.service.", + } + }, + "required": ["unit"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "journal_tail", + "description": "Show the last N journald lines for a Turnstone systemd unit.", + "parameters": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "Unit name, e.g. turnstone-server.service.", + }, + "lines": {"type": "integer", "description": "Lines to show (default 100)."}, + }, + "required": ["unit"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "http_health", + "description": ( + "GET a node or console /health endpoint and return its JSON. Pass a base URL " + "or a full /health URL." + ), + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "Base URL or /health URL to probe."} + }, + "required": ["url"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_llm_backend", + "description": ( + "Probe an LLM endpoint (the model backend a node uses) for reachability and " + "available models. Read-only; does not store anything." + ), + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "openai | anthropic | openai-compatible | google | xai.", + }, + "base_url": {"type": "string", "description": "Endpoint base URL."}, + "api_key": {"type": "string", "description": "Optional API key."}, + "model": {"type": "string", "description": "Optional model id to look for."}, + }, + "required": ["provider", "base_url"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "node_health", + "description": ( + "Read one cluster node's /health when the cluster summary isn't enough " + "(e.g. to pin down which node differs on a version drift). The reach " + "mechanism is chosen automatically from the detected install kind: " + "docker-compose execs into the node's container (nodes aren't reachable " + "from the host); systemd/bare-metal/pip GETs the node's host/URL directly. " + "Read-only." + ), + "parameters": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": ( + "Compose service name (e.g. node-1) for docker-compose; a " + "host or advertise URL for other installs." + ), + }, + "install_type": { + "type": "string", + "enum": ["docker-compose", "systemd", "pip", "git-source"], + "description": ( + "Override the detected install kind for THIS node — use for " + "mixed clusters (e.g. local compose nodes + remote systemd hosts)." + ), + }, + "compose_file": { + "type": "string", + "description": "Optional compose file path (-f); docker-compose only.", + }, + }, + "required": ["node"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "finish", + "description": ( + "Call when the diagnosis is complete. Provide a summary of findings and the " + "exact remediation commands the operator should run. Doctor never runs " + "mutating commands itself." + ), + "parameters": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Diagnosis summary + recommended commands.", + } + }, + "required": ["summary"], + }, + }, + }, +] + + +def _tool_read_file(project_dir: Path, args: dict[str, Any]) -> str: + raw = str(args["path"]) + path = _resolve_safe(project_dir, raw) + if path is None: + return f"Error: path escapes install directory: {raw}" + if path.suffix.lower() in _SECRET_FILE_SUFFIXES: + return ( + f"Refused: {args['path']} looks like a key/cert file — doctor won't dump raw secrets." + ) + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError: + return f"Error: file not found: {args['path']}" + except (OSError, UnicodeDecodeError) as exc: + return f"Error reading {args['path']}: {exc}" + if "PRIVATE KEY-----" in content: + return ( + f"Refused: {args['path']} contains a private-key block — doctor won't dump raw secrets." + ) + if len(content) > _READ_MAX_CHARS: + content = ( + content[:_READ_MAX_CHARS] + + f"\n... (truncated, {len(content) - _READ_MAX_CHARS} more chars)" + ) + # Secret masking happens centrally in execute_tool (_scrub_tool_output). + return content + + +def _tool_check_port(args: dict[str, Any]) -> str: + port = args["port"] + if not isinstance(port, int) or port < 1 or port > 65535: + return f"Error: invalid port number: {port}" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + if sock.connect_ex(("127.0.0.1", port)) == 0: + return ( + f"Port {port} is IN USE (a service is listening — expected for a running node)." + ) + return f"Port {port} is FREE (nothing listening — the service may be down)." + except OSError as exc: + return f"Error checking port {port}: {exc}" + + +def _tool_check_docker(args: dict[str, Any]) -> str: + results: list[str] = [] + try: + proc = subprocess.run( + ["docker", "version", "--format", "{{.Server.Version}}"], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode == 0: + results.append(f"Docker: installed (version {proc.stdout.strip()})") + else: + stderr = proc.stderr.strip() + if "Cannot connect" in stderr or "Is the docker daemon running" in stderr: + results.append("Docker: installed but daemon is NOT running") + else: + results.append(f"Docker: error — {stderr}") + except FileNotFoundError: + results.append("Docker: NOT installed") + except subprocess.TimeoutExpired: + results.append("Docker: timed out (daemon may be unresponsive)") + + try: + proc = subprocess.run( + ["docker", "compose", "version", "--short"], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode == 0: + results.append(f"Docker Compose: installed (version {proc.stdout.strip()})") + else: + results.append("Docker Compose: NOT available") + except (FileNotFoundError, subprocess.TimeoutExpired): + results.append("Docker Compose: NOT available") + return "\n".join(results) + + +def _tool_compose_status(project_dir: Path, args: dict[str, Any]) -> str: + cmd = ["docker", "compose"] + if args.get("compose_file"): + f = _reject_option(args["compose_file"]) + if f is None: + return "Error: invalid compose_file." + cmd += ["-f", f] + cmd += ["ps", "-a"] + return _run_readonly(cmd, cwd=project_dir) + + +def _tool_compose_logs(project_dir: Path, args: dict[str, Any]) -> str: + tail = args.get("tail", 100) + if not isinstance(tail, int) or tail < 1 or tail > 2000: + tail = 100 + cmd = ["docker", "compose"] + if args.get("compose_file"): + f = _reject_option(args["compose_file"]) + if f is None: + return "Error: invalid compose_file." + cmd += ["-f", f] + cmd += ["logs", "--no-color", "--tail", str(tail)] + if args.get("service"): + service = _reject_option(args["service"]) + if service is None: + return "Error: invalid service name." + cmd.append(service) + return _run_readonly(cmd, cwd=project_dir, timeout=30) + + +def _tool_systemd_status(args: dict[str, Any]) -> str: + unit = _safe_unit(str(args["unit"])) + if unit is None: + return "Error: invalid unit name (expected a plain systemd unit, e.g. turnstone-server.service)." + # Options first, then `--`, so the model-supplied unit can't be read as an option. + return _run_readonly(["systemctl", "status", "--no-pager", "--lines", "20", "--", unit]) + + +def _tool_journal_tail(args: dict[str, Any]) -> str: + unit = _safe_unit(str(args["unit"])) + if unit is None: + return "Error: invalid unit name (expected a plain systemd unit, e.g. turnstone-server.service)." + lines = args.get("lines", 100) + if not isinstance(lines, int) or lines < 1 or lines > 2000: + lines = 100 + return _run_readonly(["journalctl", "--no-pager", "-n", str(lines), "-u", unit], timeout=30) + + +def _tool_http_health(args: dict[str, Any]) -> str: + url = str(args["url"]).rstrip("/") + if not url.endswith("/health"): + url = url + "/health" + try: + data = _http_get_json(url, timeout=5) + except urllib.error.HTTPError as exc: + return f"{url} → HTTP {exc.code} {exc.reason}" + except (urllib.error.URLError, OSError, ValueError, TimeoutError) as exc: + return f"{url} → unreachable ({exc})" + return json.dumps(data, indent=2, default=str)[:6000] + + +def _tool_check_llm_backend(args: dict[str, Any]) -> str: + from turnstone.core.model_registry import probe_model_endpoint + + provider = str(args.get("provider", "openai")) + base_url = str(args.get("base_url", "")) + api_key = str(args.get("api_key", "")) + model = str(args.get("model", "")) + # Same scheme / metadata-host guard as http_health: this URL is model-supplied + # too, so refuse file:// and the cloud metadata endpoint before any request. + try: + _assert_safe_http_url(base_url) + except ValueError as exc: + return f"Refused: {exc}" + try: + res = probe_model_endpoint(provider, base_url, api_key, target_model=model) + except Exception as exc: # noqa: BLE001 - report any probe failure to the model + return f"Error probing {base_url}: {exc}" + return json.dumps(res, default=str)[:4000] + + +# Read-only one-liner the docker-compose path runs INSIDE a node container to +# fetch its own /health (nodes aren't reachable from the host). +_NODE_HEALTH_SNIPPET = ( + "import urllib.request,sys;" + f"sys.stdout.write(urllib.request.urlopen('http://localhost:{DEFAULT_SERVER_PORT}/health'," + "timeout=4).read().decode())" +) + + +def _tool_node_health(project_dir: Path, primary_kind: str, args: dict[str, Any]) -> str: + """Read one node's /health, choosing the mechanism from the install kind. + + Deterministic: the reach mechanism follows the *detected* install kind, not a + model decision — overridable per-call via ``install_type`` for mixed clusters + (e.g. local compose nodes + remote systemd hosts). docker-compose nodes aren't + host-reachable (internal advertise URLs), so this execs into the container and + fetches /health from inside; every other kind is a real host reached at its URL. + """ + node = _reject_option(args.get("node", "")) + if not node: + return "Error: node is required (a compose service name, or a host/URL for non-compose installs)." + kind = str(args.get("install_type") or primary_kind or "unknown").lower() + + if kind == "docker-compose": + cmd = ["docker", "compose"] + if args.get("compose_file"): + cf = _reject_option(args["compose_file"]) + if cf is None: + return "Error: invalid compose_file." + cmd += ["-f", cf] + # -T disables the pseudo-TTY (required for non-interactive exec); the + # command is the fixed read-only snippet, so only `node` is variable. + cmd += ["exec", "-T", node, "python", "-c", _NODE_HEALTH_SNIPPET] + return _run_readonly(cmd, cwd=project_dir, timeout=20) + + # systemd / pip / git-source / unknown: the node is a real host reachable at + # its advertise URL. Treat `node` as a URL or host[:port] and GET its /health, + # only adding the default port when the operator didn't supply one. + if node.startswith(("http://", "https://")): + url = node + else: + try: + has_port = urllib.parse.urlsplit(f"//{node}").port is not None + except ValueError: + has_port = False + url = f"http://{node}" if has_port else f"http://{node}:{DEFAULT_SERVER_PORT}" + return _tool_http_health({"url": url}) + + +class _FinishError(Exception): + """Raised by the finish tool to signal the diagnosis is done.""" + + def __init__(self, summary: str) -> None: + self.summary = summary + + +def _tool_finish(args: dict[str, Any]) -> str: + raise _FinishError(args.get("summary", "Diagnosis complete.")) + + +TOOL_FUNCTIONS: dict[str, Any] = { + "read_file": _tool_read_file, + "check_port": _tool_check_port, + "check_docker": _tool_check_docker, + "compose_status": _tool_compose_status, + "compose_logs": _tool_compose_logs, + "systemd_status": _tool_systemd_status, + "journal_tail": _tool_journal_tail, + "http_health": _tool_http_health, + "check_llm_backend": _tool_check_llm_backend, + "node_health": _tool_node_health, + "finish": _tool_finish, +} + +# Tools that take the install dir (cwd / scoped reads) as their first argument. +_PROJECT_DIR_TOOLS = frozenset({"read_file", "compose_status", "compose_logs"}) + + +def execute_tool( + name: str, args: dict[str, Any], project_dir: Path, *, primary_kind: str = "unknown" +) -> str: + """Execute a diagnostic tool and return the (secret-scrubbed) result string. + + Every tool result passes through :func:`_scrub_tool_output` here — the single + chokepoint that keeps secrets (DSN passwords, keys, env echoes in logs) out of + the model's context, so a newly added tool is covered automatically. + + Raises _FinishError when the finish tool is called. + """ + fn = TOOL_FUNCTIONS.get(name) + if fn is None: + return f"Error: unknown tool '{name}'" + try: + if name == "node_health": + # Needs the detected install kind to pick its reach mechanism. + result: str = _tool_node_health(project_dir, primary_kind, args) + elif name in _PROJECT_DIR_TOOLS: + result = fn(project_dir, args) + else: + result = fn(args) + except _FinishError: + raise + except Exception as exc: # noqa: BLE001 - tool errors are reported, not fatal + return f"Error executing {name}: {exc}" + return _scrub_tool_output(result) + + +# --------------------------------------------------------------------------- +# System prompt +# --------------------------------------------------------------------------- + +SYSTEM_PROMPT = """\ +You are Turnstone Doctor, an expert SRE assistant that diagnoses a running \ +Turnstone deployment and guides the operator through fixing it. + +## About Turnstone +Turnstone is a multi-node AI orchestration platform. A deployment is made of: +- **Server nodes** (turnstone-server): web UI + chat workstreams + LLM calls. \ +Each node serves `/health` (default :8080) and registers in the shared database. +- **Console** (turnstone-console): cluster dashboard + admin; serves `/health` \ +(default :8090) and discovers nodes from the database. +- **Caddy**: fronts the console over HTTPS (the published browser entry point). +- **PostgreSQL** (or SQLite): the shared database — required for nodes to be \ +discovered and for cluster state. +- **Channel** (optional): Discord/Slack gateway. + +## Install kinds (you are told which one this is in the preflight report) +- **docker-compose**: a `compose.yaml` checkout (usually from `run.sh` in \ +`~/turnstone`). Diagnose with `docker compose ps` / `docker compose logs`. Config \ +lives in `.env` next to the compose file. +- **systemd / bare-metal**: `turnstone-*.service` units; config in \ +`/etc/turnstone/config.toml`. Diagnose with `systemctl status` / `journalctl`. +- **pip**: installed package; config in `~/.config/turnstone/config.toml` (or \ +`$TURNSTONE_CONFIG`) + env vars. +- **git-source**: a developer checkout run from source. + +## Config precedence +Runtime settings resolve storage(database) > config.toml > environment > defaults. \ +Bootstrap-critical settings (`[database]`, `[auth]`, ports, API keys) come from \ +config.toml or env only — they are not hot-reloadable. + +## Reaching individual nodes +On a **docker-compose** cluster the nodes are NOT reachable from the host — they \ +advertise internal URLs (`http://node-1:8080`) and publish no host port, so the \ +preflight can only confirm them via the console's aggregate `/health`. Do NOT call \ +healthy-per-console nodes "down". When you need a specific node's `/health` (e.g. to \ +find which node a version drift is on), use the `node_health` tool — it reaches the \ +node the right way for the detected install kind (exec-into-container for compose, \ +direct HTTP for systemd/bare-metal). Pass `install_type` to override per node on a \ +mixed cluster. + +## Common failure modes and how to confirm them +- **Node not joining the console**: node up but not in the dashboard → check the \ +node `/health`, confirm it shares `TURNSTONE_JWT_SECRET` and the same \ +`TURNSTONE_DB_URL` as the console, and that its `TURNSTONE_ADVERTISE_URL` is \ +reachable from the console. +- **Database unreachable**: nodes crash-loop or the console shows no nodes → check \ +`TURNSTONE_DB_URL`, the postgres container/port, and credentials. +- **LLM backend down**: chats error or hang → use `check_llm_backend` against the \ +node's `LLM_BASE_URL`. +- **Port conflict**: a service won't bind → `check_port`. +- **TLS / ACME**: browser cert errors → Caddy fronts the console with a local CA; \ +nodes enroll via the console's plain-HTTP ACME endpoint. +- **JWT secret mismatch**: 401s between services → all services must share \ +`TURNSTONE_JWT_SECRET`. +- **Version drift**: nodes on different versions (see the version report) → \ +realign by pulling/redeploying the lagging nodes. + +## Your rules +- You are **DIAGNOSE-ONLY**. NEVER run or instruct a tool to run a mutating \ +command. Investigate with the read-only tools, then hand the operator the EXACT \ +commands to run themselves. +- Start from the preflight report you are given; use tools to CONFIRM specifics \ +before drawing conclusions. Don't guess when you can check. +- Be concise. Ask 1–2 questions at a time. +- NEVER echo secrets (JWT secret, DB password, API keys) back to the user. +- For (re)installation or adding nodes, point the user at `run.sh` \ +(`curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash`). +- When done, call `finish` with a clear summary and the precise remediation \ +commands. +""" + + +# --------------------------------------------------------------------------- +# _DoctorLLM — thin wrapper over OpenAI / Anthropic SDKs +# --------------------------------------------------------------------------- + + +class _DoctorLLM: + """Provider-agnostic wrapper for non-streaming tool-calling completions. + + ``provider`` is the wire *family* — ``"anthropic"`` or ``"openai"`` (see + :func:`family_of`); anything not ``"anthropic"`` uses the OpenAI path. + """ + + def __init__(self, provider: str, client: Any, model: str) -> None: + self.provider = provider + self.client = client + self.model = model + + def complete( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + """Run a completion and return (content, tool_calls, stop_reason).""" + if self.provider == "anthropic": + return self._complete_anthropic(messages, tools) + return self._complete_openai(messages, tools) + + # -- OpenAI path -------------------------------------------------------- + + def _complete_openai( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + resp = self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools if tools else None, + ) + # Guard against non-spec responses from proxies (Open WebUI, LiteLLM, etc.) + if resp is None: + raise RuntimeError( + "Server returned null — your OpenAI-compatible endpoint may not " + "support tool calling. Try a direct connection to the model server." + ) + choices = getattr(resp, "choices", None) + if not choices: + raise RuntimeError( + "Server returned an empty choices array. " + "The model may have hit its context limit, or the proxy " + "dropped the response." + ) + choice = choices[0] + message = getattr(choice, "message", None) + if message is None: + raise RuntimeError( + "Server returned a choice with no message. " + "Your OpenAI-compatible endpoint may not fully implement " + "the chat completions API." + ) + content = message.content or "" + tool_calls = None + if getattr(message, "tool_calls", None): + tool_calls = [ + { + "id": getattr(tc, "id", None) or f"call_{os.urandom(4).hex()}", + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in message.tool_calls + ] + return content, tool_calls, getattr(choice, "finish_reason", None) or "stop" + + # -- Anthropic path ----------------------------------------------------- + + def _complete_anthropic( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + system_text = "" + api_messages: list[dict[str, Any]] = [] + for msg in messages: + if msg["role"] == "system": + system_text = msg["content"] + elif msg["role"] == "tool": + api_messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg["tool_call_id"], + "content": msg["content"], + } + ], + } + ) + elif msg["role"] == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"type": "text", "text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append( + { + "type": "tool_use", + "id": tc["id"], + "name": tc["function"]["name"], + "input": json.loads(tc["function"]["arguments"]), + } + ) + api_messages.append({"role": "assistant", "content": blocks}) + else: + api_messages.append(msg) + + # Merge consecutive same-role messages (Anthropic requires alternation). + merged: list[dict[str, Any]] = [] + for msg in api_messages: + if merged and merged[-1]["role"] == msg["role"]: + prev = merged[-1] + prev_content = prev["content"] + new_content = msg["content"] + if isinstance(prev_content, str) and isinstance(new_content, str): + prev["content"] = prev_content + "\n" + new_content + elif isinstance(prev_content, str): + prev["content"] = [{"type": "text", "text": prev_content}] + ( + new_content if isinstance(new_content, list) else [new_content] + ) + elif isinstance(new_content, str): + prev["content"] = prev_content + [{"type": "text", "text": new_content}] + else: + prev["content"] = prev_content + new_content + else: + merged.append(msg) + api_messages = merged + + api_tools = [ + { + "name": t["function"]["name"], + "description": t["function"]["description"], + "input_schema": t["function"]["parameters"], + } + for t in tools + ] + + resp = self.client.messages.create( + model=self.model, + max_tokens=4096, + system=system_text, + messages=api_messages, + tools=api_tools if api_tools else [], + ) + + content_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + for block in resp.content: + if block.type == "text": + content_parts.append(block.text) + elif block.type == "tool_use": + tool_calls.append( + { + "id": block.id, + "type": "function", + "function": { + "name": block.name, + "arguments": json.dumps(block.input), + }, + } + ) + + return ( + "\n".join(content_parts), + tool_calls if tool_calls else None, + resp.stop_reason or "end_turn", + ) + + +# --------------------------------------------------------------------------- +# Interactive provider selection (fallback when self-config fails) +# --------------------------------------------------------------------------- + + +def _prompt_api_key(env_var: str, label: str) -> str: + """Prompt for an API key, checking the env var first.""" + env_val = os.environ.get(env_var, "") + if env_val: + prefix = env_val[:4] + "..." if len(env_val) > 4 else env_val + print(f"\n Found {CYAN}${env_var}{RESET} in environment ({DIM}{prefix}{RESET})") + try: + use_env = input(f" Use it? {BOLD}[Y/n]{RESET} ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if use_env not in ("n", "no"): + return env_val + + print(f"\n {label}") + try: + key = getpass.getpass(" API key: ") + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if not key.strip(): + print(f" {RED}API key cannot be empty.{RESET}") + sys.exit(1) + return key.strip() + + +def _prompt_model(provider: str) -> str: + """Prompt for model name with a sensible default.""" + default = _DEFAULT_MODELS.get(provider, "") + prompt = f" Model {DIM}[{default}]{RESET}: " if default else " Model name: " + try: + model = input(prompt).strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + return model or default + + +def _setup_openai() -> tuple[str, Any, str]: + from openai import OpenAI + + api_key = _prompt_api_key("OPENAI_API_KEY", "Enter your OpenAI API key:") + model = _prompt_model("openai") + return "openai", OpenAI(api_key=api_key), model + + +def _setup_anthropic() -> tuple[str, Any, str]: + import anthropic + + api_key = _prompt_api_key("ANTHROPIC_API_KEY", "Enter your Anthropic API key:") + model = _prompt_model("anthropic") + return "anthropic", anthropic.Anthropic(api_key=api_key), model + + +def _detect_models(client: Any) -> list[str]: + """Query /v1/models and return a sorted list of model IDs.""" + try: + resp = client.models.list() + return sorted(m.id for m in resp.data) + except Exception: # noqa: BLE001 - detection is best-effort + return [] + + +def _setup_local() -> tuple[str, Any, str]: + from openai import OpenAI + + print("\n Enter the base URL of your OpenAI-compatible endpoint.") + default_url = "http://localhost:8000/v1" + try: + url = input(f" Base URL {DIM}[{default_url}]{RESET}: ").strip() or default_url + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + + env_key = os.environ.get("OPENAI_API_KEY", "") + if env_key: + api_key = env_key + print(f" Using {CYAN}$OPENAI_API_KEY{RESET} from environment.") + else: + print(" API key (press Enter for 'none'):") + try: + api_key = getpass.getpass(" API key: ") or "none" + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + + client = OpenAI(api_key=api_key, base_url=url) + + print(f"\n {DIM}Querying {url} for available models...{RESET}") + available = _detect_models(client) + if len(available) == 1: + model = available[0] + print(f" Found model: {CYAN}{model}{RESET}") + elif available: + print(f" Found {len(available)} model(s):") + for i, m in enumerate(available, 1): + print(f" {CYAN}[{i}]{RESET} {m}") + try: + choice = input(f" Select model {DIM}[1]{RESET}: ").strip() or "1" + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + try: + idx = int(choice) - 1 + model = available[idx] if 0 <= idx < len(available) else choice + except ValueError: + model = choice + else: + print(f" {YELLOW}Could not auto-detect models.{RESET}") + try: + model = input(" Model name: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if not model: + print(f" {RED}Model name is required for local endpoints.{RESET}") + sys.exit(1) + + return "openai", client, model + + +def _select_provider() -> tuple[str, Any, str]: + """Interactive provider/model/key selection. Returns (family, client, model).""" + print(f" {BOLD}Which LLM should power Doctor?{RESET}") + print(f" {CYAN}[1]{RESET} OpenAI") + print(f" {CYAN}[2]{RESET} Anthropic") + print(f" {CYAN}[3]{RESET} OpenAI-compatible (local/vLLM)") + print() + while True: + try: + choice = input(f" {BOLD}>{RESET} ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if choice in ("1", "2", "3"): + break + print(f" {RED}Please enter 1, 2, or 3.{RESET}") + + if choice == "1": + return _setup_openai() + if choice == "2": + return _setup_anthropic() + return _setup_local() + + +def _validate_connection(llm: _DoctorLLM) -> tuple[bool, str]: + """Validate the LLM connection with a minimal, time-bounded request. + + Doctor runs precisely when the backend may be sick, so the probe goes through + a bounded client (10s, no retries) — without it the SDK's ~600s default could + wedge startup / ``--report`` against a reachable-but-hung endpoint. + """ + client = llm.client + with_options = getattr(client, "with_options", None) + if callable(with_options): + client = with_options(timeout=10.0, max_retries=0) + probe = _DoctorLLM(llm.provider, client, llm.model) + try: + probe.complete( + [ + {"role": "system", "content": "Reply with exactly: ok"}, + {"role": "user", "content": "ping"}, + ], + [], + ) + return True, "" + except Exception as exc: # noqa: BLE001 - any failure means unreachable + return False, str(exc) + + +# --------------------------------------------------------------------------- +# Conversation loop +# --------------------------------------------------------------------------- + + +def _run_conversation( + llm: _DoctorLLM, project_dir: Path, context_report: str, primary_kind: str = "unknown" +) -> None: + """Main LLM-driven diagnostic loop.""" + renderer = MarkdownRenderer() + + messages: list[dict[str, Any]] = [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + "Here is the deterministic preflight report for this machine:\n\n" + f"{context_report}\n\n" + "Greet the operator briefly, summarize the cluster's health at a " + "glance from this report, and ask what symptom they're seeing (or " + "offer to investigate the most likely problem). Confirm specifics " + "with your tools before drawing conclusions." + ), + }, + ] + + _max_retries = 3 + retries = 0 + + while True: + with Spinner("Thinking"): + try: + content, tool_calls, _reason = llm.complete(messages, TOOLS) + except KeyboardInterrupt: + print(f"\n{DIM}(Interrupted. Type 'quit' to exit.){RESET}") + messages.append( + {"role": "user", "content": "The user interrupted. Ask what they need."} + ) + continue + except Exception as exc: # noqa: BLE001 - retry transient LLM errors + retries += 1 + if retries >= _max_retries: + print(f"\n{RED}LLM error after {_max_retries} attempts: {exc}{RESET}") + print() + print("Troubleshooting:") + print( + f" {DIM}• If using a proxy (Open WebUI, LiteLLM), try connecting directly{RESET}" + ) + print(f" {DIM}• Verify the endpoint supports tool/function calling{RESET}") + print(f" {DIM}• Check that the model context window isn't exceeded{RESET}") + return + print(f"\n{RED}LLM error: {exc}{RESET}") + print(f"{DIM}Retrying ({retries}/{_max_retries})...{RESET}") + continue + + retries = 0 + + assistant_msg: dict[str, Any] = {"role": "assistant", "content": content or ""} + if tool_calls: + assistant_msg["tool_calls"] = tool_calls + messages.append(assistant_msg) + + if content: + rendered = renderer.feed(content + "\n") + flushed = renderer.flush() + print(rendered + flushed, end="") + + if tool_calls: + for tc in tool_calls: + name = tc["function"]["name"] + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError as exc: + result = f"Error: invalid JSON arguments: {exc}" + args = {} + else: + hint = "" + if name == "read_file" and "path" in args: + hint = f" {args['path']}" + elif name in ("compose_logs", "systemd_status", "journal_tail") and ( + args.get("service") or args.get("unit") + ): + hint = f" {args.get('service') or args.get('unit')}" + elif name == "http_health" and "url" in args: + hint = f" {args['url']}" + elif name == "check_port" and "port" in args: + hint = f" :{args['port']}" + print(f" {DIM}[{name}]{hint}{RESET}") + try: + result = execute_tool(name, args, project_dir, primary_kind=primary_kind) + except _FinishError as fin: + print(f"\n{GREEN}{BOLD} Diagnosis complete.{RESET}\n") + rendered = renderer.feed(fin.summary + "\n") + flushed = renderer.flush() + print(rendered + flushed, end="") + return + + messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + continue + + print() + try: + user_input = input(f"{BOLD}>{RESET} ").strip() + except EOFError: + print("\nGoodbye!") + return + except KeyboardInterrupt: + print(f"\n{DIM}(Press Ctrl+C again to quit, or type your response.){RESET}") + try: + user_input = input(f"{BOLD}>{RESET} ").strip() + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + return + + if user_input.lower() in ("quit", "exit", "q"): + print("Goodbye!") + return + if not user_input: + continue + messages.append({"role": "user", "content": user_input}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _print_banner() -> None: + print(f"\n{BOLD}{CYAN} Turnstone Doctor{RESET} {DIM}v{__version__}{RESET}") + print(f" {DIM}{'─' * 48}{RESET}") + print() + print(" Diagnoses a running Turnstone deployment. Read-only:") + print(" it inspects and recommends fixes, but never changes") + print(" your system. (Installs use run.sh.)") + print() + + +def _build_report( + project_dir: Path, *, offline: bool +) -> tuple[InstallProfile, VersionReport, _DoctorLLM | None, BackendVerdict, str]: + """Run the full deterministic preflight: profile + versions + brain resolution.""" + profile = detect_install_profile(project_dir) + storage, storage_err = open_storage(profile) + versions = check_versions(profile, storage, offline=offline) + brain, verdict = resolve_doctor_brain(profile, storage, storage_err) + report = render_full_report(profile, versions, verdict) + return profile, versions, brain, verdict, report + + +def main() -> None: + """Entry point for the turnstone-doctor CLI.""" + parser = argparse.ArgumentParser( + prog="turnstone-doctor", + description=( + "Diagnose a running Turnstone deployment with an LLM-backed assistant. " + "Read-only — it never mutates your system. Installs use run.sh." + ), + ) + parser.add_argument( + "--dir", + default=None, + help="Install directory to inspect (default: current directory).", + ) + parser.add_argument( + "--report", + action="store_true", + help="Print the preflight report and exit (no interactive chat; still runs a " + "bounded backend-reachability probe).", + ) + parser.add_argument( + "--offline", + action="store_true", + help="Skip the upstream GitHub version check.", + ) + args = parser.parse_args() + + project_dir = Path(args.dir).expanduser().resolve() if args.dir else Path.cwd() + + if args.report: + _, _, _, _, report = _build_report(project_dir, offline=args.offline) + print(report) + return + + _print_banner() + print(f"{DIM}Running preflight…{RESET}") + profile, _versions, brain, verdict, report = _build_report(project_dir, offline=args.offline) + print() + print(report) + print() + + if brain is None: + print(f"{YELLOW}Could not self-configure an LLM from the cluster config:{RESET}") + print(f" {DIM}{verdict.detail}{RESET}") + print(f"{DIM}Falling back to interactive provider selection.{RESET}\n") + provider, client, model = _select_provider() + brain = _DoctorLLM(provider, client, model) + ok, err = _validate_connection(brain) + if not ok: + print(f"\n {RED}Could not connect to the model: {err}{RESET}") + sys.exit(1) + print(f"\n {GREEN}Connected to {BOLD}{model}{RESET}{GREEN}.{RESET}") + else: + print(f"{GREEN}LLM backend healthy — {verdict.detail}{RESET}") + + print(f" {DIM}Handing off to the diagnostic assistant…{RESET}\n") + try: + _run_conversation(brain, project_dir, report, profile.primary_kind) + except KeyboardInterrupt: + print("\nGoodbye!") + sys.exit(0) + + +if __name__ == "__main__": + main()