Skip to content

Desktop SSH: a bare opencode silently replaces a Desktop-deployed service with an older CLI, causing a reconnect loop #55429

Open

Description

@rabi-UoxoU

Summary

When the OpenCode Desktop app manages a remote host over SSH, it deploys its own CLI copy to
~/.opencode/desktop-ssh/<version>/opencode and starts the background service from that binary. It never
touches the CLI already on PATH (~/.opencode/bin/opencode).

If those two versions differ, the next bare opencode launch in a terminal silently kills the
Desktop-started service and replaces it with its own (possibly older) binary
— dropping the Desktop
connection. Reconnecting from Desktop then puts the newer service back, so the two sides keep
overwriting each other.

No confirmation is requested, and the TUI splash renders the downgrade as OpenCode updated to 2.0.28.


Environment

OpenCode CLI on PATH 2.0.28 (~/.opencode/bin/opencode)
Desktop-deployed CLI 2.0.29 (~/.opencode/desktop-ssh/2.0.29/opencode)
Desktop app connects over SSH ("Add SSH server")
Remote host Linux x86_64, glibc 2.39 (resolved target linux-x64-baseline)
Service http://127.0.0.1:4096

No binary was ever downloaded or replaced in this scenario — only the service was swapped. Verified by
checksum: ~/.opencode/bin/opencode had md5 8666510adccd739beb743468625efb2e and reported v2.0.28
both before and after the sequence below.


Steps to reproduce

  1. From the Desktop app, add the host as an SSH server and let it bootstrap. It deploys
    2.0.29 into ~/.opencode/desktop-ssh/2.0.29/ and starts the service with it.
  2. Open a terminal on the remote host and run opencode (no arguments → TUI).
  3. The TUI splash shows:
    OpenCode updated to 2.0.28
    ✓ Ready                                              3/3
    
  4. The Desktop session disconnects at the same moment.
  5. Reconnect from Desktop → it reports Server update required, "Update and reconnect" restores 2.0.29.

Evidence

From ~/.local/share/opencode/log/opencode.log (UTC). args=[] is the TUI; args=["serve","--service"]
is the spawned service.

09:41:49.560  cli starting  version=2.0.29  args=["--version"]                      ← Desktop probes its staged copy
09:41:50.319  cli starting  version=2.0.29  args=["service","restart"]              ← replace=true path
09:41:50.517  cli starting  version=2.0.29  args=["serve","--service"]              ← service now 2.0.29

09:57:39.225  cli starting  version=2.0.28  args=[]                                 ← user runs bare `opencode`
09:57:39.308  background service starting  reason=version-mismatch  previousVersion=2.0.29
09:57:39.528  cli starting  version=2.0.28  args=["serve","--service"]              ← 2.0.29 killed, replaced by 2.0.28

09:57:40.450  update check  current=2.0.28  latest=2.0.29                            ← direction is UP, unrelated

09:58:04.778  cli starting  version=2.0.29  args=["service","restart"]              ← Desktop reconnects
09:58:04.984  cli starting  version=2.0.29  args=["serve","--service"]

reason=version-mismatch previousVersion=2.0.29 immediately followed by a 2.0.28 service start is the
whole bug in two lines.

This is not a one-off: the same pattern appears in earlier history on this host —
2026-10-13T17:28:17 reason=version-mismatch previousVersion=2.0.28,
2026-10-13T17:52:39 reason=version-mismatch previousVersion=2.0.27.


Root cause

Three independent behaviours compose into the loop.

1. Desktop deploys to a private directory and never syncs the PATH CLI

packages/gui-extensions/src/ssh/bootstrap.ts

export function binaryPath(version: string) {
  return `$HOME/.opencode/desktop-ssh/${RemoteCli.requireVersion(version)}/opencode`
}

