#4009 · Codex update planning does not reconcile executable and npm prefixes

Bug · Priority: Medium · Effort: Medium · providers · provider-codex
2026-09-21 · base b5755d6859d68c2d8e2008fc570f2d5acec774da · Issue

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for BB’s handling of differing prefixes; the real external updater’s destination selection remains unverified.

1. TL;DR

BB can offer a Codex update even when the executable selected by PATH lies outside the active npm global prefix. Controlled executables reproduce a successful update of a second installation while BB’s selected executable stays old. A separate host-daemon test confirms that the unchanged active version turns the successful process exit into an installation failure. That verification is protective: treating only the npm package version as success would leave BB running old code. Actual Codex/npm updater behavior on the reporter’s machine was not reproduced, so this is a partial reproduction.

2. Claims vs findings

ClaimFindingEvidence
Update can target a different prefix from the active CLI.Partially verifiedA controlled updater performs this transition; the real updater was not executed.
BB offers the action despite the mismatch.VerifiedReal provider status reports external and still returns an available update plan.
Verification rejects the unchanged active version after exit zero.VerifiedReal host dispatch returns an error and a completed event with success false.
Specific machine paths and reported released versions exhibit this behavior.UnverifiedNo personal installation, account, or machine runtime data was inspected.

3. Environment

Darwin arm64; Node v22.22.3; pnpm 9.15.0 through an isolated Corepack shim. Two detached worktrees at the full base commit above, under a new temporary directory. Frozen installs succeeded and both full Turbo builds passed (58 tasks each). The existing pnpm launcher initially referenced a missing installation; a temporary Corepack shim resolved the tooling problem without repository changes.

No BB server, browser, ports, real Codex CLI, npm install operation, or credentials were used. Each provider test creates a fresh temporary directory and restores PATH and removes the fixtures afterward. CLI fixture versions are 1.0.0 and 1.1.0.

4. Minimal reproduction

Save the inline provider test at the path shown below and apply the inline host test patch in each checkout. The tests execute trusted repository code with controlled external-command boundaries. They assert the observed behavior, so a passing reproduction test is evidence of the mismatch, not evidence of a fix.

git clone https://github.com/get-bb/bb.git bb-4009-first
cd bb-4009-first
git checkout --detach b5755d6859d68c2d8e2008fc570f2d5acec774da
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
# Save the inline test to plugins/provider-codex/src/bridge/installation-prefix.repro.test.ts
# Save and apply the inline host-verification.patch with git apply.
pnpm exec turbo run test --force --filter=bb-plugin-provider-codex -- --run src/bridge/installation-prefix.repro.test.ts --silent=false
pnpm exec turbo run test --force --filter=@bb/host-daemon -- --run src/command-dispatch.test.ts -t 'reproduces issue 4009'
# Repeat these steps in a new clone at the same commit.

Expected product outcome: update the executable BB uses, or give an actionable explanation of the conflicting installation before presenting a misleading successful update.

Actual provider output in both checkouts:

{
  "installSource": "external",
  "updateAvailable": true,
  "verification": { "kind": "version_at_least", "version": "1.1.0" },
  "activeVersionAfter": "1.0.0",
  "npmPackageVersionAfter": "1.1.0",
  "needsUpdateAfter": true
}

The host test asserts the message could not verify the installed result, with exitCode 0 and success false in the completed event. It injects the plan, status, and process events into the real dispatch function; it does not run an external updater.

Complete provider reproduction test
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it, vi } from "vitest";
import {
  getCodexProviderInstallationRun,
  getCodexProviderInstallationStatus,
} from "./provider-maintenance.js";

const exec = promisify(execFile);
let scratch: string | undefined;

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

