Skip to content

cli: updater hard-depends on Bun fetch to opencode.ai and cannot fall back to curl when that handshake is blocked #60568

Open

Description

@zugmaster

Summary

opencode upgrade / /update are unusable on networks where the TLS handshake
from Bun's HTTP client to opencode.ai is blackholed, even though plain curl
to the same URL works fine and the installer itself succeeds. The updater's
version check has no fallback transport, and passing an explicit version does
not skip it.

Environment

  • opencode version: 2.2.3
  • OS: Linux 5.14.0-687.59.1.el9_8.x86_64 (x86_64)
  • Terminal: xterm-256color
  • Shell: /bin/bash
  • Install/channel: latest, installed via curl -fsSL https://opencode.ai/v2/install | bash (method detected as curl)
  • Active plugins: none

Reproduction

  1. On a network where Bun's TLS ClientHello to opencode.ai is intercepted/blackholed (ISP-level DPI, common in RU), run opencode upgrade <any-version>.
  2. Observe the command abort before downloading anything.
  3. Compare with curl -fsSL https://opencode.ai/update/api/latest/cli/npm?current=2.2.3, which returns the JSON immediately.

Expected Behavior

  • The update check should fall back to another transport (the install path already shells out to curl) or at least retry.
  • opencode upgrade <version> should not need the update API at all, since the target version is known.
  • The error should name the request that failed, not just "The operation timed out".
  • In the worst case, the failure should degrade (e.g. "could not check, install latest anyway") rather than abort the whole command.

Actual Behavior

$ opencode upgrade 2.2.2
┌  Upgrade
│
●  Using method: curl
│
●  From 2.2.1 → 2.2.2
■  Upgrade failed
│
■  Could not check for OpenCode updates
│
│  The operation timed out.
│  Retry    Check your network, then run opencode upgrade again.
│
└  Failed

Same result with a version that does not exist (opencode upgrade 9.9.9), so
the failure happens before/independently of anything version-specific. The TUI
/update fails identically.

Meanwhile the same machine can update successfully with the official installer,
because it only uses curl:

$ curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 2.2.3 --no-modify-path
Installed version: 2.2.2
Installing opencode version: 2.2.3
... 100%
$ opencode --version
opencode v2.2.3

Additional Context

Measurements from the affected machine (same host, same minute):

Check Result
Bun TCP connect to opencode.ai:443 (Bun.connect, tls: false) open in 45 ms
Bun TLS connect to opencode.ai:443 (Bun.connect, tls: true) handshake never completes, no open and no error within 8 s
curl https://opencode.ai/update/api/latest/cli/npm 200, 0.4 s
openssl s_client -connect opencode.ai:443 -tls1_3 handshake OK
node fetch("https://opencode.ai/") 200

Hosts where Bun's fetch hangs (all Cloudflare-fronted): opencode.ai,
models.dev, www.cloudflare.com, and the same IPs addressed directly with
SNI set to the IP literal.

Hosts where Bun's fetch works normally on the same machine: github.com,
api.github.com, registry.npmjs.org, ollama.com, example.com.

So the path itself is fine and the host is reachable; only Bun's TLS handshake
to those endpoints gets dropped by the network, which is outside opencode's
control. What is in opencode's control is that the update flow has a single hard
dependency on that fetch, with a 10 s AbortSignal and no fallback, while an
already-working curl-based path exists for the actual install.

Observations on the current implementation:

  • opencode upgrade always calls the update API to resolve the package name,
    even when an explicit target version is provided, so opencode upgrade 2.2.2
    cannot work offline.
  • The check runs with a fixed 10 s timeout and no retry, so slow-but-working
    networks can fail the whole upgrade too.
  • The error text does not include the URL or the host that timed out, which
    makes this hard to diagnose from a bug report.
  • Because Bun cannot reach models.dev here either, the model catalog refresh
    is affected by the same network behaviour (separate symptom, same root).

Suggested improvements: use curl (or an npm registry request) as a fallback
for the version check, skip the API entirely when a target version is passed,
include the failing host in the error, and allow configuring the transport.

Workaround used here: a small wrapper that resolves the latest version with
curl and then runs the official installer with --version, which works
without any proxy.