export function startScript(version: string, replace = false) {
  return `set -eu
cli="${binaryPath(version)}"
"$cli" service ${replace ? "restart" : "start"}
...

Discovery prefers the PATH CLI (packages/gui-extensions/src/ssh/remote-cli.ts):

if [ -z "$cli" ] && [ -x "$HOME/.opencode/bin/opencode" ]; then cli="$HOME/.opencode/bin/opencode"; fi

The result is two independent installs on the same host, with nothing keeping them in sync — and a single
shared registration file (~/.local/state/opencode/service.json) recording whichever one started last.

2. The TUI replaces any version-mismatched service, with no direction check

packages/cli/src/commands/handlers/default.ts:38 hardcodes it:

const server = yield* ServerConnection.resolve({
  server: requestedServer,
  standalone: input.standalone,
  mismatch: "replace",
  onStart: (reason, previousVersion) => { ... },
})

packages/cli/src/services/server-connection.ts:45

const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })

→ options.version = OPENCODE_VERSION → packages/client/src/service-probe.ts:

export function decide(service: LocalService, options: DiscoverOptions) {
  if (!matchesVersion(service.version, options))
    return { _tag: "replace", pty: service.state === "ready" ? "handoff" : "clear" } as const

packages/client/src/service-version.ts compares by string equality only. There is no ordering, so
"service is newer" and "service is older" both take the replace branch. The replacement service is
spawned via selfCommand() → [process.execPath]
(packages/cli/src/util/process.ts:5), i.e. whatever binary the user just launched.

Net effect: whoever starts last wins, regardless of direction.

opencode run (handlers/run.ts) omits mismatch, so it defaults to "ignore" and is unaffected — the
bare TUI is the only entry point that force-takes over the service.

3. The splash renders the downgrade as an update

packages/cli/src/services/update-preflight.tsx

const completedHeader = phrase(
  ["OpenCode", colors.muted, true],
  ["updated to", colors.muted],
  [OPENCODE_VERSION, colors.accent],
)
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"]

Stage 2 is literally "Starting the new background service" — this screen is a service swap, never a
binary update. But the completed header only says "updated to", so 2.0.29 → 2.0.28 reads as a normal
upgrade. The in-progress header does show from 2.0.29 to 2.0.28, but it flashes by.


Why it is not caught

handlers/service/{start,restart}.ts call ServiceConfig.options() without checkVersion, so
options.version === undefined and matchesVersion returns true unconditionally. Only the TUI enables
the version requirement. The two paths therefore disagree about whether the running service is
acceptable, and the TUI silently wins.


Impact

  • Desktop sessions drop whenever the TUI is launched while versions differ.
  • Silent version rollback of the managed service, with no prompt.
  • Persistent ping-pong until the two installs happen to match.
  • Unbounded disk growth: ~/.opencode/desktop-ssh/ retains every deployed version
    (2.0.12, 2.0.27, 2.0.28, 2.0.29 observed — ~200 MB each, ~800 MB total) and is never pruned.

Suggested fixes

Any one of A/B breaks the loop; C removes the root cause.

A. Make decide() direction-aware — packages/client/src/service-probe.ts

if (!matchesVersion(service.version, options)) {
  if (older(service.version, options.version))            // service is newer than this client
    return { _tag: "fail", error: new Error(
      "Background server is newer than this client. Run `opencode upgrade`, or launch " + options.version + " directly.") }
  return { _tag: "replace", pty: ... } as const
}

The old client steps aside instead of dragging the newer service down.

B. Stop hardcoding mismatch: "replace" in handlers/default.ts

Either drop the field (falling back to "ignore", matching handlers/run.ts), or gate it behind an
explicit opt-in. A client should not kill a shared service it did not start.

C. Have Desktop keep the PATH CLI in sync — packages/gui-extensions/src/ssh/bootstrap.ts

After a successful service restart, mirror the staged binary to an existing PATH CLI if one is present
and older. Desktop already has remote code execution over the SSH channel and already ships this exact
binary from registry.npmjs.org, so the marginal cost is one local file copy (~10 lines). This is what
makes "upgrade once" true for users.

D. Fix the preflight wording so a downgrade is labelled as one, e.g.
Background server downgraded 2.0.29 → 2.0.28 (client is older).


Adjacent issues found while diagnosing (not the cause here)

  1. packages/cli/src/services/updater-action.ts has no version ordering.

    export function action(current: string, latest: string, policy: Policy): Action {
      if (policy === "disable") return "none"
      const currentVersion = parseReleaseVersion(current)
      const latestVersion = parseReleaseVersion(latest)
      if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
      return policy          // any inequality triggers "update", newer or older
    }

    If the release endpoint ever reports a version older than OPENCODE_VERSION (a revert, or a lagged
    channel), this performs a real binary downgrade. Adding a comparison would close it.

  2. ?current= is ignored by the update endpoint. Verified:
    /update/api/latest/cli/npm?current=2.0.28 and ?current=2.0.29 both return
    {"version":"2.0.29", ...}. The parameter suggests server-side suppression of older versions;
    honouring it would remove the downgrade vector in (1).

  3. No prerelease awareness in the comparison. A 0.0.0-beta-N build and a release build would need
    channel-aware ordering; naive semver ordering ranks the beta after the release.

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.28 without the Desktop app, on Linux:

    1. Start the background service with a 2.0.29 CLI (opencode service start). service.json records version 2.0.29.
    2. Run a bare opencode from a 2.0.28 CLI.
    3. The log shows background service starting reason=version-mismatch previousVersion=2.0.29, and the 2.0.28 binary starts its own serve --service. service.json now shows 2.0.28 with a new pid: the newer service was replaced by the older one. The splash says "from 2.0.29 to 2.0.28" and then "OpenCode updated to 2.0.28".

    The same thing happens on the latest dev (f44c40b) when it runs as a 2.0.28 release-channel client. The code that decides to replace the service hasn't changed since 2.0.28. The script below reproduces it with two CLI versions. I didn't test the Desktop SSH flow itself, or the growth of ~/.opencode/desktop-ssh/.

    Related issues: #53375 (Desktop SSH version-mismatch message), #47714 (update prompt recommending an older version), #54181 and #38567 (background service restart and race problems).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #55429 without Desktop: a newer CLI (2.0.29) starts the background service,
    # then a bare `opencode` TUI from an older CLI (2.0.28) silently replaces it.
    # NEW / OLD: paths to two opencode binaries (default: 2.0.29 local install, 2.0.28 on PATH).
    set -u
    NEW=${NEW:-/tmp/opencode/a/node_modules/.bin/opencode}
    OLD=${OLD:-opencode}
    export HOME=$(mktemp -d /tmp/opencode/home.XXXX)
    export XDG_STATE_HOME=$HOME/.local/state XDG_DATA_HOME=$HOME/.local/share XDG_CONFIG_HOME=$HOME/.config XDG_CACHE_HOME=$HOME/.cache
    mkdir -p $HOME/proj && cd $HOME/proj
    echo "NEW: $($NEW --version)   OLD: $($OLD --version)"
    "$NEW" service start
    sleep 2
    echo "--- service.json after NEW starts it:"; cat $XDG_STATE_HOME/opencode/service.json; echo
    echo "--- running bare OLD TUI for 12s in a pty"
    timeout 12 script -qfc "$OLD" /tmp/opencode/tui.typescript >/dev/null 2>&1
    echo "--- service.json after bare OLD TUI:"; cat $XDG_STATE_HOME/opencode/service.json; echo
    echo "--- log lines:"; grep -hE "version-mismatch|serve\",\"--service|cli starting" $XDG_DATA_HOME/opencode/log/*.log 2>/dev/null | tail -8
    echo "--- TUI splash text:"; sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/opencode/tui.typescript | grep -aoE "updated to [0-9.]+|from [0-9.]+ to [0-9.]+" | sort -u
    pkill -f "serve --service" || true

    Run it: bash repro.sh

  2. vladkosinov commented on Oct 26, 2026

    @vladkosinov

    Same bug without SSH: the local Desktop app plus the terminal CLI on Windows.

    The Desktop app updated itself to 2.0.32; the terminal opencode (~/.opencode/bin) stayed on 2.0.27. Running opencode in a terminal replaces the app's service, the app starts its own again, and the two keep killing each other. The app shows "Failed to fetch", the TUI "Unable to connect". Reproduced on a clean Windows 11 ARM64 VM with the vendor installers only:

    background service starting reason=version-mismatch previousVersion=2.0.32 role=cli
    managed service registration replaced; shutting down
    cli process failed ... ClientError: Transport: Unable to connect. Is the computer able to access the url?
    

    App log, same second: v2 CLI background service starting { reason: 'missing' }.

    Control: with the terminal on 2.0.32, the TUI reuses the app's service and both work.

    On Windows the gap does not close by itself: opencode upgrade needs Git Bash, so the terminal CLI stays on its install version while the app keeps updating. mismatch: "replace" is still in default.ts:38 on v2.1.4.

    #55434 adds the missing direction check. Could it be reopened and reviewed?

  3. 0xYiliu commented on Oct 27, 2026

    @0xYiliu

    I believe this issue also covers a broader SSH bootstrap behavior. The SSH bootstrap can discover an opencode on the remote PATH, but when no service is currently registered it discards that candidate and starts the private $HOME/.opencode/desktop-ssh/<version>/opencode copy instead.

    Expected behavior should be:

    • Prefer a usable, version-compatible OpenCode already installed on the remote host, including one found through the remote PATH (or an explicitly supported remote binary setting).
    • Only install/use the desktop-ssh copy when no usable remote OpenCode is available.
    • Do not silently replace a running service when the versions are incompatible; report the mismatch or require an explicit replacement.

    I am willing to implement this once the expected behavior is confirmed. If maintainers agree this is the right direction, please assign this issue to me (0xYiliu).

  4. 0xYiliu commented on Oct 27, 2026

    @0xYiliu

    @Hona @Brendonovich I noticed that PR #55434 is linked to this issue. It addresses the client-side version-direction guard so an older CLI does not replace a newer running service, but it does not change the SSH bootstrap's remote CLI discovery/start selection.

    Could you confirm whether the intended SSH behavior is to prefer an existing, usable and version-compatible opencode on the remote host, and only fall back to $HOME/.opencode/desktop-ssh/<version>/opencode when no such CLI is available? If confirmed, I would be happy to implement this; please assign the issue to @0xYiliu.

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