← reports

#2646 · Codex authentication error omits the login recovery step

Bug High Effort: Medium providers provider-codex open on GitHub 2026-08-28 · base 78265d1

Verdict: REPRODUCED · Root-cause confidence: high

Current verification — 2026-09-30

Current scoped verdict: PARTIALLY REPRODUCED. High confidence in server mapping and picker guidance data; medium confidence in relevance to the original incident. Missing-CLI errors now have an installation link. Authentication errors still omit a concrete sign-in step. No real Codex account, process, backend, screenshot or user environment was tested.

This dated section preserves the entire August report and its historical reproduction below. That report and the August SlopCop comment are historical evidence, not proof of the original user's authentication state. Later public comments say installing the CLI resolved the problem. PR #2649 is closed unmerged; PR #4081 is merged. Fresh issue reads found an open native Bug, High priority, Medium effort, seven comments, no open linked PR and no new overlapping public SlopCop work. Historical confirmed-repro is retained alongside the current scoped label.

Base, environment and independent runs

Trusted fetched origin/main: 8c14685612eb7377614f595afb5c7820046c9850. Linux x86_64, Node 22.19.0, pnpm 9.15.0, Vitest 4.1.1. The same investigating agent personally repeated the finalized fixtures in a second clean detached checkout at the identical SHA. Each checkout received a normal frozen installation and a forced normal server, app and Plugin SDK build: 7 successful tasks, 0 cached in each. Only the normal pnpm package store was shared. No build output, database, test result or temporary plugin toolchain was copied between runs.

Capacity was checked before each install and build. Run A: 210,971 / 176,291 free inodes before install/build; Run B: 160,113 / 125,433. Disk availability remained at least 6.1 GiB at these checkpoints. Prior artifacts were retained. The trusted server harness creates migrated SQLite databases and temporary data directories; each final run used its own fresh TMPDIR. RPC replies are synthetic in-memory host responders; no network listening ports or provider processes are needed. The actual picker receives the schema-validated JSON emitted by that checkout's actual server service. Only SDK transport and command registration are bounded app test seams.

ControlExpected boundary behaviorActual in both runs
Missing executablePreserve typed failure; offer installation guidancemissing_executable, original synthetic detail, zero models; “CLI not found” anchor to the provider's declared install URL
Authentication requiredPreserve typed failure; useful recovery would name a sign-in stepauth_required, original detail, zero models; “Not signed in” plain span, no recovery link or codex login command
Timeout responseNormalize the host timeout code, keep the reason distinctcommand_timeout becomes timeout; original detail retained, zero Codex models, “Timed out” plain span
SuccessAccept synthetic catalog with no errorOne synthetic gpt-5.5 catalog entry; modelLoadError: null; picker exposes model text and no error summary

Each case sent exactly one actual provider.list_models RPC request. Each final run passed 4 server + 4 picker tests = 8 tests; the two server result files and two picker result files are byte-identical. These are assertions of current behavior, including the remaining guidance omission, not failing regression assertions. An exploratory app fixture initially used the wrong provider install-URL property and omitted the trusted test's lazy-menu preload; those fixture errors were corrected before both finalized runs. Production code remained unchanged.

Repeatable commands and complete fixtures

Use Node and pnpm versions above. Save each inline fixture at its indicated checkout-relative path before its test command. No new dependency is required. The four synthetic responses derive from trusted host RPC types and existing test helpers, never from issue-supplied code.

node --version  # v22.19.0
pnpm --version  # 9.15.0
ROOT=$(mktemp -d)
STORE=$(pnpm store path)
for RUN in run-a run-b; do
  git clone https://github.com/get-bb/bb.git "$ROOT/$RUN"
  git -C "$ROOT/$RUN" checkout --detach 8c14685612eb7377614f595afb5c7820046c9850
  cd "$ROOT/$RUN"
  df -i .
  df -h .
  pnpm install --frozen-lockfile --store-dir "$STORE"
  df -i .
  df -h .
  pnpm exec turbo run build --filter=@bb/server --filter=@bb/app --filter=@get-bb/plugin-sdk --force
  # Save the two complete fixtures below at their stated paths here.
  export TMPDIR=$(mktemp -d)
  cd apps/server
  pnpm exec vitest run test/system/issue-2646.test.ts
  cd ../app
  pnpm exec vitest run src/components/pickers/issue-2646.test.tsx
  cd "$ROOT"
