#!/usr/bin/env bash # ── fallyn.ai ─────────────────────────────────────────────────────────────── # This installer came from fallyn.ai and is pinned to the Fallyn model # (inference-fallyn). # # The installer body uses bash arrays. /bin/sh is dash on Debian and Ubuntu, # and dash cannot parse them, so this check runs first and says what to do # instead of failing later with a syntax error. if [ -z "${BASH_VERSION:-}" ]; then echo "install: this installer needs bash — re-run it as:" >&2 echo "install: curl -fsSL | bash" >&2 exit 1 fi # # The API base is chosen here, on your machine, because only your machine # knows what it can reach: the branded host is preferred, and the shared # inference.vip API is used when the branded one does not answer. Both are # the SAME API, so the account and the entitlement gate are the same too. command -v curl >/dev/null 2>&1 || { echo "install: curl is required" >&2; exit 1; } FALLYN_API="" for _fallyn_candidate in https://api.fallyn.ai https://api.inference.vip; do if curl -fsS -o /dev/null --connect-timeout 5 -m 15 "$_fallyn_candidate/api/console/release/manifest.json" 2>/dev/null; then FALLYN_API="$_fallyn_candidate" break fi done [ -n "$FALLYN_API" ] || { echo "install: could not reach the Fallyn API from this machine" >&2; exit 1; } INFERENCE_API_BASE="$FALLYN_API" export INFERENCE_API_BASE echo "install: fallyn.ai — model inference-fallyn, api $FALLYN_API" >&2 # ── end fallyn.ai ─────────────────────────────────────────────────────────── # inference.vip installer (ADR-005) — minimal, auditable, cryptographically verified. # What this does, in order: # 1. detect OS/arch, require node >= 18 # 2. fetch the signed release manifest from api.inference.vip # 3. verify the manifest's Ed25519 signature with the release pubkey embedded below # 4. download the CLI artifact, verify size + SHA-256 # 5. install atomically into ~/.local/bin/inference (no sudo, no shell-rc edits) # 6. run `inference login` (device flow — no token is ever pasted) unless --no-login # # Note on `rm -rf` usage: this script intentionally does NOT use `rm` at all. # We rely on Node.js (which is required below) to do the cleanup via # `fs.rmSync`. This means the script runs cleanly under any "safe rm" wrapper # that proxies `rm` (a common developer-machine pattern that blocks raw # `rm -rf`). set -eu API="${INFERENCE_API_BASE:-https://api.inference.vip}" # Optional deterministic model contract from /install?model=. Empty means the # legacy generic install (no model is exported). The value is validated server-side. INFERENCE_MODEL='inference-fallyn' INFERENCE_MODELS='inference-balanced|MiniMax M3.1 Flash,inference-lite|GPT OSS 120B,inference-reasoner|GLM 5.2,inference-glm-free|GLM 5.2 Free,inference-k3|Kimi K3,inference-fallyn|Fallyn' [ -n "$INFERENCE_MODEL" ] && export INFERENCE_MODEL || true [ -n "$INFERENCE_MODELS" ] && export INFERENCE_MODELS || true RELEASE_PUBKEY='MCowBQYDK2VwAyEAPlvAg+e8vkuj8dobnsPXN+nkJ6NciUiLJJxBW5JWRJ0=' NO_LOGIN=0 [ "${1:-}" = "--no-login" ] && NO_LOGIN=1 fail() { echo "install: $*" >&2; exit 1; } # --- 0. banner ---------------------------------------------------------------- cat <<'EOF' _____ _ _ _ __ ___ _ | ___/ \ | | | | \ \ / / \ | | | |_ / _ \ | | | | \ V /| \| | | _/ ___ \| |___| |___| | | |\ | |_/_/ \_\_____|_____|_| |_| \_| EOF printf '\n' printf ' \033[1;36m%s\033[0m\n' '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' printf ' \033[1;36m┃ FALLYN · a fully unrestricted model for security research · paid accounts + admins FALLYN ┃\033[0m\n' printf ' \033[1;36m%s\033[0m\n\n' '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' echo " This installs the inference CLI with the model pinned to inference-fallyn." echo " The CLI itself is the same one inference.vip uses; the model is Fallyn." # --- 1. platform + node ---------------------------------------------------- OS="$(uname -s)"; ARCH="$(uname -m)" case "$OS" in Linux) P=linux ;; Darwin) P=darwin ;; *) fail "unsupported platform: $OS (Windows: use install.ps1)" ;; esac case "$ARCH" in x86_64|amd64) A=x86_64 ;; arm64|aarch64) A=arm64 ;; *) fail "unsupported architecture: $ARCH" ;; esac PLATFORM="$P-$A" command -v node >/dev/null 2>&1 || fail "node is required (>= 18). Install from https://nodejs.org and re-run." NODE_MAJOR="$(node -e 'process.stdout.write(String(parseInt(process.version.slice(1),10)))')" [ "$NODE_MAJOR" -ge 18 ] || fail "node >= 18 is required (found $(node -v))" command -v curl >/dev/null 2>&1 || fail "curl is required" # --- 2. fetch manifest into a temp workspace -------------------------------- TMP="$(mktemp -d)" # Cleanup uses Node.js (we already require it below) to avoid `rm` entirely. # This makes the script compatible with developer-machine "safe rm" wrappers # that block raw `rm -rf`. We use a function — NOT a single-quoted trap body — # for two reasons: # 1. A single-quoted trap body would never expand `$TMP` (single quotes prevent # all expansion, even at trap-fire time), so the cleanup would try to # delete the literal string "$TMP" instead of the real temp dir. # 2. Trap body is parsed as a single string; a stray token (e.g. EXIT meant # as the signal spec but written inside the quotes) gets interpreted as a # command argument and the whole trap fails with # "trap: usage: trap [-lp] [arg signal_spec ...]". # Defining a function lets `$TMP` expand at fire time (when EXIT actually # triggers) and keeps the trap invocation itself trivial. cleanup_tmp() { node -e "require('fs').rmSync(process.argv[1], { recursive: true, force: true })" "$TMP" } trap cleanup_tmp EXIT # Resilient fetch: 3 attempts with linear backoff, used for the manifest AND the # artifact so a transient network blip never kills a good install. fetch_retry() { # fetch_retry _u="$1"; _o="$2"; _i=1 while [ "$_i" -le 3 ]; do if curl -fsSL --connect-timeout 10 -m 120 "$_u" -o "$_o" 2>/dev/null; then return 0; fi [ "$_i" -lt 3 ] && echo "install: download attempt $_i failed — retrying…" >&2 && sleep $((_i * 2)) _i=$((_i + 1)) done return 1 } fetch_retry "$API/api/console/release/manifest.json" "$TMP/manifest.json" || fail "could not fetch the release manifest after 3 attempts" # --- 3. verify the manifest signature (Ed25519, embedded release pubkey) ---- cat > "$TMP/verify.mjs" <<'EOF' import fs from 'node:fs'; import crypto from 'node:crypto'; const [manifestPath, pubB64, platform] = process.argv.slice(2); const canonical = (v) => Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : v && typeof v === 'object' ? '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}' : JSON.stringify(v); const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); if (!m.signature) { console.error('UNSIGNED'); process.exit(2); } const { signature, ...unsigned } = m; const ok = crypto.verify(null, Buffer.from(canonical(unsigned)), crypto.createPublicKey({ key: Buffer.from(pubB64, 'base64'), format: 'der', type: 'spki' }), Buffer.from(signature, 'base64')); if (!ok) { console.error('BAD_SIGNATURE'); process.exit(3); } const art = m.artifacts.find((a) => `${a.platform}-${a.arch}` === platform); if (!art) { console.error('NO_ARTIFACT'); process.exit(4); } process.stdout.write(JSON.stringify({ url: art.url, sha256: art.sha256, size: art.size, version: m.version })); EOF ARTJSON="$(node "$TMP/verify.mjs" "$TMP/manifest.json" "$RELEASE_PUBKEY" "$PLATFORM")" || { code=$? case $code in 2) fail "release manifest is UNSIGNED — aborting (possible tampering)";; 3) fail "release manifest signature INVALID — aborting (possible tampering)";; 4) fail "no artifact for $PLATFORM in the release manifest";; *) fail "manifest verification failed";; esac } ART_URL="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).url)' "$ARTJSON")" ART_SHA="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).sha256)' "$ARTJSON")" ART_SIZE="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).size))' "$ARTJSON")" ART_VER="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$ARTJSON")" case "$ART_URL" in http*) ;; # P30 (2026-09-02): the manifest signature pins the artifact's relative path # to /releases/..., but Cloudflare WAF blocks that prefix on api.inference.vip. # We mirror the same content at /api/console/release/...; rewrite the path # here at extraction time (we can NOT sed the manifest file — that would # invalidate the Ed25519 signature above). /*) ART_URL="$API$(echo "$ART_URL" | sed 's|^/releases/|/api/console/release/|')" ;; *) fail "bad artifact url in manifest" ;; esac # --- 4. download artifact (retry x3 via fetch_retry), verify size + sha256 ----- fetch_retry "$ART_URL" "$TMP/inference.mjs" || fail "could not download the CLI artifact after 3 attempts" SIZE="$(wc -c < "$TMP/inference.mjs" | tr -d ' ')" [ "$SIZE" = "$ART_SIZE" ] || fail "artifact size mismatch — aborting" if command -v sha256sum >/dev/null 2>&1; then SHA="$(sha256sum "$TMP/inference.mjs" | cut -d' ' -f1)"; elif command -v shasum >/dev/null 2>&1; then SHA="$(shasum -a 256 "$TMP/inference.mjs" | cut -d' ' -f1)"; else fail "no sha256 tool found"; fi [ "$SHA" = "$ART_SHA" ] || fail "artifact checksum MISMATCH — aborting (possible tampering)" # --- 5. atomic install into ~/.local/bin (no sudo) --------------------------- # Layout: the CLI itself is installed as inference.mjs (an UNAMBIGUOUS ES-module # extension — some node builds silently misclassify a bare .js entry as CommonJS # and the CLI then exits with no output), plus a tiny `inference` shim that execs # it. The shim survives `inference update` (the CLI rewrites inference.mjs only). DEST_DIR="$HOME/.local/bin" mkdir -p "$DEST_DIR" [ -L "$DEST_DIR" ] && fail "$DEST_DIR is a symlink — refusing to install into it" [ -L "$DEST_DIR/inference" ] && fail "$DEST_DIR/inference is a symlink — refusing to overwrite" PERMS="$(ls -ld "$DEST_DIR" | cut -c8-10)" [ "$PERMS" = "rwx" ] && fail "$DEST_DIR is world-writable — refusing to install there" chmod 755 "$TMP/inference.mjs" # Atomic renames: temp name then final name, so no partial-binary window. mv "$TMP/inference.mjs" "$DEST_DIR/.inference.new.$$" mv "$DEST_DIR/.inference.new.$$" "$DEST_DIR/inference.mjs" cat > "$DEST_DIR/.inference-shim.$$" <<'SHIMEOF' #!/bin/sh # inference.vip CLI launcher — execs the ES-module CLI. Survives `inference update`. exec node "$HOME/.local/bin/inference.mjs" "$@" SHIMEOF chmod 755 "$DEST_DIR/.inference-shim.$$" mv "$DEST_DIR/.inference-shim.$$" "$DEST_DIR/inference" # --- 5b. post-install verification (self-healing) ------------------------------ # The install is only good if `inference version` prints an x.y.z version. If the # shim path misbehaves on some exotic node, fall back to calling node directly and # rebuild the shim for that environment before ever telling the user it's done. verify_cli() { _v=$("$DEST_DIR/inference" version 2>/dev/null || true) case "$_v" in *.*.*) [ -n "$_v" ] && return 0 ;; esac _v=$(node "$DEST_DIR/inference.mjs" version 2>/dev/null || true) case "$_v" in *.*.*) [ -n "$_v" ] || return 1 # Rebuild the shim to pin `node` explicitly for this box, then re-check. printf '#!/bin/sh\nexec %s "$HOME/.local/bin/inference.mjs" "$@"\n' "$(command -v node)" > "$DEST_DIR/inference" chmod 755 "$DEST_DIR/inference" _v=$("$DEST_DIR/inference" version 2>/dev/null || true) case "$_v" in *.*.*) [ -n "$_v" ] && return 0 ;; esac ;; esac return 1 } verify_cli || fail "the CLI installed but does not run on this machine (try: node $DEST_DIR/inference.mjs version). Please report this to support." CLI_VER="$("$DEST_DIR/inference" version 2>/dev/null || echo "$ART_VER")" # Capture the pre-install version BEFORE the atomic rename so we can tell the # user this run is an UPGRADE rather than a fresh install. Best-effort: any # failure here is non-fatal (we just skip the upgrade note). Mirrors the # existing-install detection in install.ps1 so the two installers give # consistent messaging on re-run. PREV_VER="" if [ -f "$DEST_DIR/inference.mjs" ]; then PREV_VER="$(node "$DEST_DIR/inference.mjs" version 2>/dev/null || true)" PREV_VER="$(printf '%s' "$PREV_VER" | tr -d '[:space:]')" fi # --- 6. PATH note (printed, never written) + login --------------------------- case ":$PATH:" in *":$DEST_DIR:"*) ;; *) echo echo "note: $DEST_DIR is not on your PATH. Add it with:" echo " export PATH=\"$DEST_DIR:\$PATH\"" ;; esac echo if [ -n "$PREV_VER" ] && [ "$PREV_VER" != "$CLI_VER" ]; then echo "inference CLI upgraded: $PREV_VER -> $CLI_VER (in $DEST_DIR)" else echo "inference CLI v$CLI_VER installed to $DEST_DIR/inference" fi # --- 7. launch wizard ---------------------------------------------------------- # Interactive when a real terminal is attached. Answers are read from /dev/tty # so the prompts still work when the script itself was piped (curl … | sh). # With no TTY (CI / cron) the recommended defaults are used and the script # keeps going unattended. Env overrides for automation: INFERENCE_AGENT, # INFERENCE_MODEL (an explicit ?model= URL contract always wins over the pick). AGENT="${INFERENCE_AGENT:-kimi}" MODEL="${INFERENCE_MODEL:-inference-balanced}" tty_ok() { [ -e /dev/tty ] && ( : /dev/null; } # --- arrow-key menu ------------------------------------------------------------ # pick_menu <opt1> [opt2 …] — arrow up/down + Enter. Prints the chosen # 1-based index. j/k and number keys also work. Falls back to "1" (the # recommended option) when there's no usable terminal, so unattended installs # never block. Works on bash 3.2 (macOS /bin/sh): keys are read byte-wise via dd # under a temporary raw stty mode, restored no matter how the menu exits. _read_key() { # prints the hex code of one keypress (arrows -> esc seq hex) _h=$(dd bs=1 count=1 2>/dev/null </dev/tty | od -An -tx1 | tr -d ' \n') if [ "$_h" = "1b" ]; then _h="${_h}$(dd bs=1 count=2 2>/dev/null </dev/tty | od -An -tx1 | tr -d ' \n')" fi printf '%s' "$_h" } pick_menu() { _title="$1"; shift _n=$#; _sel=1; _i=0 if ! tty_ok || [ "${TERM:-}" = "dumb" ]; then printf '1'; return; fi _stty=$(stty -g </dev/tty 2>/dev/null || echo '') [ -n "$_stty" ] || { printf '1'; return; } stty -icanon -echo min 1 time 0 </dev/tty 2>/dev/null || { printf '1'; return; } printf '\033[?25l' > /dev/tty 2>/dev/null || true while :; do printf '\r\033[K%s\n' "$_title" > /dev/tty _i=1 for _opt in "$@"; do if [ "$_i" -eq "$_sel" ]; then printf ' \033[36m❯\033[0m \033[1m%s\033[0m\033[K\n' "$_opt" > /dev/tty else printf ' %s\033[K\n' "$_opt" > /dev/tty fi _i=$((_i+1)) done _k=$(_read_key) case "$_k" in 0a|0d) break ;; # Enter 1b5b41|6b) _sel=$(( _sel > 1 ? _sel - 1 : _n )) ;; # up / k 1b5b42|6a) _sel=$(( _sel < _n ? _sel + 1 : 1 )) ;; # down / j 3[1-9]) _d=$(( 16#$_k - 16#30 )); [ "$_d" -le "$_n" ] && { _sel=$_d; break; } ;; esac printf '\033[%dA' $((_n+1)) > /dev/tty done printf '\033[?25h' > /dev/tty 2>/dev/null || true stty "$_stty" </dev/tty 2>/dev/null || true printf '%s' "$_sel" } if tty_ok; then printf '\n Quick setup — two questions, then you'"'"'re in. (↑/↓ to move, Enter to select)\n' > /dev/tty _agent_idx=$(pick_menu " Coding CLI:" "Kimi Code CLI (recommended)" "Claude Code CLI") [ "$_agent_idx" = "2" ] && AGENT="claude" || AGENT="kimi" # Parse the server-supplied `id|displayName,…` catalog (install.js substitutes this). # First entry gets the "(recommended)" marker so the wizard matches the MODEL # fallback below; everything else is appended verbatim. An empty catalog falls back # to the legacy hardcoded list so any older pre-rendered install.sh still parses. _model_labels=() _model_ids=() if [ -n "$INFERENCE_MODELS" ]; then _first=1 _old_ifs="$IFS" IFS=',' for _pair in $INFERENCE_MODELS; do _id="${_pair%%|*}" _name="${_pair#*|}" _model_ids[${#_model_ids[@]}]="$_id" if [ "$_first" = "1" ]; then _model_labels[${#_model_labels[@]}]="$_name (recommended)" _first=0 else _model_labels[${#_model_labels[@]}]="$_name" fi done IFS="$_old_ifs" fi if [ "${#_model_labels[@]}" -eq 0 ]; then # Legacy fallback (older install.sh without the catalog): matches the original UX. _model_labels=("MiniMax M3.1 Flash (recommended)" "GLM 5.2" "GPT OSS 120B" "Kimi K3") _model_ids=("inference-balanced" "inference-reasoner" "inference-lite" "inference-k3") fi _model_idx=$(pick_menu " Default model (switch anytime with /model):" "${_model_labels[@]}") # Map 1-based index back to the branded id. _picked_id="${_model_ids[$((_model_idx-1))]}" [ -n "$_picked_id" ] && MODEL="$_picked_id" fi # An explicit ?model= URL contract (rare, shareable links) beats the wizard pick. [ -n "$INFERENCE_MODEL" ] && MODEL="$INFERENCE_MODEL" echo echo "setup: cli=$AGENT model=$MODEL" # Persist the model choice so `inference run` and /model use it by default. "$DEST_DIR/inference" model set "$MODEL" >/dev/null 2>&1 || true if [ "$NO_LOGIN" = 0 ]; then # FALLYN: sign in with the visitor's own account. # # The canonical installer below prefers the guest quick-setup, which mints # a TRIAL identity. Fallyn is a paid feature — paid accounts and # administrators only — so a trial guest would be served a different # model: the install would look successful and the model would quietly be # something else. The device sign-in is the only path that can unlock # Fallyn, so it is tried first. If it cannot complete, the canonical guest # path below still runs, so the visitor ends up with a working CLI either # way. echo echo " Signing you in — Fallyn is available to paid accounts and administrators." if "$DEST_DIR/inference" login; then # Report the session state honestly. This says whether the CLI is # connected; it does NOT claim the account unlocks Fallyn, because # that is the API's rule and only the API can answer it. echo echo " Your session:" "$DEST_DIR/inference" status 2>&1 | sed 's/^/ /' || echo " (status unavailable — check it later with: inference status)" echo echo " Model: inference-fallyn" echo " If your first reply names a different model, your account does not" echo " unlock Fallyn — it is a paid feature. Sign in at https://fallyn.ai/chat" echo " to check what your account unlocks." exec "$DEST_DIR/inference" run "$AGENT" else echo " Device sign-in did not complete — continuing with the guest setup." echo " (Sign in at https://fallyn.ai/chat afterwards to unlock Fallyn.)" echo fi # Preferred path: the secure guest quick-setup. NO device-link approval — the # server mints a rate-limited guest identity (per-IP, 8/hr + session caps, so # trials can't be farmed), then a single-use, 10-minute hashed installer with # a managed key baked in. Fully automatic: paste one command, done. QS_JWT="" QS_RESP="$(curl -fsS -m 15 -X POST "$API/api/console/token" -H 'content-type: application/json' 2>/dev/null || true)" QS_JWT="$(printf '%s' "$QS_RESP" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).token||""))}catch{}})' 2>/dev/null || true)" QS_URL="" if [ -n "$QS_JWT" ]; then QS_RESP2="$(curl -fsS -m 15 -X POST "$API/api/console/quick-setup-token" -H "authorization: Bearer $QS_JWT" -H 'content-type: application/json' -d "{\"agent\":\"$AGENT\"}" 2>/dev/null || true)" QS_URL="$(printf '%s' "$QS_RESP2" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).url||""))}catch{}})' 2>/dev/null || true)" fi if [ -n "$QS_URL" ]; then # Fetch the single-use installer body FIRST (retry x3) and only exec it when # we actually got one — a transient 502 can never kill the install. QS_SCRIPT="" _i=1 while [ "$_i" -le 3 ] && [ -z "$QS_SCRIPT" ]; do QS_SCRIPT="$(curl -fsSL --connect-timeout 10 -m 30 "$QS_URL" 2>/dev/null || true)" [ -z "$QS_SCRIPT" ] && [ "$_i" -lt 3 ] && sleep $((_i * 2)) _i=$((_i + 1)) done if [ -n "$QS_SCRIPT" ]; then echo echo "setup: launching your secure $AGENT installer (no browser approval needed)…" INFERENCE_SETUP_AGENT="$AGENT" INFERENCE_SETUP_MODEL="$MODEL" exec bash -c "$QS_SCRIPT" fi fi # Fallback path: device login → inference run. Only reached if the guest # quick-setup is unavailable (rate-limited network, API blip). echo echo "setup: secure quick-setup unavailable right now — using device login instead." if "$DEST_DIR/inference" login; then # inference run auto-installs the chosen coding CLI on first use (Kimi Code # via the vendor installer, Claude Code via npm), writes the all-models # config, and launches straight into it. exec "$DEST_DIR/inference" run "$AGENT" else _rc=$? echo echo " ! login didn't complete (exit $_rc). Your CLI is installed and fine." echo " Retry when ready: $DEST_DIR/inference login" echo " Then launch: $DEST_DIR/inference run $AGENT" echo " Check connection: $DEST_DIR/inference status" exit $_rc fi else echo "when you're ready: $DEST_DIR/inference login && $DEST_DIR/inference run $AGENT" fi