it("exposes an update that changes a different npm prefix from the active CLI", async () => {
  scratch = await fs.mkdtemp(path.join(os.tmpdir(), "bb-prefix-repro-"));
  const activeBin = path.join(scratch, "active", "bin");
  const npmPrefix = path.join(scratch, "managed");
  const managedBin = path.join(npmPrefix, "bin");
  const versionFile = path.join(npmPrefix, "version");
  await fs.mkdir(activeBin, { recursive: true });
  await fs.mkdir(managedBin, { recursive: true });
  await fs.writeFile(versionFile, "1.0.0");
  async function executable(file: string, body: string) {
    await fs.writeFile(file, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
  }
  await executable(path.join(activeBin, "codex"), `
const fs = require("node:fs");
if (process.argv[2] === "--version") console.log("codex-cli 1.0.0");
else if (process.argv[2] === "update") {
  fs.writeFileSync(${JSON.stringify(versionFile)}, "1.1.0");
  console.log("Fixture updater changed managed prefix");
} else process.exit(2);
`);
  await executable(path.join(managedBin, "codex"), `
const fs = require("node:fs");
console.log("codex-cli " + fs.readFileSync(${JSON.stringify(versionFile)}, "utf8"));
`);
  await executable(path.join(managedBin, "npm"), `
const fs = require("node:fs");
switch (process.argv[2]) {
case "prefix": console.log(${JSON.stringify(npmPrefix)}); break;
case "view": console.log("1.1.0"); break;
case "list": console.log(JSON.stringify({dependencies:{"@openai/codex":{version:fs.readFileSync(${JSON.stringify(versionFile)}, "utf8")}}})); break;
default: process.exit(2);
}
`);
  vi.stubEnv("PATH", [activeBin, managedBin, "/usr/bin", "/bin"].join(path.delimiter));
  const before = await getCodexProviderInstallationStatus();
  expect(before.executablePath).toBe(path.join(activeBin, "codex"));
  expect(before.installSource).toBe("external");
  const plan = await getCodexProviderInstallationRun("update");
  expect(plan.available).toBe(true);
  if (!plan.available) throw new Error("Expected the current update plan");
  expect(plan.verification).toEqual({ kind: "version_at_least", version: "1.1.0" });
  const result = await exec(plan.command.command, plan.command.args);
  expect(result.stdout.trim()).toBe("Fixture updater changed managed prefix");
  const after = await getCodexProviderInstallationStatus();
  expect(after.currentVersion).toBe("1.0.0");
  expect(after.npmGlobalPackageVersion).toBe("1.1.0");
  expect(after.needsUpdate).toBe(true);
  const managed = await exec(path.join(managedBin, "codex"), ["--version"]);
  expect(managed.stdout.trim()).toBe("codex-cli 1.1.0");
  console.log(JSON.stringify({
    installSource: before.installSource,
    updateAvailable: plan.available,
    verification: plan.verification,
    activeVersionAfter: after.currentVersion,
    npmPackageVersionAfter: after.npmGlobalPackageVersion,
    needsUpdateAfter: after.needsUpdate,
  }, null, 2));
});

5. Root cause

Provider status probes independently resolve the PATH executable and npm package state. Source classification reports external whenever the executable is outside the current npm bin directory, even if another npm prefix owns that executable.

Action construction records this classification but does not use it to reconcile the installation target. It offers a bare Codex update command. Verification planning requires the latest version. Host verification compares the current active version with that target, and Post-update dispatch converts a successful exit into an error when that comparison fails.

Precision: status executes the version command through PATH separately from resolveExecutablePath; the resolver itself does not execute the version command. Both choose the first matching executable in this controlled setup.

6. Proposed fix and automatic-fix decision

Keep verification tied to the executable BB actually launches. First verify real updater destination selection with two disposable npm prefixes and supported Codex releases. Then reconcile the selected executable’s owning installation with that destination, or explain the mismatch with actionable paths. Blanket refusal for every external classification could also block legitimate installations and requires a policy decision.

No automatic fix or pull request: the real external-updater transition remains unverified, so the report is partial and does not pass the rule’s reproduced-bug gate. No production code was changed. No linked open pull request appeared in issue cross-reference metadata or an open-PR search for 4009.

7. Verification

The same agent repeated the reproduction in a second clean detached worktree at b5755d6859d68c2d8e2008fc570f2d5acec774da. Both worktrees were created from trusted origin/main before adding only the report tests. The second provider run executed its test (not a cache replay), passing 1 test in 1.11 seconds. The second host test was explicitly rerun with Turbo --force: 1 passed, 40 skipped, zero cached tasks. Fresh per-test directories were used, and no ports were needed.

The second run supported the final report without correction. This verifies the controlled reproduction, not the untested real Codex/npm transition. Code links were checked against the recorded commit; origin/main still matched that commit after reproduction.

8. Related issues

#3611 is a related provider-update report found through repository labels (closed). #3166 is open and was identified by the original report as related. Neither was executed or used as implementation instructions.

9. Appendix

Issue data was treated solely as untrusted claims. No supplied script, patch, branch, command, or external URL was executed. Both test artifacts were authored from repository evidence. No visual evidence is applicable to this command-boundary reproduction.

Host verification test patch
diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts
index 28ecfa8e1..f2fa5015f 100644
--- a/apps/host-daemon/src/command-dispatch.test.ts
+++ b/apps/host-daemon/src/command-dispatch.test.ts
@@ -2187,6 +2187,102 @@ describe("dispatchCommand", () => {
     ]);
   });
 