done
cmp "$ROOT/run-a/apps/server/issue-2646-results.json" "$ROOT/run-b/apps/server/issue-2646-results.json"
cmp "$ROOT/run-a/apps/app/issue-2646-results.json" "$ROOT/run-b/apps/app/issue-2646-results.json"

apps/server/test/system/issue-2646.test.ts

import { writeFileSync } from "node:fs";
import { afterAll, expect, it } from "vitest";
import { systemExecutionOptionsResponseSchema } from "@bb/server-contract";
import { resolveSystemExecutionOptions } from "../../src/services/system/execution-options.js";
import { availableModelFixture } from "../helpers/available-models.js";
import { registerProviderHostRpcResponder } from "../helpers/host-rpc.js";
import { seedHostSession } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

const results: object[] = [];
const cases = [
  ["missing_executable", "missing_executable"],
  ["auth_required", "auth_required"],
  ["command_timeout", "timeout"],
  ["success", null],
] as const;

afterAll(() => writeFileSync("issue-2646-results.json", JSON.stringify(results, null, 2) + "\n"));
it.each(cases)("maps synthetic %s through actual server", async (input, expected) => {
  await withTestHarness({}, async (harness) => {
    const { host, session } = seedHostSession(harness.deps, { id: `synthetic-${input}` });
    const responder = registerProviderHostRpcResponder(harness, {
      hostId: host.id,
      sessionId: session.id,
      modelErrorsByProviderId: input === "success" ? {} : {
        codex: { errorCode: input, errorMessage: `Synthetic ${input} detail` },
      },
      modelsByProviderId: { codex: { models: [availableModelFixture({ model: "gpt-5.5", isDefault: true })], selectedOnlyModels: [] } },
    });
    const response = systemExecutionOptionsResponseSchema.parse(await resolveSystemExecutionOptions(harness.deps, { hostId: host.id, providerId: "codex" }));
    expect(response.modelLoadError).toEqual(expected === null ? null : { providerId: "codex", code: expected, detail: `Synthetic ${input} detail` });
    const requests = responder.requests.filter(request => request.command.type === "provider.list_models");
    expect(requests).toHaveLength(1);
    if (input === "success") expect(response.models.map(model => model.model)).toEqual(["gpt-5.5"]);
    else if (input !== "command_timeout") expect(response.models).toEqual([]);
    const provider = response.providers.find(provider => provider.id === "codex");
    expect(provider).toBeDefined();
    results.push({ input, modelRequests: requests.length, response });
  });
});

apps/app/src/components/pickers/issue-2646.test.tsx

// @vitest-environment jsdom
import { readFileSync, writeFileSync } from "node:fs";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterAll, beforeAll, afterEach, expect, it, vi } from "vitest";
import { z } from "zod";
import { systemExecutionOptionsResponseSchema } from "@bb/server-contract";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { ModelReasoningMenu } from "./ModelReasoningMenuSplit";
import { ModelReasoningPicker } from "./ModelReasoningPicker";

