Skip to content

[Bug] Upgrade can target the wrong install: method detection guesses instead of resolving the running binary #1305

Description

@saravmajestic

Description

Installation.method() never establishes where the running executable actually came from. It guesses, in two ways, and both are unsound. The visible symptom is that in-app Update now can never succeed on a root-owned npm global prefix, but that is a consequence of the weaker detection layer beneath it.

1. Path substring matching

// packages/opencode/src/installation/index.ts:267-270
if (process.execPath.includes(path.join(".altimate", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method

~/.local/bin is a generic user bin directory, not a marker of a standalone install. With npm config set prefix ~/.local — a common way to avoid needing sudo — npm installs to ~/.local/lib/node_modules and links ~/.local/bin/altimate. That install is classified curl, so altimate upgrade runs curl … | bash, writes a standalone binary into the standalone dir, and leaves the npm-managed copy stale and orphaned. Two installs then coexist, and which one runs depends purely on PATH order.

2. "Is it installed anywhere?" instead of "did this binary come from here?"

// packages/opencode/src/installation/index.ts:303
if (output.includes(installedName)) return check.name

The probe loop runs npm list -g --depth=0, pnpm list -g, bun pm ls -g, etc., and returns the first manager whose output mentions the package. That answers whether a package manager has the package — not whether the currently running executable is the one it manages. When more than one install exists, the result is effectively arbitrary, and the upgrade is routed at an install the user is not running.

It is also needlessly expensive: up to seven subprocess spawns on a code path that runs during startup update checks.

Impact

  • Upgrades can target the wrong install. The user upgrades, sees no version change, and repeats — because the upgraded copy is not the one on PATH.
  • An npm install can be silently converted into a standalone install via the .local/bin path above, orphaning the package-manager copy.
  • In-app upgrade is permanently non-functional on a root-owned npm prefix (details below), with no actionable error.

Steps to Reproduce

The permissions case is the easiest reproducer:

  1. Install into a root-owned npm global prefix (i.e. npm prefix -g resolves somewhere owned by root:wheel, such as /usr/local):
    sudo npm install -g @altimateai/altimate-code@<older-version>
  2. Launch altimate and wait for the "new version available" prompt.
  3. Click Update now.

Expected Behavior

The upgrade acts on the install the user is actually running, and either succeeds or fails with a message naming the cause and the remedy — e.g.:

Cannot write to the npm global prefix (<prefix>). Run sudo npm install -g @altimateai/altimate-code@<version>, or switch to a user-owned prefix with npm config set prefix ~/.npm-global.

Actual Behavior

Installation.upgrade() shells out as the current user with no privilege escalation and no writability check:

// packages/opencode/src/installation/index.ts:389
case "npm":
  upgradeResult = yield* run(["npm", "install", "-g", `@altimateai/altimate-code@${target}`])

npm fails with EACCES, and the surfaced error is generic:

Upgrade failed for npm (exit code 243).

upgradeFailure() intentionally suppresses package-manager stderr so tokens/env cannot leak (index.ts:172-177), so the underlying EACCES never reaches the user. The background auto-upgrade path fails identically:

[warn] [upgrade] auto-upgrade failed, notifying instead (method=npm, target=0.11.0-beta.4): UpgradeFailedError: Upgrade failed for npm (exit code 243).

Observed repeatedly across separate sessions (three identical entries in ~/.local/share/opencode/log/tui-worker-*.log) and across multiple target versions. The same command run manually with sudo always succeeds, confirming write permission on the global prefix is the only differing factor.

Suggested Fix

Resolve the install from the running binary instead of guessing. The ground truth is already on disk: a package-manager install links its bin entry into the package directory, e.g.

<prefix>/bin/altimate -> ../lib/node_modules/@altimateai/altimate-code/bin/altimate

whereas a standalone install is a plain compiled executable that does not resolve elsewhere. So:

  1. fs.realpathSync(process.execPath).
  2. If the resolved path lies inside …/node_modules/@altimateai/altimate-code/, it is a package-manager install — walk up to the prefix and compare it against each manager's reported global prefix (e.g. npm prefix -g) to identify which one owns it.
  3. If it resolves under the Homebrew prefix (brew --prefix), it is brew.
  4. Otherwise treat it as the standalone/curl install.

This is deterministic, stays correct when several installs coexist, removes the .local/bin misclassification, and replaces up to seven subprocess spawns with one realpath plus at most one prefix query. It is a net simplification rather than added machinery.

The permissions fix then falls out of it. Once the real install root is known, pre-check it with fs.accessSync(dir, W_OK) before attempting anything. If it is not writable, do not shell out at all — surface the exact command to run. This needs no EACCES string-matching and leaves the stderr redaction in upgradeFailure() untouched.

Worth applying the same pre-check to the curl method, which writes to the standalone install dir.

Rejected alternative: auto-sudo on permission failure

Not recommended. A TUI cannot host an interactive password prompt safely, sudo npm install -g executes package lifecycle scripts as root, and it would let a network-sourced version check trigger root-level writes. Printing the command for the user to run is the better trade.

Environment

  • Install method: npm global (method=npm) for the reproducer; the detection flaw is method-independent
  • Not platform-specific: applies to any root-owned npm global prefix

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions