← reports

#1587 · Transcription often fails with HTTP 403 due to cloudflare when using codex subscription

Bug Medium Effort: unset providers provider-codex open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high (mechanism) / medium (why the reporter's network is challenged more often than mine) · linked open PRs: none

TL;DR

Plain-language framing. When bb is configured with the default transcription model codex/gpt-transcribe and the local Codex CLI is logged in with a ChatGPT subscription (~/.codex/auth.json has auth_mode: "chatgpt"), voice input (bb voice transcribe file.mp3 or the microphone button in the prompt box) is executed by the host daemon, which POSTs the audio to https://chatgpt.com/backend-api/transcribe with the subscription's bearer token. That URL is not a Codex endpoint: it is the ChatGPT web app's transcription endpoint, and it sits behind Cloudflare bot management (a web application firewall, WAF, rule that issues a "managed challenge"). The Codex endpoint the daemon uses for the other helper calls, https://chatgpt.com/backend-api/codex/responses, is not behind that challenge.

When Cloudflare decides to challenge a request it answers HTTP 403 with the header cf-mitigated: challenge and a "Just a moment…" HTML page that only a JavaScript-running browser can solve. The daemon already knows about this: it re-sends the request once with the Cloudflare cookies from the challenge response. If the retry is challenged too, the daemon gives up with the generic, permanent error code codex_request_failed whose message embeds the first 400 characters of the challenge page. The server treats that code as non-transient (no second attempt, no fallback), so the user sees Error: HTTP 502: Codex transcription request failed with HTTP 403: <html> <head> <meta name="viewport" ….

Whether a given request is challenged is decided by Cloudflare per IP / TLS fingerprint / headers and fluctuates. On my network the daemon's exact header set (Authorization + chatgpt-account-id + originator: bb + User-Agent: bb-host-daemon) passed 10/10 times, while the same request with only Authorization was challenged 7/10 times, and unauthenticated probes were challenged 10/10 for every User-Agent tried (all of them pass the /backend-api/codex/responses path). The reporters ("usually fails on my machine", "same here") are presumably on networks where the same daemon request is scored badly (inferred, not observed: Cloudflare's per-IP/fingerprint scoring is opaque and the issue does not say). I therefore could not trigger a real challenge with the real headers from here; I reproduced the failure end to end by feeding the daemon the real captured challenge response through a Node preload, and I verified the mechanism live with curl. The issue's guess that an API key would avoid it is correct: auth_mode: "apikey" (or BB_TRANSCRIPTION=openai/… + OPENAI_API_KEY) goes to api.openai.com/v1/audio/transcriptions, which is not behind that WAF rule. Note that the issue body does not quote an error string (it only says "fails with HTTP 403 due to cloudflare"); the exact wording shown below is what the code produces on a challenged network, reproduced here by simulation.

Claims vs findings

