Skip to content

v0.9.21

Choose a tag to compare

@github-actions github-actions released this 01 Sep 11:47
· 31 commits to main since this release

[0.9.21] - 2026-08-29 — security hardening, backups, observability, in-app server upgrade, agent CLI surfaces

Server self-upgrade — the About page can upgrade a server deployment

  • Browser deployments finally have an upgrade button. The check-for-updates entry in Settings → About was Tauri-only (double-gated on isTauriEnv() + __TAURI_INTERNALS__) — a browser session against neomind serve showed a version number and nothing else, and upgrading meant SSH + neomind upgrade. Non-Tauri access now checks the server's release state (GET /api/system/upgrade/check, admin-only): current → target with release notes, then POST /api/system/upgrade drives the whole thing with live progress (SystemUpgradeProgress WS events plus a 2s status poll — the poll is the only channel during the restart window, when the WS is down); once the server answers again on the new version the page reloads itself (index.html is no-cache, so the reload lands the new frontend too). Docker installs show a docker compose pull && up -d hint instead of a button; the existing 24h auto-check now also runs in browser mode and drives the About badge. Endpoints sit in the JWT-gated admin_routes group (same class as /api/settings/backup — API keys cannot trigger an upgrade).
  • Two-phase apply across the privilege boundary. The API runs as the sandboxed neomind user (ProtectSystem=full + NoNewPrivileges=true): it can neither write /usr/local/bin nor sudo (NoNewPrivileges keeps any child permanently non-root inside the unit — a sudoers rule would silently never work). So the API only STAGES: it streams the release into data/upgrade/v<ver>/ (2GB cap, downloaded binary --version-verified before anything is touched) and writes apply.trigger. A new root neomind-upgrade-apply.path unit watches that file with inotify — no sudoers, no polkit, nothing relaxed in the main unit's sandbox — and starts neomind-upgrade-apply.service, which runs neomind upgrade --apply-staged --yes as root: back up .bakinstall -m 755 atomic swap → web-dir stage-swap → systemctl restart neomind. scripts/install.sh writes and enables both helper units; existing installs need ONE re-run of install.sh to gain the feature (the check endpoint detects the missing helper and says exactly that).
  • One implementation, three callers. The release/semver/download/apply primitives moved out of the CLI into neomind-api/src/upgrade/ (the CLI depends on the API; hosting them there avoids a dependency cycle), shared by neomind upgrade (interactive), --apply-staged (the root helper) and the API's staging task. Two latent CLI bugs fixed in the move: the web-dir swap ignored NEOMIND_WEB_DIR (hardcoded /var/www/neomind), and it chowned the swapped dir to neomind:neomind unconditionally (install.sh prefers www-data — nginx reads broke on www-data-owned installs).
  • A top-right quick entry appears while an update is available. The update state used to be reachable only from Settings → About; now the floating cluster (theme/language/alerts) gains a pulsing update icon driven by the same updateInfo slice the 24h auto-check populates — one click from ANY page opens the environment's dialog (desktop OTA in Tauri, the server self-upgrade dialog in a browser). The server dialog moved from AboutTab to a global mount behind a shared store flag so the indicator and the About page open the same instance.
  • Verified end-to-end on real hardware (Jetson/arm64, Ubuntu 22.04, systemd 249, real GitHub artifacts): deployed the 0.9.21 build, POST /api/system/upgrade {"version":"0.9.20"} staged + verified + triggered + applied + swapped binaries and web dir + restarted — service healthy on the new version in ~10s wall clock, .bak rollback copies and the printed rollback command in place, staging dir cleaned. The test also caught (and this entry ships the fix for) the helper units initially resolving a data dir one level off the main unit's: the main service sets NO NEOMIND_DATA_DIR, so its store tree is ${DATA_DIR}/data via cwd-relative resolution — the helper units must resolve the same way (WorkingDirectory only, no env override) and watch ${DATA_DIR}/data/upgrade/apply.trigger, or the API stages under a tree nobody watches.

