#!/bin/sh set -e # ── Pre-flight: verify volume mount ────────────────────────── # Without a volume mount, the vault key and all data are lost on restart. PREFLIGHT_FILE="$HOME/.lynox/.volume-check" if printf 'ok' > "$PREFLIGHT_FILE" 2>/dev/null && [ -f "$PREFLIGHT_FILE" ]; then rm -f "$PREFLIGHT_FILE" else echo "" >&2 echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" >&2 echo " WARNING: Volume mount not detected or not writable!" >&2 echo " Data will be LOST when this container restarts." >&2 echo "" >&2 echo " Add to your docker run command:" >&2 echo " -v ~/.lynox:/home/lynox/.lynox" >&2 echo "" >&2 echo " Or in docker-compose.yml:" >&2 echo " volumes:" >&2 echo " - \${HOME}/.lynox:/home/lynox/.lynox" >&2 echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" >&2 echo "" >&2 fi # Where the vault key comes from, for the engine to report (`LYNOX_VAULT_KEY_SOURCE`, read in # src/core/vault-key-source.ts): env | volume-env | minted | none. Set here whatever the # environment said, so a value from outside cannot claim a source. if [ -n "${LYNOX_VAULT_KEY:-}" ]; then KEY_SOURCE=env else KEY_SOURCE=none fi # Load vault key from .env if it exists ENV_FILE="$HOME/.lynox/.env" if [ -z "${LYNOX_VAULT_KEY:-}" ] && [ -f "$ENV_FILE" ] && [ ! -L "$ENV_FILE" ]; then FILE_PERMS=$(stat -c '%a' "$ENV_FILE" 2>/dev/null || stat -f '%Lp' "$ENV_FILE" 2>/dev/null || echo "unknown") case "$FILE_PERMS" in 600|400) VAULT_KEY=$(grep '^LYNOX_VAULT_KEY=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-) if [ -n "$VAULT_KEY" ]; then export LYNOX_VAULT_KEY="$VAULT_KEY" KEY_SOURCE=volume-env fi ;; esac fi # Auto-generate access token if not set TOKEN_FILE="$HOME/.lynox/.access-token" if [ -z "${LYNOX_HTTP_SECRET:-}" ]; then # Reuse persisted token from previous run (survives container restarts) if [ -f "$TOKEN_FILE" ]; then LYNOX_HTTP_SECRET=$(cat "$TOKEN_FILE") export LYNOX_HTTP_SECRET echo " Access token loaded from volume (not shown in logs)." >&2 echo " Retrieve: cat ~/.lynox/.access-token (on the host)" >&2 echo " with the documented \${HOME}/.lynox volume mount" >&2 echo "" >&2 else LYNOX_HTTP_SECRET=$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))") export LYNOX_HTTP_SECRET # Persist to volume so it survives container restarts mkdir -p "$HOME/.lynox" printf '%s' "$LYNOX_HTTP_SECRET" > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" # POSIX-compatible 8-char preview — entrypoint runs under dash (`/bin/sh` of the # node:22-slim image), which does not support bash's ${VAR:0:8} substring # expansion (was emitting `Bad substitution` on every fresh boot, leaving # users with no token preview at all — pre-HN-launch P0 fix). TOKEN_PREVIEW=$(printf '%s' "$LYNOX_HTTP_SECRET" | head -c 8) echo "" >&2 echo "========================================" >&2 echo " Access Token (enter in browser):" >&2 echo " ${TOKEN_PREVIEW}..." >&2 echo "========================================" >&2 echo " Stored in volume — same token on every restart." >&2 echo " Full token: cat ~/.lynox/.access-token (on the host)" >&2 echo " with the documented \${HOME}/.lynox volume mount" >&2 echo " Override: set LYNOX_HTTP_SECRET in .env or docker run" >&2 echo "" >&2 fi fi # Auto-generate vault key if not set (persist to volume). A new key is generated only where # it cannot replace or sit beside one that data was encrypted under: # - not when the data dir already holds a vault. The engine then decides as it does without # this script: it reads the key from $ENV_FILE if it can, then the data dir's vault.key, and # never generates a key over an existing vault (`ensureVaultKey`). # - not into a .env that is not a regular file with mode 600, or that names LYNOX_VAULT_KEY # outside a comment line although the reader above took no key from it (an empty value, an # indented line, a byte-order mark, a space before `=`). The start stops instead. A second key line would split the readers: the installer reads the first key line # and takes an empty one for "no key" (`readVaultKeyFromRecoveryFile`), so it would write a # new key over the one appended here. # - the key is appended, so other lines in the file stay. # The data dir follows the engine's rule (`lynoxDirFor`): LYNOX_DATA_DIR, then LYNOX_DIR, an # empty value counting as unset. DATA_DIR="${LYNOX_DATA_DIR:-${LYNOX_DIR:-$HOME/.lynox}}" if [ -z "${LYNOX_VAULT_KEY:-}" ] && [ -e "$DATA_DIR/vault.db" ]; then if [ ! -e "$DATA_DIR/vault.key" ]; then echo "" >&2 echo " WARNING: this start found no vault key, and $DATA_DIR/vault.db already exists." >&2 echo " No new key was generated: it could not open what is encrypted there." >&2 echo " If lynox then logs \"Encrypted vault found but LYNOX_VAULT_KEY is not set\" or" >&2 echo " \"History: encrypted data found but no encryption key set\", or your chats, saved" >&2 echo " connections and API keys are missing, set LYNOX_VAULT_KEY to the key this install was" >&2 echo " created with (docker run -e, or the compose .env), or put it in $ENV_FILE with mode 600." >&2 echo "" >&2 fi elif [ -z "${LYNOX_VAULT_KEY:-}" ]; then ENV_REFUSED="" if [ -L "$ENV_FILE" ]; then ENV_REFUSED="it is a symbolic link" elif [ -e "$ENV_FILE" ] && [ ! -f "$ENV_FILE" ]; then ENV_REFUSED="it is not a regular file" elif [ -e "$ENV_FILE" ]; then case "$(stat -c '%a' "$ENV_FILE" 2>/dev/null || stat -f '%Lp' "$ENV_FILE" 2>/dev/null || echo unknown)" in 600) # Any mention outside a comment counts, not only the shapes a reader takes a key from: # the three readers (this script, the engine, the installer) differ in what they trim. # -a: a NUL byte anywhere would make grep treat the file as binary and print nothing. if grep -a -v '^[[:space:]]*#' "$ENV_FILE" 2>/dev/null | grep -a -q 'LYNOX_VAULT_KEY'; then ENV_REFUSED="it names LYNOX_VAULT_KEY in a line this start cannot read a key from" fi ;; 400) ENV_REFUSED="it is read-only (mode 400), and this start read no key from it" ;; *) ENV_REFUSED="its mode is not 600 or 400, so it was not read" ;; esac fi if [ -n "$ENV_REFUSED" ]; then echo "" >&2 echo " ERROR: no vault key is set, and no new key can be written to $ENV_FILE: $ENV_REFUSED." >&2 echo " A new key there could replace or sit beside the one your data is encrypted under, so" >&2 echo " lynox does not start. Set LYNOX_VAULT_KEY, or make the file a regular file with mode 600" >&2 echo " (chmod 600) that holds the key on one line: LYNOX_VAULT_KEY=." >&2 echo "" >&2 exit 1 fi LYNOX_VAULT_KEY=$(node -e "process.stdout.write(require('crypto').randomBytes(48).toString('base64'))") export LYNOX_VAULT_KEY KEY_SOURCE=minted mkdir -p "$HOME/.lynox" # A new file is created with mode 600; an existing one is appended to only at mode 600. # The leading newline keeps the key off a last line that has none (`FOO=bar` would become # `FOO=barLYNOX_VAULT_KEY=…`, which no reader takes a key from). (umask 077 && printf '\nLYNOX_VAULT_KEY=%s\n' "$LYNOX_VAULT_KEY" >> "$ENV_FILE") echo " Vault key generated and saved to volume." echo " Save it to a password manager: Settings → Config → Security" echo "" else echo " Vault key active — data is encrypted." echo "" fi # Check ANTHROPIC_API_KEY (warn but don't exit — browse mode still works) if [ -z "${ANTHROPIC_API_KEY:-}" ]; then CONFIG_FILE="$HOME/.lynox/config.json" if [ ! -f "$CONFIG_FILE" ] || ! grep -q '"api_key"' "$CONFIG_FILE" 2>/dev/null; then echo "" >&2 echo " Warning: ANTHROPIC_API_KEY not set" >&2 echo " AI responses disabled — browse mode only" >&2 echo " Set via Web UI Settings or: docker run -e ANTHROPIC_API_KEY=sk-ant-... ..." >&2 echo "" >&2 fi fi # Pre-create + warn-if-unwritable for the data + cache subdirs the engine # expects. The mkdir is important because docker-compose mounts a tmpfs at # /home/lynox/.cache, which masks the Dockerfile's build-time directory # creation — without the mkdir here the subsequent Xenova ONNX cache write # fails with ENOENT on the first web_research call. The writability check # stays as a defensive signal in case a misconfigured host-volume mount # strips owner. for dir in "$HOME/.lynox" "$HOME/.cache/huggingface"; do mkdir -p "$dir" 2>/dev/null || true if [ ! -w "$dir" ] 2>/dev/null; then echo "Warning: $dir is not writable by user $(id -u). Data persistence may fail." >&2 fi done # Detect ownership drift inside ~/.lynox/. The base image's default user used # to be `node` (uid 1000) before we switched to `lynox` (uid 1001). Volumes # persisted across that switch keep their old ownership, which makes file- # write tools (api_setup refine, artifact save, …) fail silently — the # directory is writable so the dir-check above passes, but individual file # rewrites inside it return EACCES. Surface the diagnosis so the operator # knows the one-liner fix instead of debugging "refine doesn't work". CURRENT_UID=$(id -u) # The container may run as any host uid (the installer sets `user:` to the host user's), and most # have no name in the image. `id -un` then fails, and under `set -e` a failed command substitution # in an assignment ends the script: the container restarted in a loop before the engine started. CURRENT_USER=$(id -un 2>/dev/null) || CURRENT_USER="(no name)" if [ -d "$HOME/.lynox" ]; then # Limit to the first few mismatched paths so the log stays readable; the # operator only needs to know *that* there are wrong-owned files, not the # full list. Errors from `find` (e.g. unreadable subdirs) are silenced. WRONG_OWNED=$(find "$HOME/.lynox" -mindepth 1 -not -uid "$CURRENT_UID" 2>/dev/null | head -3) if [ -n "$WRONG_OWNED" ]; then echo "" >&2 echo " WARNING: files in $HOME/.lynox are owned by another user." >&2 echo " The container runs as '$CURRENT_USER' (uid $CURRENT_UID), but some files were created" >&2 echo " by an older image version under a different uid. Examples:" >&2 echo "$WRONG_OWNED" | sed 's/^/ /' >&2 echo "" >&2 echo " Symptom: api_setup refine, artifact save, and other in-place rewrites fail with EACCES." >&2 echo " Fix from the host (one-time):" >&2 echo " docker compose stop lynox" >&2 echo " chown -R \$(docker exec lynox id -u):\$(docker exec lynox id -g) " >&2 echo " docker compose start lynox" >&2 echo "" >&2 fi fi # SvelteKit CSRF: ORIGIN must match the browser's Origin header on form POSTs. # Behind a reverse proxy / tunnel, the browser sends the public URL as Origin, # but the server sees localhost — causing a CSRF mismatch (403). if [ -z "${ORIGIN:-}" ]; then if [ -n "${LYNOX_ALLOWED_ORIGINS:-}" ]; then # Use the first allowed origin (comma-separated list) as the CSRF origin export ORIGIN="${LYNOX_ALLOWED_ORIGINS%%,*}" elif [ -n "${LYNOX_TLS_CERT:-}" ]; then export ORIGIN="https://localhost:${LYNOX_HTTP_PORT:-3000}" else export ORIGIN="http://localhost:${LYNOX_HTTP_PORT:-3000}" fi fi export LYNOX_VAULT_KEY_SOURCE="$KEY_SOURCE" # Single process: Engine auto-loads Web UI handler from /app/web-ui/handler.js echo "Starting lynox on port ${LYNOX_HTTP_PORT:-3000}..." echo " Phone access: Settings → Mobile Access → scan QR code" >&2 exec node /app/dist/index.js --http-api