{"owner":"QuipNetwork","repo":"quip-node-manager","hasSkills":true,"totalSkillsCount":1,"totalTokensCount":7814,"categories":["root-instruction"],"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). The dashboard image runs Caddy, the log collector, and its\nown index. Runs the stack via Docker Compose, or in Native mode (default on macOS) where the miner\nbinary runs on the host and the dashboard runs in Docker. The same binary also exposes a headless\nTUI (`--cli`, or when no display is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS\nfrontend.\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,\n│                                  # chain-specs/aglais-network.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        ├── service_ports.rs       # Shared host port catalog and validation\n        ├── container_http.rs      # HTTP probes inside the Compose network\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\n        ├── stack_assets.rs        # include_str! the compose.yml + chain spec;\n        │                          # 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.3.2-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), and `quip-dashboard`. In Docker mode, the\n  dashboard reaches the miner via the compose network alias `quip-miner:8086`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the faucet for the selected channel and registers\n  its keystore in `QuantumPow.Miners`, so there is no separate one-shot\n  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**: advanced settings in the GUI and TUI group host publications by\n  service. See \"Port Handling\" for the complete list. Container addresses stay\n  fixed regardless of host publishing settings.\n- **Native listeners**: validator RPC defaults to `127.0.0.1:9944`. Native mode\n  requires this host mapping, with public access optional. The native miner REST\n  listener defaults to `0.0.0.0:20100` so the dashboard container reaches it through\n  `host.docker.internal`.\n\n## Docker Images\n\n`vendor/nodes.quip.network/docker-compose.yml` declares the images, with\n`${QUIP_*_TAG:-…}` placeholders. The manager's authoritative image **paths**\nlive in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`).\n\nThe binary carries no default tag. Every start resolves a tag per image from\nthat image's own GitLab container registry for the selected update channel\n(`resolve_channel_image_tags`) and writes the three results to `.env` as\n`QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`. The repositories\nadvance on their own cadence, so the three tags often differ.\n\nWhen a registry does not answer, the manager holds the tag `.env` already pins,\nwhich keeps the stack on the version it runs instead of moving it backwards.\nWhen `.env` pins nothing either, it takes the newest tag of that image already\nin the local Docker cache. Only when all three are empty — a first start with\nno network — does the start fail with a message. A repository with no stable\ntag puts its newest beta in the Release slot, so Release never falls back to a\npin that the repository does not carry.\n\nWhen `docker compose pull` fails but every image the stack needs is already\nlocal (`config --images` + `docker image inspect`), the start logs a warning\nand runs `up --pull never`. The start needs that flag because the compose file\nsets `pull_policy: always`, which would otherwise make `up` pull again and fail.\n\nA compiled-in default tag cannot fill that gap: it ages\ninto a tag the registry no longer carries, which converts a passing network\nfault into a permanent `pull` failure. Falling through to the compose file's own\n`:-latest` default is also forbidden, because the update monitor compares\ndigests tag by tag, and `latest` moves under it.\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver-cuda`\n\n  Upstream renamed the project from quip-miner to quip-solver at\n  `v0.3.5-rc12`. The old `quip-miner/v0.3/*` registry paths no longer exist.\n  A test (`embedded_compose_file_uses_the_resolved_image_paths`) fails when\n  these constants and the vendored compose file disagree.\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`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network`\n\nThe manager holds the dashboard image to a floor of `MIN_DASHBOARD_TAG =\nv0.2.3-rc2` (`stack_assets.rs`). `v0.2.3-rc1` is the first tag with Caddy\nembedded in the image, and `v0.2.3-rc2` is the first tag with the `/files`\nallowlist the upstream changelog requires.\n\nThe validator has a floor too, `MIN_VALIDATOR_TAG = v0.4.0`. The embedded chain\nspec is the v0.4.0 export for the spec-119 Aglais relaunch. Move the floor, the\nspec, and the two per-genesis directories (`VALIDATOR_DB_DIR`,\n`DASHBOARD_DATA_DIR`) together at the next relaunch.\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 the dashboard's embedded Caddy\n  provisions TLS (`:80`/`:443` are published by default and configurable in\n  advanced settings).\n\nThe dashboard and validator services are always part of the `cpu`/`cuda`\nprofile — 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` (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` — 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` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` |\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. The dashboard is\nalways in both profiles. The vendored compose file also defines an opt-in\n`faucet` profile (`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard`) that omits the miner, so `--profile` gates\neligibility while positional args restrict what actually starts.\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_TOKEN, DWAVE_API_SOLVER, DWAVE_API_REGION, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION, QUIP_LOG_PORT (both modes), and (Native only) QUIP_MINER_REST_URL and QUIP_MINER_UPSTREAM. The file has 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-validator:9944`, which is correct in both modes because the validator is a container in both.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with the dashboard's public API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944`, and the `QUIP_MINER_UPSTREAM` patch that adds the dashboard's embedded Caddy upstream, which upstream compose does not pass |\n| `chain-specs/aglais-network.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/aglais-119-chain-db/` (`stack_assets::VALIDATOR_DB_DIR`, mounted as the validator container's `/data`) |\n| `dashboard-data-119/` | bind-mount target for the dashboard | the dashboard index (`dashboard.db`) and collector 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_caddy-data`, `quip_caddy-config`. The dashboard container mounts them.\nThe upstream compose pins fixed global `name:`s (`quip-caddy-data`, …). The\nmanager strips them at stage time (`stack_assets::strip_volume_names`) so they\ndo not collide with other Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds only a `data_dir` override.\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` is **embedded into the binary\nat compile time** via `include_str!`. This avoids Tauri's runtime resource\nresolution entirely — on Windows, the CI ships a raw `.exe`\n(`tauri build --no-bundle`) with no sibling resource folder, and a\n`BaseDirectory::Resource` lookup would fail. Embedding makes the staged file\ntravel as a `&'static str` in `.rodata`.\n\n`start_stack` and `pull_compose_images` call\n`stack_assets::sync_stack_assets(run_mode, config)` before invoking Docker Compose.\nIt stages the embedded compose.yml and chain spec\n(`chain-specs/aglais-network.json`), always overwriting — no merge. Patches:\n\n1. **Compose host publications**: rebuild each managed service's `ports` list\n   from `service_ports::PORT_SPECS` and the saved enable switches and host ports.\n   Internal ports stay fixed. Public mappings omit the host address so Docker\n   can publish on both IPv4 and IPv6. Omit mappings with their switch off.\n\n2. **Validator public address**: when the user sets `public_host` and turns on\n   P2P publishing, insert `--public-addr=<multiaddr>` using `validator_port`.\n\n3. **Native validator RPC**: always publish\n   `127.0.0.1:<validator_rpc_port>:9944/tcp`. The public-access switch removes\n   the loopback restriction. Publish Docker RPC only when the user turns it on.\n\n4. **Dashboard miner upstream**: add `QUIP_MINER_UPSTREAM` to the dashboard\n   environment. The dashboard image's embedded Caddy reads it, but upstream\n   compose passes only `QUIP_MINER_REST_URL` (the backend's own upstream), so\n   the patch fills the gap.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the validator `--public-addr` arg requires rewriting a YAML\ntoken, not just supplying an env var, so all the port/host remaps live in one\npatch pass for consistency.\n\n## Port Handling\n\nContainer ports stay fixed. Each row below has an enable switch and host-port\nfield in advanced settings. The shared catalog is `service_ports::PORT_SPECS`.\n\n| Service | Ports and purpose | Public by default |\n|---------|-------------------|-------------------|\n| `quip-validator` | 30333 TCP/UDP P2P, 9944 TCP RPC, and 9615 TCP metrics | P2P only |\n| `quip-miner` | 8086 TCP REST for CPU or CUDA | No |\n| `quip-dashboard` | 20049 TCP API, 80 TCP HTTP, and 443 TCP HTTPS | Yes |\n| `quip-dashboard` | 8088 TCP internal HTTP | No |\n| `quip-dashboard` | 5514 UDP log collector | Always on, 127.0.0.1 only |\n\nHost ports default to the container ports. The log collector row is always\npublished on loopback, and only its host port can change. The manager writes\nthat port to `QUIP_LOG_PORT`, which the Docker logging driver reads.\n\nHTTP/3 has no row. QUIC has no plaintext mode, so it exists only on a site\nthat serves TLS, and it must answer on that site's own port because that is\nwhat `Alt-Svc` advertises. With `tls_enabled` set, `published_protocols` adds\na UDP leg to the Public API and HTTPS rows on their existing host ports.\n`service_ports::validate` and the compose mappings both read that one\nfunction, so they cannot disagree about which UDP ports are published.\n\nNative mode requires RPC, which defaults to 9944 and remains local unless the\nuser selects public access. The dashboard also needs native miner REST.\nIts separate host listener defaults to 20100. Switching modes preserves the\nDocker miner's optional host mapping. The manager probes Docker services from\ninside the validator container. Health reporting works with public ports off.\nPublic API and P2P checks show a warning when their switches are off, without\nrunning an external probe.\n\nThe existing `port`, `validator_port`, and `validator_rpc_port` fields hold\nhost port numbers. Their switches are `public_api_enabled`,\n`validator_p2p_enabled`, and `validator_rpc_enabled`. Other bindings live in\n`NodeConfig.service_ports`. Saves reject zero, ports greater than 65535, and active\nbindings that conflict on the same transport protocol.\n\nThe generated miner config and the staged first-start template both use only\n`ws://quip-validator:9944` in Docker mode. Native uses\n`ws://127.0.0.1:<validator_rpc_port>`. Public host mappings never change these\ninternal Docker URLs.\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 = \"0.0.0.0:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. This bind lets the dashboard container's\nCaddy reach the listener through the Docker host gateway.\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  (RPC), service_ports, publishing switches, 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\nThe Rust service attaches container logs before stack startup. The follower waits for\nstaged files and retries after Docker exits. Each log session owns its stop flag\nand child process. Native startup replaces the container session with a combined\nfile and container session. Stop cancels the current session and its retries.\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`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- `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# Lint the install scripts the way CI does (all three targets use Docker)\nmake lint-shell\nmake lint-powershell\nmake test-powershell\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-miner/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-miner); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Signed Release Tags\n\nEvery `v*` tag must carry an SSH signature from a key in\n`.gitlab/allowed-signers`. The `verify-tag-signature` job checks that\nsignature in the `lint` stage of a tag pipeline. It fails when the tag is\nlightweight, when the tag is unsigned, and when the key is absent from the\nfile. The `build`, `sign` and `release` stages run after `lint`, so a failure\nstops both the Windows signing and the GitLab Release.\n\nThe job also reports the signature state of the last 20 commits. An unsigned\ncommit does not fail the job, because GitLab writes its own merge commits\nwithout a signature.\n\nSet up signing once per clone:\n\n```bash\ngit config gpg.format ssh\ngit config user.signingkey \"key::$(cat ~/.ssh/<your-key>.pub)\"\ngit config tag.gpgsign true\ngit config commit.gpgsign true\ngit config gpg.ssh.allowedSignersFile .gitlab/allowed-signers\n```\n\n`git tag -a v0.2.9-rc2 -m \"0.2.9-rc2\"` then signs the tag. Check it before you\npush with `git tag -v v0.2.9-rc2`.\n\nTwo values must agree, or verification fails:\n\n| Value | Must match |\n|-------|------------|\n| The tagger email | A principal in `.gitlab/allowed-signers` |\n| The signing key | The key listed for that principal |\n\nTo add a signer, add a line to `.gitlab/allowed-signers` and merge it. The file\nholds public keys only. Add the same key to your GitLab profile as a signing\nkey so the web interface marks the tag as verified.\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). The dashboard image runs Caddy, the log collector, and its\nown index. Runs the stack via Docker Compose, or in Native mode (default on macOS) where the miner\nbinary runs on the host and the dashboard runs in Docker. The same binary also exposes a headless\nTUI (`--cli`, or when no display is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS\nfrontend.\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,\n│                                  # chain-specs/aglais-network.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        ├── service_ports.rs       # Shared host port catalog and validation\n        ├── container_http.rs      # HTTP probes inside the Compose network\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\n        ├── stack_assets.rs        # include_str! the compose.yml + chain spec;\n        │                          # 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.3.2-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), and `quip-dashboard`. In Docker mode, the\n  dashboard reaches the miner via the compose network alias `quip-miner:8086`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the faucet for the selected channel and registers\n  its keystore in `QuantumPow.Miners`, so there is no separate one-shot\n  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**: advanced settings in the GUI and TUI group host publications by\n  service. See \"Port Handling\" for the complete list. Container addresses stay\n  fixed regardless of host publishing settings.\n- **Native listeners**: validator RPC defaults to `127.0.0.1:9944`. Native mode\n  requires this host mapping, with public access optional. The native miner REST\n  listener defaults to `0.0.0.0:20100` so the dashboard container reaches it through\n  `host.docker.internal`.\n\n## Docker Images\n\n`vendor/nodes.quip.network/docker-compose.yml` declares the images, with\n`${QUIP_*_TAG:-…}` placeholders. The manager's authoritative image **paths**\nlive in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`).\n\nThe binary carries no default tag. Every start resolves a tag per image from\nthat image's own GitLab container registry for the selected update channel\n(`resolve_channel_image_tags`) and writes the three results to `.env` as\n`QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`. The repositories\nadvance on their own cadence, so the three tags often differ.\n\nWhen a registry does not answer, the manager holds the tag `.env` already pins,\nwhich keeps the stack on the version it runs instead of moving it backwards.\nWhen `.env` pins nothing either, it takes the newest tag of that image already\nin the local Docker cache. Only when all three are empty — a first start with\nno network — does the start fail with a message. A repository with no stable\ntag puts its newest beta in the Release slot, so Release never falls back to a\npin that the repository does not carry.\n\nWhen `docker compose pull` fails but every image the stack needs is already\nlocal (`config --images` + `docker image inspect`), the start logs a warning\nand runs `up --pull never`. The start needs that flag because the compose file\nsets `pull_policy: always`, which would otherwise make `up` pull again and fail.\n\nA compiled-in default tag cannot fill that gap: it ages\ninto a tag the registry no longer carries, which converts a passing network\nfault into a permanent `pull` failure. Falling through to the compose file's own\n`:-latest` default is also forbidden, because the update monitor compares\ndigests tag by tag, and `latest` moves under it.\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver-cuda`\n\n  Upstream renamed the project from quip-miner to quip-solver at\n  `v0.3.5-rc12`. The old `quip-miner/v0.3/*` registry paths no longer exist.\n  A test (`embedded_compose_file_uses_the_resolved_image_paths`) fails when\n  these constants and the vendored compose file disagree.\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`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network`\n\nThe manager holds the dashboard image to a floor of `MIN_DASHBOARD_TAG =\nv0.2.3-rc2` (`stack_assets.rs`). `v0.2.3-rc1` is the first tag with Caddy\nembedded in the image, and `v0.2.3-rc2` is the first tag with the `/files`\nallowlist the upstream changelog requires.\n\nThe validator has a floor too, `MIN_VALIDATOR_TAG = v0.4.0`. The embedded chain\nspec is the v0.4.0 export for the spec-119 Aglais relaunch. Move the floor, the\nspec, and the two per-genesis directories (`VALIDATOR_DB_DIR`,\n`DASHBOARD_DATA_DIR`) together at the next relaunch.\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 the dashboard's embedded Caddy\n  provisions TLS (`:80`/`:443` are published by default and configurable in\n  advanced settings).\n\nThe dashboard and validator services are always part of the `cpu`/`cuda`\nprofile — 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` (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` — 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` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` |\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. The dashboard is\nalways in both profiles. The vendored compose file also defines an opt-in\n`faucet` profile (`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard`) that omits the miner, so `--profile` gates\neligibility while positional args restrict what actually starts.\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_TOKEN, DWAVE_API_SOLVER, DWAVE_API_REGION, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION, QUIP_LOG_PORT (both modes), and (Native only) QUIP_MINER_REST_URL and QUIP_MINER_UPSTREAM. The file has 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-validator:9944`, which is correct in both modes because the validator is a container in both.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with the dashboard's public API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944`, and the `QUIP_MINER_UPSTREAM` patch that adds the dashboard's embedded Caddy upstream, which upstream compose does not pass |\n| `chain-specs/aglais-network.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/aglais-119-chain-db/` (`stack_assets::VALIDATOR_DB_DIR`, mounted as the validator container's `/data`) |\n| `dashboard-data-119/` | bind-mount target for the dashboard | the dashboard index (`dashboard.db`) and collector 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_caddy-data`, `quip_caddy-config`. The dashboard container mounts them.\nThe upstream compose pins fixed global `name:`s (`quip-caddy-data`, …). The\nmanager strips them at stage time (`stack_assets::strip_volume_names`) so they\ndo not collide with other Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds only a `data_dir` override.\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` is **embedded into the binary\nat compile time** via `include_str!`. This avoids Tauri's runtime resource\nresolution entirely — on Windows, the CI ships a raw `.exe`\n(`tauri build --no-bundle`) with no sibling resource folder, and a\n`BaseDirectory::Resource` lookup would fail. Embedding makes the staged file\ntravel as a `&'static str` in `.rodata`.\n\n`start_stack` and `pull_compose_images` call\n`stack_assets::sync_stack_assets(run_mode, config)` before invoking Docker Compose.\nIt stages the embedded compose.yml and chain spec\n(`chain-specs/aglais-network.json`), always overwriting — no merge. Patches:\n\n1. **Compose host publications**: rebuild each managed service's `ports` list\n   from `service_ports::PORT_SPECS` and the saved enable switches and host ports.\n   Internal ports stay fixed. Public mappings omit the host address so Docker\n   can publish on both IPv4 and IPv6. Omit mappings with their switch off.\n\n2. **Validator public address**: when the user sets `public_host` and turns on\n   P2P publishing, insert `--public-addr=<multiaddr>` using `validator_port`.\n\n3. **Native validator RPC**: always publish\n   `127.0.0.1:<validator_rpc_port>:9944/tcp`. The public-access switch removes\n   the loopback restriction. Publish Docker RPC only when the user turns it on.\n\n4. **Dashboard miner upstream**: add `QUIP_MINER_UPSTREAM` to the dashboard\n   environment. The dashboard image's embedded Caddy reads it, but upstream\n   compose passes only `QUIP_MINER_REST_URL` (the backend's own upstream), so\n   the patch fills the gap.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the validator `--public-addr` arg requires rewriting a YAML\ntoken, not just supplying an env var, so all the port/host remaps live in one\npatch pass for consistency.\n\n## Port Handling\n\nContainer ports stay fixed. Each row below has an enable switch and host-port\nfield in advanced settings. The shared catalog is `service_ports::PORT_SPECS`.\n\n| Service | Ports and purpose | Public by default |\n|---------|-------------------|-------------------|\n| `quip-validator` | 30333 TCP/UDP P2P, 9944 TCP RPC, and 9615 TCP metrics | P2P only |\n| `quip-miner` | 8086 TCP REST for CPU or CUDA | No |\n| `quip-dashboard` | 20049 TCP API, 80 TCP HTTP, and 443 TCP HTTPS | Yes |\n| `quip-dashboard` | 8088 TCP internal HTTP | No |\n| `quip-dashboard` | 5514 UDP log collector | Always on, 127.0.0.1 only |\n\nHost ports default to the container ports. The log collector row is always\npublished on loopback, and only its host port can change. The manager writes\nthat port to `QUIP_LOG_PORT`, which the Docker logging driver reads.\n\nHTTP/3 has no row. QUIC has no plaintext mode, so it exists only on a site\nthat serves TLS, and it must answer on that site's own port because that is\nwhat `Alt-Svc` advertises. With `tls_enabled` set, `published_protocols` adds\na UDP leg to the Public API and HTTPS rows on their existing host ports.\n`service_ports::validate` and the compose mappings both read that one\nfunction, so they cannot disagree about which UDP ports are published.\n\nNative mode requires RPC, which defaults to 9944 and remains local unless the\nuser selects public access. The dashboard also needs native miner REST.\nIts separate host listener defaults to 20100. Switching modes preserves the\nDocker miner's optional host mapping. The manager probes Docker services from\ninside the validator container. Health reporting works with public ports off.\nPublic API and P2P checks show a warning when their switches are off, without\nrunning an external probe.\n\nThe existing `port`, `validator_port`, and `validator_rpc_port` fields hold\nhost port numbers. Their switches are `public_api_enabled`,\n`validator_p2p_enabled`, and `validator_rpc_enabled`. Other bindings live in\n`NodeConfig.service_ports`. Saves reject zero, ports greater than 65535, and active\nbindings that conflict on the same transport protocol.\n\nThe generated miner config and the staged first-start template both use only\n`ws://quip-validator:9944` in Docker mode. Native uses\n`ws://127.0.0.1:<validator_rpc_port>`. Public host mappings never change these\ninternal Docker URLs.\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 = \"0.0.0.0:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. This bind lets the dashboard container's\nCaddy reach the listener through the Docker host gateway.\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  (RPC), service_ports, publishing switches, 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\nThe Rust service attaches container logs before stack startup. The follower waits for\nstaged files and retries after Docker exits. Each log session owns its stop flag\nand child process. Native startup replaces the container session with a combined\nfile and container session. Stop cancels the current session and its retries.\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`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- `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# Lint the install scripts the way CI does (all three targets use Docker)\nmake lint-shell\nmake lint-powershell\nmake test-powershell\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-miner/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-miner); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Signed Release Tags\n\nEvery `v*` tag must carry an SSH signature from a key in\n`.gitlab/allowed-signers`. The `verify-tag-signature` job checks that\nsignature in the `lint` stage of a tag pipeline. It fails when the tag is\nlightweight, when the tag is unsigned, and when the key is absent from the\nfile. The `build`, `sign` and `release` stages run after `lint`, so a failure\nstops both the Windows signing and the GitLab Release.\n\nThe job also reports the signature state of the last 20 commits. An unsigned\ncommit does not fail the job, because GitLab writes its own merge commits\nwithout a signature.\n\nSet up signing once per clone:\n\n```bash\ngit config gpg.format ssh\ngit config user.signingkey \"key::$(cat ~/.ssh/<your-key>.pub)\"\ngit config tag.gpgsign true\ngit config commit.gpgsign true\ngit config gpg.ssh.allowedSignersFile .gitlab/allowed-signers\n```\n\n`git tag -a v0.2.9-rc2 -m \"0.2.9-rc2\"` then signs the tag. Check it before you\npush with `git tag -v v0.2.9-rc2`.\n\nTwo values must agree, or verification fails:\n\n| Value | Must match |\n|-------|------------|\n| The tagger email | A principal in `.gitlab/allowed-signers` |\n| The signing key | The key listed for that principal |\n\nTo add a signer, add a line to `.gitlab/allowed-signers` and merge it. The file\nholds public keys only. Add the same key to your GitLab profile as a signing\nkey so the web interface marks the tag as verified.\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","rawUrl":"https://raw.githubusercontent.com/QuipNetwork/quip-node-manager/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","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). The dashboard image runs Caddy, the log collector, and its\nown index. Runs the stack via Docker Compose, or in Native mode (default on macOS) where the miner\nbinary runs on the host and the dashboard runs in Docker. The same binary also exposes a headless\nTUI (`--cli`, or when no display is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS\nfrontend.\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,\n│                                  # chain-specs/aglais-network.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        ├── service_ports.rs       # Shared host port catalog and validation\n        ├── container_http.rs      # HTTP probes inside the Compose network\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\n        ├── stack_assets.rs        # include_str! the compose.yml + chain spec;\n        │                          # 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.3.2-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), and `quip-dashboard`. In Docker mode, the\n  dashboard reaches the miner via the compose network alias `quip-miner:8086`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the faucet for the selected channel and registers\n  its keystore in `QuantumPow.Miners`, so there is no separate one-shot\n  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**: advanced settings in the GUI and TUI group host publications by\n  service. See \"Port Handling\" for the complete list. Container addresses stay\n  fixed regardless of host publishing settings.\n- **Native listeners**: validator RPC defaults to `127.0.0.1:9944`. Native mode\n  requires this host mapping, with public access optional. The native miner REST\n  listener defaults to `0.0.0.0:20100` so the dashboard container reaches it through\n  `host.docker.internal`.\n\n## Docker Images\n\n`vendor/nodes.quip.network/docker-compose.yml` declares the images, with\n`${QUIP_*_TAG:-…}` placeholders. The manager's authoritative image **paths**\nlive in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`).\n\nThe binary carries no default tag. Every start resolves a tag per image from\nthat image's own GitLab container registry for the selected update channel\n(`resolve_channel_image_tags`) and writes the three results to `.env` as\n`QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`. The repositories\nadvance on their own cadence, so the three tags often differ.\n\nWhen a registry does not answer, the manager holds the tag `.env` already pins,\nwhich keeps the stack on the version it runs instead of moving it backwards.\nWhen `.env` pins nothing either, it takes the newest tag of that image already\nin the local Docker cache. Only when all three are empty — a first start with\nno network — does the start fail with a message. A repository with no stable\ntag puts its newest beta in the Release slot, so Release never falls back to a\npin that the repository does not carry.\n\nWhen `docker compose pull` fails but every image the stack needs is already\nlocal (`config --images` + `docker image inspect`), the start logs a warning\nand runs `up --pull never`. The start needs that flag because the compose file\nsets `pull_policy: always`, which would otherwise make `up` pull again and fail.\n\nA compiled-in default tag cannot fill that gap: it ages\ninto a tag the registry no longer carries, which converts a passing network\nfault into a permanent `pull` failure. Falling through to the compose file's own\n`:-latest` default is also forbidden, because the update monitor compares\ndigests tag by tag, and `latest` moves under it.\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver-cuda`\n\n  Upstream renamed the project from quip-miner to quip-solver at\n  `v0.3.5-rc12`. The old `quip-miner/v0.3/*` registry paths no longer exist.\n  A test (`embedded_compose_file_uses_the_resolved_image_paths`) fails when\n  these constants and the vendored compose file disagree.\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`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network`\n\nThe manager holds the dashboard image to a floor of `MIN_DASHBOARD_TAG =\nv0.2.3-rc2` (`stack_assets.rs`). `v0.2.3-rc1` is the first tag with Caddy\nembedded in the image, and `v0.2.3-rc2` is the first tag with the `/files`\nallowlist the upstream changelog requires.\n\nThe validator has a floor too, `MIN_VALIDATOR_TAG = v0.4.0`. The embedded chain\nspec is the v0.4.0 export for the spec-119 Aglais relaunch. Move the floor, the\nspec, and the two per-genesis directories (`VALIDATOR_DB_DIR`,\n`DASHBOARD_DATA_DIR`) together at the next relaunch.\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 the dashboard's embedded Caddy\n  provisions TLS (`:80`/`:443` are published by default and configurable in\n  advanced settings).\n\nThe dashboard and validator services are always part of the `cpu`/`cuda`\nprofile — 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` (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` — 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` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` |\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. The dashboard is\nalways in both profiles. The vendored compose file also defines an opt-in\n`faucet` profile (`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard`) that omits the miner, so `--profile` gates\neligibility while positional args restrict what actually starts.\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_TOKEN, DWAVE_API_SOLVER, DWAVE_API_REGION, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION, QUIP_LOG_PORT (both modes), and (Native only) QUIP_MINER_REST_URL and QUIP_MINER_UPSTREAM. The file has 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-validator:9944`, which is correct in both modes because the validator is a container in both.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with the dashboard's public API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944`, and the `QUIP_MINER_UPSTREAM` patch that adds the dashboard's embedded Caddy upstream, which upstream compose does not pass |\n| `chain-specs/aglais-network.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/aglais-119-chain-db/` (`stack_assets::VALIDATOR_DB_DIR`, mounted as the validator container's `/data`) |\n| `dashboard-data-119/` | bind-mount target for the dashboard | the dashboard index (`dashboard.db`) and collector 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_caddy-data`, `quip_caddy-config`. The dashboard container mounts them.\nThe upstream compose pins fixed global `name:`s (`quip-caddy-data`, …). The\nmanager strips them at stage time (`stack_assets::strip_volume_names`) so they\ndo not collide with other Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds only a `data_dir` override.\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` is **embedded into the binary\nat compile time** via `include_str!`. This avoids Tauri's runtime resource\nresolution entirely — on Windows, the CI ships a raw `.exe`\n(`tauri build --no-bundle`) with no sibling resource folder, and a\n`BaseDirectory::Resource` lookup would fail. Embedding makes the staged file\ntravel as a `&'static str` in `.rodata`.\n\n`start_stack` and `pull_compose_images` call\n`stack_assets::sync_stack_assets(run_mode, config)` before invoking Docker Compose.\nIt stages the embedded compose.yml and chain spec\n(`chain-specs/aglais-network.json`), always overwriting — no merge. Patches:\n\n1. **Compose host publications**: rebuild each managed service's `ports` list\n   from `service_ports::PORT_SPECS` and the saved enable switches and host ports.\n   Internal ports stay fixed. Public mappings omit the host address so Docker\n   can publish on both IPv4 and IPv6. Omit mappings with their switch off.\n\n2. **Validator public address**: when the user sets `public_host` and turns on\n   P2P publishing, insert `--public-addr=<multiaddr>` using `validator_port`.\n\n3. **Native validator RPC**: always publish\n   `127.0.0.1:<validator_rpc_port>:9944/tcp`. The public-access switch removes\n   the loopback restriction. Publish Docker RPC only when the user turns it on.\n\n4. **Dashboard miner upstream**: add `QUIP_MINER_UPSTREAM` to the dashboard\n   environment. The dashboard image's embedded Caddy reads it, but upstream\n   compose passes only `QUIP_MINER_REST_URL` (the backend's own upstream), so\n   the patch fills the gap.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the validator `--public-addr` arg requires rewriting a YAML\ntoken, not just supplying an env var, so all the port/host remaps live in one\npatch pass for consistency.\n\n## Port Handling\n\nContainer ports stay fixed. Each row below has an enable switch and host-port\nfield in advanced settings. The shared catalog is `service_ports::PORT_SPECS`.\n\n| Service | Ports and purpose | Public by default |\n|---------|-------------------|-------------------|\n| `quip-validator` | 30333 TCP/UDP P2P, 9944 TCP RPC, and 9615 TCP metrics | P2P only |\n| `quip-miner` | 8086 TCP REST for CPU or CUDA | No |\n| `quip-dashboard` | 20049 TCP API, 80 TCP HTTP, and 443 TCP HTTPS | Yes |\n| `quip-dashboard` | 8088 TCP internal HTTP | No |\n| `quip-dashboard` | 5514 UDP log collector | Always on, 127.0.0.1 only |\n\nHost ports default to the container ports. The log collector row is always\npublished on loopback, and only its host port can change. The manager writes\nthat port to `QUIP_LOG_PORT`, which the Docker logging driver reads.\n\nHTTP/3 has no row. QUIC has no plaintext mode, so it exists only on a site\nthat serves TLS, and it must answer on that site's own port because that is\nwhat `Alt-Svc` advertises. With `tls_enabled` set, `published_protocols` adds\na UDP leg to the Public API and HTTPS rows on their existing host ports.\n`service_ports::validate` and the compose mappings both read that one\nfunction, so they cannot disagree about which UDP ports are published.\n\nNative mode requires RPC, which defaults to 9944 and remains local unless the\nuser selects public access. The dashboard also needs native miner REST.\nIts separate host listener defaults to 20100. Switching modes preserves the\nDocker miner's optional host mapping. The manager probes Docker services from\ninside the validator container. Health reporting works with public ports off.\nPublic API and P2P checks show a warning when their switches are off, without\nrunning an external probe.\n\nThe existing `port`, `validator_port`, and `validator_rpc_port` fields hold\nhost port numbers. Their switches are `public_api_enabled`,\n`validator_p2p_enabled`, and `validator_rpc_enabled`. Other bindings live in\n`NodeConfig.service_ports`. Saves reject zero, ports greater than 65535, and active\nbindings that conflict on the same transport protocol.\n\nThe generated miner config and the staged first-start template both use only\n`ws://quip-validator:9944` in Docker mode. Native uses\n`ws://127.0.0.1:<validator_rpc_port>`. Public host mappings never change these\ninternal Docker URLs.\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 = \"0.0.0.0:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. This bind lets the dashboard container's\nCaddy reach the listener through the Docker host gateway.\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  (RPC), service_ports, publishing switches, 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\nThe Rust service attaches container logs before stack startup. The follower waits for\nstaged files and retries after Docker exits. Each log session owns its stop flag\nand child process. Native startup replaces the container session with a combined\nfile and container session. Stop cancels the current session and its retries.\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`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- `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# Lint the install scripts the way CI does (all three targets use Docker)\nmake lint-shell\nmake lint-powershell\nmake test-powershell\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-miner/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-miner); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Signed Release Tags\n\nEvery `v*` tag must carry an SSH signature from a key in\n`.gitlab/allowed-signers`. The `verify-tag-signature` job checks that\nsignature in the `lint` stage of a tag pipeline. It fails when the tag is\nlightweight, when the tag is unsigned, and when the key is absent from the\nfile. The `build`, `sign` and `release` stages run after `lint`, so a failure\nstops both the Windows signing and the GitLab Release.\n\nThe job also reports the signature state of the last 20 commits. An unsigned\ncommit does not fail the job, because GitLab writes its own merge commits\nwithout a signature.\n\nSet up signing once per clone:\n\n```bash\ngit config gpg.format ssh\ngit config user.signingkey \"key::$(cat ~/.ssh/<your-key>.pub)\"\ngit config tag.gpgsign true\ngit config commit.gpgsign true\ngit config gpg.ssh.allowedSignersFile .gitlab/allowed-signers\n```\n\n`git tag -a v0.2.9-rc2 -m \"0.2.9-rc2\"` then signs the tag. Check it before you\npush with `git tag -v v0.2.9-rc2`.\n\nTwo values must agree, or verification fails:\n\n| Value | Must match |\n|-------|------------|\n| The tagger email | A principal in `.gitlab/allowed-signers` |\n| The signing key | The key listed for that principal |\n\nTo add a signer, add a line to `.gitlab/allowed-signers` and merge it. The file\nholds public keys only. Add the same key to your GitLab profile as a signing\nkey so the web interface marks the tag as verified.\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","isInternal":false,"tokens":7814,"sizeBytes":31244}],"systemPromptSnippet":"<yakaai_skills repo=\"QuipNetwork/quip-node-manager\">\n<!-- File: AGENTS.md (Tokens: ~7814 | Category: root-instruction) -->\n# 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). The dashboard image runs Caddy, the log collector, and its\nown index. Runs the stack via Docker Compose, or in Native mode (default on macOS) where the miner\nbinary runs on the host and the dashboard runs in Docker. The same binary also exposes a headless\nTUI (`--cli`, or when no display is available — SSH/headless). Rust backend + vanilla HTML/CSS/JS\nfrontend.\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,\n│                                  # chain-specs/aglais-network.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        ├── service_ports.rs       # Shared host port catalog and validation\n        ├── container_http.rs      # HTTP probes inside the Compose network\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\n        ├── stack_assets.rs        # include_str! the compose.yml + chain spec;\n        │                          # 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.3.2-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), and `quip-dashboard`. In Docker mode, the\n  dashboard reaches the miner via the compose network alias `quip-miner:8086`,\n  and the validator via `quip-validator`. The miner self-bootstraps on first\n  start — it auto-funds via the faucet for the selected channel and registers\n  its keystore in `QuantumPow.Miners`, so there is no separate one-shot\n  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**: advanced settings in the GUI and TUI group host publications by\n  service. See \"Port Handling\" for the complete list. Container addresses stay\n  fixed regardless of host publishing settings.\n- **Native listeners**: validator RPC defaults to `127.0.0.1:9944`. Native mode\n  requires this host mapping, with public access optional. The native miner REST\n  listener defaults to `0.0.0.0:20100` so the dashboard container reaches it through\n  `host.docker.internal`.\n\n## Docker Images\n\n`vendor/nodes.quip.network/docker-compose.yml` declares the images, with\n`${QUIP_*_TAG:-…}` placeholders. The manager's authoritative image **paths**\nlive in `src-tauri/src/compose.rs` (`CPU_IMAGE`, `CUDA_IMAGE`,\n`VALIDATOR_IMAGE`, `DASHBOARD_IMAGE`).\n\nThe binary carries no default tag. Every start resolves a tag per image from\nthat image's own GitLab container registry for the selected update channel\n(`resolve_channel_image_tags`) and writes the three results to `.env` as\n`QUIP_MINER_TAG`/`QUIP_VALIDATOR_TAG`/`QUIP_DASHBOARD_TAG`. The repositories\nadvance on their own cadence, so the three tags often differ.\n\nWhen a registry does not answer, the manager holds the tag `.env` already pins,\nwhich keeps the stack on the version it runs instead of moving it backwards.\nWhen `.env` pins nothing either, it takes the newest tag of that image already\nin the local Docker cache. Only when all three are empty — a first start with\nno network — does the start fail with a message. A repository with no stable\ntag puts its newest beta in the Release slot, so Release never falls back to a\npin that the repository does not carry.\n\nWhen `docker compose pull` fails but every image the stack needs is already\nlocal (`config --images` + `docker image inspect`), the start logs a warning\nand runs `up --pull never`. The start needs that flag because the compose file\nsets `pull_policy: always`, which would otherwise make `up` pull again and fail.\n\nA compiled-in default tag cannot fill that gap: it ages\ninto a tag the registry no longer carries, which converts a passing network\nfault into a permanent `pull` failure. Falling through to the compose file's own\n`:-latest` default is also forbidden, because the update monitor compares\ndigests tag by tag, and `latest` moves under it.\n\n- Miner (CPU): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver`\n- Miner (CUDA): `registry.gitlab.com/quip.network/quip-solver/v0.3/quip-solver-cuda`\n\n  Upstream renamed the project from quip-miner to quip-solver at\n  `v0.3.5-rc12`. The old `quip-miner/v0.3/*` registry paths no longer exist.\n  A test (`embedded_compose_file_uses_the_resolved_image_paths`) fails when\n  these constants and the vendored compose file disagree.\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`\n- Dashboard: `registry.gitlab.com/quip.network/dashboard.quip.network`\n\nThe manager holds the dashboard image to a floor of `MIN_DASHBOARD_TAG =\nv0.2.3-rc2` (`stack_assets.rs`). `v0.2.3-rc1` is the first tag with Caddy\nembedded in the image, and `v0.2.3-rc2` is the first tag with the `/files`\nallowlist the upstream changelog requires.\n\nThe validator has a floor too, `MIN_VALIDATOR_TAG = v0.4.0`. The embedded chain\nspec is the v0.4.0 export for the spec-119 Aglais relaunch. Move the floor, the\nspec, and the two per-genesis directories (`VALIDATOR_DB_DIR`,\n`DASHBOARD_DATA_DIR`) together at the next relaunch.\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 the dashboard's embedded Caddy\n  provisions TLS (`:80`/`:443` are published by default and configurable in\n  advanced settings).\n\nThe dashboard and validator services are always part of the `cpu`/`cuda`\nprofile — 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` (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` — 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` |\n| `cuda` | `cuda` miner + `quip-validator` + `dashboard` |\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. The dashboard is\nalways in both profiles. The vendored compose file also defines an opt-in\n`faucet` profile (`quip-faucet`), which the manager never selects.\n\nIn Native mode, `start_stack` passes an explicit positional service list\n(`quip-validator dashboard`) that omits the miner, so `--profile` gates\neligibility while positional args restrict what actually starts.\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_TOKEN, DWAVE_API_SOLVER, DWAVE_API_REGION, QUIP_MINER_TAG, QUIP_DASHBOARD_TAG, QUIP_VALIDATOR_TAG, QUIP_MINER_CPUSET, VALIDATOR_NAME, QUIP_GPU_UTILIZATION, QUIP_LOG_PORT (both modes), and (Native only) QUIP_MINER_REST_URL and QUIP_MINER_UPSTREAM. The file has 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-validator:9944`, which is correct in both modes because the validator is a container in both.) |\n| `docker-compose.yml` | stack_assets.rs (embedded copy + patch) | Upstream compose with the dashboard's public API port → `<port>:20049`, validator libp2p → `<validator_port>:30333/tcp+udp`, `--public-addr` injected when `public_host` set, (Native) validator RPC published on `127.0.0.1:<validator_rpc_port>:9944`, and the `QUIP_MINER_UPSTREAM` patch that adds the dashboard's embedded Caddy upstream, which upstream compose does not pass |\n| `chain-specs/aglais-network.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/aglais-119-chain-db/` (`stack_assets::VALIDATOR_DB_DIR`, mounted as the validator container's `/data`) |\n| `dashboard-data-119/` | bind-mount target for the dashboard | the dashboard index (`dashboard.db`) and collector 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_caddy-data`, `quip_caddy-config`. The dashboard container mounts them.\nThe upstream compose pins fixed global `name:`s (`quip-caddy-data`, …). The\nmanager strips them at stage time (`stack_assets::strip_volume_names`) so they\ndo not collide with other Quip stacks on the same host.\n\nBootstrap state at `~/.config/quip-node-manager/bootstrap.json`:\nholds only a `data_dir` override.\n\n## Stack Asset Patching\n\n`vendor/nodes.quip.network/docker-compose.yml` is **embedded into the binary\nat compile time** via `include_str!`. This avoids Tauri's runtime resource\nresolution entirely — on Windows, the CI ships a raw `.exe`\n(`tauri build --no-bundle`) with no sibling resource folder, and a\n`BaseDirectory::Resource` lookup would fail. Embedding makes the staged file\ntravel as a `&'static str` in `.rodata`.\n\n`start_stack` and `pull_compose_images` call\n`stack_assets::sync_stack_assets(run_mode, config)` before invoking Docker Compose.\nIt stages the embedded compose.yml and chain spec\n(`chain-specs/aglais-network.json`), always overwriting — no merge. Patches:\n\n1. **Compose host publications**: rebuild each managed service's `ports` list\n   from `service_ports::PORT_SPECS` and the saved enable switches and host ports.\n   Internal ports stay fixed. Public mappings omit the host address so Docker\n   can publish on both IPv4 and IPv6. Omit mappings with their switch off.\n\n2. **Validator public address**: when the user sets `public_host` and turns on\n   P2P publishing, insert `--public-addr=<multiaddr>` using `validator_port`.\n\n3. **Native validator RPC**: always publish\n   `127.0.0.1:<validator_rpc_port>:9944/tcp`. The public-access switch removes\n   the loopback restriction. Publish Docker RPC only when the user turns it on.\n\n4. **Dashboard miner upstream**: add `QUIP_MINER_UPSTREAM` to the dashboard\n   environment. The dashboard image's embedded Caddy reads it, but upstream\n   compose passes only `QUIP_MINER_REST_URL` (the backend's own upstream), so\n   the patch fills the gap.\n\nWhy embedded + patched at stage time (instead of compose's `${VAR}` env\nsubstitution): the validator `--public-addr` arg requires rewriting a YAML\ntoken, not just supplying an env var, so all the port/host remaps live in one\npatch pass for consistency.\n\n## Port Handling\n\nContainer ports stay fixed. Each row below has an enable switch and host-port\nfield in advanced settings. The shared catalog is `service_ports::PORT_SPECS`.\n\n| Service | Ports and purpose | Public by default |\n|---------|-------------------|-------------------|\n| `quip-validator` | 30333 TCP/UDP P2P, 9944 TCP RPC, and 9615 TCP metrics | P2P only |\n| `quip-miner` | 8086 TCP REST for CPU or CUDA | No |\n| `quip-dashboard` | 20049 TCP API, 80 TCP HTTP, and 443 TCP HTTPS | Yes |\n| `quip-dashboard` | 8088 TCP internal HTTP | No |\n| `quip-dashboard` | 5514 UDP log collector | Always on, 127.0.0.1 only |\n\nHost ports default to the container ports. The log collector row is always\npublished on loopback, and only its host port can change. The manager writes\nthat port to `QUIP_LOG_PORT`, which the Docker logging driver reads.\n\nHTTP/3 has no row. QUIC has no plaintext mode, so it exists only on a site\nthat serves TLS, and it must answer on that site's own port because that is\nwhat `Alt-Svc` advertises. With `tls_enabled` set, `published_protocols` adds\na UDP leg to the Public API and HTTPS rows on their existing host ports.\n`service_ports::validate` and the compose mappings both read that one\nfunction, so they cannot disagree about which UDP ports are published.\n\nNative mode requires RPC, which defaults to 9944 and remains local unless the\nuser selects public access. The dashboard also needs native miner REST.\nIts separate host listener defaults to 20100. Switching modes preserves the\nDocker miner's optional host mapping. The manager probes Docker services from\ninside the validator container. Health reporting works with public ports off.\nPublic API and P2P checks show a warning when their switches are off, without\nrunning an external probe.\n\nThe existing `port`, `validator_port`, and `validator_rpc_port` fields hold\nhost port numbers. Their switches are `public_api_enabled`,\n`validator_p2p_enabled`, and `validator_rpc_enabled`. Other bindings live in\n`NodeConfig.service_ports`. Saves reject zero, ports greater than 65535, and active\nbindings that conflict on the same transport protocol.\n\nThe generated miner config and the staged first-start template both use only\n`ws://quip-validator:9944` in Docker mode. Native uses\n`ws://127.0.0.1:<validator_rpc_port>`. Public host mappings never change these\ninternal Docker URLs.\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 = \"0.0.0.0:<native_rest_port>\"` (default 20100) and\n`data_dir = <data_dir>/attempts`. This bind lets the dashboard container's\nCaddy reach the listener through the Docker host gateway.\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  (RPC), service_ports, publishing switches, 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\nThe Rust service attaches container logs before stack startup. The follower waits for\nstaged files and retries after Docker exits. Each log session owns its stop flag\nand child process. Native startup replaces the container session with a combined\nfile and container session. Stop cancels the current session and its retries.\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`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- `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# Lint the install scripts the way CI does (all three targets use Docker)\nmake lint-shell\nmake lint-powershell\nmake test-powershell\n```\n\n## Versioning & Release Tags\n\nCanonical spec: `quip-miner/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-miner); this repo ships\n  desktop binaries via a per-tag GitLab Release and has no `:latest` to gate.\n\n## Signed Release Tags\n\nEvery `v*` tag must carry an SSH signature from a key in\n`.gitlab/allowed-signers`. The `verify-tag-signature` job checks that\nsignature in the `lint` stage of a tag pipeline. It fails when the tag is\nlightweight, when the tag is unsigned, and when the key is absent from the\nfile. The `build`, `sign` and `release` stages run after `lint`, so a failure\nstops both the Windows signing and the GitLab Release.\n\nThe job also reports the signature state of the last 20 commits. An unsigned\ncommit does not fail the job, because GitLab writes its own merge commits\nwithout a signature.\n\nSet up signing once per clone:\n\n```bash\ngit config gpg.format ssh\ngit config user.signingkey \"key::$(cat ~/.ssh/<your-key>.pub)\"\ngit config tag.gpgsign true\ngit config commit.gpgsign true\ngit config gpg.ssh.allowedSignersFile .gitlab/allowed-signers\n```\n\n`git tag -a v0.2.9-rc2 -m \"0.2.9-rc2\"` then signs the tag. Check it before you\npush with `git tag -v v0.2.9-rc2`.\n\nTwo values must agree, or verification fails:\n\n| Value | Must match |\n|-------|------------|\n| The tagger email | A principal in `.gitlab/allowed-signers` |\n| The signing key | The key listed for that principal |\n\nTo add a signer, add a line to `.gitlab/allowed-signers` and merge it. The file\nholds public keys only. Add the same key to your GitLab profile as a signing\nkey so the web interface marks the tag as verified.\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</yakaai_skills>"}