Wrap-up

  • Zip-bomb caps are now one per-INSTALL budget instead of one per extraction phase: the manifest, binary, bundled-library sweep and each directory (frontend/models/assets/config) each carried their own 500MB budget, so a crafted package could legally extract ~2.7GB; the cumulative budget (500MB / 10k files across the whole install) is charged by every phase (round-4 review: "narrowed but not capped").
  • Logout invalidates in-flight dashboard syncs: both the store slice and the persistence layer carry an epoch bumped on clear; a sync that was mid-await when the user logged out used to complete afterwards and re-persist the previous account's dashboard into localStorage, which the next account's local-only merge would adopt.
  • Round-4 adversarial review fixes (the round-3 fixes got their own review): the startup .nep cache scan now passes the extensions ROOT (it still passed packages/, so top-level .nep files remained invisible to the boot trigger — round 2's fix was half-applied); a failed Ping send cancels its own in-flight entry (a leaked entry pinned pending_count ≥ 1 forever, permanently blinding hang detection for that extension); flushSync clears the pending-edits guard only after the flush resolves (the debounce path's in-flight window fix had an identical unguarded sibling here); a failed debounced sync releases the guard after 30s instead of pinning it for the session (localStorage quota failures on embedded devices would otherwise block server refreshes forever); one unused import that broke the committed tree's clippy (found by verifying HEAD in a clean worktree — round 2/3 each briefly shipped non-compiling trees via dangling paths references, closed by 21425d4).
  • TCP_NODELAY on the listener (from 5539c78): accepted connections inherit it — per-message WebSocket writes on the video-push path no longer interlock with delayed ACKs (~40-200ms stall per message on a fast link).
  • TLS front proxy (NEOMIND_TLS_PORT/CERT/KEY, from 5539c78): a rustls front forwarding to the loopback listener, for secure-context deployments. Known limitations in this release: the upstream target assumes the default 0.0.0.0 bind (loopback forward), and proxied clients share the 127.0.0.1 rate-limit/throttle buckets — run it behind a real reverse proxy if you need per-client limits.
  • jemalloc is now a cargo feature (from 5539c78): default on for server builds, disable with --no-default-features as a cross-compile escape hatch.
  • Full post-0.9.20 review round 3 fixes: the API-key reload-on-miss now also lives on validate_key_info — the entry point the main auth middleware actually uses (the original fix landed on the wrong function, so keys created after boot still 401'd on most routes until restart); GGUF array parsing caps nesting depth at 16 (a crafted header could drive unbounded recursion → stack overflow → SIGSEGV kills the whole process via upload-model); the model-download loop has a 60s per-chunk stall timeout (a wedged connection used to park the stream forever, holding the single-flight lock and defeating cancel); the upload temp sweep only deletes files older than 24h instead of every file (it unlinked concurrent uploads' temp copies); frontend: the extension grid counts/filters Crashed under error-class instead of dropping it from both tabs, the details-dialog badge turns destructive for Crashed, crashed/crashedTooltip gained zh/en strings, and the dangling importLocalBadge reference was removed; dashboard persistence: the unsynced-edits guard now holds for the whole in-flight sync (pending was cleared before sending — the exact window the guard existed to close) and logout cancels the pending debounced sync (it could re-persist the previous account's dashboard into the next account via the local-only merge).
  • Pre-release review fixes (all found by the pre-release audit of this very iteration):
    • the liveness probe skipped counting failures while commands are in flight — a busy runner (single-task message pump, sync FFI command) legitimately can't answer Ping for the command's full duration (300s budget), and the probe used to kill healthy extensions mid-command, feeding a false crash → restart → circuit-break → alert chain;
    • the bundled-library extraction loop got the same zip-bomb caps (count/total) as the other two extraction paths — it had slipped the caps during the unification, leaving a disk-fill path via the 512MB upload endpoint;
    • backup creation is now process-serialized (scheduler + manual trigger) with millisecond-granularity ids, and a failure during the finalize phase (manifest write/rename) cleans the tmp dir — a manifest-less tmp leak was invisible to prune and sat forever;
    • the extension card no longer shows "Crashed" for a RUNNING extension that merely has crash history;
    • the .nep cache sync scans BOTH extensions/ and extensions/packages/ (the startup task and the manual trigger each saw only one of the two locations);
    • the backup scheduler and the manual trigger read their schedule config from the SAME data directory they back up (a hardcoded data/settings.redb split the two when NEOMIND_DATA_DIR pointed elsewhere);
    • marketplace detail/readme/install validate the extension id before building URLs (the raw id was interpolated into a path — ../.. turned the marketplace client into a limited arbitrary-GET against the market host);
    • POST /api/extensions now persists the RESOLVED canonical path, not the raw request value, so load_from_storage's verbatim replay can't resurrect an outside-data-dir path on every boot (the load-side confinement check itself rides the in-flight path-unification work).
  • neomind user set-role (offline).
  • neomind user set-role (offline). Recovers installations whose "admin" account was created through the old always-User self-registration (username ≠ role): promote/demote admin|user|viewer with shell access, e.g. neomind user set-role admin admin.
  • The extension marketplace source is switchable. It was hardcoded to raw.githubusercontent.com with NO override — unreachable from CN networks, while the component market and LLM catalog both had env overrides. Settings → Preferences (admin) now has an Extension Marketplace source field; precedence is saved value > NEOMIND_EXTENSION_MARKET_URL env > default, effective on the next marketplace request (no restart). The field warns that after switching, package integrity verifies against the mirror's artifacts.
  • Crash alerts reach the user. A circuit-broken extension (restart attempts exhausted) now sends a system message through the notification channels instead of only logging — previously a repeatedly-crashing extension just quietly stopped working.
  • The Crashed state is visible in the UI. Extension cards show an error-tinted "Crashed" chip with the crash reason and consecutive count on hover.
  • The serve startup tests run in CI. A test-only NEOMIND_EXIT_AFTER_READY_MS lets neomind serve exit gracefully after startup, so the three spawn-a-real-server tests assert a full boot (bind → stores → services → ready → clean exit) instead of "alive after 500ms", run with per-test temp data dirs, and no longer need the CI skip.

Extension system — hang detection and honest crash state

  • Hung extensions are now detected. A deadlock without exit never closes stdout, so the death monitor saw nothing and every subsequent command just burned its full timeout (produce_metrics didn't even kill). Each process now runs a liveness probe (Ping every 30s, 5s timeout, configurable: health_check_* on IsolatedExtensionConfig); after repeated failures the process is killed through the same path a command timeout takes, handing it to the existing crash/restart machinery. Wiring this up exposed a real protocol bug: Pong carried no request_id, so the host's receiver thread classified it as unroutable and silently dropped it — Ping/Pong now carry one like every other request/response.
  • The API can now say "Crashed" instead of "Stopped". Crash-loop counters (consecutive count + reason: exit status / signal / IPC failure / hang) flow from the process through the runtime info to the extension DTO (consecutive_crashes, last_crash_reason); a stopped extension with crash history reports state Crashed, so the UI can finally distinguish "stopped on purpose" from "died". Counters survive same-path reloads and are snapshotted into the info cache on death, before the restart decision reads them.

Extension system — per-call fixed costs removed

  • The SDK no longer builds a fresh multi-thread tokio Runtime for every FFI call. Extension commands run on FFI threads with no tokio context, and each execute_command/produce_metrics used to construct AND tear down a full CPU-count worker runtime — milliseconds of setup per call, the single biggest fixed tax on extension invocations. One 2-worker runtime is now cached per process.
  • Binary IPC payloads travel as base64 instead of decimal number arrays. StreamDataChunk/StreamChunkResult/ChunkResult/PushOutput carried Vec<u8> fields that serde_json encoded as [104,116,116,112,…] — ~4× wire size and an order of magnitude slower parsing, on the path that carries video frames (the push pipeline additionally transcoded base64→bytes→numbers). The existing base64_vec helper (which still deserializes the legacy number-array form) is now applied to all five fields. Host and runner ship in the same package, so the wire change is release-coupled on both ends by construction.

Extension system — security hardening (from the design review)

  • The async package-extraction path now has the same zip-bomb/symlink defenses as the sync path. ExtensionPackage::install() (used by /api/extensions/upload) had NO size/count caps while the sync installer did — same crate, two extract implementations, asymmetric defenses. Caps are now one shared set of constants, and both paths explicitly reject symlink entries.
  • file_path request fields are confined to the data directory. register/upload/validate used to accept any host path, making them a read-and-try-load primitive over arbitrary files for anyone holding credentials. Paths now resolve against (and must stay inside) the data dir, after canonicalization.
  • POST /api/extensions/sync actually installs. It reported installed: N, upgraded: M while doing nothing — process_nep_file classified packages and returned without touching the disk. It now installs (on the blocking pool — the async installer holds a non-Send ZipFile across awaits) and registers results with the runtime, carrying user config forward like the marketplace path. The scan dir is the data-dir extensions folder (it used to be a CWD-relative extensions/ that had nothing to do with the data dir).
  • Update checks use semver and only flag upgrades. The string != compared versions, so downgrades and build-suffix drift showed as "update available"; combined with extensions hardcoding "2.0.0" (weather/yolo), some entries showed a permanent false update.
  • Package sha256 verification is now actually reachable. The marketplace index carries no sha256, so the existing fail-closed verify branch never fired. The installer now falls back to the release-level checksums.txt (uploaded alongside the .nep assets starting with the next Extensions release), warns loudly when no integrity data exists, and NEOMIND_STRICT_PACKAGE_SHA256=1 refuses unverified packages outright.
  • The runner's resource-limit flags are wired (IsolatedExtensionConfig::rlimit_memory_mb), though OFF by default: RLIMIT_AS caps virtual address space and CUDA/ONNX runtimes reserve multi-GB VA at init, so a naive cap kills exactly the heavy extensions it should protect. RSS polling remains the default enforcement.

Security

  • Self-registration is closed by default. POST /api/auth/register was a public, unconditionally-open account-creation endpoint on a server that binds 0.0.0.0 — any LAN client could mint a UserRole::User account. It now returns 403 unless an admin opens it via PUT /api/settings/registration (GET reads it; both admin-only, persisted in users.redb so the choice survives restarts). Nothing user-facing regresses: the first admin comes from the setup wizard and additional users from the admin-only POST /api/users (the web UI has no register form — the endpoint had no honest product caller). The Playwright e2e fixture now bootstraps its test account through the setup wizard instead of self-registering.
  • LLM provider API keys are sealed at rest. LlmSettings.api_key and LlmBackendInstance.api_key were stored as plaintext JSON in settings.redb while the platform's own API keys already went through AES-256-GCM — the asymmetry meant a copied data dir leaked every cloud key. Both stores now seal the key field with the shared CryptoService (same data/encryption_key the auth store uses; NEOMIND_ENCRYPTION_KEY still wins). Legacy plaintext rows load unchanged and get sealed on their next save, so upgrades are transparent. Config-change history records the sealed form too — it duplicated the plaintext key on every tracked save. Exports stay plaintext on purpose (users need their keys to migrate).
  • CryptoService moved from neomind-api to neomind_core::crypto (api re-exports it, so crate::crypto::… paths still work) so the storage layer can share one key instead of growing a second crypto implementation.

Data safety — backups

  • The data directory can finally be backed up. An edge box that loses power or corrupts a redb file previously lost everything — no backup mechanism existed anywhere. neomind_storage::backup copies every *.redb plus the two secret files (encryption_key — without it the sealed LLM keys in the backup are undecryptable — and .jwt_secret) into data/backups/backup-<ts>/ (0700; secrets stay 0600), verifies each copied database opens (redb's crash-safe format means an online copy is equivalent to a post-power-cut file, and open-time recovery is exactly the check that matters), writes a manifest.json, and only then renames the …​.tmp dir into place — a crashed backup never masquerades as a restorable one. A verification failure discards the whole backup.
  • Two triggers: POST /api/settings/backup (admin, immediate, returns the manifest + how many old backups were pruned) and GET /api/settings/backups (admin, newest first). A scheduler runs the same path on the configured interval (first interval after boot skipped so a fresh start doesn't copy databases while services warm up).
  • The schedule is runtime-configurable in Settings → Preferences (GET/PUT /api/settings/backup-config): enable/disable, interval (6h–7d), retention count — the scheduler re-reads the config every minute so edits apply without a restart. Env vars (NEOMIND_BACKUP_INTERVAL_SECS=0 disables, NEOMIND_BACKUP_KEEP) only seed the default until something is saved in the UI. The section also shows the last backup (time + size) and an admin-only "back up now" button.
  • Restoring is deliberately manual (stop server → copy files back → start): an automated restore-on-boot path could silently roll the platform back to stale data, which is worse than a documented procedure.

Observability — /api/metrics

  • Prometheus text metrics endpoint (public, like the health checks — counters only, nothing per-user/device): neomind_http_requests_total / _responses_4xx_total / _responses_5xx_total (global middleware counts every route), neomind_uptime_seconds, neomind_build_info. Edge-box triage previously meant grepping logs; a scraper alert on the 5xx rate beats that.
  • EventBus silent event loss is finally measurable. The bus now keeps a process-wide sum of every event dropped by any lagging subscriber (neomind_eventbus_dropped_total, plus neomind_eventbus_subscribers); receivers share the counter so drops are counted without holding bus handles. The lagged warn-log has literally said "surface this, don't let the system fail quietly" since it was written — now it is surfaced. Non-zero and growing = a rules/telemetry/automation subscriber is missing events and deserves investigation.

Storage — rollback guard (deliberately NOT a migration framework)

  • Every storage database is now version-stamped. The real corruption risk on edge boxes isn't old data meeting new code (serde defaults handle additive changes fine) — it's the reverse: a rolled-back install opening newer data. Unknown fields are silently dropped and new fields default-filled, so the first save-back would permanently destroy newer-format rows with no error anywhere. Each store's open() now stamps its database with the row-format version and refuses to open one stamped by a newer build, with an explicit "upgrade instead of rolling back" error.
  • This is ~100 lines, not a framework: CURRENT_SCHEMA_VERSION bumps only for changes older code cannot safely read; one-shot migrations hang off the version hook when such a change ever lands (none have yet). The device registry's pre-existing "tables missing → delete and recreate the database" path now sits behind the guard too — a newer-build database can no longer be silently deleted by an older binary. Engine swaps (the one-time sled→redb) remain out of scope by design: they need per-store export/import code, not version bookkeeping.

CI — the regression net finally catches Rust

  • ci.yml now runs the full workspace test suite (cargo test --workspace --locked): previously CI executed exactly two targeted test jobs while 3000+ tests ran only on whatever dev machine happened to remember. The three serve spawn tests are skipped in CI (--skip commands::serve_test) — they need ports 9375/1883 free and a clean data dir, and real-server startup is already smoke-gated in build.yml and docker.yml.
  • clippy is a hard gate (--workspace --all-targets -- -D warnings) and rustfmt is checked. Getting there from a standing start surfaced ~60 findings across 8 crates (the first clippy run had ever aborted early on a deny-by-default lint, hiding most of them): needless returns behind spawn_blocking, let-else?, field-reassign-with-default in test builders, redundant closures/field names, two type-complexity aliases, a large_enum_variant boxed, a dead && false left over from the asset-path traversal fix, a vacuous len() > 0 || is_empty() assertion, and ~16 files reformatted. The few intentional patterns (test-serialization guards held across awaits, const-boundary guards) carry explicit #[allow]s with reasons.

Eval-driven CLI ergonomics pass (fixes the top small-model failure mode)

  • What the eval showed (Qwen3.5-4B zh, 30-case regression, 2026-09-01 run qwen35-4b-zh-cmd-20260831): cmd_ok 81%, and of the failures not one was an unparseable command string — the dominant friction was first-shot flag hallucination (channel-create --url where the real surface is --type/--config), JSON-blob quoting inside --body/--config, and models reverse-engineering the surface at runtime via --help | head pipes. The model even invented a flat flag syntax for rule create (--trigger-device/--operator/--threshold) that didn't exist — this release ships that syntax.
  • --param key=value repeatable flag on device control and message channel-create (new kv.rs: first = splits so values may contain =; conservative type inference — true/false→bool, numbers only when they round-trip without leading zeros so 007 stays a string; JSON form still accepted, --param entries merge over it). The channel-create schema-validation error now suggests --param <field>=<value> directly, so a wrong first shot self-corrects in one retry instead of blind flag roulette.
  • rule create flag fast path for single-metric threshold rules — the shape both deploy eval cases actually needed: --name --trigger-device --metric --operator --threshold --notify [--severity] [--cooldown] (plus --source extension:<id>:<metric> / transform:<id>:<field> for non-device metrics). Operator aliases (>, gte, …) map to the canonical six; --cooldown defaults to 300000 so notify rules can't ship without storm protection; complex rules (range/logical/multi-action) still go through --body, mutually exclusive at the clap layer.
  • rule update accepts --id <ID> alongside the positional form (exactly one; clap-enforced). Eval traces showed models coming off a rule create response habitually write rule update --id <uuid> --body ... and burn a round-trip on the clap error before self-correcting — now the first shot parses. The same tolerance can be extended to other update-by-ID commands when traces justify it.
  • Skill docs re-lead with the flag forms (device-onboarding, message-management, rule-management): models copy the first example they see, so --param k=v and the rule fast path now come first, JSON demoted to "complex cases". Drift manifest unchanged (it validates domain/action shape only).
  • Repair-path instrumentation (two tracing lines): clap parse failures log argv at neomind::cli_dispatch (WARN), and the agent-side structured-call→command-string rewrite logs at neomind::agent::mapper (DEBUG) — per-command hit rates for these two lines are the "first-shot flag error rate" going forward.
  • Validation (Qwen3.5-4B @ Ollama): the 3-case surface micro-eval passes 3/3 HARD; traces show the designed converge-in-one-retry loop (--url guess → clap error → --param url=... success). On the full building deploy case, the model converged to six consecutive correct fast-path rule create calls (all success:true, rule_count 6/5) plus a --param channel-create — the case still HARD_FAILs only because the model omits the device control step entirely (multi-step planning, not flag friction). Two follow-up surface findings from the same traces: rule update's --id temptation (fixed above), and flat-flag guesses on device control (--valve-open true) that self-correct via --param but could be pre-empted.
  • Eval case library reorg verified + regression gate extended to 33 cases: the bilingual suite is perfectly synced post-reorg (160 ids × en/zh after this change, zero dupes/parse failures, validate-all 320 cases 0 failed, skill-cli drift green). Three new surface-micro cases (micro-rule-create / micro-channel-create / micro-device-control, en+zh) isolate single-command surface accuracy from multi-step planning noise and join the regression set; cases absent from the committed baseline run without affecting the gate verdict until --update-baseline. Full 33-case gate ran clean end-to-end (4 improvements / 3 flagged, all three flagged cases re-verified PASS on rerun — single-round noise floor, consistent with the gate's own ~7pp guidance to use --rounds 2).
  • Also: install_budget_tests in neomind-core gained the missing #[cfg(test)] gate (its use super::* was warning-clean only in the test target, breaking the clippy hard gate for everyone downstream).

Release-blocker repairs (found by the first green-CI push since 2026-08-29)

  • wasmtime 36.0.13 → 36.0.14 in the lock: fixes RUSTSEC-2026-0269 (filesystem sandbox escape via trailing slashes, high severity 8.8) that started failing cargo-audit the day the advisory published. The lock had also drifted from committed manifests (sdk 0.6.6 vs lock 0.6.5), making every --locked invocation on the committed tree fail before running a single test.
  • rustfmt debt repaid: ~35 sites in the upgrade/self-update/testkit files that landed after the last green fmt run; formatted with the repo-pinned toolchain (a Homebrew toolchain shadows rustup on PATH here and formats differently — use ~/.cargo/bin/cargo +1.92.0 for local gates).
  • linux-only clippy fix: two &mut *process_guard explicit derefs in the extension kill paths trip explicit_auto_deref only on the linux build (cfg-dependent code), invisible to every macOS check since the hang-detection commit.
  • Docs sweep: extension-ID examples drop the -v2 suffix across skill guide, SDK readmes, verification script, DESIGN_SPEC.

Downloads

Desktop Application (Recommended for Personal Use)

  • macOS: Download .dmg file
  • Windows: Download .msi installer or .exe portable
  • Linux: Download .AppImage (universal) or .deb (Debian/Ubuntu)

Server Deployment

  1. Backend Server: Download neomind-server-{os}-{arch}.tar.gz

    • Extract and run: ./neomind serve
    • Default port: 9375
  2. Frontend Static Files: Download neomind-web.tar.gz

All-in-One (default, no nginx needed):

  • Extract frontend to /var/www/neomind (or configure NEOMIND_WEB_DIR)
  • The server serves both API and Web UI on the same port

Frontend-Backend Separation (optional, with nginx):

  • Add USE_NGINX=true during installation
  • Frontend served by nginx on port 80, API on port 9375

One-line Installation (Linux & macOS)

# All-in-one (default)
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | sh

# Backend only (users connect via local desktop app)
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | NO_WEB=true sh

# With nginx for frontend-backend separation
curl -fsSL https://raw.githubusercontent.com/camthink-ai/NeoMind/main/scripts/install.sh | USE_NGINX=true sh

Full Changelog: v0.9.20...v0.9.21