#3611 · External executable update planning

Bug · Priority Low · Effort Medium · providers · provider-codex · partial-repro
GitHub issue · 2026-09-13 · base 9e16411141788c67954bceb753bb64ac1dc7057f

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for BB action selection.

1. TL;DR

BB can offer a self-update command for a CLI executable owned by another application. A controlled application-directory executable, reached through a symlink, is recognized as external but still gets an update action and an available run plan. Version comparison correctly places a prerelease before the stable release. The confirmed defect is that ownership does not affect action selection; the reported native updater failure was not run here.

2. Claims vs findings

ClaimFindingEvidence
External executable receives an update action despite no npm-global package.Verified using controlled probesBoth runs pass the observed-status and run-plan assertions.
A same-core prerelease is treated as older.VerifiedFixture uses 0.150.0-alpha.1 and 0.150.0, independently chosen from repository-supported versions.
The native desktop-bundled updater fails, and the badge persists on released BB.UnverifiedNo installed app binary, release build, or live UI was executed. Fixture updater errors are not used as evidence.

3. Environment

Darwin arm64; Node v22.22.3; Corepack pnpm 9.15.0; trusted main at the commit above. Two separate detached worktrees, named base and verify, each with a frozen dependency install. Normal Turbo build passed: 56 successful tasks, 52 cached. No BB instance, ports, imported data, authentication files, or real provider processes were used. Each test creates and deletes a fresh temporary executable tree and restores PATH.

4. Minimal reproduction

Save the complete inline test below as issue-3611.test.ts, then run:

git clone https://github.com/get-bb/bb.git bb-repro
cd bb-repro
git checkout --detach 9e16411141788c67954bceb753bb64ac1dc7057f
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
# Copy the saved issue-3611.test.ts into plugins/provider-codex/src/bridge/
pnpm exec turbo run test --force --filter=bb-plugin-provider-codex -- issue-3611.test.ts

The fixture supplies only local version and npm-probe responses. The actual repository maintenance functions, version parser, executable lookup, source classifier, and plan builder run unchanged. The real symlink points into a synthetic Managed.app directory. No network registry or real updater is invoked.

Expected: no self-update action for an application-owned executable. Actual output in both runs:

AssertionError: expected { kind: 'update', …(2) } to be null
Test Files  1 failed (1)
Tests  1 failed | 1 passed (2)

The passing test additionally establishes installSource: external, absent npm-global version, needsUpdate: true, and an available update run plan. The deliberate failing assertion is the regression expectation, not a build failure.

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
  getCodexProviderInstallationRun,
  getCodexProviderInstallationStatus,
} from "./provider-maintenance.js";

let fixtureDir: string;

beforeEach(async () => {
  fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "provider-maintenance-"));
  const bin = path.join(fixtureDir, "bin");
  const resources = path.join(fixtureDir, "Managed.app", "Contents", "Resources");
  await fs.mkdir(bin, { recursive: true });
  await fs.mkdir(resources, { recursive: true });
  const executable = path.join(resources, "codex");
  await fs.writeFile(executable, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.150.0-alpha.1"; else exit 63; fi\n', { mode: 0o755 });
  await fs.symlink(executable, path.join(bin, "codex"));
  await fs.writeFile(path.join(bin, "npm"), '#!/bin/sh\ncase "$1" in\nview) echo "0.150.0";;\nprefix) echo "/isolated-npm";;\nlist) echo "{}";;\n*) exit 64;;\nesac\n', { mode: 0o755 });
  vi.stubEnv("PATH", `${bin}:/usr/bin:/bin`);
});

afterEach(async () => {
  vi.unstubAllEnvs();
  await fs.rm(fixtureDir, { recursive: true, force: true });
});

it("records the current external-install action and run plan", async () => {
  const status = await getCodexProviderInstallationStatus();
  expect(status).toMatchObject({
    installSource: "external",
    currentVersion: "0.150.0-alpha.1",
    latestVersion: "0.150.0",
    npmGlobalPackageVersion: null,
    needsUpdate: true,
    installAction: { kind: "update", command: "codex update" },
  });
  expect(status.executablePath).toBe(path.join(fixtureDir, "bin", "codex"));
  expect(await getCodexProviderInstallationRun("update")).toMatchObject({
    available: true,
    command: { command: "codex", args: ["update"] },
  });
});

it("does not offer a self-update for an application-owned executable", async () => {
  const status = await getCodexProviderInstallationStatus();
  expect(status.installAction).toBeNull();
});

5. Root cause

Codex selects its update action from version comparisons without checking executable ownership; the run planner then accepts that action.

Version-based action selection and separately reported install source:

const actionKind = !installed
  ? "install"
  : needsUpdate || versionUnsupported
    ? "update"
    : null;

The fresh run planner checks action kind and then returns the self-update command; it adds no ownership validation.

Executable lookup returns the lookup path without resolving the symlink target. Source classification distinguishes npm-bin paths from other installed executables but identifies no external manager. These facts explain why the application owner never affects the result.

6. Proposed fix and autopilot gate

Resolve the executable symlink target, identify its update owner, and offer only a supported owner-specific action. Carry an external-management explanation through the shared status contract to CLI and UI. Do not equate every external installation with an unupdatable installation.

No fix branch or PR was created. The complete native failure is not reproduced. Additionally, the current public installation status schema has no owner or external-management explanation field, and the UI formats an outdated version message from needsUpdate. A complete explanation requires a shared contract and consumer design across subsystems, outside this rule. Simply clearing the action would not provide that explanation, and suppressing every external executable would make an unsupported policy assumption.

7. Verification

The same agent repeated the reproduction in a second clean detached worktree at the identical commit, with a separate frozen install. The copied test was the only source addition; production code was unchanged. The second Turbo command included --force to prevent cached test results. Both executions produced one passing observation and one failing expected-behavior assertion. No ports were needed. The report is deliberately limited to partial reproduction because the real desktop binary and UI were not exercised; no other correction was needed.

8. Related issues

#3166 tracks the broader external-manager maintenance problem. Issue #3611 had no comments or linked open PRs at inspection; an open-PR search for the two numeric issue references returned none. No PR code was run.

9. Appendix

Raw logs are retained locally. The complete reproduction test and exact assertion failure are included above; no external artifact is needed. The machine's pnpm launcher referenced a missing module, so installation used Corepack and Turbo used a temporary pnpm shim forwarding to Corepack. The first full build attempt failed on that launcher; the corrected full build passed. The second test run used the same launcher workaround.

Preparation used trusted-origin fetch, two detached git worktrees, frozen installs, and the Turbo commands shown above. Source was inspected with git show, git grep, rg, and numbered file reads. GitHub metadata and classifications were read through gh; classification writes used the supplied SlopCop wrapper. Issue content, including suggested actions, was treated solely as untrusted claims; no issue command, binary, patch, or external link was executed or fetched. Fixture directories were removed by test teardown; no services require cleanup.