diff --git a/CHANGELOG.md b/CHANGELOG.md
index b433f8c06..25415f601 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## [Unreleased]
+- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
+ Settings. API-key voice remains the default. Local sign-in can start from
+ the auth button and reports completion, failure, timeout, or expired
+ credentials. OAuth credentials stay on the server; local routes require
+ loopback access and same-site requests. OAuth sessions retain usage data
+ and show unknown cost. The auth button keeps keyboard focus after a mode
+ change, and pending sign-in does not discard text in the key fields.
+
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 569ce2420..f7de72983 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -3925,6 +3925,7 @@ and unreachable upstream (502/504) separately from road geometry.
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
- **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate.
+- **Auth button focus**: changing voice auth updates the existing controls in place. Enter, Space, and mouse selection keep focus on the auth button; Tab then follows the normal control order. Pending sign-in blocks repeated activation without removing the button from the focus order. Completion does not take focus from another control or discard text in the key fields. `scripts/qa-voice-auth-focus.mjs` checks these paths in Chromium with fixture sign-in responses.
- **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract.
### AI HUD Summary (June 2026)
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 662b2a43a..0d9530871 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -36,6 +36,10 @@
"src/main.js",
"docs/APPLICATION.md",
"scripts/qa-application.mjs",
+ "scripts/qa-voice-auth.mjs",
+ "scripts/qa-voice-auth-focus.mjs",
+ "src/codexOauthRealtime.test.mjs",
+ "src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
diff --git a/scripts/qa-voice-auth-focus.mjs b/scripts/qa-voice-auth-focus.mjs
new file mode 100644
index 000000000..6d131a22c
--- /dev/null
+++ b/scripts/qa-voice-auth-focus.mjs
@@ -0,0 +1,328 @@
+#!/usr/bin/env node
+/**
+ * Check auth-mode keyboard focus with the real settings template and controls.
+ * Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs
+ * Sign-in responses are fixtures. No credentials or live login are used.
+ */
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { after, before, test } from 'node:test';
+import puppeteer from 'puppeteer';
+
+const root = new URL('../', import.meta.url);
+const template = await readFile(
+ new URL('src/ui/templates/provider-settings.html', root),
+ 'utf8',
+);
+const assets = new Set([
+ '/src/keySetup.js',
+ '/src/voice/cloudVoiceAuth.js',
+ '/src/ui/surfaceKeyboard.js',
+ '/src/ui/styles/provider-settings.css',
+]);
+const observations = [];
+let server;
+let browser;
+let origin;
+
+before(async () => {
+ server = createServer(async (req, res) => {
+ const pathname = new URL(req.url, 'http://localhost').pathname;
+ if (pathname === '/') {
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.end(`