{"owner":"QuipNetwork","repo":"quip-node-manager","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"skills":{"AGENTS.md":"# AGENTS.md\n\nInstructions for AI coding agents (Claude Code, Codex, Cursor, etc.).\n\n## Project Overview\n\nQuip Node Desktop Manager — a Tauri v2 desktop app that orchestrates and monitors the Quip node\nstack (miner + validator + dashboard + postgres + caddy). Runs the stack via Docker\nCompose, or in Native mode (default on macOS) where the miner binary runs on the host and the\nsupport services run in Docker. The same binary also exposes a headless TUI (`--cli`, or when no\ndisplay is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS frontend.\n\n## Architecture\n\n```\nquip-node-manager/\n├── src/                           # Frontend (vanilla HTML/CSS/JS)\n│   ├── index.html\n│   ├── styles.css\n│   └── app.js\n├── vendor/\n│   └── nodes.quip.network/        # git submodule — upstream compose stack\n│                                  # (docker-compose.yml, caddy/Caddyfile,\n│                                  # chain-specs/quip-testnet.json). Embedded into\n│                                  # the binary via include_str! in stack_assets.rs\n│                                  # at compile time (NOT Tauri's bundle.resources),\n│                                  # then staged + patched into ~/quip-data on\n│                                  # every Start. See \"Stack Asset Patching\".\n└── src-tauri/                     # Rust backend (Tauri v2)\n    ├── Cargo.toml\n    ├── tauri.conf.json            # no bundle.resources — resources are\n    │                              # compile-time embedded\n    ├── capabilities/\n    │   └── default.json\n    └── src/\n        ├── main.rs                # Entry point; GUI by default, TUI when\n        │                          # --cli passed or no display (headless/SSH)\n        ├── lib.rs                 # Tauri builder, command registration,\n        │                          # tray icon, background update monitor\n        ├── settings.rs            # AppSettings, NodeConfig, ImageTag (Cpu|Cuda),\n        │                          # StackStatus/StackHealth, DwaveConfig\n        ├── secret.rs              # Node secret (64-char hex)\n        ├── config.rs              # config.toml generation\n        ├── cmd.rs                 # Command wrapper: PATH augmentation (login-shell\n        │                          # $PATH + known tool dirs) + Windows no-console-flash\n        ├── compose.rs             # docker compose orchestration: miner +\n        │                          # validator + dashboard + postgres + caddy\n        ├── stack_assets.rs        # include_str! the compose.yml + Caddyfile + chain\n        │                          # spec; patch ports + Native upstream at stage time\n        ├── log_stream.rs          # docker compose logs -f → Tauri events\n        ├── native.rs              # native binary download + lifecycle\n        ├── hardware.rs            # GPU/Docker/Python detection\n        ├── network.rs             # Public IP detection only\n        ├── update.rs              # Multi-image + app update monitor\n        ├── migration_v2.rs        # v0.1 → v0.2 config/.env migration; backs up\n        │                          # old files and promotes hand-edited host/port.\n        │                          # REMOVE in v0.3 (drop v0.1 → v0.2 upgrades)\n        ├── hostnames.rs           # public_host parsing → Caddy hostname +\n        │                          # validator libp2p --public-addr multiaddr\n        ├── checklist.rs           # Pre-flight checks → checklist-update events;\n        │                          # also owns the port-reachability probe\n        ├── tui_app.rs             # Headless TUI app state + run loop (ratatui)\n        ├── tui_input.rs           # TUI terminal event → Action handling\n        └── tui_ui.rs              # TUI ratatui frame rendering\n```\n\n## Key Details\n\n- **Tauri version**: v2\n- **JS tooling**: Bun\n- **App version**: 0.2.3-rc2\n- **Window size**: 900×700\n- **Data directory**: `~/quip-data/` by default (bind-mount root for the compose\n  stack). Overridable via `set_data_dir` → the `data_dir` key in\n  `~/.config/quip-node-manager/bootstrap.json`; `~/quip-data` is only the\n  fallback when unset.\n- **Compose project name**: `quip` (→ `docker compose --project-name quip …`)\n- **Compose command**: always via the `docker compose` (v2) CLI; not\n  `docker-compose` (v1), not the Python bindings.\n- **Container names** (from compose `container_name`): `quip-cpu` or\n  `quip-cuda` (miner, chosen by GPU presence), `quip-validator` (Substrate\n  block-producing validator), `quip-dashboard`, `quip-postgres`, `quip-caddy`. The\n  dashboard/Caddy reach the miner via the compose network alias `quip-miner`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the testnet faucet and registers its keystore in\n  `QuantumPow.Miners`, so there is no separate one-shot bootstrap container.\n  D-Wave QPU mining activates on top of\n  the CPU image via `config.toml [dwave]` (no separate qpu service). The\n  upstream compose also defines an optional `quip-faucet` service behind a\n  `faucet` profile, which the manager never starts.\n- **Ports** (published by the Caddy + validator services):\n  - `<settings.node_config.port>:20049/tcp` — Caddy public API port: dashboard\n    SPA, miner `/api/v1/*` REST, and Substrate `/rpc` WebSocket. Container-internal\n    port is always 20049; the host side is rewritten at stage time to the user's\n    configured port (default 20049). See \"Port Handling\".\n  - `80/tcp + 443/tcp` — Caddy ACME/TLS (always published; TLS only provisioned\n    when `QUIP_HOSTNAME` is a real DNS name).\n  - `<settings.node_config.validator_port>:30333/tcp + /udp` — validator libp2p\n    peering (host default 30333, container 30333 — a 1:1 mapping unless the user\n    overrides the host port). Must be reachable from the public internet for\n    chain peering.\n  - `127.0.0.1:<validator_rpc_port>:9944` (Native mode only) — validator raw\n    JSON-RPC published on host loopback (default 9944) so the host-side miner\n    connects via `ws://127.0.0.1:9944`.\n  - `<native_rest_port>/tcp` — native miner REST (default 20100, bound to\n    `127.0.0.1`); the dashboard container reaches it via\n    `host.docker.internal:<rest_port>`.\n\n## Docker Images\n\nImages are declared in `vendor/nodes.quip.network/docker-compose.yml` (with\n`${QUIP_*_TAG:-v0.2}` placeholders); the manager's authoritative image paths +\ntag live in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`, `COMPOSE_IMAGE_TAG = \"v0.3.0-rc7\"`), written into\n`.env` as `QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`:\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner:v0.3.0-rc7`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner-cuda:v0.3.0-rc7`\n\n  The v0.3 images are a **separate repository line**, not new tags on the v0.2\n  paths, which stop at `v0.2.1-rc54`. The CPU image also dropped its `-cpu`\n  suffix when the coordinator absorbed the miner binaries, so the two names are\n  no longer symmetric. Pointing a v0.3 tag at a v0.2 path fails the pull with\n  `not found`.\n- Validator: `registry.gitlab.com/quip.network/quip-validator/quip-network-node:v0.2`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network:v0.2`\n- Postgres: `postgres:16` (Docker Hub)\n- Caddy: `caddy:2-alpine` (Docker Hub)\n\nSelected by `AppSettings`:\n- `image_tag: ImageTag` — `Cpu` | `Cuda`. D-Wave QPU mining is not a separate\n  image: it rides on the CPU image via the `[dwave]` section in `config.toml`.\n- `tls_enabled: bool` — controls whether Caddy provisions TLS (`:80`/`:443` are\n  always published by the caddy service).\n\nThe dashboard + postgres + caddy + validator services are always part\nof the `cpu`/`cuda` profile — there is no `dashboard_enabled` toggle.\n\n## Run Modes\n\n| run_mode | node | compose services run |\n|----------|------|----------------------|\n| `Docker` | `quip-{cpu,cuda}` miner container via compose | every profile service: miner + `quip-validator` + `dashboard` + `postgres` + `caddy` (empty positional list ⇒ compose starts the whole profile) |\n| `Native` (macOS only) | native miner binary on the host (`~/quip-data/bin/quip-miner-*`) | explicit list `quip-validator dashboard postgres caddy` — no miner container. The validator's JSON-RPC (9944) is published on `127.0.0.1:<validator_rpc_port>` so the host miner connects via `ws://127.0.0.1:<validator_rpc_port>`; the dashboard reaches the host miner's REST at `host.docker.internal:<rest_port>` |\n\n## Compose Profiles\n\n`image_tag → profile` (a single profile name, no TLS/dashboard variants):\n\n| profile | services started |\n|---------|------------------|\n| `cpu` | `cpu` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n\n`compose_profile(image_tag)` returns the image's service name (`cpu` or `cuda`) —\nthere is no `qpu` profile (D-Wave mining rides on the CPU image via\n`config.toml [dwave]`), and no `-notls`/`-nodash` variants. Caddy is always in\nboth profiles. The vendored compose file also defines an opt-in `faucet` profile\n(`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard postgres caddy`) that omits the miner, so\n`--profile` gates eligibility while positional args restrict what actually\nstarts.\n\n## Data Files (all in `~/quip-data/`)\n\n| File | Generated / managed by | Purpose |\n|------|------------------------|---------|\n| `app-settings.json` | settings.rs (user preferences) | UI toggles + NodeConfig |\n| `config.toml` | config.rs on every Start | Node config (bind-mounted into the node container in Docker mode; read directly by the binary in Native mode) |\n| `.env` | compose.rs on every Start | Compose env: PUID, PGID, QUIP_HOSTNAME, CERT_EMAIL, ZEROSSL_API_KEY, DWAVE_API_KEY, POSTGRES_PASSWORD, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION; mode 0600 on Unix. (No QUIP_NODE_URL — removed in v0.2. No QUIP_VALIDATORS — the upstream compose made the miner fully config-driven, so validators live only in `config.toml`. QUIP_VALIDATOR_RPC_URLS is deliberately NOT written — it defers to the compose default `ws://quip-caddy:8088/rpc`, Caddy's internal front door, so the dashboard resolves both the chain RPC and the local miner REST from one host.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with Caddy host API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, and (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944` |\n| `caddy/Caddyfile` | stack_assets.rs (embedded copy + patch) | Caddy routes; the local faucet route is always stripped; in Native mode the `/api/v1/*` upstream is rewritten from `quip-miner:8086` to `host.docker.internal:<rest_port>` |\n| `chain-specs/quip-testnet.json` | stack_assets.rs (embedded copy) | Quip Testnet chain spec mounted into the validator container |\n| `keystore.json` | native.rs (Native mode) | Native miner signer keystore (generated via `quip-miner keygen`) |\n| `data/` | bind-mount target for the miner's `/data` (Docker) and host config.toml path (Native) | miner runtime `config.toml`, `keystore.json`; the validator's state lives under `data/validator-data/` (mounted as the validator container's `/data`) |\n| `dashboard-data/` | bind-mount target for the dashboard | Dashboard auxiliary state |\n| `node-secret.json` | secret.rs | `{ \"secret\": \"<64-hex>\" }` — read by secret.rs and gates the `secret` pre-flight check, but NOT written into config.toml in v0.2. The node's actual signing identity is `keystore.json` (Docker `/data/keystore.json`, Native `keystore.json`). |\n| `bin/quip-miner-*` | native.rs | Downloaded native miner binary (`quip-miner-macos-arm64` / `-x86_64`). Legacy pre-v0.2 `quip-network-node-*` binaries here are auto-deleted on launch. |\n\nProject-scoped Docker volumes (survive `docker compose down` by design):\n`quip_pgdata`, `quip_caddy-data`, `quip_caddy-config`. The upstream compose\npins fixed global `name:`s (`quip-pgdata`, …); the manager strips them at\nstage time (`stack_assets::strip_volume_names`) so they don't collide with\nother Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds a `data_dir` override plus a per-install `postgres_password`\n(generated once on first access, never rotated — it's keyed to the stored\nPostgres volume hash).\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` and `caddy/Caddyfile` are\n**embedded into the binary at compile time** via `include_str!`. This\navoids Tauri's runtime resource resolution entirely — on Windows, the CI\nships a raw `.exe` (`tauri build --no-bundle`) with no sibling resource\nfolder, and a `BaseDirectory::Resource` lookup would fail. Embedding\nmakes the staged files travel as `&'static str` in `.rodata`.\n\n`stack_assets::sync_stack_assets(run_mode, public_api_port, validator_port,\npublic_host, native_rest_port, validator_rpc_port)` is called from both\n`start_stack` and `pull_compose_images` before any `docker compose` invocation.\nIt stages the embedded compose.yml, Caddyfile, and chain spec\n(`chain-specs/quip-testnet.json`), always overwriting — no merge. Patches:\n\n1. **compose.yml port remap** (always): Caddy's host-published `\"20049:20049\"`\n   is rewritten to `\"<public_api_port>:20049\"`, and the validator's\n   `\"30333:30333/tcp\"` and `\"30333:30333/udp\"` mappings to\n   `\"<validator_port>:30333/<proto>\"`. Container-internal ports (20049, 30333)\n   stay fixed; only host sides move. No-op when the configured ports equal the\n   upstream defaults.\n\n2. **compose.yml `--public-addr`** (when `public_host` is set): a\n   `--public-addr=<multiaddr>` arg (built from `public_host` + `validator_port`,\n   e.g. `/dns4/host/tcp/30333` or `/ip4/.../tcp/30333`) is inserted into the\n   validator command after `--validator`.\n\n3. **compose.yml validator RPC publish** (Native mode only): a\n   `\"127.0.0.1:<validator_rpc_port>:9944\"` mapping is inserted into the\n   validator's ports list so the host-side miner reaches the raw JSON-RPC\n   directly. Docker mode does not publish it.\n\n4. **Caddyfile faucet strip** (always): the optional local faucet route block is\n   removed (the manager relies on the public testnet faucet).\n\n5. **Caddyfile upstream rewrite** (Native mode only): `quip-miner:8086` becomes\n   `host.docker.internal:<native_rest_port>` so the dashboard container reaches\n   the host miner. Docker mode keeps `quip-miner:8086`.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the Caddyfile upstream rewrite and the validator `--public-addr`\narg both require rewriting a YAML/Caddyfile token, not just supplying an env\nvar, so all the port/host remaps live in one patch pass for consistency.\n\n## Port Handling\n\nEvery container-internal port is fixed; only the host side is remapped at stage\ntime (in `stack_assets`). v0.2 has three independently host-mappable\nvalidator/API ports:\n\n| setting (`NodeConfig`) | container port | host default | published as |\n|------------------------|----------------|--------------|--------------|\n| `port` | Caddy 20049 (public API) | 20049 | `<port>:20049` |\n| `validator_port` | validator libp2p 30333 | 30333 | `<validator_port>:30333/tcp+udp` |\n| `validator_rpc_port` | validator JSON-RPC 9944 | 9944 | Native only: `127.0.0.1:<validator_rpc_port>:9944` |\n\nFor the miner's own `config.toml`: `config.rs` always emits `public_port` in both\nmodes. It takes `config.public_port` when the user sets an override, and falls\nback to `port` (the Caddy front door) otherwise, because that is the port an\noutside peer actually reaches. There is no separate top-level `port` key in the\nminer config — that is the v0.1 schema, and a test asserts it stays gone. The\nThe miner's REST surface is a `[dashboard]` section, not the v0.2\n`[miner].rest_host` / `rest_port` pair. The v0.3 coordinator ignores those two\nkeys outright, and it disables the dashboard unless **both** `listen` and\n`data_dir` are set, so neither may be omitted. Docker renders\n`listen = \"0.0.0.0:8086\"` to match the Caddyfile's `quip-miner:8086` upstream,\nwith `data_dir = \"/data/attempts\"` inside the volume. Native renders\n`listen = \"127.0.0.1:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. Native stays on loopback because Docker\nDesktop's `host.docker.internal` originates the connection on the host, so the\nCaddy container still reaches it.\n\n### `public_host` resolution and the start gate\n\nBoth start paths (`compose::start_stack_core` and `native::start_native_node_core`)\nfill an unset `public_host` before they write `config.toml`. The value comes from\n`checklist::fetch_public_ip` (check.quip.network first, ipify as a fallback), which\nis the same fetcher behind the `ip` checklist row, so the row and the advertised\naddress cannot disagree. The resolution is per start and is never persisted to\n`app-settings.json`.\n\n`checklist::require_public_host` then hard-aborts the start when the resolved value\nis one no outside peer can reach: loopback, unspecified, RFC1918 private,\n169.254.0.0/16 link-local, 100.64.0.0/10 carrier-grade NAT, multicast,\n240.0.0.0/4 reserved, IPv6 `fc00::/7` unique-local, IPv6 `fe80::/10` link-local,\nand (for names) anything `hostnames::is_public_dns_host` rejects, including mDNS\n`.local`. IPv4-mapped IPv6 is unwrapped before the test. A local-network or\nair-gapped deployment that wants to advertise a private address cannot start, and\nno opt-in override exists yet.\n\nThere is no standalone `public-host` checklist row. The two port rows already probe\nhost and port together through `/checkport`, and they stay warn-only.\n\n## Pre-flight Port Reachability Check\n\n`run_check_port` (public API port) and `run_check_port_validator` (validator\nlibp2p port) in `checklist.rs` each answer: *is this port reachable from the\npublic internet?* Both call `probe_port_forwarding_with_ctx`, which runs **one\n`/checkport?port=N` TCP probe per recheck** against `check.quip.network`\n(`CHECK_SERVICE` in checklist.rs). The probe is the same for both ports; only\nthe local-socket branch differs:\n\n- **Port already bound locally** (`TcpListener::bind` fails): a service is\n  already holding the port. Probe it directly — a `HostResponded` result maps to\n  `Verified`.\n- **Port free locally**: bind a temporary TCP listener and hold it for the\n  duration of the probe (background accept loop, aborted on return), so the\n  external probe has something to accept into — `HostResponded` maps to\n  `ForwardReady`.\n\nThere is no `/checkconn`/QUIC endpoint; the manager never speaks QUIP itself.\nBoth states use `/checkport` over TCP. Users click Recheck after starting the\nnode to escalate `ForwardReady` → `Verified`.\n\n### Response Classification\n\nProbe responses are classified into `ProbeOutcome` with these rules:\n\n| Service response (`/checkport`) | `ProbeOutcome` | Rationale |\n|---------------------------------|----------------|-----------|\n| HTTP 200, `reachable:true` | `HostResponded` | TCP connect succeeded — forward works and something is listening |\n| HTTP 200, `reachable:false` (any `error`: timeout, RST/\"connection refused\", ...) | `Unreachable` | the external TCP connect could not be established |\n| HTTP 429 | `RateLimited(retry_after_seconds)` | service rate-limited us |\n| HTTP 5xx / network error / malformed body | `ServiceError` | not the user's fault |\n\n`PortProbeResult` maps these to five user-facing states:\n\n- `Verified` (Pass) — port bound locally + `HostResponded`\n- `ForwardReady` (Pass) — port free locally + `HostResponded`\n- `Unreachable` (Warn) — `Unreachable`\n- `Unverified` (Warn) — `ServiceError`: check.quip.network was down/errored, so\n  we couldn't verify (no green check we didn't earn)\n- `RateLimited { retry_after_secs, endpoint }` (Warn) — service rate-limited\n  (HTTP 429), so we couldn't verify; the retry time is shown so the user can\n  recheck after the cool-down. Not a green check we didn't earn.\n\nA check only goes **green** when check.quip.network positively confirmed the\nport (`Verified`/`ForwardReady`); `is_externally_reachable()` is true for those\ntwo and nothing else.\n\n**Design rule:** *`/checkport` is a plain-TCP connect, so reachability is\nbinary.* Only `reachable:true` (a SYN-ACK proving the forward works and a\nlistener is up) passes. Every `reachable:false` — timeout, RST, or\n\"connection refused\" — fails the check, because in each case the prober\ncould not open a TCP connection to the port.\n\n### Probe Diagnostics\n\nEvery probe call emits a `[probe]` line to the `node-log` event with the\nfull request URL, HTTP status, and response body (truncated at 1 KB).\nUsers can copy/paste the raw output into support threads — the raw `error`\nstring is the ground truth for *why* a port was unreachable (it no longer\naffects classification, which is binary on `reachable`). The `AppHandle` is\nplumbed via `Option<AppHandle>` on `CheckCtx`,\nso non-Tauri callers (the TUI) probe silently.\n\n## Shared Types (defined in `settings.rs`)\n\n- `RunMode` — `Docker | Native` (Native is macOS-only)\n- `ImageTag` — `Cpu | Cuda` (serialised lowercase; a legacy `\"qpu\"` JSON string\n  is accepted as an alias for `Cpu` via `deserialize_image_tag_compat`)\n- `GpuBackend` — `Local | Modal | Mps`\n- `NodeConfig` — port (public API), validator_port (libp2p), validator_rpc_port\n  (Native), secret, peers, GPU/QPU, REST, telemetry, …\n- `AppSettings` — `{ node_config, active_tab, window_maximized, image_tag,\n  tls_enabled, hostname (alias dashboard_hostname), cert_email, zerossl_api_key,\n  run_mode, auto_update_enabled }`\n- `StackStatus` — `{ services: Vec<ServiceStatus>, overall: StackHealth }`\n- `ServiceStatus` — `{ name, service, running, health, status_text, image }`\n- `StackHealth` — `Running | Degraded | Unhealthy | Stopped`\n\n## Frontend IPC\n\nThe frontend uses `window.__TAURI__.core.invoke` (`withGlobalTauri: true`).\n\nEvents emitted by backend (complete set): `node-log`, `checklist-update`,\n`pull-progress`, `pull-complete`, `stop-started`, `stop-complete`,\n`dashboard-db-mismatch`, `image-update-available`, `binary-update-available`,\n`binary-download-progress`, `app-update-available`.\n\n- `node-log` → `{ timestamp, level, message }`\n- `checklist-update` → `CheckItem { id, state, label, detail, required,\n  fixable, updated_at_ms }`\n- `pull-progress` → `{ line }` (one `docker compose pull` output line) or a\n  `--progress json` layer event forwarded verbatim\n- `pull-complete` → `{ gen, success, error }` (emitted when the pull process\n  exits — the authoritative \"pull is over\" signal)\n- `stop-started`, `stop-complete` — stop lifecycle\n- `dashboard-db-mismatch` → `{ message }` (Postgres volume password mismatch)\n- `image-update-available` → `{ image, info }` (emitted per image whose digest\n  changed, gated on `info.update_available`)\n- `binary-update-available` → native-binary UpdateInfo\n- `binary-download-progress` → `BinaryDownloadProgress` (native binary download %)\n- `app-update-available` → node-manager UpdateInfo\n\nKey Tauri commands (lib.rs `invoke_handler`):\n- `start_stack` / `stop_stack` / `get_stack_status` / `get_stack_config`\n- `pull_compose_images`\n- `check_docker_installed` / `check_docker_hello_world` /\n  `check_docker_compose_installed`\n- `start_native_node` / `stop_native_node` / `get_native_node_status`\n- `check_image_update(image_tag)` — node image digest\n- `check_dashboard_image_update()` — dashboard image digest\n- settings: `get_settings` / `update_settings` / `is_first_boot` /\n  `get_default_data_dir` / `get_data_dir` / `set_data_dir` / `restart_app`\n- `get_node_secret` / `generate_node_secret` / `generate_config_toml`\n- hardware: `detect_gpu_backend` / `list_gpu_devices` / `run_hardware_survey`\n- native: `check_native_binary` / `download_native_binary` /\n  `check_binary_update` / `start_native_log_tail`\n- `detect_public_ip` / `get_checklist` / `recheck`\n- updates: `get_app_version` / `get_node_version` / `check_app_update`\n- log streaming: `start_log_stream` / `stop_log_stream`\n\n## Commands\n\n```bash\n# One-time after clone: pull the compose submodule\ngit submodule update --init --recursive\n\n# Development\nbun run dev\n\n# Production build\nbun run build\n\n# Install dependencies\nbun install\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-protocol/docs/VERSIONING.md`. This repo follows the same\ncross-repo standard so `update.rs::parse_semver` orders release candidates\ncorrectly — it splits the pre-release on `-`, so a no-hyphen `v0.2.1rc18` loses\n*both* the patch and the rc number and collapses every rc to one value, which\nfreezes deployed nodes on an old rc.\n\n| Artifact | Format | Example |\n|----------|--------|---------|\n| Git release tag (pre-release) | hyphenated SemVer `vMAJOR.MINOR.PATCH-rcN` | `v0.2.1-rc18` |\n| Git release tag (stable) | `vMAJOR.MINOR.PATCH` | `v0.2.1` |\n| Package version (`package.json`, `Cargo.toml`, `tauri.conf.json`) | toolchain-native (npm/Cargo SemVer; PEP 440 elsewhere) | `0.2.1-rc2` |\n\nRules:\n- Pre-release git tags MUST be hyphenated (`-rcN` / `-alphaN` / `-betaN`); never\n  the PEP 440 no-hyphen form for a git tag.\n- Numeric parts (MAJOR.MINOR.PATCH and the rc number) MUST match between the git\n  tag and the package version; only the separator may differ.\n- CI: pre-release tags publish `:<tag>` + the rolling `:vMAJOR.MINOR` and MUST\n  NOT move `:latest`; only `main` / a stable `vX.Y.Z` tag moves `:latest`. The\n  `:latest` rule binds on image-publishing repos (quip-protocol); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Code Standards\n\n- All Rust files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- All JS files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- Tauri commands return `Result<T, String>` (the common case; a few infallible\n  commands return bare values, e.g. `is_first_boot -> bool`,\n  `get_node_version -> Option<String>`, `restart_app -> ()`)\n- No relative imports (`..`) in Rust — use `crate::module::Type`\n- Line length ≤ 100 chars\n\n## License\n\nAGPL-3.0-or-later. All new source files require the standard license header.\n"},"files":{"AGENTS.md":"# AGENTS.md\n\nInstructions for AI coding agents (Claude Code, Codex, Cursor, etc.).\n\n## Project Overview\n\nQuip Node Desktop Manager — a Tauri v2 desktop app that orchestrates and monitors the Quip node\nstack (miner + validator + dashboard + postgres + caddy). Runs the stack via Docker\nCompose, or in Native mode (default on macOS) where the miner binary runs on the host and the\nsupport services run in Docker. The same binary also exposes a headless TUI (`--cli`, or when no\ndisplay is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS frontend.\n\n## Architecture\n\n```\nquip-node-manager/\n├── src/                           # Frontend (vanilla HTML/CSS/JS)\n│   ├── index.html\n│   ├── styles.css\n│   └── app.js\n├── vendor/\n│   └── nodes.quip.network/        # git submodule — upstream compose stack\n│                                  # (docker-compose.yml, caddy/Caddyfile,\n│                                  # chain-specs/quip-testnet.json). Embedded into\n│                                  # the binary via include_str! in stack_assets.rs\n│                                  # at compile time (NOT Tauri's bundle.resources),\n│                                  # then staged + patched into ~/quip-data on\n│                                  # every Start. See \"Stack Asset Patching\".\n└── src-tauri/                     # Rust backend (Tauri v2)\n    ├── Cargo.toml\n    ├── tauri.conf.json            # no bundle.resources — resources are\n    │                              # compile-time embedded\n    ├── capabilities/\n    │   └── default.json\n    └── src/\n        ├── main.rs                # Entry point; GUI by default, TUI when\n        │                          # --cli passed or no display (headless/SSH)\n        ├── lib.rs                 # Tauri builder, command registration,\n        │                          # tray icon, background update monitor\n        ├── settings.rs            # AppSettings, NodeConfig, ImageTag (Cpu|Cuda),\n        │                          # StackStatus/StackHealth, DwaveConfig\n        ├── secret.rs              # Node secret (64-char hex)\n        ├── config.rs              # config.toml generation\n        ├── cmd.rs                 # Command wrapper: PATH augmentation (login-shell\n        │                          # $PATH + known tool dirs) + Windows no-console-flash\n        ├── compose.rs             # docker compose orchestration: miner +\n        │                          # validator + dashboard + postgres + caddy\n        ├── stack_assets.rs        # include_str! the compose.yml + Caddyfile + chain\n        │                          # spec; patch ports + Native upstream at stage time\n        ├── log_stream.rs          # docker compose logs -f → Tauri events\n        ├── native.rs              # native binary download + lifecycle\n        ├── hardware.rs            # GPU/Docker/Python detection\n        ├── network.rs             # Public IP detection only\n        ├── update.rs              # Multi-image + app update monitor\n        ├── migration_v2.rs        # v0.1 → v0.2 config/.env migration; backs up\n        │                          # old files and promotes hand-edited host/port.\n        │                          # REMOVE in v0.3 (drop v0.1 → v0.2 upgrades)\n        ├── hostnames.rs           # public_host parsing → Caddy hostname +\n        │                          # validator libp2p --public-addr multiaddr\n        ├── checklist.rs           # Pre-flight checks → checklist-update events;\n        │                          # also owns the port-reachability probe\n        ├── tui_app.rs             # Headless TUI app state + run loop (ratatui)\n        ├── tui_input.rs           # TUI terminal event → Action handling\n        └── tui_ui.rs              # TUI ratatui frame rendering\n```\n\n## Key Details\n\n- **Tauri version**: v2\n- **JS tooling**: Bun\n- **App version**: 0.2.3-rc2\n- **Window size**: 900×700\n- **Data directory**: `~/quip-data/` by default (bind-mount root for the compose\n  stack). Overridable via `set_data_dir` → the `data_dir` key in\n  `~/.config/quip-node-manager/bootstrap.json`; `~/quip-data` is only the\n  fallback when unset.\n- **Compose project name**: `quip` (→ `docker compose --project-name quip …`)\n- **Compose command**: always via the `docker compose` (v2) CLI; not\n  `docker-compose` (v1), not the Python bindings.\n- **Container names** (from compose `container_name`): `quip-cpu` or\n  `quip-cuda` (miner, chosen by GPU presence), `quip-validator` (Substrate\n  block-producing validator), `quip-dashboard`, `quip-postgres`, `quip-caddy`. The\n  dashboard/Caddy reach the miner via the compose network alias `quip-miner`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the testnet faucet and registers its keystore in\n  `QuantumPow.Miners`, so there is no separate one-shot bootstrap container.\n  D-Wave QPU mining activates on top of\n  the CPU image via `config.toml [dwave]` (no separate qpu service). The\n  upstream compose also defines an optional `quip-faucet` service behind a\n  `faucet` profile, which the manager never starts.\n- **Ports** (published by the Caddy + validator services):\n  - `<settings.node_config.port>:20049/tcp` — Caddy public API port: dashboard\n    SPA, miner `/api/v1/*` REST, and Substrate `/rpc` WebSocket. Container-internal\n    port is always 20049; the host side is rewritten at stage time to the user's\n    configured port (default 20049). See \"Port Handling\".\n  - `80/tcp + 443/tcp` — Caddy ACME/TLS (always published; TLS only provisioned\n    when `QUIP_HOSTNAME` is a real DNS name).\n  - `<settings.node_config.validator_port>:30333/tcp + /udp` — validator libp2p\n    peering (host default 30333, container 30333 — a 1:1 mapping unless the user\n    overrides the host port). Must be reachable from the public internet for\n    chain peering.\n  - `127.0.0.1:<validator_rpc_port>:9944` (Native mode only) — validator raw\n    JSON-RPC published on host loopback (default 9944) so the host-side miner\n    connects via `ws://127.0.0.1:9944`.\n  - `<native_rest_port>/tcp` — native miner REST (default 20100, bound to\n    `127.0.0.1`); the dashboard container reaches it via\n    `host.docker.internal:<rest_port>`.\n\n## Docker Images\n\nImages are declared in `vendor/nodes.quip.network/docker-compose.yml` (with\n`${QUIP_*_TAG:-v0.2}` placeholders); the manager's authoritative image paths +\ntag live in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`, `COMPOSE_IMAGE_TAG = \"v0.3.0-rc7\"`), written into\n`.env` as `QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`:\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner:v0.3.0-rc7`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner-cuda:v0.3.0-rc7`\n\n  The v0.3 images are a **separate repository line**, not new tags on the v0.2\n  paths, which stop at `v0.2.1-rc54`. The CPU image also dropped its `-cpu`\n  suffix when the coordinator absorbed the miner binaries, so the two names are\n  no longer symmetric. Pointing a v0.3 tag at a v0.2 path fails the pull with\n  `not found`.\n- Validator: `registry.gitlab.com/quip.network/quip-validator/quip-network-node:v0.2`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network:v0.2`\n- Postgres: `postgres:16` (Docker Hub)\n- Caddy: `caddy:2-alpine` (Docker Hub)\n\nSelected by `AppSettings`:\n- `image_tag: ImageTag` — `Cpu` | `Cuda`. D-Wave QPU mining is not a separate\n  image: it rides on the CPU image via the `[dwave]` section in `config.toml`.\n- `tls_enabled: bool` — controls whether Caddy provisions TLS (`:80`/`:443` are\n  always published by the caddy service).\n\nThe dashboard + postgres + caddy + validator services are always part\nof the `cpu`/`cuda` profile — there is no `dashboard_enabled` toggle.\n\n## Run Modes\n\n| run_mode | node | compose services run |\n|----------|------|----------------------|\n| `Docker` | `quip-{cpu,cuda}` miner container via compose | every profile service: miner + `quip-validator` + `dashboard` + `postgres` + `caddy` (empty positional list ⇒ compose starts the whole profile) |\n| `Native` (macOS only) | native miner binary on the host (`~/quip-data/bin/quip-miner-*`) | explicit list `quip-validator dashboard postgres caddy` — no miner container. The validator's JSON-RPC (9944) is published on `127.0.0.1:<validator_rpc_port>` so the host miner connects via `ws://127.0.0.1:<validator_rpc_port>`; the dashboard reaches the host miner's REST at `host.docker.internal:<rest_port>` |\n\n## Compose Profiles\n\n`image_tag → profile` (a single profile name, no TLS/dashboard variants):\n\n| profile | services started |\n|---------|------------------|\n| `cpu` | `cpu` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n\n`compose_profile(image_tag)` returns the image's service name (`cpu` or `cuda`) —\nthere is no `qpu` profile (D-Wave mining rides on the CPU image via\n`config.toml [dwave]`), and no `-notls`/`-nodash` variants. Caddy is always in\nboth profiles. The vendored compose file also defines an opt-in `faucet` profile\n(`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard postgres caddy`) that omits the miner, so\n`--profile` gates eligibility while positional args restrict what actually\nstarts.\n\n## Data Files (all in `~/quip-data/`)\n\n| File | Generated / managed by | Purpose |\n|------|------------------------|---------|\n| `app-settings.json` | settings.rs (user preferences) | UI toggles + NodeConfig |\n| `config.toml` | config.rs on every Start | Node config (bind-mounted into the node container in Docker mode; read directly by the binary in Native mode) |\n| `.env` | compose.rs on every Start | Compose env: PUID, PGID, QUIP_HOSTNAME, CERT_EMAIL, ZEROSSL_API_KEY, DWAVE_API_KEY, POSTGRES_PASSWORD, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION; mode 0600 on Unix. (No QUIP_NODE_URL — removed in v0.2. No QUIP_VALIDATORS — the upstream compose made the miner fully config-driven, so validators live only in `config.toml`. QUIP_VALIDATOR_RPC_URLS is deliberately NOT written — it defers to the compose default `ws://quip-caddy:8088/rpc`, Caddy's internal front door, so the dashboard resolves both the chain RPC and the local miner REST from one host.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with Caddy host API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, and (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944` |\n| `caddy/Caddyfile` | stack_assets.rs (embedded copy + patch) | Caddy routes; the local faucet route is always stripped; in Native mode the `/api/v1/*` upstream is rewritten from `quip-miner:8086` to `host.docker.internal:<rest_port>` |\n| `chain-specs/quip-testnet.json` | stack_assets.rs (embedded copy) | Quip Testnet chain spec mounted into the validator container |\n| `keystore.json` | native.rs (Native mode) | Native miner signer keystore (generated via `quip-miner keygen`) |\n| `data/` | bind-mount target for the miner's `/data` (Docker) and host config.toml path (Native) | miner runtime `config.toml`, `keystore.json`; the validator's state lives under `data/validator-data/` (mounted as the validator container's `/data`) |\n| `dashboard-data/` | bind-mount target for the dashboard | Dashboard auxiliary state |\n| `node-secret.json` | secret.rs | `{ \"secret\": \"<64-hex>\" }` — read by secret.rs and gates the `secret` pre-flight check, but NOT written into config.toml in v0.2. The node's actual signing identity is `keystore.json` (Docker `/data/keystore.json`, Native `keystore.json`). |\n| `bin/quip-miner-*` | native.rs | Downloaded native miner binary (`quip-miner-macos-arm64` / `-x86_64`). Legacy pre-v0.2 `quip-network-node-*` binaries here are auto-deleted on launch. |\n\nProject-scoped Docker volumes (survive `docker compose down` by design):\n`quip_pgdata`, `quip_caddy-data`, `quip_caddy-config`. The upstream compose\npins fixed global `name:`s (`quip-pgdata`, …); the manager strips them at\nstage time (`stack_assets::strip_volume_names`) so they don't collide with\nother Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds a `data_dir` override plus a per-install `postgres_password`\n(generated once on first access, never rotated — it's keyed to the stored\nPostgres volume hash).\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` and `caddy/Caddyfile` are\n**embedded into the binary at compile time** via `include_str!`. This\navoids Tauri's runtime resource resolution entirely — on Windows, the CI\nships a raw `.exe` (`tauri build --no-bundle`) with no sibling resource\nfolder, and a `BaseDirectory::Resource` lookup would fail. Embedding\nmakes the staged files travel as `&'static str` in `.rodata`.\n\n`stack_assets::sync_stack_assets(run_mode, public_api_port, validator_port,\npublic_host, native_rest_port, validator_rpc_port)` is called from both\n`start_stack` and `pull_compose_images` before any `docker compose` invocation.\nIt stages the embedded compose.yml, Caddyfile, and chain spec\n(`chain-specs/quip-testnet.json`), always overwriting — no merge. Patches:\n\n1. **compose.yml port remap** (always): Caddy's host-published `\"20049:20049\"`\n   is rewritten to `\"<public_api_port>:20049\"`, and the validator's\n   `\"30333:30333/tcp\"` and `\"30333:30333/udp\"` mappings to\n   `\"<validator_port>:30333/<proto>\"`. Container-internal ports (20049, 30333)\n   stay fixed; only host sides move. No-op when the configured ports equal the\n   upstream defaults.\n\n2. **compose.yml `--public-addr`** (when `public_host` is set): a\n   `--public-addr=<multiaddr>` arg (built from `public_host` + `validator_port`,\n   e.g. `/dns4/host/tcp/30333` or `/ip4/.../tcp/30333`) is inserted into the\n   validator command after `--validator`.\n\n3. **compose.yml validator RPC publish** (Native mode only): a\n   `\"127.0.0.1:<validator_rpc_port>:9944\"` mapping is inserted into the\n   validator's ports list so the host-side miner reaches the raw JSON-RPC\n   directly. Docker mode does not publish it.\n\n4. **Caddyfile faucet strip** (always): the optional local faucet route block is\n   removed (the manager relies on the public testnet faucet).\n\n5. **Caddyfile upstream rewrite** (Native mode only): `quip-miner:8086` becomes\n   `host.docker.internal:<native_rest_port>` so the dashboard container reaches\n   the host miner. Docker mode keeps `quip-miner:8086`.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the Caddyfile upstream rewrite and the validator `--public-addr`\narg both require rewriting a YAML/Caddyfile token, not just supplying an env\nvar, so all the port/host remaps live in one patch pass for consistency.\n\n## Port Handling\n\nEvery container-internal port is fixed; only the host side is remapped at stage\ntime (in `stack_assets`). v0.2 has three independently host-mappable\nvalidator/API ports:\n\n| setting (`NodeConfig`) | container port | host default | published as |\n|------------------------|----------------|--------------|--------------|\n| `port` | Caddy 20049 (public API) | 20049 | `<port>:20049` |\n| `validator_port` | validator libp2p 30333 | 30333 | `<validator_port>:30333/tcp+udp` |\n| `validator_rpc_port` | validator JSON-RPC 9944 | 9944 | Native only: `127.0.0.1:<validator_rpc_port>:9944` |\n\nFor the miner's own `config.toml`: `config.rs` always emits `public_port` in both\nmodes. It takes `config.public_port` when the user sets an override, and falls\nback to `port` (the Caddy front door) otherwise, because that is the port an\noutside peer actually reaches. There is no separate top-level `port` key in the\nminer config — that is the v0.1 schema, and a test asserts it stays gone. The\nThe miner's REST surface is a `[dashboard]` section, not the v0.2\n`[miner].rest_host` / `rest_port` pair. The v0.3 coordinator ignores those two\nkeys outright, and it disables the dashboard unless **both** `listen` and\n`data_dir` are set, so neither may be omitted. Docker renders\n`listen = \"0.0.0.0:8086\"` to match the Caddyfile's `quip-miner:8086` upstream,\nwith `data_dir = \"/data/attempts\"` inside the volume. Native renders\n`listen = \"127.0.0.1:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. Native stays on loopback because Docker\nDesktop's `host.docker.internal` originates the connection on the host, so the\nCaddy container still reaches it.\n\n### `public_host` resolution and the start gate\n\nBoth start paths (`compose::start_stack_core` and `native::start_native_node_core`)\nfill an unset `public_host` before they write `config.toml`. The value comes from\n`checklist::fetch_public_ip` (check.quip.network first, ipify as a fallback), which\nis the same fetcher behind the `ip` checklist row, so the row and the advertised\naddress cannot disagree. The resolution is per start and is never persisted to\n`app-settings.json`.\n\n`checklist::require_public_host` then hard-aborts the start when the resolved value\nis one no outside peer can reach: loopback, unspecified, RFC1918 private,\n169.254.0.0/16 link-local, 100.64.0.0/10 carrier-grade NAT, multicast,\n240.0.0.0/4 reserved, IPv6 `fc00::/7` unique-local, IPv6 `fe80::/10` link-local,\nand (for names) anything `hostnames::is_public_dns_host` rejects, including mDNS\n`.local`. IPv4-mapped IPv6 is unwrapped before the test. A local-network or\nair-gapped deployment that wants to advertise a private address cannot start, and\nno opt-in override exists yet.\n\nThere is no standalone `public-host` checklist row. The two port rows already probe\nhost and port together through `/checkport`, and they stay warn-only.\n\n## Pre-flight Port Reachability Check\n\n`run_check_port` (public API port) and `run_check_port_validator` (validator\nlibp2p port) in `checklist.rs` each answer: *is this port reachable from the\npublic internet?* Both call `probe_port_forwarding_with_ctx`, which runs **one\n`/checkport?port=N` TCP probe per recheck** against `check.quip.network`\n(`CHECK_SERVICE` in checklist.rs). The probe is the same for both ports; only\nthe local-socket branch differs:\n\n- **Port already bound locally** (`TcpListener::bind` fails): a service is\n  already holding the port. Probe it directly — a `HostResponded` result maps to\n  `Verified`.\n- **Port free locally**: bind a temporary TCP listener and hold it for the\n  duration of the probe (background accept loop, aborted on return), so the\n  external probe has something to accept into — `HostResponded` maps to\n  `ForwardReady`.\n\nThere is no `/checkconn`/QUIC endpoint; the manager never speaks QUIP itself.\nBoth states use `/checkport` over TCP. Users click Recheck after starting the\nnode to escalate `ForwardReady` → `Verified`.\n\n### Response Classification\n\nProbe responses are classified into `ProbeOutcome` with these rules:\n\n| Service response (`/checkport`) | `ProbeOutcome` | Rationale |\n|---------------------------------|----------------|-----------|\n| HTTP 200, `reachable:true` | `HostResponded` | TCP connect succeeded — forward works and something is listening |\n| HTTP 200, `reachable:false` (any `error`: timeout, RST/\"connection refused\", ...) | `Unreachable` | the external TCP connect could not be established |\n| HTTP 429 | `RateLimited(retry_after_seconds)` | service rate-limited us |\n| HTTP 5xx / network error / malformed body | `ServiceError` | not the user's fault |\n\n`PortProbeResult` maps these to five user-facing states:\n\n- `Verified` (Pass) — port bound locally + `HostResponded`\n- `ForwardReady` (Pass) — port free locally + `HostResponded`\n- `Unreachable` (Warn) — `Unreachable`\n- `Unverified` (Warn) — `ServiceError`: check.quip.network was down/errored, so\n  we couldn't verify (no green check we didn't earn)\n- `RateLimited { retry_after_secs, endpoint }` (Warn) — service rate-limited\n  (HTTP 429), so we couldn't verify; the retry time is shown so the user can\n  recheck after the cool-down. Not a green check we didn't earn.\n\nA check only goes **green** when check.quip.network positively confirmed the\nport (`Verified`/`ForwardReady`); `is_externally_reachable()` is true for those\ntwo and nothing else.\n\n**Design rule:** *`/checkport` is a plain-TCP connect, so reachability is\nbinary.* Only `reachable:true` (a SYN-ACK proving the forward works and a\nlistener is up) passes. Every `reachable:false` — timeout, RST, or\n\"connection refused\" — fails the check, because in each case the prober\ncould not open a TCP connection to the port.\n\n### Probe Diagnostics\n\nEvery probe call emits a `[probe]` line to the `node-log` event with the\nfull request URL, HTTP status, and response body (truncated at 1 KB).\nUsers can copy/paste the raw output into support threads — the raw `error`\nstring is the ground truth for *why* a port was unreachable (it no longer\naffects classification, which is binary on `reachable`). The `AppHandle` is\nplumbed via `Option<AppHandle>` on `CheckCtx`,\nso non-Tauri callers (the TUI) probe silently.\n\n## Shared Types (defined in `settings.rs`)\n\n- `RunMode` — `Docker | Native` (Native is macOS-only)\n- `ImageTag` — `Cpu | Cuda` (serialised lowercase; a legacy `\"qpu\"` JSON string\n  is accepted as an alias for `Cpu` via `deserialize_image_tag_compat`)\n- `GpuBackend` — `Local | Modal | Mps`\n- `NodeConfig` — port (public API), validator_port (libp2p), validator_rpc_port\n  (Native), secret, peers, GPU/QPU, REST, telemetry, …\n- `AppSettings` — `{ node_config, active_tab, window_maximized, image_tag,\n  tls_enabled, hostname (alias dashboard_hostname), cert_email, zerossl_api_key,\n  run_mode, auto_update_enabled }`\n- `StackStatus` — `{ services: Vec<ServiceStatus>, overall: StackHealth }`\n- `ServiceStatus` — `{ name, service, running, health, status_text, image }`\n- `StackHealth` — `Running | Degraded | Unhealthy | Stopped`\n\n## Frontend IPC\n\nThe frontend uses `window.__TAURI__.core.invoke` (`withGlobalTauri: true`).\n\nEvents emitted by backend (complete set): `node-log`, `checklist-update`,\n`pull-progress`, `pull-complete`, `stop-started`, `stop-complete`,\n`dashboard-db-mismatch`, `image-update-available`, `binary-update-available`,\n`binary-download-progress`, `app-update-available`.\n\n- `node-log` → `{ timestamp, level, message }`\n- `checklist-update` → `CheckItem { id, state, label, detail, required,\n  fixable, updated_at_ms }`\n- `pull-progress` → `{ line }` (one `docker compose pull` output line) or a\n  `--progress json` layer event forwarded verbatim\n- `pull-complete` → `{ gen, success, error }` (emitted when the pull process\n  exits — the authoritative \"pull is over\" signal)\n- `stop-started`, `stop-complete` — stop lifecycle\n- `dashboard-db-mismatch` → `{ message }` (Postgres volume password mismatch)\n- `image-update-available` → `{ image, info }` (emitted per image whose digest\n  changed, gated on `info.update_available`)\n- `binary-update-available` → native-binary UpdateInfo\n- `binary-download-progress` → `BinaryDownloadProgress` (native binary download %)\n- `app-update-available` → node-manager UpdateInfo\n\nKey Tauri commands (lib.rs `invoke_handler`):\n- `start_stack` / `stop_stack` / `get_stack_status` / `get_stack_config`\n- `pull_compose_images`\n- `check_docker_installed` / `check_docker_hello_world` /\n  `check_docker_compose_installed`\n- `start_native_node` / `stop_native_node` / `get_native_node_status`\n- `check_image_update(image_tag)` — node image digest\n- `check_dashboard_image_update()` — dashboard image digest\n- settings: `get_settings` / `update_settings` / `is_first_boot` /\n  `get_default_data_dir` / `get_data_dir` / `set_data_dir` / `restart_app`\n- `get_node_secret` / `generate_node_secret` / `generate_config_toml`\n- hardware: `detect_gpu_backend` / `list_gpu_devices` / `run_hardware_survey`\n- native: `check_native_binary` / `download_native_binary` /\n  `check_binary_update` / `start_native_log_tail`\n- `detect_public_ip` / `get_checklist` / `recheck`\n- updates: `get_app_version` / `get_node_version` / `check_app_update`\n- log streaming: `start_log_stream` / `stop_log_stream`\n\n## Commands\n\n```bash\n# One-time after clone: pull the compose submodule\ngit submodule update --init --recursive\n\n# Development\nbun run dev\n\n# Production build\nbun run build\n\n# Install dependencies\nbun install\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-protocol/docs/VERSIONING.md`. This repo follows the same\ncross-repo standard so `update.rs::parse_semver` orders release candidates\ncorrectly — it splits the pre-release on `-`, so a no-hyphen `v0.2.1rc18` loses\n*both* the patch and the rc number and collapses every rc to one value, which\nfreezes deployed nodes on an old rc.\n\n| Artifact | Format | Example |\n|----------|--------|---------|\n| Git release tag (pre-release) | hyphenated SemVer `vMAJOR.MINOR.PATCH-rcN` | `v0.2.1-rc18` |\n| Git release tag (stable) | `vMAJOR.MINOR.PATCH` | `v0.2.1` |\n| Package version (`package.json`, `Cargo.toml`, `tauri.conf.json`) | toolchain-native (npm/Cargo SemVer; PEP 440 elsewhere) | `0.2.1-rc2` |\n\nRules:\n- Pre-release git tags MUST be hyphenated (`-rcN` / `-alphaN` / `-betaN`); never\n  the PEP 440 no-hyphen form for a git tag.\n- Numeric parts (MAJOR.MINOR.PATCH and the rc number) MUST match between the git\n  tag and the package version; only the separator may differ.\n- CI: pre-release tags publish `:<tag>` + the rolling `:vMAJOR.MINOR` and MUST\n  NOT move `:latest`; only `main` / a stable `vX.Y.Z` tag moves `:latest`. The\n  `:latest` rule binds on image-publishing repos (quip-protocol); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Code Standards\n\n- All Rust files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- All JS files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- Tauri commands return `Result<T, String>` (the common case; a few infallible\n  commands return bare values, e.g. `is_first_boot -> bool`,\n  `get_node_version -> Option<String>`, `restart_app -> ()`)\n- No relative imports (`..`) in Rust — use `crate::module::Type`\n- Line length ≤ 100 chars\n\n## License\n\nAGPL-3.0-or-later. All new source files require the standard license header.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","title":"AGENTS.md","content":"# AGENTS.md\n\nInstructions for AI coding agents (Claude Code, Codex, Cursor, etc.).\n\n## Project Overview\n\nQuip Node Desktop Manager — a Tauri v2 desktop app that orchestrates and monitors the Quip node\nstack (miner + validator + dashboard + postgres + caddy). Runs the stack via Docker\nCompose, or in Native mode (default on macOS) where the miner binary runs on the host and the\nsupport services run in Docker. The same binary also exposes a headless TUI (`--cli`, or when no\ndisplay is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS frontend.\n\n## Architecture\n\n```\nquip-node-manager/\n├── src/                           # Frontend (vanilla HTML/CSS/JS)\n│   ├── index.html\n│   ├── styles.css\n│   └── app.js\n├── vendor/\n│   └── nodes.quip.network/        # git submodule — upstream compose stack\n│                                  # (docker-compose.yml, caddy/Caddyfile,\n│                                  # chain-specs/quip-testnet.json). Embedded into\n│                                  # the binary via include_str! in stack_assets.rs\n│                                  # at compile time (NOT Tauri's bundle.resources),\n│                                  # then staged + patched into ~/quip-data on\n│                                  # every Start. See \"Stack Asset Patching\".\n└── src-tauri/                     # Rust backend (Tauri v2)\n    ├── Cargo.toml\n    ├── tauri.conf.json            # no bundle.resources — resources are\n    │                              # compile-time embedded\n    ├── capabilities/\n    │   └── default.json\n    └── src/\n        ├── main.rs                # Entry point; GUI by default, TUI when\n        │                          # --cli passed or no display (headless/SSH)\n        ├── lib.rs                 # Tauri builder, command registration,\n        │                          # tray icon, background update monitor\n        ├── settings.rs            # AppSettings, NodeConfig, ImageTag (Cpu|Cuda),\n        │                          # StackStatus/StackHealth, DwaveConfig\n        ├── secret.rs              # Node secret (64-char hex)\n        ├── config.rs              # config.toml generation\n        ├── cmd.rs                 # Command wrapper: PATH augmentation (login-shell\n        │                          # $PATH + known tool dirs) + Windows no-console-flash\n        ├── compose.rs             # docker compose orchestration: miner +\n        │                          # validator + dashboard + postgres + caddy\n        ├── stack_assets.rs        # include_str! the compose.yml + Caddyfile + chain\n        │                          # spec; patch ports + Native upstream at stage time\n        ├── log_stream.rs          # docker compose logs -f → Tauri events\n        ├── native.rs              # native binary download + lifecycle\n        ├── hardware.rs            # GPU/Docker/Python detection\n        ├── network.rs             # Public IP detection only\n        ├── update.rs              # Multi-image + app update monitor\n        ├── migration_v2.rs        # v0.1 → v0.2 config/.env migration; backs up\n        │                          # old files and promotes hand-edited host/port.\n        │                          # REMOVE in v0.3 (drop v0.1 → v0.2 upgrades)\n        ├── hostnames.rs           # public_host parsing → Caddy hostname +\n        │                          # validator libp2p --public-addr multiaddr\n        ├── checklist.rs           # Pre-flight checks → checklist-update events;\n        │                          # also owns the port-reachability probe\n        ├── tui_app.rs             # Headless TUI app state + run loop (ratatui)\n        ├── tui_input.rs           # TUI terminal event → Action handling\n        └── tui_ui.rs              # TUI ratatui frame rendering\n```\n\n## Key Details\n\n- **Tauri version**: v2\n- **JS tooling**: Bun\n- **App version**: 0.2.3-rc2\n- **Window size**: 900×700\n- **Data directory**: `~/quip-data/` by default (bind-mount root for the compose\n  stack). Overridable via `set_data_dir` → the `data_dir` key in\n  `~/.config/quip-node-manager/bootstrap.json`; `~/quip-data` is only the\n  fallback when unset.\n- **Compose project name**: `quip` (→ `docker compose --project-name quip …`)\n- **Compose command**: always via the `docker compose` (v2) CLI; not\n  `docker-compose` (v1), not the Python bindings.\n- **Container names** (from compose `container_name`): `quip-cpu` or\n  `quip-cuda` (miner, chosen by GPU presence), `quip-validator` (Substrate\n  block-producing validator), `quip-dashboard`, `quip-postgres`, `quip-caddy`. The\n  dashboard/Caddy reach the miner via the compose network alias `quip-miner`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the testnet faucet and registers its keystore in\n  `QuantumPow.Miners`, so there is no separate one-shot bootstrap container.\n  D-Wave QPU mining activates on top of\n  the CPU image via `config.toml [dwave]` (no separate qpu service). The\n  upstream compose also defines an optional `quip-faucet` service behind a\n  `faucet` profile, which the manager never starts.\n- **Ports** (published by the Caddy + validator services):\n  - `<settings.node_config.port>:20049/tcp` — Caddy public API port: dashboard\n    SPA, miner `/api/v1/*` REST, and Substrate `/rpc` WebSocket. Container-internal\n    port is always 20049; the host side is rewritten at stage time to the user's\n    configured port (default 20049). See \"Port Handling\".\n  - `80/tcp + 443/tcp` — Caddy ACME/TLS (always published; TLS only provisioned\n    when `QUIP_HOSTNAME` is a real DNS name).\n  - `<settings.node_config.validator_port>:30333/tcp + /udp` — validator libp2p\n    peering (host default 30333, container 30333 — a 1:1 mapping unless the user\n    overrides the host port). Must be reachable from the public internet for\n    chain peering.\n  - `127.0.0.1:<validator_rpc_port>:9944` (Native mode only) — validator raw\n    JSON-RPC published on host loopback (default 9944) so the host-side miner\n    connects via `ws://127.0.0.1:9944`.\n  - `<native_rest_port>/tcp` — native miner REST (default 20100, bound to\n    `127.0.0.1`); the dashboard container reaches it via\n    `host.docker.internal:<rest_port>`.\n\n## Docker Images\n\nImages are declared in `vendor/nodes.quip.network/docker-compose.yml` (with\n`${QUIP_*_TAG:-v0.2}` placeholders); the manager's authoritative image paths +\ntag live in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`, `COMPOSE_IMAGE_TAG = \"v0.3.0-rc7\"`), written into\n`.env` as `QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`:\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner:v0.3.0-rc7`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-miner/v0.3/quip-miner-cuda:v0.3.0-rc7`\n\n  The v0.3 images are a **separate repository line**, not new tags on the v0.2\n  paths, which stop at `v0.2.1-rc54`. The CPU image also dropped its `-cpu`\n  suffix when the coordinator absorbed the miner binaries, so the two names are\n  no longer symmetric. Pointing a v0.3 tag at a v0.2 path fails the pull with\n  `not found`.\n- Validator: `registry.gitlab.com/quip.network/quip-validator/quip-network-node:v0.2`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network:v0.2`\n- Postgres: `postgres:16` (Docker Hub)\n- Caddy: `caddy:2-alpine` (Docker Hub)\n\nSelected by `AppSettings`:\n- `image_tag: ImageTag` — `Cpu` | `Cuda`. D-Wave QPU mining is not a separate\n  image: it rides on the CPU image via the `[dwave]` section in `config.toml`.\n- `tls_enabled: bool` — controls whether Caddy provisions TLS (`:80`/`:443` are\n  always published by the caddy service).\n\nThe dashboard + postgres + caddy + validator services are always part\nof the `cpu`/`cuda` profile — there is no `dashboard_enabled` toggle.\n\n## Run Modes\n\n| run_mode | node | compose services run |\n|----------|------|----------------------|\n| `Docker` | `quip-{cpu,cuda}` miner container via compose | every profile service: miner + `quip-validator` + `dashboard` + `postgres` + `caddy` (empty positional list ⇒ compose starts the whole profile) |\n| `Native` (macOS only) | native miner binary on the host (`~/quip-data/bin/quip-miner-*`) | explicit list `quip-validator dashboard postgres caddy` — no miner container. The validator's JSON-RPC (9944) is published on `127.0.0.1:<validator_rpc_port>` so the host miner connects via `ws://127.0.0.1:<validator_rpc_port>`; the dashboard reaches the host miner's REST at `host.docker.internal:<rest_port>` |\n\n## Compose Profiles\n\n`image_tag → profile` (a single profile name, no TLS/dashboard variants):\n\n| profile | services started |\n|---------|------------------|\n| `cpu` | `cpu` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` + `postgres` + `caddy` |\n\n`compose_profile(image_tag)` returns the image's service name (`cpu` or `cuda`) —\nthere is no `qpu` profile (D-Wave mining rides on the CPU image via\n`config.toml [dwave]`), and no `-notls`/`-nodash` variants. Caddy is always in\nboth profiles. The vendored compose file also defines an opt-in `faucet` profile\n(`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard postgres caddy`) that omits the miner, so\n`--profile` gates eligibility while positional args restrict what actually\nstarts.\n\n## Data Files (all in `~/quip-data/`)\n\n| File | Generated / managed by | Purpose |\n|------|------------------------|---------|\n| `app-settings.json` | settings.rs (user preferences) | UI toggles + NodeConfig |\n| `config.toml` | config.rs on every Start | Node config (bind-mounted into the node container in Docker mode; read directly by the binary in Native mode) |\n| `.env` | compose.rs on every Start | Compose env: PUID, PGID, QUIP_HOSTNAME, CERT_EMAIL, ZEROSSL_API_KEY, DWAVE_API_KEY, POSTGRES_PASSWORD, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION; mode 0600 on Unix. (No QUIP_NODE_URL — removed in v0.2. No QUIP_VALIDATORS — the upstream compose made the miner fully config-driven, so validators live only in `config.toml`. QUIP_VALIDATOR_RPC_URLS is deliberately NOT written — it defers to the compose default `ws://quip-caddy:8088/rpc`, Caddy's internal front door, so the dashboard resolves both the chain RPC and the local miner REST from one host.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with Caddy host API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, and (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944` |\n| `caddy/Caddyfile` | stack_assets.rs (embedded copy + patch) | Caddy routes; the local faucet route is always stripped; in Native mode the `/api/v1/*` upstream is rewritten from `quip-miner:8086` to `host.docker.internal:<rest_port>` |\n| `chain-specs/quip-testnet.json` | stack_assets.rs (embedded copy) | Quip Testnet chain spec mounted into the validator container |\n| `keystore.json` | native.rs (Native mode) | Native miner signer keystore (generated via `quip-miner keygen`) |\n| `data/` | bind-mount target for the miner's `/data` (Docker) and host config.toml path (Native) | miner runtime `config.toml`, `keystore.json`; the validator's state lives under `data/validator-data/` (mounted as the validator container's `/data`) |\n| `dashboard-data/` | bind-mount target for the dashboard | Dashboard auxiliary state |\n| `node-secret.json` | secret.rs | `{ \"secret\": \"<64-hex>\" }` — read by secret.rs and gates the `secret` pre-flight check, but NOT written into config.toml in v0.2. The node's actual signing identity is `keystore.json` (Docker `/data/keystore.json`, Native `keystore.json`). |\n| `bin/quip-miner-*` | native.rs | Downloaded native miner binary (`quip-miner-macos-arm64` / `-x86_64`). Legacy pre-v0.2 `quip-network-node-*` binaries here are auto-deleted on launch. |\n\nProject-scoped Docker volumes (survive `docker compose down` by design):\n`quip_pgdata`, `quip_caddy-data`, `quip_caddy-config`. The upstream compose\npins fixed global `name:`s (`quip-pgdata`, …); the manager strips them at\nstage time (`stack_assets::strip_volume_names`) so they don't collide with\nother Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds a `data_dir` override plus a per-install `postgres_password`\n(generated once on first access, never rotated — it's keyed to the stored\nPostgres volume hash).\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` and `caddy/Caddyfile` are\n**embedded into the binary at compile time** via `include_str!`. This\navoids Tauri's runtime resource resolution entirely — on Windows, the CI\nships a raw `.exe` (`tauri build --no-bundle`) with no sibling resource\nfolder, and a `BaseDirectory::Resource` lookup would fail. Embedding\nmakes the staged files travel as `&'static str` in `.rodata`.\n\n`stack_assets::sync_stack_assets(run_mode, public_api_port, validator_port,\npublic_host, native_rest_port, validator_rpc_port)` is called from both\n`start_stack` and `pull_compose_images` before any `docker compose` invocation.\nIt stages the embedded compose.yml, Caddyfile, and chain spec\n(`chain-specs/quip-testnet.json`), always overwriting — no merge. Patches:\n\n1. **compose.yml port remap** (always): Caddy's host-published `\"20049:20049\"`\n   is rewritten to `\"<public_api_port>:20049\"`, and the validator's\n   `\"30333:30333/tcp\"` and `\"30333:30333/udp\"` mappings to\n   `\"<validator_port>:30333/<proto>\"`. Container-internal ports (20049, 30333)\n   stay fixed; only host sides move. No-op when the configured ports equal the\n   upstream defaults.\n\n2. **compose.yml `--public-addr`** (when `public_host` is set): a\n   `--public-addr=<multiaddr>` arg (built from `public_host` + `validator_port`,\n   e.g. `/dns4/host/tcp/30333` or `/ip4/.../tcp/30333`) is inserted into the\n   validator command after `--validator`.\n\n3. **compose.yml validator RPC publish** (Native mode only): a\n   `\"127.0.0.1:<validator_rpc_port>:9944\"` mapping is inserted into the\n   validator's ports list so the host-side miner reaches the raw JSON-RPC\n   directly. Docker mode does not publish it.\n\n4. **Caddyfile faucet strip** (always): the optional local faucet route block is\n   removed (the manager relies on the public testnet faucet).\n\n5. **Caddyfile upstream rewrite** (Native mode only): `quip-miner:8086` becomes\n   `host.docker.internal:<native_rest_port>` so the dashboard container reaches\n   the host miner. Docker mode keeps `quip-miner:8086`.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the Caddyfile upstream rewrite and the validator `--public-addr`\narg both require rewriting a YAML/Caddyfile token, not just supplying an env\nvar, so all the port/host remaps live in one patch pass for consistency.\n\n## Port Handling\n\nEvery container-internal port is fixed; only the host side is remapped at stage\ntime (in `stack_assets`). v0.2 has three independently host-mappable\nvalidator/API ports:\n\n| setting (`NodeConfig`) | container port | host default | published as |\n|------------------------|----------------|--------------|--------------|\n| `port` | Caddy 20049 (public API) | 20049 | `<port>:20049` |\n| `validator_port` | validator libp2p 30333 | 30333 | `<validator_port>:30333/tcp+udp` |\n| `validator_rpc_port` | validator JSON-RPC 9944 | 9944 | Native only: `127.0.0.1:<validator_rpc_port>:9944` |\n\nFor the miner's own `config.toml`: `config.rs` always emits `public_port` in both\nmodes. It takes `config.public_port` when the user sets an override, and falls\nback to `port` (the Caddy front door) otherwise, because that is the port an\noutside peer actually reaches. There is no separate top-level `port` key in the\nminer config — that is the v0.1 schema, and a test asserts it stays gone. The\nThe miner's REST surface is a `[dashboard]` section, not the v0.2\n`[miner].rest_host` / `rest_port` pair. The v0.3 coordinator ignores those two\nkeys outright, and it disables the dashboard unless **both** `listen` and\n`data_dir` are set, so neither may be omitted. Docker renders\n`listen = \"0.0.0.0:8086\"` to match the Caddyfile's `quip-miner:8086` upstream,\nwith `data_dir = \"/data/attempts\"` inside the volume. Native renders\n`listen = \"127.0.0.1:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. Native stays on loopback because Docker\nDesktop's `host.docker.internal` originates the connection on the host, so the\nCaddy container still reaches it.\n\n### `public_host` resolution and the start gate\n\nBoth start paths (`compose::start_stack_core` and `native::start_native_node_core`)\nfill an unset `public_host` before they write `config.toml`. The value comes from\n`checklist::fetch_public_ip` (check.quip.network first, ipify as a fallback), which\nis the same fetcher behind the `ip` checklist row, so the row and the advertised\naddress cannot disagree. The resolution is per start and is never persisted to\n`app-settings.json`.\n\n`checklist::require_public_host` then hard-aborts the start when the resolved value\nis one no outside peer can reach: loopback, unspecified, RFC1918 private,\n169.254.0.0/16 link-local, 100.64.0.0/10 carrier-grade NAT, multicast,\n240.0.0.0/4 reserved, IPv6 `fc00::/7` unique-local, IPv6 `fe80::/10` link-local,\nand (for names) anything `hostnames::is_public_dns_host` rejects, including mDNS\n`.local`. IPv4-mapped IPv6 is unwrapped before the test. A local-network or\nair-gapped deployment that wants to advertise a private address cannot start, and\nno opt-in override exists yet.\n\nThere is no standalone `public-host` checklist row. The two port rows already probe\nhost and port together through `/checkport`, and they stay warn-only.\n\n## Pre-flight Port Reachability Check\n\n`run_check_port` (public API port) and `run_check_port_validator` (validator\nlibp2p port) in `checklist.rs` each answer: *is this port reachable from the\npublic internet?* Both call `probe_port_forwarding_with_ctx`, which runs **one\n`/checkport?port=N` TCP probe per recheck** against `check.quip.network`\n(`CHECK_SERVICE` in checklist.rs). The probe is the same for both ports; only\nthe local-socket branch differs:\n\n- **Port already bound locally** (`TcpListener::bind` fails): a service is\n  already holding the port. Probe it directly — a `HostResponded` result maps to\n  `Verified`.\n- **Port free locally**: bind a temporary TCP listener and hold it for the\n  duration of the probe (background accept loop, aborted on return), so the\n  external probe has something to accept into — `HostResponded` maps to\n  `ForwardReady`.\n\nThere is no `/checkconn`/QUIC endpoint; the manager never speaks QUIP itself.\nBoth states use `/checkport` over TCP. Users click Recheck after starting the\nnode to escalate `ForwardReady` → `Verified`.\n\n### Response Classification\n\nProbe responses are classified into `ProbeOutcome` with these rules:\n\n| Service response (`/checkport`) | `ProbeOutcome` | Rationale |\n|---------------------------------|----------------|-----------|\n| HTTP 200, `reachable:true` | `HostResponded` | TCP connect succeeded — forward works and something is listening |\n| HTTP 200, `reachable:false` (any `error`: timeout, RST/\"connection refused\", ...) | `Unreachable` | the external TCP connect could not be established |\n| HTTP 429 | `RateLimited(retry_after_seconds)` | service rate-limited us |\n| HTTP 5xx / network error / malformed body | `ServiceError` | not the user's fault |\n\n`PortProbeResult` maps these to five user-facing states:\n\n- `Verified` (Pass) — port bound locally + `HostResponded`\n- `ForwardReady` (Pass) — port free locally + `HostResponded`\n- `Unreachable` (Warn) — `Unreachable`\n- `Unverified` (Warn) — `ServiceError`: check.quip.network was down/errored, so\n  we couldn't verify (no green check we didn't earn)\n- `RateLimited { retry_after_secs, endpoint }` (Warn) — service rate-limited\n  (HTTP 429), so we couldn't verify; the retry time is shown so the user can\n  recheck after the cool-down. Not a green check we didn't earn.\n\nA check only goes **green** when check.quip.network positively confirmed the\nport (`Verified`/`ForwardReady`); `is_externally_reachable()` is true for those\ntwo and nothing else.\n\n**Design rule:** *`/checkport` is a plain-TCP connect, so reachability is\nbinary.* Only `reachable:true` (a SYN-ACK proving the forward works and a\nlistener is up) passes. Every `reachable:false` — timeout, RST, or\n\"connection refused\" — fails the check, because in each case the prober\ncould not open a TCP connection to the port.\n\n### Probe Diagnostics\n\nEvery probe call emits a `[probe]` line to the `node-log` event with the\nfull request URL, HTTP status, and response body (truncated at 1 KB).\nUsers can copy/paste the raw output into support threads — the raw `error`\nstring is the ground truth for *why* a port was unreachable (it no longer\naffects classification, which is binary on `reachable`). The `AppHandle` is\nplumbed via `Option<AppHandle>` on `CheckCtx`,\nso non-Tauri callers (the TUI) probe silently.\n\n## Shared Types (defined in `settings.rs`)\n\n- `RunMode` — `Docker | Native` (Native is macOS-only)\n- `ImageTag` — `Cpu | Cuda` (serialised lowercase; a legacy `\"qpu\"` JSON string\n  is accepted as an alias for `Cpu` via `deserialize_image_tag_compat`)\n- `GpuBackend` — `Local | Modal | Mps`\n- `NodeConfig` — port (public API), validator_port (libp2p), validator_rpc_port\n  (Native), secret, peers, GPU/QPU, REST, telemetry, …\n- `AppSettings` — `{ node_config, active_tab, window_maximized, image_tag,\n  tls_enabled, hostname (alias dashboard_hostname), cert_email, zerossl_api_key,\n  run_mode, auto_update_enabled }`\n- `StackStatus` — `{ services: Vec<ServiceStatus>, overall: StackHealth }`\n- `ServiceStatus` — `{ name, service, running, health, status_text, image }`\n- `StackHealth` — `Running | Degraded | Unhealthy | Stopped`\n\n## Frontend IPC\n\nThe frontend uses `window.__TAURI__.core.invoke` (`withGlobalTauri: true`).\n\nEvents emitted by backend (complete set): `node-log`, `checklist-update`,\n`pull-progress`, `pull-complete`, `stop-started`, `stop-complete`,\n`dashboard-db-mismatch`, `image-update-available`, `binary-update-available`,\n`binary-download-progress`, `app-update-available`.\n\n- `node-log` → `{ timestamp, level, message }`\n- `checklist-update` → `CheckItem { id, state, label, detail, required,\n  fixable, updated_at_ms }`\n- `pull-progress` → `{ line }` (one `docker compose pull` output line) or a\n  `--progress json` layer event forwarded verbatim\n- `pull-complete` → `{ gen, success, error }` (emitted when the pull process\n  exits — the authoritative \"pull is over\" signal)\n- `stop-started`, `stop-complete` — stop lifecycle\n- `dashboard-db-mismatch` → `{ message }` (Postgres volume password mismatch)\n- `image-update-available` → `{ image, info }` (emitted per image whose digest\n  changed, gated on `info.update_available`)\n- `binary-update-available` → native-binary UpdateInfo\n- `binary-download-progress` → `BinaryDownloadProgress` (native binary download %)\n- `app-update-available` → node-manager UpdateInfo\n\nKey Tauri commands (lib.rs `invoke_handler`):\n- `start_stack` / `stop_stack` / `get_stack_status` / `get_stack_config`\n- `pull_compose_images`\n- `check_docker_installed` / `check_docker_hello_world` /\n  `check_docker_compose_installed`\n- `start_native_node` / `stop_native_node` / `get_native_node_status`\n- `check_image_update(image_tag)` — node image digest\n- `check_dashboard_image_update()` — dashboard image digest\n- settings: `get_settings` / `update_settings` / `is_first_boot` /\n  `get_default_data_dir` / `get_data_dir` / `set_data_dir` / `restart_app`\n- `get_node_secret` / `generate_node_secret` / `generate_config_toml`\n- hardware: `detect_gpu_backend` / `list_gpu_devices` / `run_hardware_survey`\n- native: `check_native_binary` / `download_native_binary` /\n  `check_binary_update` / `start_native_log_tail`\n- `detect_public_ip` / `get_checklist` / `recheck`\n- updates: `get_app_version` / `get_node_version` / `check_app_update`\n- log streaming: `start_log_stream` / `stop_log_stream`\n\n## Commands\n\n```bash\n# One-time after clone: pull the compose submodule\ngit submodule update --init --recursive\n\n# Development\nbun run dev\n\n# Production build\nbun run build\n\n# Install dependencies\nbun install\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-protocol/docs/VERSIONING.md`. This repo follows the same\ncross-repo standard so `update.rs::parse_semver` orders release candidates\ncorrectly — it splits the pre-release on `-`, so a no-hyphen `v0.2.1rc18` loses\n*both* the patch and the rc number and collapses every rc to one value, which\nfreezes deployed nodes on an old rc.\n\n| Artifact | Format | Example |\n|----------|--------|---------|\n| Git release tag (pre-release) | hyphenated SemVer `vMAJOR.MINOR.PATCH-rcN` | `v0.2.1-rc18` |\n| Git release tag (stable) | `vMAJOR.MINOR.PATCH` | `v0.2.1` |\n| Package version (`package.json`, `Cargo.toml`, `tauri.conf.json`) | toolchain-native (npm/Cargo SemVer; PEP 440 elsewhere) | `0.2.1-rc2` |\n\nRules:\n- Pre-release git tags MUST be hyphenated (`-rcN` / `-alphaN` / `-betaN`); never\n  the PEP 440 no-hyphen form for a git tag.\n- Numeric parts (MAJOR.MINOR.PATCH and the rc number) MUST match between the git\n  tag and the package version; only the separator may differ.\n- CI: pre-release tags publish `:<tag>` + the rolling `:vMAJOR.MINOR` and MUST\n  NOT move `:latest`; only `main` / a stable `vX.Y.Z` tag moves `:latest`. The\n  `:latest` rule binds on image-publishing repos (quip-protocol); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Code Standards\n\n- All Rust files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- All JS files: `// SPDX-License-Identifier: AGPL-3.0-or-later` header\n- Tauri commands return `Result<T, String>` (the common case; a few infallible\n  commands return bare values, e.g. `is_first_boot -> bool`,\n  `get_node_version -> Option<String>`, `restart_app -> ()`)\n- No relative imports (`..`) in Rust — use `crate::module::Type`\n- Line length ≤ 100 chars\n\n## License\n\nAGPL-3.0-or-later. All new source files require the standard license header.\n","category":"root","tokens":6588}]}