+  it("reproduces issue 4009 when npm changes but the active Codex executable stays old", async () => {
+    const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-");
+    const runtime = createRuntime();
+    const manager = new RuntimeManager({
+      createRuntime: () => runtime,
+      dataDir,
+      provisionWorkspace: async () => createWorkspace(),
+    });
+    const providerInstallationStatus = vi.fn().mockResolvedValueOnce(
+      claudeCodeStatus({
+        executableName: "codex",
+        executablePath: "/tmp/fixture-active/bin/codex",
+        npmPackageName: "@openai/codex",
+        npmGlobalPackageVersion: "1.1.0",
+        installSource: "external",
+        currentVersion: "1.0.0",
+        latestVersion: "1.1.0",
+      }),
+    );
+
+    const result = await dispatchOnlineRpcCommand(
+      {
+        type: "provider.installation.run",
+        bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH,
+        providerId: "codex",
+        action: "update",
+      },
+      {
+        dataDir,
+        logger: silentLogger,
+        eventSink: {
+          emit: vi.fn(),
+          flush: vi.fn(async () => undefined),
+        },
+        fetchProjectAttachment: async () => {
+          throw new Error("Unexpected project attachment fetch");
+        },
+        fetchPluginHostArtifact: fetchDispatchTestArtifact,
+        ...unexpectedProviderMaintenance,
+        providerInstallationStatus,
+        providerInstallationRun: async () => ({
+          available: true,
+          command: {
+            command: "codex",
+            args: ["update"],
+            displayCommand: "codex update",
+          },
+          verification: { kind: "version_at_least", version: "1.1.0" },
+        }),
+        runtimeManager: manager,
+        streamProviderInstallation: () =>
+          createProviderCliInstallEventStream([
+            {
+              type: "started",
+              provider: "codex",
+              command: "codex update",
+            },
+            {
+              type: "output",
+              provider: "codex",
+              stream: "stdout",
+              text: "Successfully updated from 1.0.0 to version 1.1.0\n",
+            },
+            {
+              type: "completed",
+              provider: "codex",
+              exitCode: 0,
+              signal: null,
+              success: true,
+            },
+          ]),
+        threadStorageRootPath: "/tmp/bb-thread-storage",
+      },
+    );
+
+    expect(providerInstallationStatus).toHaveBeenCalledOnce();
+    expect(result.events).toEqual([
+      expect.objectContaining({ type: "started" }),
+      expect.objectContaining({ type: "output" }),
+      expect.objectContaining({
+        type: "error",
+        provider: "codex",
+        message: expect.stringContaining(
+          "could not verify the installed result",
+        ),
+      }),
+      {
+        type: "completed",
+        provider: "codex",
+        exitCode: 0,
+        signal: null,
+        success: false,
+      },
+    ]);
+  });
+
   it("does not spawn when the provider withdraws a stale installation action", async () => {
     const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-");
     const manager = new RuntimeManager({
repro-first.log: result excerpt
bb-plugin-provider-codex:test:   "activeVersionAfter": "1.0.0",
bb-plugin-provider-codex:test:   "npmPackageVersionAfter": "1.1.0",
bb-plugin-provider-codex:test:   "needsUpdateAfter": true
bb-plugin-provider-codex:test: }
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  ✓ |bb-plugin-provider-codex:isolated| src/bridge/installation-prefix.repro.test.ts (1 test) 1017ms
bb-plugin-provider-codex:test:    ✓ exposes an update that changes a different npm prefix from the active CLI  1016ms
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  Test Files  1 passed (1)
bb-plugin-provider-codex:test:       Tests  1 passed (1)
bb-plugin-provider-codex:test:    Start at  12:11:43
bb-plugin-provider-codex:test:    Duration  2.75s (transform 1.31s, setup 0ms, import 1.60s, tests 1.02s, environment 0ms)
bb-plugin-provider-codex:test: 

 Tasks:    5 successful, 5 total
Cached:    0 cached, 5 total
  Time:    4.753s 
repro-second.log: result excerpt
bb-plugin-provider-codex:test:   "activeVersionAfter": "1.0.0",
bb-plugin-provider-codex:test:   "npmPackageVersionAfter": "1.1.0",
bb-plugin-provider-codex:test:   "needsUpdateAfter": true
bb-plugin-provider-codex:test: }
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  ✓ |bb-plugin-provider-codex:isolated| src/bridge/installation-prefix.repro.test.ts (1 test) 1112ms
bb-plugin-provider-codex:test:    ✓ exposes an update that changes a different npm prefix from the active CLI  1111ms
bb-plugin-provider-codex:test: 
bb-plugin-provider-codex:test:  Test Files  1 passed (1)
bb-plugin-provider-codex:test:       Tests  1 passed (1)
bb-plugin-provider-codex:test:    Start at  12:11:45
bb-plugin-provider-codex:test:    Duration  2.48s (transform 904ms, setup 0ms, import 1.23s, tests 1.11s, environment 0ms)
bb-plugin-provider-codex:test: 

 Tasks:    5 successful, 5 total
Cached:    3 cached, 5 total
  Time:    4.431s 
host-first.log: result excerpt
@bb/host-daemon:test: > @bb/host-daemon@0.0.1 test /tmp/issue-4009/first/apps/host-daemon
@bb/host-daemon:test: > vitest run --config vitest.config.ts "--run" "src/command-dispatch.test.ts" "-t" "reproduces issue 4009"
@bb/host-daemon:test: 
@bb/host-daemon:test: 
@bb/host-daemon:test:  RUN  v4.1.1 /tmp/issue-4009/first/apps/host-daemon
@bb/host-daemon:test: 
@bb/host-daemon:test:  ✓ |@bb/host-daemon:isolated| src/command-dispatch.test.ts (41 tests | 40 skipped) 9ms
@bb/host-daemon:test: 
@bb/host-daemon:test:  Test Files  1 passed (1)
@bb/host-daemon:test:       Tests  1 passed | 40 skipped (41)
@bb/host-daemon:test:    Start at  12:12:07
@bb/host-daemon:test:    Duration  1.12s (transform 551ms, setup 0ms, import 1.02s, tests 9ms, environment 0ms)
@bb/host-daemon:test: 

 Tasks:    6 successful, 6 total
Cached:    0 cached, 6 total
  Time:    2.681s 
host-second.log: result excerpt
@bb/host-daemon:test: > @bb/host-daemon@0.0.1 test /tmp/issue-4009/second/apps/host-daemon
@bb/host-daemon:test: > vitest run --config vitest.config.ts "--run" "src/command-dispatch.test.ts" "-t" "reproduces issue 4009"
@bb/host-daemon:test: 
@bb/host-daemon:test: 
@bb/host-daemon:test:  RUN  v4.1.1 /tmp/issue-4009/second/apps/host-daemon
@bb/host-daemon:test: 
@bb/host-daemon:test:  ✓ |@bb/host-daemon:isolated| src/command-dispatch.test.ts (41 tests | 40 skipped) 10ms
@bb/host-daemon:test: 
@bb/host-daemon:test:  Test Files  1 passed (1)
@bb/host-daemon:test:       Tests  1 passed | 40 skipped (41)
@bb/host-daemon:test:    Start at  12:12:34
@bb/host-daemon:test:    Duration  1.14s (transform 609ms, setup 0ms, import 1.04s, tests 10ms, environment 0ms)
@bb/host-daemon:test: 

 Tasks:    6 successful, 6 total
Cached:    0 cached, 6 total
  Time:    2.627s 
build-first.log: result excerpt
@bb/desktop:build: 
@bb/desktop:build: > @bb/desktop@0.43.3 build /tmp/issue-4009/first/apps/desktop
@bb/desktop:build: > node scripts/build.mjs
@bb/desktop:build: 
@bb/desktop:build: ▲ [WARNING] "import.meta" is not available with the "cjs" output format and will be empty [empty-import-meta]
@bb/desktop:build: 
@bb/desktop:build:     ../../packages/config/src/file-lock.ts:16:39:
@bb/desktop:build:       16 │ ...: unknown = createRequire(import.meta.url)("fs-native-extension...
@bb/desktop:build:          ╵                              ~~~~~~~~~~~
@bb/desktop:build: 
@bb/desktop:build:   You need to set the output format to "esm" for "import.meta" to work correctly.
@bb/desktop:build: 
@bb/desktop:build: @bb/desktop: built Electron entries

 Tasks:    58 successful, 58 total
Cached:    4 cached, 58 total
  Time:    1m10.994s 
build-second.log: result excerpt
@bb/desktop:build: 
@bb/desktop:build: > @bb/desktop@0.43.3 build /tmp/issue-4009/second/apps/desktop
@bb/desktop:build: > node scripts/build.mjs
@bb/desktop:build: 
@bb/desktop:build: ▲ [WARNING] "import.meta" is not available with the "cjs" output format and will be empty [empty-import-meta]
@bb/desktop:build: 
@bb/desktop:build:     ../../packages/config/src/file-lock.ts:16:39:
@bb/desktop:build:       16 │ ...: unknown = createRequire(import.meta.url)("fs-native-extension...
@bb/desktop:build:          ╵                              ~~~~~~~~~~~
@bb/desktop:build: 
@bb/desktop:build:   You need to set the output format to "esm" for "import.meta" to work correctly.
@bb/desktop:build: 
@bb/desktop:build: @bb/desktop: built Electron entries

 Tasks:    58 successful, 58 total
Cached:    53 cached, 58 total
  Time:    46.936s 

Full local evidence remains in the report work directory and its cleaned backup; repository publication policy permits inline evidence only.