ClaimStatusEvidence
Transcription fails with HTTP 403 from Cloudflare when using a Codex subscriptionVerified (mechanism)https://chatgpt.com/backend-api/transcribe is behind Cloudflare managed challenge (HTTP 403, cf-mitigated: challenge, HTML body; captured in cf-challenge-headers.txt). The daemon's transcription code path is the only bb code that hits it, and only for auth_mode: "chatgpt". End-to-end repro below shows exactly the reported error text.
"seems to usually fail on my machine"Unverifiable here; plausibleCloudflare's decision is per-network and fluctuates: with the daemon's headers I got 0/18 challenges (8 real daemon calls + 10 curl); with Authorization only, 7/10; unauthenticated, 10/10 with every UA. A network with a worse bot score sees the same fuzziness with the full headers. See waf-sample.log.
"it hits a chatgpt.com endpoint meant for internal use vs an actual official api endpoint"VerifiedCHATGPT_TRANSCRIBE_URL = "https://chatgpt.com/backend-api/transcribe" (codex-chatgpt-client.ts#L28). The installed codex-cli 0.147.0 binary contains no reference to /backend-api/transcribe (its voice input uses realtime/calls); the string only exists in the ChatGPT web client. Probing /backend-api/codex/transcribe and two other codex-prefixed variants returns 404 (log), so there is no un-challenged Codex-scoped equivalent.
"I suspect an API key would fix it"Verified by code path (not run: no API key here)fetchTranscription routes auth.type === "apiKey" to https://api.openai.com/v1/audio/transcriptions (L905-L934); the server-side openai/* route does the same. Neither goes through chatgpt.com.
Comment "same here" (jandornig)No detailsSecond independent report; no version, OS or network info.

Environment

Minimal reproduction

Three layers, from "prove the endpoint is behind a Cloudflare challenge" to "the CLI error the code produces on a challenged network" (the issue itself does not quote the error text). No secrets are printed by any script; the auth probes read ~/.codex/auth.json in-process.

A. The endpoint is behind Cloudflare managed challenge; the Codex endpoint is not (live, no bb involved)

Script: waf-probe.sh. Unauthenticated POSTs to both URLs with five User-Agents. A request that passes the WAF (Cloudflare's web application firewall) reaches the app and gets 401; a challenged one gets 403 + cf-mitigated: challenge and never reaches the app.

$ bash /tmp/bb-reports/issues/1587/repro/waf-probe.sh
UA=bb-host-daemon                       https://chatgpt.com/backend-api/transcribe       HTTP/2 403  cf-mitigated: challenge server: cloudflare
UA=bb-host-daemon                       https://chatgpt.com/backend-api/codex/responses  HTTP/2 401  server: cloudflare
UA=codex_cli_rs/0.147.0 (Linux …)       https://chatgpt.com/backend-api/transcribe       HTTP/2 403  cf-mitigated: challenge server: cloudflare
UA=codex_cli_rs/0.147.0 (Linux …)       https://chatgpt.com/backend-api/codex/responses  HTTP/2 401  server: cloudflare
UA=curl/8.5.0                           https://chatgpt.com/backend-api/transcribe       HTTP/2 403  cf-mitigated: challenge server: cloudflare
UA=curl/8.5.0                           https://chatgpt.com/backend-api/codex/responses  HTTP/2 401  server: cloudflare
UA=python-requests/2.31                 https://chatgpt.com/backend-api/transcribe       HTTP/2 403  cf-mitigated: challenge server: cloudflare
UA=python-requests/2.31                 https://chatgpt.com/backend-api/codex/responses  HTTP/2 401  server: cloudflare
UA=Mozilla/5.0 (… Chrome/128.0 …)       https://chatgpt.com/backend-api/transcribe       HTTP/2 403  cf-mitigated: challenge server: cloudflare
UA=Mozilla/5.0 (… Chrome/128.0 …)       https://chatgpt.com/backend-api/codex/responses  HTTP/2 401  server: cloudflare

Ran twice, 10 min apart, identical result (run 1, run 2). The full challenge response is saved: headers, 10,492-byte HTML body ("Just a moment…", loads /cdn-cgi/challenge-platform/…).

B. Which headers matter, and how "often" it fails (live)

Scripts: waf-probe-headers.py (one request per header combination) and waf-sample.py (10 interleaved pairs). Both send the daemon's multipart body and User-Agent.

$ python3 /tmp/bb-reports/issues/1587/repro/waf-probe-headers.py
auth+account+originator        HTTP/2 200                           body={"text":"...","asset_pointer":"sediment://file_…
auth+account                   HTTP/2 200                           body={"text":"...", …
auth+originator=bb             HTTP/2 200                           body={"text":"...", …
auth+originator=codex_cli_rs   HTTP/2 200                           body={"text":"...", …
auth only                      HTTP/2 403 cf-mitigated: challenge   body=<html>   <head>     <meta name="viewport" …
account only (no auth)         HTTP/2 401                           body={"detail":"Unauthorized"}
originator only (no auth)      HTTP/2 401                           body={"detail":"Unauthorized"}
nothing                        HTTP/2 403 cf-mitigated: challenge   body=<html>   <head> …

$ python3 /tmp/bb-reports/issues/1587/repro/waf-sample.py | tail -1
tally: {"full-daemon-headers": {"200": 10}, "authorization-only": {"403": 7, "200": 3}}

Read: on this network the presence of chatgpt-account-id or originator (which the daemon does send) is enough to pass, and without them the outcome is a coin flip weighted toward the challenge (7/10). That coin flip is what "often fails" looks like; the reporters' networks are scored so that it applies even with the full header set. Re-sending the challenge's own cookies is what the daemon's single retry does; with curl and a cookie jar (waf-cookie-retry.sh) my first run went 403, 403, 401 and a second run a minute later 401 ×8 (log of the second run): the retry can help, but only sometimes, and never deterministically.

C. The daemon path against the real endpoint (live, this network): passes

live-probe-1587.ts calls the real transcribeCodexVoice() from apps/host-daemon/src/codex-chatgpt-client.ts with the local subscription auth. Copy it into apps/host-daemon/ and run pnpm exec tsx live-probe-1587.ts /tmp/bb-reports/issues/1587/repro/tone.mp3 8:

#1 OK 580ms text="..."
#2 OK 896ms text="..."
#3 OK 521ms text="..."
#4 OK 524ms text="..."
#5 OK 587ms text="..."
#6 OK 500ms text="..."
#7 OK 655ms text="..."
#8 OK 1602ms text="..."

So the live failure is network-dependent and did not occur from here with the daemon's headers. Steps D and E reproduce what happens on a network where it does.

D. End to end: bb voice transcribe on a challenged network (deterministic simulation)

cf-challenge-preload.mjs is a Node --import preload that wraps globalThis.fetch: while the flag file FORCE_CF_CHALLENGE exists, any POST to https://chatgpt.com/backend-api/transcribe gets the real captured Cloudflare response from step A (403, cf-mitigated: challenge, the HTML body, a __cf_bm set-cookie); everything else goes to the network. It logs each intercepted call, which lets us count the daemon's attempts. Both scripts are configured through the environment: the preload reads CF_PRELOAD_DIR (flag file + log location, default /tmp/bb-1587-preload) and finds cf-challenge-body.html next to itself; the wrapper run-cli-transcribe.sh reads WORKTREE (default $PWD), BB_SERVER_URL (required) and the same CF_PRELOAD_DIR. Nothing needs editing.

  1. In your built bb worktree, start your dev instance with the preload: export NODE_OPTIONS="--import /tmp/bb-reports/issues/1587/repro/cf-challenge-preload.mjs" && scripts/bb-dev-app current (the launcher passes the environment to the server and daemon). Then unset NODE_OPTIONS; eval "$(scripts/bb-dev-app env)"; export WORKTREE=$PWD and confirm curl -s $BB_SERVER_URL/api/v1/system/config shows "voiceTranscriptionEnabled":true (give it ~30 s to boot).
  2. Baseline (real network): bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh baseline. Expected and actual: the CLI prints ... (the transcript of the 1 s tone, printed as plain text because the wrapper does not pass --json) and exit=0:
  3. Challenged: bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh challenge.
$ bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh baseline
$ bb voice transcribe tone.mp3 --type audio/mpeg   # mode=baseline server=http://localhost:25796
...
exit=0

$ bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh challenge
$ bb voice transcribe tone.mp3 --type audio/mpeg   # mode=challenge server=http://localhost:25796
Error: HTTP 502: Codex transcription request failed with HTTP 403: <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style global>body{font-family:Arial,Helvetica,sans-serif}.container{align-items:center;display:flex;flex-direction:column;gap:2rem;height:100%;justify-content:center;width:100%}@keyframes enlarge-appear{0%{opacity:0;transform:scale(75%) rotate(-90deg)}to{opacity:1;transform:scale(100%) rotate(0deg)}}.lo...
exit=1
--- daemon fetch calls intercepted by the preload during this run (/tmp/bb-1587-preload/cf-challenge-preload.log):
2026-08-18T14:27:13.470Z pid=3478951 intercepted POST https://chatgpt.com/backend-api/transcribe cookie="__cf_bm=6DH8…; __cflb=04dT…; _cfuvid=zv0y…" ua=bb-host-daemon
2026-08-18T14:27:13.471Z pid=3478951 intercepted POST https://chatgpt.com/backend-api/transcribe cookie="__cf_bm=SIMULATED.CHALLENGE.COOKIE-1787038601; __cflb=04dT…; _cfuvid=zv0y…" ua=bb-host-daemon

Expected: a transcript, or at least a retry/fallback and a human-readable error. Actual: exit 1 with the error the code produces for a challenged request (HTTP 502 wrapping "HTTP 403" and the challenge page's CSS). The preload log shows the daemon's two fetches (original + one cookie retry, the second carrying the __cf_bm from the challenge). The server log for the same second shows a single attempt out of two allowed and the daemon's error code:

@bb/host-daemon:dev: [14:27:13] DEBUG: [host-daemon] Online host RPC {"serverUrl":"http://127.0.0.1:25796","commandType":"codex.voice.transcribe","errorCode":"codex_request_failed","handlerMs":1.9,"ok":false}
@bb/server:dev:      [14:27:13] WARN: [server] Voice transcription failed {"attempts":1,"durationMs":7,"maxAttempts":2,"model":"codex/gpt-transcribe","reason":"failed"}

Full CLI output: cli-challenge-run.log. Running run-cli-transcribe.sh baseline again (which removes the flag file /tmp/bb-1587-preload/FORCE_CF_CHALLENGE) makes the same instance transcribe again (..., exit=0). Preload log of the run: cf-challenge-preload.log.

E. Unit-level repro test (fails on main)

codex-chatgpt-client-1587.test.ts: drop it at apps/host-daemon/src/codex-chatgpt-client-1587.test.ts and run cd apps/host-daemon && pnpm exec vitest run src/codex-chatgpt-client-1587.test.ts. It stubs fetch with the challenge response (real HTML head) and calls the real transcribeCodexVoice() against a temp CODEX_HOME with a ChatGPT-mode auth.json. Test 1 documents today's behavior and passes; test 2 asserts the behavior the server needs and fails:

✓ documents current behavior: one cookie retry, then a permanent codex_request_failed carrying raw HTML
× EXPECTED (fails on main): a Cloudflare challenge is transient and must not leak the challenge HTML
  → expected 'codex_request_failed' to be 'codex_service_unavailable' // Object.is equality
    ❯ src/codex-chatgpt-client-1587.test.ts:130:26
 Test Files  1 failed (1)   Tests  1 failed | 1 passed (2)

Which assertion fails and why: expect(failure.code).toBe("codex_service_unavailable") at line 130 receives codex_request_failed because codexRequestErrorCode(403) falls through to the generic code; the following assertion (message must not contain <html>) would fail too. Full log: vitest-1587.log. The test file inline:

// Repro for get-bb/bb#1587 (excerpt; full file linked above)
function cloudflareChallengeResponse(): Response {
  return new Response(CF_CHALLENGE_HTML, {
    status: 403,
    headers: {
      "content-type": "text/html; charset=UTF-8",
      "cf-mitigated": "challenge",
      server: "cloudflare",
      "set-cookie": "__cf_bm=cloudflare-cookie; HttpOnly; SameSite=None; Secure; Path=/; Domain=chatgpt.com",
    },
  });
}

it("documents current behavior: one cookie retry, then a permanent codex_request_failed carrying raw HTML", async () => {
  await writeChatGptAuth();
  const fetchMock = vi.fn<typeof fetch>();
  fetchMock.mockImplementation(async () => cloudflareChallengeResponse());
  vi.stubGlobal("fetch", fetchMock);

  const failure = await runTranscribe();

  expect(fetchMock).toHaveBeenCalledTimes(2);
  const retryHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
  expect(retryHeaders.get("cookie")).toBe("__cf_bm=cloudflare-cookie");
  expect(failure.code).toBe("codex_request_failed");
  expect(failure.message).toContain(
    "Codex transcription request failed with HTTP 403: <html> <head> <meta name=\"viewport\"",
  );
});

it("EXPECTED (fails on main): a Cloudflare challenge is transient and must not leak the challenge HTML", async () => {
  await writeChatGptAuth();
  const fetchMock = vi.fn<typeof fetch>();
  fetchMock.mockImplementation(async () => cloudflareChallengeResponse());
  vi.stubGlobal("fetch", fetchMock);

  const failure = await runTranscribe();

  expect(failure.code).toBe("codex_service_unavailable"); // <-- FAILS on main: "codex_request_failed"
  expect(failure.message).not.toContain("<html>");        // <-- FAILS on main
});

Root cause

1. The subscription transcription path uses a ChatGPT web-app endpoint that is behind Cloudflare bot management. codex-chatgpt-client.ts#L27-L30:

const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
const CHATGPT_TRANSCRIBE_URL = "https://chatgpt.com/backend-api/transcribe";
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
const OPENAI_TRANSCRIBE_URL = "https://api.openai.com/v1/audio/transcriptions";

fetchTranscription (L920-L934) picks CHATGPT_TRANSCRIBE_URL whenever readCodexAuthCredentials() returns type: "chatgpt". Step A shows that Cloudflare applies a managed challenge to /backend-api/transcribe and not to /backend-api/codex/responses; the challenge requires executing JavaScript from /cdn-cgi/challenge-platform/, which a Node fetch client cannot do. Codex CLI itself never calls this URL (its binary has no such string), so OpenAI has no reason to exempt non-browser clients on it. The headers bb sends (createChatGptHeaders: bearer, chatgpt-account-id, originator: bb, User-Agent: bb-host-daemon) lower the challenge rate here to 0/18 but the decision is made by Cloudflare per client and per network; the reporters' clients get challenged "usually".

2. The daemon's mitigation is a single blind retry. isCloudflareChallenge detects the header, and fetchChatGpt re-sends once with whatever Cloudflare service cookies were stored (chatgpt-cloudflare-cookies.ts, in-memory, process-global):

  const response = await fetchOnce();
  if (!isCloudflareChallenge(response)) {
    return response;
  }
  return fetchOnce();

A challenge response only sets __cf_bm; cf_clearance (the cookie that actually clears a challenge) is issued only after the JS challenge is solved. So the retry works only if Cloudflare's score happens to flip (step B: 403, 403, 401), and the second 403 is then treated like any other HTTP error.

3. The 403 is classified as a permanent failure, so the server neither retries nor falls back, and the raw HTML is put in the message. codexRequestErrorCode maps 401, 429 and 5xx; 403 falls to codex_request_failed. createCodexHttpError then appends extractProviderErrorMessage(readErrorText(...)), which for a text/html body is the whitespace-collapsed HTML truncated to 400 chars. On the server, isTransientInferenceError only recognises codex_rate_limited, codex_service_unavailable, codex_request_timeout, command_timeout; inferenceCompleteWithFallback therefore stops after attempt 1 of maxAttempts: 2 (server log: "attempts":1,"maxAttempts":2), and transcribeWithCodexHostDaemon re-throws the daemon error as-is: HTTP 502: Codex transcription request failed with HTTP 403: <html> …. Note also that the transcription "fallback" is the same model (fallbackModel: transcriptionModel), so even a transient classification would only buy one more try of the same challenged endpoint after 250 ms, not a switch to api.openai.com.

Deeper issue. bb depends on an undocumented ChatGPT web endpoint whose bot protection is outside its control; a Cloudflare rule change can break subscription-mode voice for everyone at once. There is no Codex-scoped transcription endpoint to move to (step A/codex-path probe = 404). The realistic goals are: don't make a challenge look like a bb bug, retry/fallback sensibly, and tell the user what to configure.

Proposed fix (first principles)

  1. Daemon (host-local primitive: classify the provider response). In transcribeCodexVoice/createCodexHttpError, when isCloudflareChallenge(response) is still true after the retry, throw a dedicated ExpectedCommandDispatchError — either codex_service_unavailable with a clean message such as "ChatGPT transcription endpoint returned a Cloudflare challenge (HTTP 403); this network is being bot-filtered. Try again, or configure OPENAI_API_KEY / an API-key Codex login for transcription." — or a new code (codex_cloudflare_challenge) that the server adds to isTransientInferenceError. Also stop leaking HTML in general: in createCodexHttpError, if the response content-type is text/html, do not append the body. Test 2 of the repro file is the regression test. Since only the error code/message content changes and both are already free-form strings on the wire, no HOST_DAEMON_PROTOCOL_VERSION bump is strictly needed for the first variant; a new code that the server switches on is a wire-meaning change and should bump it.
  2. Server (product policy). In transcribeWithCodexHostDaemon, make the fallback meaningful: if deps.config.openAiApiKey is set, use openai/<same model id> (the existing transcribeWithOpenAi path against api.openai.com) as the fallback attempt after a transient/challenge failure instead of fallbackModel: transcriptionModel; otherwise keep the one delayed retry (a real retry does sometimes pass, step B). Map the final challenge failure to the existing buildTranscriptionUnavailableError() (503 transcription_unavailable) so the CLI/app show a sentence, not CSS. Document in docs/configuration.md that subscription-mode voice can be blocked by Cloudflare and that BB_TRANSCRIPTION=openai/gpt-4o-transcribe + OPENAI_API_KEY avoids chatgpt.com.
  3. Optional hardening (daemon). Persist the Cloudflare cookies across daemon restarts (they are already scoped and allow-listed) and consider mirroring what a browser sends more closely (e.g. Accept, Origin: https://chatgpt.com, Referer), measured against real challenge rates. I did not verify that this changes Cloudflare's score; step B suggests originator/chatgpt-account-id already carry most of the weight here.

What could go wrong: classifying every 403 as transient would hide real permission errors; key the change on cf-mitigated: challenge only. Falling back to api.openai.com spends API credit for users who thought they were on a subscription; gate it on an explicit configured key and log it. What would settle the "why their network" question: a reporter running waf-sample.py from their machine (it prints only status codes) and pasting the tally.

Related issues

Appendix

Files

Captured challenge response headers (step A)

HTTP/2 403
date: Tue, 18 Aug 2026 07:36:41 GMT
content-type: text/html; charset=UTF-8
content-length: 10492
cf-mitigated: challenge
server: cloudflare
server-timing: chlray;desc="a2cf3fb8fc1c64b6"
set-cookie: __cf_bm=…; HttpOnly; SameSite=None; Secure; Path=/; Domain=chatgpt.com; Expires=Tue, 18 Aug 2026 08:06:41 GMT
cf-ray: a2cf3fb8fc1c64b6-SJC

Codex-prefixed transcription paths do not exist

$ python3 /tmp/bb-reports/issues/1587/repro/waf-probe-codex-path.py
https://chatgpt.com/backend-api/codex/transcribe               HTTP/2 404   body={"detail":"Not Found"}
https://chatgpt.com/backend-api/codex/audio/transcriptions     HTTP/2 404   body={"detail":"Not Found"}
https://chatgpt.com/backend-api/codex/v1/audio/transcriptions  HTTP/2 404   body={"detail":"Not Found"}
https://chatgpt.com/backend-api/transcribe                     HTTP/2 200   body={"text":"...","asset_pointer":"sediment://file_…

Codex CLI binary does not reference the endpoint

$ strings -n 6 ~/.codex/packages/standalone/current/bin/codex > codex-strings.txt
$ grep -oE ".{0,60}(backend-api/transcribe|/transcribe|audio/transcriptions).{0,60}" codex-strings.txt | sort -u
(no output)
$ grep -oE ".{0,40}realtime/calls.{0,40}" codex-strings.txt | head -1
…sdpsession/backend-apirealtime/callscodex-realtime-call-boundaryContent-Dispositio…

Commands run (in order)

gh issue view 1587 --comments
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
git fetch origin main; git log 16ceb3a54..origin/main --oneline -- apps/host-daemon/src/codex-chatgpt-client.ts apps/host-daemon/src/chatgpt-cloudflare-cookies.ts apps/host-daemon/src/codex-auth.ts apps/server/src/services/ai
codex --version; strings -n 6 ~/.codex/packages/standalone/current/bin/codex > /tmp/bb-reports/issues/1587/codex-strings.txt
ffmpeg -f lavfi -i "sine=frequency=440:duration=1" -ac 1 -ar 16000 -c:a libmp3lame -q:a 6 tone.mp3
cd apps/host-daemon && pnpm exec tsx live-probe-1587.ts /tmp/bb-reports/issues/1587/repro/tone.mp3 8
bash repro/waf-probe.sh; bash repro/waf-probe-auth.sh; python3 repro/waf-probe-headers.py; bash repro/waf-cookie-retry.sh (x2); python3 repro/waf-probe-codex-path.py; python3 repro/waf-sample.py
curl -s -o repro/cf-challenge-body.html -D repro/cf-challenge-headers.txt -X POST -A bb-host-daemon https://chatgpt.com/backend-api/transcribe -F file=@repro/tone.mp3 -F model=gpt-transcribe
export NODE_OPTIONS="--import /tmp/bb-reports/issues/1587/repro/cf-challenge-preload.mjs" && scripts/bb-dev-app current
unset NODE_OPTIONS; eval "$(scripts/bb-dev-app env)"; export WORKTREE=$PWD
curl -s $BB_SERVER_URL/api/v1/system/config   # voiceTranscriptionEnabled: true
bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh baseline    # "..." exit=0
bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh challenge   # HTTP 502 ... HTTP 403 ... exit=1
bash /tmp/bb-reports/issues/1587/repro/run-cli-transcribe.sh baseline    # "..." exit=0 again
grep -a "Voice transcription failed\|codex.voice.transcribe" ~/.bb-dev/launchers/projects-bb-.claude-worktrees-wf_6b6686dc-4c2-26/dev.log
cd apps/host-daemon && pnpm exec vitest run src/codex-chatgpt-client-1587.test.ts
pnpm dev:stop

Verification

An independent verifier followed all five layers (A–E) in a separate worktree at the same HEAD and confirmed every result: waf-probe.sh gave the identical 403/cf-mitigated: challenge vs 401 table; waf-probe-headers.py gave the identical header-combination table and waf-sample.py tallied full-daemon-headers 10/10 200 vs authorization-only 9/10 challenged (7/10 in the original run: the "coin flip"); live-probe-1587.ts 8/8 OK; the dev-instance run reproduced the exact HTTP 502: Codex transcription request failed with HTTP 403: <html>… output with two intercepted daemon fetches, codex_request_failed in the daemon log and attempts:1,maxAttempts:2 in the server log; vitest 1 passed / 1 failed at the codex_service_unavailable assertion. All root-cause code excerpts were confirmed at 16ceb3a54, and git log 16ceb3a54..origin/main touches none of the files.

Changed in this revision. (1) The verifier's one major finding: run-cli-transcribe.sh and cf-challenge-preload.mjs were hardcoded to the original worktree path, ports and flag/log paths (and the wrapper deleted the preload-log artifact on every run). Both were rewritten to take WORKTREE, BB_SERVER_URL and CF_PRELOAD_DIR from the environment (defaults: $PWD, required, /tmp/bb-1587-preload), the preload locates cf-challenge-body.html relative to itself, and the wrapper only prints the log lines appended during its own run. Step D was then re-run from scratch in a third worktree (wf_6b6686dc-4c2-26, ports 25796/33796) with the new scripts and no manual edits; the baseline / challenge / baseline outputs and log lines shown in step D and cli-challenge-run.log are from that re-run. (2) Minor: step D's baseline expectation now says the CLI prints ... (no --json). (3) Minor: "the reporter's error" is now phrased as the error the code produces on a challenged network, with a note that the issue does not quote an error string. (4) Minor: WAF is expanded once, and the per-network bot-score explanation is marked as inferred in the TL;DR.