v0.9.21
[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 againstneomind serveshowed 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, thenPOST /api/system/upgradedrives the whole thing with live progress (SystemUpgradeProgressWS 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 adocker compose pull && up -dhint 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-gatedadmin_routesgroup (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
neominduser (ProtectSystem=full+NoNewPrivileges=true): it can neither write/usr/local/binnor 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 intodata/upgrade/v<ver>/(2GB cap, downloaded binary--version-verified before anything is touched) and writesapply.trigger. A new rootneomind-upgrade-apply.pathunit watches that file with inotify — no sudoers, no polkit, nothing relaxed in the main unit's sandbox — and startsneomind-upgrade-apply.service, which runsneomind upgrade --apply-staged --yesas root: back up.bak→install -m 755atomic swap → web-dir stage-swap →systemctl restart neomind.scripts/install.shwrites 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 byneomind 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 ignoredNEOMIND_WEB_DIR(hardcoded/var/www/neomind), and it chowned the swapped dir toneomind:neomindunconditionally (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,.bakrollback 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 NONEOMIND_DATA_DIR, so its store tree is${DATA_DIR}/datavia 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 pinnedpending_count ≥ 1forever, permanently blinding hang detection for that extension);flushSyncclears 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 danglingpathsreferences, 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-featuresas 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/crashedTooltipgained zh/en strings, and the danglingimportLocalBadgereference 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/andextensions/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.redbsplit the two whenNEOMIND_DATA_DIRpointed 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/extensionsnow persists the RESOLVED canonical path, not the raw request value, soload_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.comwith 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_URLenv > 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_MSletsneomind serveexit 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_*onIsolatedExtensionConfig); 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:Pongcarried norequest_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 stateCrashed, 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_metricsused 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/PushOutputcarriedVec<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 existingbase64_vechelper (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_pathrequest 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/syncactually installs. It reportedinstalled: N, upgraded: Mwhile doing nothing —process_nep_fileclassified packages and returned without touching the disk. It now installs (on the blocking pool — the async installer holds a non-SendZipFileacross 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-relativeextensions/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, andNEOMIND_STRICT_PACKAGE_SHA256=1refuses 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/registerwas a public, unconditionally-open account-creation endpoint on a server that binds 0.0.0.0 — any LAN client could mint aUserRole::Useraccount. It now returns 403 unless an admin opens it viaPUT /api/settings/registration(GETreads 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-onlyPOST /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_keyandLlmBackendInstance.api_keywere stored as plaintext JSON insettings.redbwhile 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 sharedCryptoService(samedata/encryption_keythe auth store uses;NEOMIND_ENCRYPTION_KEYstill 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). CryptoServicemoved from neomind-api toneomind_core::crypto(api re-exports it, socrate::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::backupcopies every*.redbplus the two secret files (encryption_key— without it the sealed LLM keys in the backup are undecryptable — and.jwt_secret) intodata/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 amanifest.json, and only then renames the….tmpdir 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) andGET /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=0disables,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, plusneomind_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_VERSIONbumps 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 threeservespawn 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 behindspawn_blocking,let-else→?, field-reassign-with-default in test builders, redundant closures/field names, two type-complexity aliases, alarge_enum_variantboxed, a dead&& falseleft over from the asset-path traversal fix, a vacuouslen() > 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 --urlwhere the real surface is--type/--config), JSON-blob quoting inside--body/--config, and models reverse-engineering the surface at runtime via--help | headpipes. The model even invented a flat flag syntax forrule create(--trigger-device/--operator/--threshold) that didn't exist — this release ships that syntax. --param key=valuerepeatable flag ondevice controlandmessage channel-create(newkv.rs: first=splits so values may contain=; conservative type inference —true/false→bool, numbers only when they round-trip without leading zeros so007stays a string; JSON form still accepted,--paramentries 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 createflag 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;--cooldowndefaults 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 updateaccepts--id <ID>alongside the positional form (exactly one; clap-enforced). Eval traces showed models coming off arule createresponse habitually writerule 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=vand 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 atneomind::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 (
--urlguess → clap error →--param url=...success). On the full building deploy case, the model converged to six consecutive correct fast-pathrule createcalls (allsuccess:true, rule_count 6/5) plus a--paramchannel-create — the case still HARD_FAILs only because the model omits thedevice controlstep entirely (multi-step planning, not flag friction). Two follow-up surface findings from the same traces:rule update's--idtemptation (fixed above), and flat-flag guesses ondevice control(--valve-open true) that self-correct via--parambut 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-all320 cases 0 failed, skill-cli drift green). Three newsurface-microcases (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_testsin neomind-core gained the missing#[cfg(test)]gate (itsuse 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
--lockedinvocation 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.0for local gates). - linux-only clippy fix: two
&mut *process_guardexplicit derefs in the extension kill paths tripexplicit_auto_derefonly on the linux build (cfg-dependent code), invisible to every macOS check since the hang-detection commit. - Docs sweep: extension-ID examples drop the
-v2suffix across skill guide, SDK readmes, verification script, DESIGN_SPEC.
Downloads
Desktop Application (Recommended for Personal Use)
- macOS: Download
.dmgfile - Windows: Download
.msiinstaller or.exeportable - Linux: Download
.AppImage(universal) or.deb(Debian/Ubuntu)
Server Deployment
-
Backend Server: Download
neomind-server-{os}-{arch}.tar.gz- Extract and run:
./neomind serve - Default port: 9375
- Extract and run:
-
Frontend Static Files: Download
neomind-web.tar.gz
All-in-One (default, no nginx needed):
- Extract frontend to
/var/www/neomind(or configureNEOMIND_WEB_DIR) - The server serves both API and Web UI on the same port
Frontend-Backend Separation (optional, with nginx):
- Add
USE_NGINX=trueduring 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 shFull Changelog: v0.9.20...v0.9.21