← reports

#3166 · Provider maintenance can update manager-owned executables

Bug Medium Effort: Medium providers provider-claude-code provider-codex provider-pi open on GitHub 2026-09-05 · base 6cdb4ba61

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

Provider maintenance has no ownership state for executables managed outside npm or a vendor installer. A deterministic Claude Code test places the resolved executable under a package-manager tree, but status still queries npm, reports a newer npm release, and offers claude update. The same registry-first ordering exists in the Codex and Pi bridges, and their update actions are also calculated without checking installation ownership. The result is unnecessary registry traffic and an update button that can target an executable owned by another package manager.

2. Claims vs findings

ClaimStatusEvidence
A manager-owned Claude executable can be classified external while still receiving an in-place vendor update action.VerifiedBoth clean test runs received installSource: "external" together with latestVersion: "2.1.263", needsUpdate: true, and an update action whose command was claude update.
Claude maintenance queries the npm registry before ownership is established.VerifiedThe focused test recorded a call equivalent to npm view @anthropic-ai/claude-code dist-tags --json in both clean runs.
Codex and Pi also query npm for externally installed executables.Verified staticallyEach status function starts executable resolution, version probing, npm latest-version lookup, and npm-global probing in one unconditional Promise.all.
Codex and Pi suppress update actions whenever the executable is not npm-global.RefutedAt this base, both functions derive actionKind from version comparisons before computing installSource; neither action builder checks that source. A newer npm version therefore creates an update action for an external executable.
The exact reporter machine and third-party package manager produce the same result.UnverifiedNo user runtime, private path, third-party binary, or provider account was accessed. The test reproduces the ownership layout and probe responses deterministically.

3. Environment

4. Minimal reproduction

  1. Save the linked test as plugins/provider-claude-code/src/bridge/provider-maintenance.external.test.ts at the trusted base.
  2. Run
    pnpm exec turbo run test --filter=bb-plugin-provider-claude-code -- src/bridge/provider-maintenance.external.test.ts
  3. The expected result is one passing test, no npm view call, and external status with no latest version or action. The actual result is one failing test with two failed soft assertions:
    Expected: installAction null, latestVersion null, needsUpdate false
    Received: installAction { command: "claude update", kind: "update", label: "Update" }, latestVersion "2.1.263", needsUpdate true
    
    Received npm call: ["npm", ["view", "@anthropic-ai/claude-code", "dist-tags", "--json"]]
    
    Test Files  1 failed (1)
    Tests       1 failed (1)

Artifacts: focused regression test · primary failure log · verification failure log

Reproduction test

import { afterEach, describe, expect, it, vi } from "vitest";

const probes = vi.hoisted(() => ({
  commandOutput: vi.fn(),
}));

vi.mock("@get-bb/plugin-sdk/provider-bridge", async (importOriginal) => {
  const original =
    await importOriginal<typeof import("@get-bb/plugin-sdk/provider-bridge")>();
  return {
    ...original,
    experimental_commandOutput: probes.commandOutput,
    experimental_probeNpmGlobalPackage: vi.fn(async () => ({
      npmBin: "/usr/local/bin",
      npmGlobalPackageVersion: null,
    })),
    experimental_resolveExecutablePath: vi.fn(async () =>
      "/opt/homebrew/Cellar/claude-code/2.1.261/bin/claude"
    ),
  };
});

import { getClaudeProviderInstallationStatus } from "./provider-maintenance.js";

afterEach(() => {
  probes.commandOutput.mockReset();
});

describe("Claude Code maintenance for an externally managed executable", () => {
  it("does not probe npm or offer an in-place update", async () => {
    probes.commandOutput.mockImplementation(
      async (command: string, args: readonly string[]) => {
        if (command === "claude" && args[0] === "--version") {
          return "2.1.261";
        }
        if (command === "claude" && args[0] === "doctor") {
          return "Running: native\nAuto-update channel: latest";
        }
        if (args[0] === "view") {
          return JSON.stringify({ latest: "2.1.263", stable: "2.1.263" });
        }
        return null;
      },
    );

    const status = await getClaudeProviderInstallationStatus();

    expect.soft(status).toMatchObject({
      installSource: "external",
      latestVersion: null,
      installAction: null,
      needsUpdate: false,
    });
    expect.soft(probes.commandOutput).not.toHaveBeenCalledWith(
      expect.any(String),
      expect.arrayContaining(["view"]),
    );
  });
});

Verification

The test was copied into a second clean checkout detached at 6cdb4ba6125514b7660332cf311037bc09c82b0e. After a separate frozen install and full Turbo build, the same focused command failed with the identical returned status and npm call. No report claim required correction after the second run.

5. Root cause

The shared executable resolver returns the path printed by which without calling realpath, while the shared source classifier distinguishes only “inside npm global bin” from “external.” It cannot represent external package-manager ownership: resolver lines 19–42 and source-classifier lines 192–212.

return stdout.split(/\r?\n/u).find((line) => line.trim())?.trim() ?? null;

return executablePath inside npmBin ? "npmGlobal" : "external";

Claude starts the npm dist-tag lookup in the same Promise.all as executable resolution, so ownership cannot suppress registry access. It then allows any doctor result of native to update, independent of the external path: probe ordering lines 118–137 and action gating lines 157–175.

const canRunUpdate =
  doctor.installMethod === "native" ||
  nativeFallback ||
  (installSource === "npmGlobal" && ...);

Codex and Pi repeat the registry-first structure and calculate update actions without consulting source: Codex lines 85–142 and Pi lines 173–225. This shared absence of ownership detection explains both the egress and the unsafe action.

6. Proposed fix (first principles)

Add one shared bridge-kit ownership probe that canonicalizes the executable path, recognizes package-manager roots through explicit platform discovery and a validated path-list override, and returns an “externally managed” result before any registry or vendor update lookup. Each provider should resolve ownership first; for externally managed executables it should retain the installed/current-version information but return no latest version, no update action, and no update need. Tests should cover symlink targets, path-boundary matching, absent or malformed override entries, and all three provider status functions.

This should not be implemented as a narrow one-plugin patch: the shared helper becomes an experimental public Plugin SDK surface and the path override is user-facing configuration. Repository policy therefore requires SDK export/audit and Plugin Guide coverage in addition to the shared helper, three provider integrations, and tests.

7. Related issues

#3119 and its landed follow-up addressed Bun ownership only inside the Pi provider. That narrower fix confirms package-manager ownership matters, but the current trusted base still lacks a provider-agnostic external-manager state. No open pull request linked to #3166 or matched its issue number at investigation time.

8. Appendix

The issue title, body, comments, links, code blocks, and paths were treated only as untrusted claims. No issue-supplied command, patch, binary, branch, test, or external link was executed. There were no comments and no linked pull requests. The report uses only trusted repository code, GitHub metadata, and an independently authored test.

Commands used for repeatable evidence:

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-provider-claude-code -- src/bridge/provider-maintenance.external.test.ts
git log 6cdb4ba6125514b7660332cf311037bc09c82b0e..origin/main --oneline -- plugins/provider-claude-code/src/bridge/provider-maintenance.ts plugins/provider-codex/src/bridge/provider-maintenance.ts plugins/provider-pi/src/bridge/provider-maintenance.ts packages/provider-bridge-protocol/src/bridge-kit/provider-maintenance-kit.ts