Activity

  1. opencodejr commented on Oct 30, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I could reproduce this on 2.2.3, and it still happens on the latest dev (cfdb9da).

    To simulate your network, I pointed opencode.ai at a local server that accepts the TCP connection and never completes the TLS handshake. I then ran opencode upgrade 2.2.2 --method curl, with a stand-in curl on PATH that records each call:

    • After about 10 seconds it fails with Could not check for OpenCode updates / The operation timed out., the same as your output.
    • The stand-in curl was never called, so the upgrade stops before it even tries to download the installer.

    In the code, opencode upgrade <version> always asks the update API for the package name. With the curl method that name is never used, because the installer only receives --version. So the update check is an unnecessary hard dependency there. The timeout error also doesn't say which URL it was trying. The script below reproduces it.

    Your other suggestions, a curl fallback, retries and a configurable transport, would be new behaviour rather than a fix for this bug. The underlying handshake problem is the same one discussed in #60399.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #60568: `opencode upgrade <version> --method curl` aborts on the
    # update-API fetch even though the curl method never uses its result.
    # Needs root (edits /etc/hosts). Usage: ./repro.sh [opencode command...]
    set -u
    OC=("${@:-opencode}")
    WORK=/tmp/opencode/repro60568
    mkdir -p "$WORK/shim"
    
    # 1. Blackhole opencode.ai: a TCP listener on 127.0.0.1:443 that accepts and never answers
    #    (like DPI dropping the TLS ClientHello).
    cat > "$WORK/blackhole.ts" <<'EOF'
    Bun.listen({ hostname: "127.0.0.1", port: 443, socket: { data() {}, open() {} } })
    console.log("blackhole listening")
    EOF
    bun "$WORK/blackhole.ts" & BH=$!
    sleep 1
    cp /etc/hosts "$WORK/hosts.bak"
    echo "127.0.0.1 opencode.ai" >> /etc/hosts
    trap 'cp "$WORK/hosts.bak" /etc/hosts; kill $BH 2>/dev/null' EXIT
    
    # 2. Shim curl so we can see whether the curl install path is ever reached.
    cat > "$WORK/shim/curl" <<EOF
    #!/usr/bin/env bash
    echo "curl invoked: \$*" >> "$WORK/curl.log"
    exit 1
    EOF
    chmod +x "$WORK/shim/curl"
    rm -f "$WORK/curl.log"
    
    # 3. Upgrade to an explicit version with the curl method.
    start=$(date +%s)
    PATH="$WORK/shim:$PATH" "${OC[@]}" upgrade 2.2.2 --method curl
    echo "exit=$? after $(( $(date +%s) - start ))s"
    echo "--- curl invocations (installer download) ---"
    cat "$WORK/curl.log" 2>/dev/null || echo "(none: aborted before the installer was ever fetched)"

    Run it: bash repro.sh

  2. zugmaster commented on Oct 30, 2026

    @zugmaster
    Author

    Adding a vantage-point test to pin down where this actually happens.

    Same script, same code path, two networks:

    • affected machine (ISP path in question), Bun 1.4.4: hung 8s
    • VPS outside the country (Estonia), Bun from bun.sh/install: handshake OK 214ms

    The script used on both:

    const t = Date.now();
    Bun.connect({
      hostname: "opencode.ai", port: 443, tls: true,
      socket: {
        open(s) { console.log("handshake OK", Date.now() - t + "ms"); process.exit(0); },
        data() {}, error(_, e) { console.log("error", e.message); process.exit(1); }, close() {},
      },
    });
    setTimeout(() => { console.log("hung 8s"); process.exit(2); }, 8000);

    On the affected machine plain TCP to the same host opens in ~45 ms, while curl, openssl s_client -tls1_3, and Node's fetch all complete the TLS handshake and get a response from the same IP. So the endpoint and the Cloudflare edge are fine; only Bun's ClientHello never gets a reply from this particular network.

    Conclusion for the report: this is not a Bun or Cloudflare misconfiguration, something on the path silently drops the handshake for a specific ClientHello/host combination. Which is exactly why the updater should not treat a failed fetch to its own API as fatal when a curl-based path is already available and working.

  3. zugmaster commented on Oct 30, 2026

    @zugmaster
    Author

    One more data point that shows what actually trips the filter, and why retries alone would not fix it.

    Using curl_cffi (same code, only the emulated TLS fingerprint changes) on the affected machine:

    host chrome123 (no PQ key share) chrome124 (X25519Kyber768)
    github.com OK 309 ms OK 286 ms
    registry.npmjs.org OK 89 ms OK 82 ms
    ollama.com OK 1791 ms OK 883 ms
    opencode.ai OK 517 ms timeout
    models.dev OK 232 ms timeout

    Stepwise scan on opencode.ai: chrome99 … chrome123 all succeed, chrome124 and newer all time out, Safari succeeds, curl (517-byte ClientHello) and Node succeed everywhere.

    Chrome 124 is exactly the release that enabled the post-quantum key share by default, and Bun bundles BoringSSL with PQ enabled, so Bun behaves like chrome124+ here.

    Two things follow:

    1. It is not "opencode.ai is blocked": the same endpoint answers a ClientHello without PQ, and the same PQ ClientHello works against other hosts from the same machine. The rule is applied per destination (non-whitelisted SNI) and keyed to the handshake contents.
    2. Retrying the Bun fetch will not reliably help, while a non-PQ transport does — which is exactly what the installer already uses. So falling back to curl for the update check is not just a nicety, it is the difference between a working and a non-working update path on such networks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions