#!/usr/bin/env bash # Install / update the Windsurf language server binary. # # Default path (manifest-driven, fail-closed): # 1. Read the reviewed pins from ls-pins.json. # 2. Fetch https://windsurf-stable.codeium.com/api/versions. # 3. Pick the newest pinned stable release (currently 3.10.67) and require # the manifest's commit + archive sha256 to match the pin. # 4. Download the desktop archive, verify its sha256, extract the LS # member, verify the inner binary sha256 against the pin, then # atomically rename it into place. # # There is no silent fallback: an unreachable manifest, a missing sha256 # tool, an unpinned version, or any hash mismatch exits non-zero before the # target is touched. The old third-party fallback hop is gone. # # Usage: # ./install-ls.sh # newest pinned stable release # ./install-ls.sh --version 3.10.67 # exact pinned upstream version # ./install-ls.sh --dry-run # print the plan, write nothing # ./install-ls.sh --rollback # pinned 3.5.17 mirror rollback # ./install-ls.sh --version 3.5.17 --allow-mirror # ./install-ls.sh --file /path/to/bin # install a local binary # ./install-ls.sh /path/to/bin # same # ./install-ls.sh --url --sha256 [--allowed-host ]... # # Environment overrides: # WINDSURFAPI_LS_VERSION=3.10.67 # same as --version # WINDSURFAPI_LS_CHANNEL=stable|next # manifest channel (default stable) # WINDSURFAPI_LS_MANIFEST_URL= # manifest override # WINDSURFAPI_LS_PINS=/path/to/ls-pins.json # WINDSURFAPI_LS_ALLOW_MIRROR=1 # permit mirror pins (3.5.17) # WINDSURFAPI_LS_ALLOW_UNPINNED=1 # permit versions absent from pins # WINDSURFAPI_LS_PLATFORM=linux-x64|linux-arm64|darwin-arm64|darwin-x64 # LS_INSTALL_PATH=/path/to/binary # install target # LS_INSTALL_STAGE_PATH=/path/to/staging # internal: stage before publish set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" >/dev/null 2>&1 && pwd)" LS_VERSION="${WINDSURFAPI_LS_VERSION:-}" LS_CHANNEL="${WINDSURFAPI_LS_CHANNEL:-stable}" LS_MANIFEST="${WINDSURFAPI_LS_MANIFEST_URL:-}" LS_PINS="${WINDSURFAPI_LS_PINS:-$SCRIPT_DIR/ls-pins.json}" ALLOW_MIRROR="${WINDSURFAPI_LS_ALLOW_MIRROR:-0}" ALLOW_UNPINNED="${WINDSURFAPI_LS_ALLOW_UNPINNED:-0}" PLATFORM_OVERRIDE="${WINDSURFAPI_LS_PLATFORM:-}" MIRROR_BASE="https://github.com/dwgx/windsurf-ls-release/releases/download" # Test/ops hook: a fake curl can be pointed at this path without changing # PATH (Git Bash prepends its own mingw64/bin, so PATH prepending is not # reliable on Windows). Defaults to the system curl. CURL_BIN="${WINDSURFAPI_LS_CURL:-curl}" DRY_RUN=0 ROLLBACK=0 INPUT_FILE="" INPUT_URL="" INPUT_SHA="" # Space-separated host allowlist for download/redirect hops. Empty means # "stay on the host of the initial URL" (strict same-host redirect policy). ALLOWED_HOSTS="" log() { echo -e "\033[1;34m==>\033[0m $*"; } err() { echo -e "\033[1;31m!!\033[0m $*" >&2; } usage() { sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//' } # Git Bash/MSYS runs POSIX paths, but the Windows curl/tar/unzip/gzip binaries # need native paths. Mixed mode (C:/...) works for both. On Linux/macOS # cygpath is absent and this is a no-op. native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1" else printf '%s' "$1" fi } # GNU tar treats a C:/... argument as a remote host. Convert target paths # back to POSIX form for MSYS tar/unzip/gzip on Git Bash. posix_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1" else printf '%s' "$1" fi } sha256_file() { # GNU coreutils prefixes the digest with a literal backslash when the file # name contains a backslash (Windows paths: C:\...). Strip it so callers # always compare 64 hex characters. if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' | sed 's/^\\//' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' | sed 's/^\\//' elif command -v node >/dev/null 2>&1; then node -e 'const c=require("crypto"),f=require("fs");const h=c.createHash("sha256");f.createReadStream(process.argv[1]).on("data",d=>h.update(d)).on("end",()=>process.stdout.write(h.digest("hex")+"\n")).on("error",e=>{console.error(e.message);process.exit(1)})' "$(native_path "$1")" else return 127 fi } # Authority (host[:port]) of an absolute URL, without userinfo/path/query. url_authority() { local rest="${1#*://}" rest="${rest%%/*}" rest="${rest%%\?*}" rest="${rest%%#*}" printf '%s' "${rest##*@}" } # Lowercase hostname of an absolute URL, without port/brackets. url_host() { local authority authority="$(url_authority "$1")" if [[ "$authority" == \[*\]* ]]; then authority="${authority#\[}" authority="${authority%%\]*}" else authority="${authority%%:*}" fi printf '%s' "$authority" | tr '[:upper:]' '[:lower:]' } host_in_list() { local host="$1" local list="$2" local candidate for candidate in $list; do [[ "$host" == "$candidate" ]] && return 0 done return 1 } # Resolve a Location header against the URL that produced it (absolute, # protocol-relative, root-relative, query-only and path-relative forms). resolve_redirect() { local base="$1" local location="$2" case "$location" in https://*) printf '%s' "$location" ;; //*) printf '%s:%s' "${base%%:*}" "$location" ;; /*) printf '%s://%s%s' "${base%%://*}" "$(url_authority "$base")" "$location" ;; \?*) printf '%s%s' "${base%%\?*}" "$location" ;; *) local origin="${base%%://*}://$(url_authority "$base")" local rest="${base#*://}" rest="/${rest#*/}" rest="${rest%%\?*}" local dir="${rest%/*}" [[ "$dir" == "/" ]] && dir="" printf '%s%s/%s' "$origin" "$dir" "$location" ;; esac } # Download to $dest. When allowed_hosts is non-empty every hop (initial URL # and each redirect Location) must use one of those exact hosts. When empty, # redirects may not leave the initial URL's host. curl is invoked with # --max-redirs 0 and each 3xx is followed here, so a redirect is validated # BEFORE any bytes from the next host are fetched. This closes the old # `curl -L` behaviour where an allowlisted github.com URL could bounce to an # arbitrary host and still be installed. download() { local url="$1" local dest="$2" local allowed_hosts="${3:-$ALLOWED_HOSTS}" case "$url" in file://*) local p="${url#file://}" # Windows file URLs look like file:///C:/...; Git Bash wants /c/... or C:/... case "$p" in /[A-Za-z]:/*) p="${p#/}" ;; esac if [[ ! -f "$p" ]]; then err "Local archive not found: $p" return 1 fi cp -f "$p" "$dest" ;; https://*) local current="$url" local initial_host initial_host="$(url_host "$url")" local hops=0 local max_hops=5 local header_file="$WORK_DIR/download-headers.$$" while :; do local host host="$(url_host "$current")" if [[ -z "$host" ]]; then err "Invalid download URL: $current" return 1 fi if [[ -n "$allowed_hosts" ]]; then if ! host_in_list "$host" "$allowed_hosts"; then err "Download host not allowed: $host (permitted: $allowed_hosts)" return 1 fi elif [[ "$host" != "$initial_host" ]]; then err "Refusing redirect to a different host: $host (initial: $initial_host)" return 1 fi : > "$header_file" local code if ! code="$("$CURL_BIN" -S --progress-bar --proto '=https' --tlsv1.2 --retry 3 --retry-delay 2 \ --max-time 900 --max-filesize 600000000 --max-redirs 0 \ -D "$header_file" -o "$dest" -w '%{http_code}' "$current")"; then err "Download failed: $current" return 1 fi case "$code" in 2??) rm -f "$header_file" return 0 ;; 301|302|303|307|308) local location location="$(awk 'BEGIN{IGNORECASE=1} /^[Ll]ocation:/{sub(/^[^:]*:[[:space:]]*/,""); sub(/\r$/,""); print; exit}' "$header_file")" if [[ -z "$location" ]]; then err "Redirect without a Location header: $current" return 1 fi hops=$((hops + 1)) if [[ "$hops" -gt "$max_hops" ]]; then err "Too many redirects (limit $max_hops): $url" return 1 fi current="$(resolve_redirect "$current" "$location")" ;; *) err "Download failed with HTTP $code: $current" return 1 ;; esac done ;; *) err "Refusing non-https download URL: $url" return 1 ;; esac } # ─── Argument parsing ────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --version) [[ -n "${2:-}" ]] || { err "--version needs a value"; exit 2; } LS_VERSION="$2"; shift 2 ;; --channel) [[ -n "${2:-}" ]] || { err "--channel needs a value"; exit 2; } LS_CHANNEL="$2"; shift 2 ;; --manifest) [[ -n "${2:-}" ]] || { err "--manifest needs a value"; exit 2; } LS_MANIFEST="$2"; shift 2 ;; --pins) [[ -n "${2:-}" ]] || { err "--pins needs a value"; exit 2; } LS_PINS="$2"; shift 2 ;; --platform) [[ -n "${2:-}" ]] || { err "--platform needs a value"; exit 2; } PLATFORM_OVERRIDE="$2"; shift 2 ;; --file) [[ -n "${2:-}" ]] || { err "--file needs a value"; exit 2; } INPUT_FILE="$2"; shift 2 ;; --url) [[ -n "${2:-}" ]] || { err "--url needs a value"; exit 2; } INPUT_URL="$2"; shift 2 ;; --sha256) [[ -n "${2:-}" ]] || { err "--sha256 needs a value"; exit 2; } INPUT_SHA="$2"; shift 2 ;; --allowed-host) [[ -n "${2:-}" ]] || { err "--allowed-host needs a value"; exit 2; } ALLOWED_HOSTS="${ALLOWED_HOSTS:+$ALLOWED_HOSTS }$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --rollback) ROLLBACK=1; ALLOW_MIRROR=1; shift ;; --allow-mirror) ALLOW_MIRROR=1; shift ;; --allow-unpinned) ALLOW_UNPINNED=1; shift ;; -h|--help) usage; exit 0 ;; -*) err "Unknown option: $1"; usage >&2; exit 2 ;; *) if [[ -f "$1" ]]; then INPUT_FILE="$1"; shift else err "Unknown argument: $1"; usage >&2; exit 2 fi ;; esac done if [[ -n "$INPUT_FILE" && -n "$INPUT_URL" ]]; then err "--file and --url are mutually exclusive"; exit 2 fi if [[ "$ROLLBACK" == "1" && -n "$LS_VERSION" ]]; then err "--rollback and --version are mutually exclusive"; exit 2 fi MODE="manifest" if [[ -n "$INPUT_FILE" ]]; then MODE="file" elif [[ -n "$INPUT_URL" ]]; then MODE="url" fi if [[ -n "$INPUT_SHA" ]]; then INPUT_SHA="$(printf '%s' "$INPUT_SHA" | tr 'A-F' 'a-f')" if [[ ! "$INPUT_SHA" =~ ^[0-9a-f]{64}$ ]]; then err "--sha256 must be 64 hex characters"; exit 2 fi fi # ─── Platform detection ──────────────────────────────── if [[ -n "$PLATFORM_OVERRIDE" ]]; then TARGET_ID="$PLATFORM_OVERRIDE" else os="$(uname -s)" arch="$(uname -m)" case "$os" in Linux) case "$arch" in x86_64|amd64) TARGET_ID='linux-x64' ;; aarch64|arm64) TARGET_ID='linux-arm64' ;; *) err "Unsupported Linux arch: $arch"; exit 1 ;; esac ;; Darwin) case "$arch" in x86_64) TARGET_ID='darwin-x64' ;; arm64) TARGET_ID='darwin-arm64' ;; *) err "Unsupported macOS arch: $arch"; exit 1 ;; esac ;; *) err "Unsupported OS: $os (only Linux and macOS are supported)" exit 1 ;; esac fi case "$TARGET_ID" in linux-x64) ASSET='language_server_linux_x64' DEFAULT_PATH="/opt/windsurf/${ASSET}" MEMBER_PATH_DEFAULT='Devin/resources/app/extensions/windsurf/bin/language_server_linux_x64' COMPRESSION_DEFAULT='tar.gz' ;; linux-arm64) ASSET='language_server_linux_arm' DEFAULT_PATH="/opt/windsurf/${ASSET}" MEMBER_PATH_DEFAULT='' COMPRESSION_DEFAULT='' ;; darwin-arm64) ASSET='language_server_macos_arm' DEFAULT_PATH="$HOME/.windsurf/${ASSET}" MEMBER_PATH_DEFAULT='Devin.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm' COMPRESSION_DEFAULT='zip' ;; darwin-x64) ASSET='language_server_macos_x64' DEFAULT_PATH="$HOME/.windsurf/${ASSET}" MEMBER_PATH_DEFAULT='Devin.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_x64' COMPRESSION_DEFAULT='zip' ;; *) err "Unsupported target: $TARGET_ID" exit 1 ;; esac # LS_INSTALL_STAGE_PATH lets the dashboard download/verify a custom binary in # a sibling staging file; the route publishes it with rename after its own # digest check. Normal installs have no stage path and write TARGET directly. TARGET="${LS_INSTALL_STAGE_PATH:-${LS_INSTALL_PATH:-$DEFAULT_PATH}}" log "Platform: $TARGET_ID → asset=$ASSET" log "Target: $TARGET" if [[ "$MODE" == "manifest" && -z "$MEMBER_PATH_DEFAULT" ]]; then err "Upstream does not ship a language server for $TARGET_ID." err "Supported upstream targets: linux-x64, darwin-arm64, darwin-x64." err "Copy the binary from a desktop install and use: bash install-ls.sh --file " exit 1 fi # ─── Temp files ──────────────────────────────────────── WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/windsurfapi-ls.XXXXXX")" TMP_TARGET="${TARGET}.new.$$" SIDECAR="${TARGET}.version.json" TMP_SIDECAR="${SIDECAR}.new.$$" # Keep the 345 MB archive on the target's filesystem, not in a possibly small # /tmp. The final binary is written to a sibling temp path so the mv is an # atomic rename; a running LS keeps its old inode and the next exec gets the # new binary without ETXTBSY ("Text file busy"). TMP_ARCHIVE="${TARGET}.archive.$$" TMP_MANIFEST="$WORK_DIR/manifest.json" cleanup() { rm -rf "$WORK_DIR" rm -f "$TMP_TARGET" "$TMP_SIDECAR" "$TMP_ARCHIVE" } trap cleanup EXIT INSTALLED_SHA='' # ─── Manual modes ────────────────────────────────────── if [[ "$MODE" == "file" ]]; then if [[ ! -f "$INPUT_FILE" ]]; then err "File not found: $INPUT_FILE"; exit 1 fi actual="$(sha256_file "$INPUT_FILE")" || { err "No sha256 tool available (sha256sum, shasum, or node is required)"; exit 1; } if [[ "$DRY_RUN" == "1" ]]; then echo "source=file" echo "file=$INPUT_FILE" echo "sha256=$actual" echo "install_path=$TARGET" exit 0 fi mkdir -p "$(dirname "$TARGET")" log "Installing from local file: $INPUT_FILE" cp -f "$INPUT_FILE" "$TMP_TARGET" if [[ -n "$INPUT_SHA" && "$actual" != "$INPUT_SHA" ]]; then err "Checksum mismatch for $INPUT_FILE" err "Expected: $INPUT_SHA" err "Actual: $actual" exit 1 fi INSTALLED_SHA="$actual" elif [[ "$MODE" == "url" ]]; then if [[ "$INPUT_URL" != https://* ]]; then err "Only https URLs are supported: $INPUT_URL"; exit 1 fi # A05: a custom URL has no pin/manifest trust anchor, so the operator must # supply the expected digest. Refuse before any network I/O or filesystem # write. The old flow printed a warning and installed unverified bytes. if [[ -z "$INPUT_SHA" ]]; then err "--url requires --sha256 <64 hex characters>; refusing an unverified download" exit 2 fi # Standalone GitHub release URLs legitimately redirect to GitHub's asset # CDNs. Keep that convenience without weakening the per-hop check: only # these fixed GitHub hosts are auto-allowed, and every other URL keeps the # strict same-host redirect policy. if [[ -z "$ALLOWED_HOSTS" ]]; then case "$(url_host "$INPUT_URL")" in github.com) ALLOWED_HOSTS="github.com objects.githubusercontent.com release-assets.githubusercontent.com api.github.com" ;; esac fi if [[ -n "$ALLOWED_HOSTS" ]]; then input_host="$(url_host "$INPUT_URL")" if ! host_in_list "$input_host" "$ALLOWED_HOSTS"; then err "Download host not allowed: $input_host (permitted: $ALLOWED_HOSTS)" exit 2 fi fi if [[ "$DRY_RUN" == "1" ]]; then echo "source=url" echo "url=$INPUT_URL" echo "sha256=$INPUT_SHA" echo "install_path=$TARGET" exit 0 fi mkdir -p "$(dirname "$TARGET")" log "Downloading from: $INPUT_URL" download "$INPUT_URL" "$TMP_TARGET" actual="$(sha256_file "$TMP_TARGET")" || { err "No sha256 tool available (sha256sum, shasum, or node is required)"; exit 1; } if [[ "$actual" != "$INPUT_SHA" ]]; then err "Checksum mismatch for $INPUT_URL" err "Expected: $INPUT_SHA" err "Actual: $actual" exit 1 fi log "Binary sha256 verified: $actual" INSTALLED_SHA="$actual" else # ─── Manifest mode ─────────────────────────────────── if [[ -z "$LS_MANIFEST" ]]; then case "$LS_CHANNEL" in stable) LS_MANIFEST='https://windsurf-stable.codeium.com/api/versions' ;; next) LS_MANIFEST='https://windsurf-next.codeium.com/api/versions' ;; *) err "Unsupported channel: $LS_CHANNEL (stable|next)"; exit 1 ;; esac fi command -v node >/dev/null 2>&1 || { err "Node.js is required to read the release manifest and pins"; exit 1 } MANIFEST_IS_LOCAL=0 MIRROR_PIN=0 if [[ "$ROLLBACK" == "1" ]]; then MIRROR_PIN=1 elif [[ -n "$LS_VERSION" && -f "$LS_PINS" ]]; then MIRROR_PIN="$(node -e ' const fs = require("fs"); const [pinsFile, target, version] = process.argv.slice(1); let pins; try { pins = JSON.parse(fs.readFileSync(pinsFile, "utf8")); } catch { process.stdout.write("0"); process.exit(0); } const releases = (pins && Array.isArray(pins.releases)) ? pins.releases : []; const hit = releases.find(r => r && r.version === version && r.source === "mirror" && r.targets && r.targets[target]); process.stdout.write(hit ? "1" : "0"); ' "$(native_path "$LS_PINS")" "$TARGET_ID" "$LS_VERSION")" || MIRROR_PIN=0 fi if [[ "$MIRROR_PIN" == "1" ]]; then log "Rollback pin selected; skipping the upstream manifest" elif [[ "$LS_MANIFEST" == https://* ]]; then log "Fetching manifest: $LS_MANIFEST" if ! curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 --max-filesize 8388608 \ -o "$TMP_MANIFEST" "$LS_MANIFEST"; then err "Could not fetch the release manifest: $LS_MANIFEST" err "Refusing to fall back to the stale mirror. Use --rollback for the pinned 3.5.17 mirror." exit 1 fi elif [[ "$LS_MANIFEST" == http://* ]]; then err "Refusing plain http manifest URL: $LS_MANIFEST"; exit 1 elif [[ -f "$LS_MANIFEST" ]]; then cp -f "$LS_MANIFEST" "$TMP_MANIFEST" MANIFEST_IS_LOCAL=1 else err "Manifest not found: $LS_MANIFEST"; exit 1 fi plan="$(node - "$(native_path "$TMP_MANIFEST")" "$(native_path "$LS_PINS")" "$TARGET_ID" "$LS_VERSION" "$LS_CHANNEL" \ "$ROLLBACK" "$ALLOW_UNPINNED" "$ALLOW_MIRROR" "$MANIFEST_IS_LOCAL" "$MIRROR_BASE" <<'NODE' const fs = require('fs'); const path = require('path'); const [ manifestFile, pinsFile, target, requested, channel, rollbackStr, allowUnpinnedStr, allowMirrorStr, manifestIsLocalStr, mirrorBase, ] = process.argv.slice(2); const rollback = rollbackStr === '1'; const allowUnpinned = allowUnpinnedStr === '1'; const allowMirror = allowMirrorStr === '1'; const manifestIsLocal = manifestIsLocalStr === '1'; const fail = (msg) => { process.stderr.write('install-ls: ' + msg + '\n'); process.exit(1); }; const HEX64 = /^[0-9a-f]{64}$/; const TARGETS = { 'linux-x64': { member: 'Devin/resources/app/extensions/windsurf/bin/language_server_linux_x64', compression: 'tar.gz' }, 'darwin-arm64': { member: 'Devin.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm', compression: 'zip' }, 'darwin-x64': { member: 'Devin.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_x64', compression: 'zip' }, }; if (!TARGETS[target]) fail('unsupported target: ' + target); const spec = TARGETS[target]; let pins = { schemaVersion: 1, releases: [] }; if (fs.existsSync(pinsFile)) { let raw; try { raw = fs.readFileSync(pinsFile, 'utf8'); } catch (e) { fail('cannot read pins ' + pinsFile + ': ' + e.message); } try { pins = JSON.parse(raw); } catch (e) { fail('cannot parse pins ' + pinsFile + ': ' + e.message); } if (!pins || pins.schemaVersion !== 1 || !Array.isArray(pins.releases)) fail('invalid pins file: ' + pinsFile); } else if (!allowUnpinned) { fail('pins file not found: ' + pinsFile + ' (pass --allow-unpinned to bypass)'); } const releases = pins.releases || []; const pinFor = (v) => releases.find(r => r && r.version === v && r.targets && r.targets[target]); function loadManifest() { let raw; try { raw = fs.readFileSync(manifestFile, 'utf8'); } catch (e) { fail('cannot read manifest: ' + e.message); } let arr; try { arr = JSON.parse(raw); } catch (e) { fail('manifest is not valid JSON: ' + e.message); } if (!Array.isArray(arr) || arr.length === 0) fail('manifest is not a non-empty array'); return arr; } const entryFor = (x) => (x && x.manifest && x.manifest[target] && typeof x.manifest[target] === 'object') ? x.manifest[target] : null; const verOf = (x) => { const m = entryFor(x); return String((x && x.version) || (m && m.windsurfVersion) || ''); }; const score = (x) => Math.max(0, ...Object.values((x && x.manifest) || {}).map(m => Number(m && m.timestamp) || 0)); function checkUrl(url, pinned) { let parsed; try { parsed = new URL(url); } catch { fail('invalid download URL: ' + url); } if (parsed.protocol === 'file:') { if (!manifestIsLocal) fail('file:// archive URLs are only allowed with a local manifest'); return; } if (parsed.protocol !== 'https:') fail('refusing non-https download URL: ' + url); if (!pinned) { const host = parsed.hostname; if (host !== 'windsurf-stable.codeiumdata.com' && host !== 'windsurf-next.codeiumdata.com') { fail('unpinned archive host not allowed: ' + host); } } } function emit(source, version, commit, url, archiveSha, member, binarySha, compression, archiveName, releaseTag, pinned, ch) { const fields = [source, version, commit, url, archiveSha, member, binarySha, compression, archiveName, releaseTag, pinned ? '1' : '0', ch]; for (const value of fields) { if (/[|\r\n]/.test(String(value))) fail('resolved field contains a forbidden delimiter'); } process.stdout.write(fields.join('|') + '\n'); } let chosenPin = null; let chosenEntry = null; let source = 'upstream'; if (rollback) { const mirrors = releases.filter(r => r && r.source === 'mirror' && r.targets && r.targets[target]); mirrors.sort((a, b) => String(b.releasedAt || '').localeCompare(String(a.releasedAt || ''))); if (!mirrors.length) fail('no mirror rollback pin for ' + target + ' in ' + pinsFile); chosenPin = mirrors[0]; source = 'mirror'; } else if (requested) { if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(requested)) fail('invalid version: ' + requested); const p = pinFor(requested); if (p) { chosenPin = p; source = p.source === 'mirror' ? 'mirror' : 'upstream'; } else if (allowUnpinned) { source = 'upstream'; } else { fail('version ' + requested + ' is not pinned in ' + pinsFile + ' (pass --allow-unpinned to bypass)'); } } if (source === 'mirror') { if (!allowMirror) fail('version ' + chosenPin.version + ' is a mirror rollback pin; re-run with --allow-mirror (or --rollback)'); const t = chosenPin.targets[target]; if (!t || typeof t !== 'object') fail('mirror pin has no ' + target + ' target'); if (!HEX64.test(t.archiveSha256 || '')) fail('mirror pin ' + chosenPin.version + ' has no archive sha256 for ' + target); if (!HEX64.test(t.binarySha256 || '')) fail('mirror pin ' + chosenPin.version + ' has no binary sha256 for ' + target); if (typeof t.archive !== 'string' || !t.archive) fail('mirror pin ' + chosenPin.version + ' has no archive name for ' + target); if (typeof chosenPin.releaseTag !== 'string' || !/^v[0-9A-Za-z._+-]+$/.test(chosenPin.releaseTag)) fail('mirror pin ' + chosenPin.version + ' has no valid releaseTag'); const url = mirrorBase + '/' + chosenPin.releaseTag + '/' + t.archive; emit('mirror', chosenPin.version, chosenPin.commit || '', url, t.archiveSha256, '', t.binarySha256, t.compression || 'gzip', t.archive, chosenPin.releaseTag, true, chosenPin.channel || channel); process.exit(0); } const manifest = loadManifest(); const candidates = manifest.filter(x => entryFor(x)); if (!candidates.length) fail('manifest has no ' + target + ' entries'); if (requested && chosenPin) { const x = candidates.find(c => verOf(c) === requested); if (!x) fail('upstream manifest no longer lists ' + requested + ' for ' + target); const t = chosenPin.targets[target]; const m = entryFor(x); if (!HEX64.test(t.archiveSha256 || '')) fail('pin ' + requested + ' has no archive sha256 for ' + target); if (!HEX64.test(t.binarySha256 || '')) fail('pin ' + requested + ' has no binary sha256 for ' + target); if (m.sha256hash !== t.archiveSha256) fail('manifest archive sha256 for ' + requested + '/' + target + ' does not match the pin'); if (chosenPin.commit && m.version !== chosenPin.commit) fail('manifest commit for ' + requested + '/' + target + ' does not match the pin'); if (t.member && t.member !== spec.member) fail('pin member path for ' + requested + '/' + target + ' does not match the expected path'); chosenEntry = x; } else if (requested) { const x = candidates.find(c => verOf(c) === requested); if (!x) fail('upstream manifest has no ' + requested + ' entry for ' + target); chosenEntry = x; } else { const pinnedUpstream = releases.filter(r => r && r.source !== 'mirror' && (r.channel || 'stable') === channel && r.targets && r.targets[target]); const confirmed = []; let mismatch = ''; for (const r of pinnedUpstream) { const x = candidates.find(c => verOf(c) === r.version); if (!x) continue; const t = r.targets[target]; const m = entryFor(x); if (!HEX64.test(t.archiveSha256 || '') || !HEX64.test(t.binarySha256 || '')) { mismatch = 'pin ' + r.version + ' is incomplete for ' + target; continue; } if (m.sha256hash !== t.archiveSha256) { mismatch = 'manifest archive sha256 for ' + r.version + '/' + target + ' does not match the pin'; continue; } if (r.commit && m.version !== r.commit) { mismatch = 'manifest commit for ' + r.version + '/' + target + ' does not match the pin'; continue; } confirmed.push({ r, x }); } if (confirmed.length) { confirmed.sort((a, b) => score(b.x) - score(a.x)); chosenPin = confirmed[0].r; chosenEntry = confirmed[0].x; } else if (allowUnpinned) { // Explicit escape hatch: no pinned release matched, so take the newest // manifest entry. Archive verification still happens in bash. chosenEntry = candidates.slice().sort((a, b) => score(b) - score(a))[0]; } else if (mismatch) { fail(mismatch); } else if (!pinnedUpstream.length) { fail('no pinned upstream release for ' + target + ' in ' + pinsFile); } else { fail('no pinned release for ' + target + ' matches the upstream manifest (pins: ' + pinnedUpstream.map(r => r.version).join(', ') + ')'); } } const m = entryFor(chosenEntry); const version = verOf(chosenEntry); const commit = String(m.version || ''); const url = String(m.url || ''); const archiveSha = String(m.sha256hash || ''); if (!HEX64.test(archiveSha)) fail('manifest entry ' + version + ' has an invalid sha256hash'); const t = chosenPin ? (chosenPin.targets[target] || {}) : {}; if (chosenPin) { if (t.archiveSha256 !== archiveSha) fail('pin/manifest archive sha256 mismatch for ' + version + '/' + target); if (!HEX64.test(t.binarySha256 || '')) fail('pin ' + version + ' has no binary sha256 for ' + target); if (t.member && t.member !== spec.member) fail('pin member path for ' + version + '/' + target + ' does not match the expected path'); } const pinned = !!chosenPin; if (!pinned && !allowUnpinned) fail('refusing unpinned release ' + version); checkUrl(url, pinned); const archiveName = (t.archive && String(t.archive)) || path.basename(new URL(url).pathname) || 'archive'; emit('upstream', version, commit, url, archiveSha, spec.member, pinned ? t.binarySha256 : '', spec.compression, archiveName, '', pinned, chosenPin && chosenPin.channel ? chosenPin.channel : channel); NODE )" || { err "Could not resolve a language server release"; exit 1; } IFS='|' read -r SOURCE VERSION COMMIT URL ARCHIVE_SHA MEMBER_PATH BINARY_SHA COMPRESSION ARCHIVE_NAME RELEASE_TAG PINNED CHANNEL <<< "$plan" if [[ -z "$VERSION" || -z "$URL" || -z "$ARCHIVE_SHA" ]]; then err "Resolver returned an incomplete plan"; exit 1 fi if [[ ! "$ARCHIVE_SHA" =~ ^[0-9a-f]{64}$ ]]; then err "Resolver returned an invalid archive sha256"; exit 1 fi if [[ -z "$COMPRESSION" ]]; then err "Resolver returned no archive type for $TARGET_ID"; exit 1 fi if [[ "$COMPRESSION" != "gzip" && -z "$MEMBER_PATH" ]]; then err "Resolver returned no archive member for $TARGET_ID"; exit 1 fi if [[ "$PINNED" == "1" && ! "$BINARY_SHA" =~ ^[0-9a-f]{64}$ ]]; then err "Resolver returned an invalid binary sha256"; exit 1 fi if [[ "$DRY_RUN" == "1" ]]; then echo "version=$VERSION" echo "commit=$COMMIT" echo "channel=$CHANNEL" echo "target=$TARGET_ID" echo "source=$SOURCE" echo "url=$URL" echo "archive=$ARCHIVE_NAME" echo "archive_sha256=$ARCHIVE_SHA" echo "member=$MEMBER_PATH" if [[ -n "$BINARY_SHA" ]]; then echo "binary_sha256=$BINARY_SHA" else echo "binary_sha256=unpinned" fi echo "pinned=$PINNED" echo "install_path=$TARGET" exit 0 fi # Manifest mode is already hash-pinned, but redirect hops still get the # same host validation as custom URLs. GitHub release downloads legitimately # bounce to its asset CDNs; everything else must stay on the initial host. manifest_hosts="$(url_host "$URL")" case "$manifest_hosts" in github.com) manifest_hosts="$manifest_hosts objects.githubusercontent.com release-assets.githubusercontent.com" ;; esac ALLOWED_HOSTS="$manifest_hosts" mkdir -p "$(dirname "$TARGET")" log "Installing Windsurf LS $VERSION ($TARGET_ID, $SOURCE)" log "Downloading: $URL" download "$URL" "$TMP_ARCHIVE" log "Verifying archive sha256..." actual_archive_sha="$(sha256_file "$TMP_ARCHIVE")" || { err "No sha256 tool available (sha256sum, shasum, or node is required)"; exit 1 } if [[ "$actual_archive_sha" != "$ARCHIVE_SHA" ]]; then err "Archive checksum mismatch for $ARCHIVE_NAME" err "Expected: $ARCHIVE_SHA" err "Actual: $actual_archive_sha" exit 1 fi log "Archive sha256 verified: $actual_archive_sha" if [[ -n "$MEMBER_PATH" ]]; then log "Extracting: $MEMBER_PATH" else log "Decompressing: $ARCHIVE_NAME" fi case "$COMPRESSION" in tar.gz) command -v tar >/dev/null 2>&1 || { err "tar is required to extract $ARCHIVE_NAME"; exit 1; } if ! tar -xOf "$(posix_path "$TMP_ARCHIVE")" "$MEMBER_PATH" > "$TMP_TARGET"; then err "Could not extract $MEMBER_PATH from $ARCHIVE_NAME"; exit 1 fi ;; zip) command -v unzip >/dev/null 2>&1 || { err "unzip is required to extract $ARCHIVE_NAME"; exit 1; } if ! unzip -p "$(posix_path "$TMP_ARCHIVE")" "$MEMBER_PATH" > "$TMP_TARGET"; then err "Could not extract $MEMBER_PATH from $ARCHIVE_NAME"; exit 1 fi ;; gzip) command -v gzip >/dev/null 2>&1 || { err "gzip is required to decompress $ARCHIVE_NAME"; exit 1; } if ! gzip -dc "$(posix_path "$TMP_ARCHIVE")" > "$TMP_TARGET"; then err "Could not decompress $ARCHIVE_NAME"; exit 1 fi ;; *) err "Unsupported archive type: $COMPRESSION"; exit 1 ;; esac if [[ ! -s "$TMP_TARGET" ]]; then err "Extracted binary is empty (member not found: $MEMBER_PATH)"; exit 1 fi actual_bin_sha="$(sha256_file "$TMP_TARGET")" || { err "No sha256 tool available (sha256sum, shasum, or node is required)"; exit 1 } if [[ -n "$BINARY_SHA" ]]; then if [[ "$actual_bin_sha" != "$BINARY_SHA" ]]; then err "Binary checksum mismatch for $ASSET" err "Expected: $BINARY_SHA" err "Actual: $actual_bin_sha" exit 1 fi log "Binary sha256 verified: $actual_bin_sha" else log "WARNING: no pinned binary sha256 for $VERSION/$TARGET_ID; computed $actual_bin_sha" fi INSTALLED_SHA="$actual_bin_sha" rm -f "$TMP_ARCHIVE" fi # ─── Atomic install ──────────────────────────────────── if [[ "$MODE" == "manifest" ]]; then installed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" sidecar_json="$(node -e ' const [version, commit, archiveSha, binarySha, url, archive, source, pinned, installedAt] = process.argv.slice(1); process.stdout.write(JSON.stringify({windsurfVersion: version, commit, archiveSha256: archiveSha, binarySha256: binarySha, downloadUrl: url, archive, resolver: source, pinned: pinned === "1", installedAt}) + "\n"); ' "$VERSION" "$COMMIT" "$ARCHIVE_SHA" "$INSTALLED_SHA" "$URL" "$ARCHIVE_NAME" "$SOURCE" "$PINNED" "$installed_at")" printf '%s' "$sidecar_json" > "$TMP_SIDECAR" fi chmod +x "$TMP_TARGET" mv -f "$TMP_TARGET" "$TARGET" if [[ "$MODE" == "manifest" ]]; then mv -f "$TMP_SIDECAR" "$SIDECAR" log "Wrote metadata: $SIDECAR" else # A local/custom binary has no manifest metadata; do not leave a stale # sidecar describing the previous manifest install. rm -f "$SIDECAR" fi rm -rf "$WORK_DIR" trap - EXIT size="$(du -h "$TARGET" | cut -f1)" sha_short="$(printf '%s' "$INSTALLED_SHA" | cut -c1-16)" log "Installed: $TARGET ($size, sha256:${sha_short}...)" if [[ "$TARGET_ID" == darwin-* ]]; then log "" log "macOS users: set this in your .env:" log " LS_BINARY_PATH=$TARGET" fi