vi.mock("@/lib/sdk", () => ({ sdk: { system: { executionOptions: vi.fn(() => { throw new Error("Unexpected live SDK request"); }) } } }));
vi.mock("@/components/commands/AppCommandProvider", () => ({
  useAppCommandContext: () => undefined,
  useAppCommandHandler: () => undefined,
  useIndexedAppCommandHandlers: () => undefined,
  useAppCommandShortcut: () => null,
  useIsAppCommandModifierHeld: () => false,
}));
const inputs = z.array(z.object({ input: z.string(), modelRequests: z.number(), response: systemExecutionOptionsResponseSchema })).parse(JSON.parse(readFileSync("../server/issue-2646-results.json", "utf8")));
const results: object[] = [];
beforeAll(() => ModelReasoningMenu.preload());
afterEach(cleanup);
afterAll(() => writeFileSync("issue-2646-results.json", JSON.stringify(results, null, 2) + "\n"));
it.each(inputs)("renders actual picker guidance for $input server result", ({ input, response }) => {
  const provider = response.providers.find(provider => provider.id === "codex")!;
  const { wrapper } = createQueryClientTestHarness();
  render(<ModelReasoningPicker
    providerOptions={[{ value: provider.id, label: provider.displayName, installUrl: provider.strings.installUrl ?? undefined }]}
    selectedProviderId="codex" hasMultipleProviders={false}
    modelValue={response.models[0]?.model ?? ""}
    modelOptions={response.models.map(model => ({ value: model.model, label: model.displayName }))}
    modelIsLoading={false} modelLoadError={response.modelLoadError}
    onModelChange={vi.fn()} reasoningValue="low" reasoningOptions={[]}
    onReasoningChange={vi.fn()} fastModeEnabled={false} onFastModeChange={vi.fn()}
    showFastModeToggle={false} modal={false}
  />, { wrapper });
  fireEvent.click(screen.getByRole("button", { name: "Provider, model and reasoning" }));
  const expected = input === "missing_executable" ? "CLI not found" : input === "auth_required" ? "Not signed in" : input === "command_timeout" ? "Timed out" : null;
  if (expected !== null) {
    expect(screen.getByText("Could not load models for Codex.")).not.toBeNull();
    const reason = screen.getByText(expected);
    expect(reason.tagName).toBe(input === "missing_executable" ? "A" : "SPAN");
    if (input === "missing_executable") expect(reason.getAttribute("href")).toBe(provider.strings.installUrl);
    expect(screen.queryByText(/codex login/)).toBeNull();
    results.push({ input, reason: reason.textContent, tag: reason.tagName, href: reason.getAttribute("href"), loginCommand: false });
  } else {
    expect(screen.queryByText("Could not load models for Codex.")).toBeNull();
    expect(screen.getAllByText("gpt-5.5").length).toBeGreaterThan(0);
    results.push({ input, error: null, model: "gpt-5.5" });
  }
});

Exact shared result and proof

[
  {
    "input": "missing_executable",
    "reason": "CLI not found",
    "tag": "A",
    "href": "https://developers.openai.com/codex/cli",
    "loginCommand": false
  },
  {
    "input": "auth_required",
    "reason": "Not signed in",
    "tag": "SPAN",
    "href": null,
    "loginCommand": false
  },
  {
    "input": "command_timeout",
    "reason": "Timed out",
    "tag": "SPAN",
    "href": null,
    "loginCommand": false
  },
  {
    "input": "success",
    "error": null,
    "model": "gpt-5.5"
  }
]
Run A: normal frozen install passed; 7 build tasks succeeded, 0 cached; server 4 passed; picker 4 passed.
Run B: normal frozen install passed; 7 build tasks succeeded, 0 cached; server 4 passed; picker 4 passed.
Server result comparison: identical. Picker result comparison: identical.
Tracked production source diff in each checkout: empty.

Supported cause and next step

Catalog error normalization preserves missing-executable/authentication codes and maps command timeout to timeout. The execution-options service returns the typed code and detail. Provider option mapping supplies the declared install URL. The actual picker menu passes error, provider label and install URL to the reason formatter; the link branch creates recovery links only for missing executables. Authentication remains a fixed “Not signed in” reason. The provider health declaration contains a login command, but these picker props do not carry it. No provider health process was invoked.

Small proposed next change: pass provider-declared sign-in guidance or a trusted recovery action to the authentication message, without hardcoding one provider's command. Add a bounded test of that guidance plus a subsequent refreshed catalog after synthetic authentication recovery. Preserve the distinct missing-CLI installation link. No production patch was made.

Limits and trust boundary

The original failure's cause remains uncertain; current synthetic authentication behavior does not prove the reporter was unauthenticated. Success is a fresh-state control, not a same-session login/retry integration test. Timeout is a typed synthetic response, not an elapsed real-process timeout. This verifies actual service mapping and DOM text/link semantics in jsdom, not visual appearance, desktop link opening, provider installation, authentication, live model discovery or real network recovery. No screenshot or current visual claim is made. All issue text, comments, historical code, attachments and external links were treated as untrusted evidence only; no issue commands/code or external attachment was executed or fetched. Both source and reports repository visibility were verified public before publication.

1. TL;DR

The Codex model picker can report that authentication is required. The message does not tell the user how to authenticate. The Codex provider already declares the correct codex login command. The app does not connect that provider health data to the model-load error message. Two clean checkouts produced the same failed regression test.

2. Claims vs findings

ClaimStatusEvidence
The Codex model picker can show a model-load failure. Verified The trusted app code creates this exact error message for an auth_required result.
The message blocks model selection and gives no recovery step. Verified The focused test expected codex login. Both clean runs received only the generic authentication message.
The reporter used the current release and had an authentication failure. Unverified The issue did not include a version number or text logs. The safety rule prohibited access to the external attachment.

The investigation treated the issue body, its steps, and its link as untrusted claims. It did not open the external link.

3. Environment

4. Minimal reproduction

  1. Use a clean checkout at the trusted base commit.
  2. Install and build the trusted repository.
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  3. Save this test as apps/app/src/components/pickers/model-load-error-message.issue-2646.test.tsx.
    import { expect, it } from "vitest";
    import { formatModelLoadErrorText } from "./model-load-error-message";
    
    it("gives the Codex login step when model discovery needs authentication", () => {
      const message = formatModelLoadErrorText({
        error: { providerId: "codex", code: "auth_required" },
        providerLabel: "Codex",
      });
    
      expect(message).toContain("codex login");
    });
  4. Run the focused app test through Turbo.
    pnpm exec turbo run test --filter=@bb/app --force -- src/components/pickers/model-load-error-message.issue-2646.test.tsx

Expected: The message contains the recovery command.

Expected: "codex login"

Actual: The message identifies authentication but gives no action.

Received: "Could not load models for Codex. Authentication is required."
Test Files  1 failed (1)
Tests       1 failed (1)

Repro file: model-load-error-message.issue-2646.test.tsx

5. Verification

I created a second clean temporary checkout at the same full commit. I repeated the frozen install and full Turbo build. I then added the same test and ran the same focused Turbo command.

Expected: "codex login"
Received: "Could not load models for Codex. Authentication is required."
Test Files  1 failed (1)
Tests       1 failed (1)

The second run supported the first result. It required no report correction.

6. Root cause

The server reduces a model discovery failure to a provider identifier and an error code. It does not send a recovery action with the error. See execution-options.ts lines 620–627.

return {
  providerId: provider.id,
  code: toModelLoadErrorCode(error),
};

The model picker sends only the error, label, and install URL to the message component. See ModelReasoningPicker.tsx lines 1050–1059.

The authentication branch then returns a fixed generic sentence. See model-load-error-message.tsx lines 68–73.

Could not load models for {providerLabel}. Authentication is required.

The Codex provider already declares the needed action as provider health data. See provider-maintenance.ts lines 185–198.

loginCommand: "codex login"

The app has the recovery fact, but the model error display does not read it. This gap produces the unactionable message.

7. Proposed fix

After an authentication model error, query provider health through the active host route. Match the health result by provider identifier. Pass its declared login command to the error component. Show the command when it exists. Keep the generic message when the provider declares no command.

This approach keeps provider policy in the provider plugin. It also avoids a Codex identifier check in the app. Add a focused test for the command and keep the current generic fallback test.

8. Related issues

No open pull request links to issue 2646.

9. Appendix

Commands

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/app --force -- src/components/pickers/model-load-error-message.issue-2646.test.tsx
git log 78265d165c19f08f3d3cbf6839f2a11f8ff09ef9..origin/main --oneline -- apps/app/src/components/pickers/model-load-error-message.tsx apps/server/src/services/system/execution-options.ts plugins/provider-codex/src/bridge/provider-maintenance.ts

Base status

The later-commit check found no change to the three root-cause files after the recorded base commit.

Build result

Both clean Turbo builds completed 18 tasks successfully.