diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md
index 5aa846f364e..56fbf660e94 100644
--- a/docs-site/src/content/docs/fr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md
@@ -157,6 +157,28 @@ OpenCodex demandent à Codex de transmettre les remplacements à `spawn_agent` ;
Le plan de gestion du tableau de bord est séparé du trafic modèle direct client→hub. **Connexion → Clés API** affiche les rotations en attente, montre le secret de remplacement une seule fois et exige une validation ou une annulation explicite. La déconnexion du navigateur n'invalide que la session courante. L'usage connecté vient du hub filtré par `apiKeyId`; l'usage déconnecté est local, sans réplication.
+### Afficher une clé API enregistrée
+
+L'affichage de la valeur complète d'une clé enregistrée exige une session du tableau de bord
+créée par un appairage explicite ou une identité Tailscale de confiance. Une session locale
+créée automatiquement peut lire la liste masquée, mais ne peut pas révéler les valeurs enregistrées.
+Le jeton administrateur ne peut pas non plus appeler cette opération réservée aux sessions.
+Utilisez le processus d'appairage Remote Hub existant ou une connexion Tailscale de confiance, si disponible.
+
+Le formulaire d'appairage local est proposé uniquement sur un tableau de bord autonome de même origine,
+ouvert en HTTP à l'adresse IP littérale `127.0.0.1` ou `[::1]`. L'origine doit correspondre à l'adresse
+IP de bouclage configurée pour l'écoute du serveur. Depuis `localhost` ou un alias, vérifiez cette adresse :
+rouvrez `http://127.0.0.1:` pour une écoute sur `127.0.0.1`, ou `http://[::1]:` pour
+une écoute sur `::1`, puis effectuez l'appairage. Un serveur dont le hostname est `localhost` n'a
+aucune origine admissible pour l'appairage local. Le tableau de bord ne peut pas déduire l'adresse
+d'écoute d'une URL avec un alias : il affiche donc le refus général sans suggérer une autre URL.
+
+Après l'appairage, cliquez à nouveau sur la clé pour demander sa valeur. L'appairage seul ne révèle aucune clé.
+Les valeurs affichées et l'état de copie sont effacés lorsque la session partagée est supprimée ou remplacée,
+au début d'un appairage, en quittant ce panneau, en masquant l'onglet ou la fenêtre de l'application,
+ou en changeant de serveur. Cela inclut les valeurs des clés créées ou remplacées, affichées une seule fois.
+Si la session autorisée expire ou est révoquée, effectuez à nouveau l'appairage.
+
La garantie de remplacement lors d'une création de sous-agent s'applique au texte de consignes v2 **intégré**.
Un `injectionPrompt` personnalisé remplace entièrement ce texte et doit contenir les espaces réservés
`{{model}}` et `{{effort}}` — et facultativement `{{roster}}` — sans quoi ces valeurs n'apparaîtront pas dans
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 9d137750c3f..d25c15a07ef 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -268,7 +268,33 @@ they have been synchronized. See
## Remote Hub sessions, keys, and usage
-In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place, and clicking a key shows its full value with a **Copy** button. Revealing a key needs a signed-in dashboard session; the admin token cannot read key values.
+In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place. Clicking a key can show its full value with a **Copy** button only from an independently authorized session.
+
+### Reading an existing API key
+
+Showing a stored key's full value requires a dashboard session established by explicit pairing or
+trusted Tailscale identity. An automatic local dashboard session can still show the masked key list,
+but cannot reveal existing values; a raw admin token cannot call this session-only action either.
+Use the existing [dashboard pairing flow](/guides/remote-hub/#pairing-this-browser-with-a-hub) to establish an
+operator-authorized session before requesting a stored value; a refused reveal states the requirement
+on the page. The local pairing form is offered only by a same-origin standalone dashboard opened
+over HTTP at the literal `127.0.0.1` or `[::1]` address. The origin must match the server's
+configured literal loopback bind. On `http://localhost:` (or another localhost alias),
+check the server's bind: reopen `http://127.0.0.1:` for a `127.0.0.1` bind or
+`http://[::1]:` for a `::1` bind, then pair there. A server configured with hostname
+`localhost` has no eligible local pairing origin. The dashboard cannot infer the configured
+bind from an alias URL, so it shows the generic denial without suggesting a replacement URL.
+Use the existing remote-hub pairing flow or trusted Tailscale sign-in where available.
+Pair again if the authorized session expires or is revoked. Ordinary dashboard sign-in,
+key creation, rotation, and deletion are unchanged.
+
+After pairing, click the key again to request its value. Pairing itself never reveals a key.
+Displayed key values and copy feedback are cleared when the shared session is cleared or replaced,
+pairing starts, you leave this panel, hide the browser tab or desktop window, or switch servers.
+This also clears newly created and replacement values, so copy a one-time value before leaving
+the panel. If creation or rotation succeeds after the session or view changes, its one-time
+value stays hidden. After switching servers, returning to the
+original server revalidates its key list. Pending rotations remain available for explicit commit or abort.
The dashboard's management plane is separate from direct client→hub model traffic. **Connect → API Keys** shows pending rotations, displays a replacement secret only once, and requires explicit commit or abort. Browser logout invalidates only the current remote session. Connected usage is the hub store filtered by the client's `apiKeyId`; disconnected usage is local, with no mirroring.
diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md
index 385964bfd97..3bc41575906 100644
--- a/docs-site/src/content/docs/ja/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md
@@ -123,6 +123,27 @@ Codex タスクだけに適用され、このオプション自体が委任を
ダッシュボードの管理プレーンと client→hub のモデル通信は別経路です。**接続 → API キー** は保留中の更新を表示し、新しい秘密値を一度だけ示し、明示的な確定または中止を要求します。ブラウザーのログアウトは現在のセッションだけを無効にします。接続中の使用量は hub で `apiKeyId` に絞り、切断後はローカル記録を使い、ミラーリングしません。
+### 保存済み API キーの全体表示
+
+保存済みキーの全体を表示するには、明示的なペアリングまたは信頼された Tailscale 認証で
+発行されたダッシュボードセッションが必要です。自動発行されたローカルセッションでは
+マスク済みの一覧を確認できますが、保存済みキーの値は表示できません。管理トークンも
+このセッション専用操作には使えません。既存の Remote Hub ペアリング手順、または利用可能な
+信頼された Tailscale ログインを使用してください。
+
+ローカルペアリングフォームを表示するのは、同一オリジンの単独ダッシュボードを HTTP の
+`127.0.0.1` または `[::1]` で開いた場合だけです。オリジンはサーバーに設定された
+ループバック IP のバインドと一致する必要があります。`localhost` やその別名で開いた場合は
+バインドを確認し、`127.0.0.1` なら `http://127.0.0.1:`、`::1` なら
+`http://[::1]:` で開き直してペアリングしてください。hostname が `localhost` に
+設定されたサーバーには、ローカルペアリングを許可するオリジンがありません。ダッシュボードは
+別名 URL からバインドを判定できないため、代替 URL を提案せず一般的な拒否理由を表示します。
+
+ペアリング後はキーをもう一度クリックして表示を要求してください。ペアリングだけで値は表示されません。
+共有セッションの削除・切り替え、ペアリング開始、パネルからの移動、ブラウザータブやデスクトップ
+ウィンドウの非表示、サーバーの切り替えで、表示中の値とコピー状態を消去します。新規作成・交換した
+一度だけ表示される値も対象です。承認済みセッションが失効した場合は再度ペアリングしてください。
+
セレクターには有効化されたネイティブおよびルーティングモデルと Codex グローバル推論段階が表示されます。API は
選んだ強度がグローバル段階にあるか検査し、Codex は再び対象カタログ項目がその強度をサポートするか
検査します。
diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md
index f6e78c66223..560e2bc61c0 100644
--- a/docs-site/src/content/docs/ko/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md
@@ -139,6 +139,31 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적
대시보드 관리 API와 클라이언트에서 허브로 가는 모델 요청은 서로 다른 경로입니다. **연결 → API 키**에서는 진행 중인 키 교체를 확인하고, 새 키를 한 번만 표시하며, 확정 또는 취소를 직접 눌러야 합니다. 브라우저 로그아웃은 현재 원격 세션만 끝냅니다. 연결 중 사용량은 허브에서 해당 `apiKeyId`만 보고, 연결 해제 후에는 로컬 기록을 보며 서로 복제하지 않습니다.
+### 기존 API 키의 전체 값 조회
+
+저장된 키의 전체 값을 보려면 명시적인 페어링이나 신뢰된 Tailscale 인증으로 발급된
+대시보드 세션이 필요합니다. 자동 발급된 로컬 세션은 마스킹된 목록을 계속 볼 수 있지만,
+기존 키의 전체 값은 조회할 수 없습니다. 관리자 토큰도 이 세션 전용 동작을 호출하지 못합니다.
+기존 대시보드 페어링 절차로 세션을 승인한 뒤 조회하세요. 거부된 조회는 페이지에서
+요구 사항을 안내합니다. 로컬 페어링 폼은 같은 출처의 단독 대시보드를 HTTP의
+`127.0.0.1` 또는 `[::1]` IP 주소에서 열었을 때만 제공합니다. 오리진은 서버에 설정된
+루프백 IP 바인드와 일치해야 합니다. `http://localhost:`나 다른 localhost 별칭으로
+접속했다면 서버의 바인드를 먼저 확인하세요. `127.0.0.1` 바인드이면 `http://127.0.0.1:`,
+`::1` 바인드이면 `http://[::1]:`로 다시 열어 페어링하세요. hostname을 `localhost`로
+설정한 서버에는 로컬 페어링을 허용하는 오리진이 없습니다. 대시보드는 별칭 URL만으로 실제
+바인드를 알 수 없으므로 대체 URL을 제안하지 않고 일반 거절 안내만 표시합니다.
+사용 가능한 경우 기존 Remote Hub 페어링 절차나 신뢰된 Tailscale 로그인을 이용하세요.
+승인된 세션이 만료되거나 해제되면 다시 페어링해야 합니다. 일반 대시보드 접속과
+키 생성·교체·삭제는 변경하지 않습니다.
+
+페어링을 마친 뒤 키를 다시 클릭해야 전체 값을 조회합니다. 페어링만으로 키를
+자동 조회하지 않습니다. 공유 세션이 해제되거나 교체될 때, 페어링을 시작할 때,
+이 패널을 벗어나거나 브라우저 탭 또는 데스크톱 창을 숨길 때, 서버를 바꿀 때 표시된
+키와 복사 상태를 지웁니다. 새로 생성하거나 교체한 값도 지우므로 일회성 값은 패널을
+벗어나기 전에 복사하세요. 생성·교체 요청 중 세션이나 화면이 바뀌면 작업이 성공하더라도
+일회성 값은 숨깁니다. 서버를 바꾼 뒤 원래 서버로 돌아오면 키 목록을 다시 검증해
+불러옵니다. 진행 중인 교체는 명시적으로 확정하거나 취소할 수 있습니다.
+
선택기에는 활성화된 네이티브 및 라우팅 모델과 Codex 전역 reasoning 단계가 표시됩니다. API는
선택한 강도가 전역 단계에 있는지 검사하고, Codex는 다시 대상 카탈로그 항목이 그 강도를 지원하는지
검사합니다.
diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md
index e5d83921b22..40b8b0afc3d 100644
--- a/docs-site/src/content/docs/ru/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md
@@ -127,6 +127,28 @@ bun run dev:gui
Контур управления панели отделён от прямого трафика client→hub. **Подключение → Ключи API** показывает ожидающую ротацию, отображает новый секрет один раз и требует явного подтверждения или отмены. Logout браузера отзывает только текущую сессию. При подключении статистика hub фильтруется по `apiKeyId`; после отключения используется локальная, без зеркалирования.
+### Просмотр сохранённого API-ключа
+
+Для просмотра полного значения сохранённого ключа нужна сессия панели, выданная после явного
+сопряжения или аутентификации через доверенную учётную запись Tailscale. Автоматическая локальная
+сессия позволяет просматривать маскированный список, но не раскрывать сохранённые значения.
+Административный токен также не подходит для этой операции, доступной только сессиям.
+Используйте существующий процесс сопряжения с Remote Hub или доверенный вход Tailscale, если он доступен.
+
+Локальная форма сопряжения предлагается только в автономной панели с тем же origin, открытой
+по HTTP на буквальном адресе `127.0.0.1` или `[::1]`. Origin должен соответствовать настроенному
+loopback IP-адресу привязки сервера. Если панель открыта через `localhost` или его псевдоним,
+проверьте привязку: для `127.0.0.1` откройте `http://127.0.0.1:`, а для `::1` —
+`http://[::1]:`, затем выполните сопряжение. При hostname `localhost` подходящего origin для
+локального сопряжения нет. Панель не может определить привязку по URL с псевдонимом, поэтому
+показывает общий отказ без предложения другого URL.
+
+После сопряжения снова нажмите на ключ, чтобы запросить его значение. Само сопряжение ключ не раскрывает.
+При очистке или замене общей сессии, начале сопряжения, выходе из панели, скрытии вкладки браузера
+или окна приложения либо смене сервера показанные значения и состояние копирования очищаются.
+Это относится и к значениям новых и заменённых ключей, отображаемым один раз. При истечении срока
+или отзыве авторизованной сессии выполните сопряжение заново.
+
Селектор предлагает включённые нативные и маршрутизируемые модели, а также глобальную шкалу уровней
рассуждений Codex. API валидирует выбранный уровень глобально; Codex дополнительно валидирует
уровень порождения по целевой записи каталога.
diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md
index d133ef6f62c..ba55ef9788d 100644
--- a/docs-site/src/content/docs/tr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md
@@ -164,6 +164,26 @@ için [Alt Ajan Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın.
Pano yönetim düzlemi doğrudan client→hub model trafiğinden ayrıdır. **Bağlantı → API Anahtarları** bekleyen döndürmeyi gösterir, yeni sırrı bir kez görüntüler ve açık onay veya iptal ister. Tarayıcı logout yalnızca mevcut oturumu geçersiz kılar. Bağlı kullanım hub üzerinde `apiKeyId` ile filtrelenir; bağlantı kesilince yerel kayıt kullanılır ve yansıtma yapılmaz.
+### Kaydedilmiş bir API anahtarını görüntüleme
+
+Kaydedilmiş bir anahtarın tam değerini göstermek için açık eşleştirme veya güvenilir Tailscale
+kimlik doğrulamasıyla oluşturulmuş bir pano oturumu gerekir. Otomatik yerel oturum maskelenmiş
+listeyi okuyabilir, ancak kaydedilmiş değerleri gösteremez. Yönetici belirteci de yalnızca oturumlara
+izin verilen bu işlemi çağıramaz. Mevcut Remote Hub eşleştirme akışını veya varsa güvenilir Tailscale girişini kullanın.
+
+Yerel eşleştirme formu yalnızca HTTP üzerinden doğrudan `127.0.0.1` veya `[::1]` IP adresinde açılmış,
+aynı origin'e sahip bağımsız panoda sunulur. Origin, sunucunun yapılandırılmış geri döngü IP bağlama
+adresine uymalıdır. `localhost` veya bir takma adres kullanıyorsanız sunucunun bağlama adresini kontrol edin:
+`127.0.0.1` için `http://127.0.0.1:`, `::1` için `http://[::1]:` adresinde yeniden açıp
+eşleştirin. hostname değeri `localhost` olan sunucuda uygun bir yerel eşleştirme origin'i yoktur.
+Pano, takma adresli URL'den gerçek bağlama adresini belirleyemediği için başka bir URL önermeden genel ret açıklamasını gösterir.
+
+Eşleştirmeden sonra değeri istemek için anahtara yeniden tıklayın. Eşleştirme tek başına anahtarı göstermez.
+Paylaşılan oturum temizlendiğinde veya değiştirildiğinde, eşleştirme başladığında, panelden ayrıldığınızda,
+tarayıcı sekmesi ya da masaüstü penceresi gizlendiğinde veya sunucu değiştiğinde gösterilen değerler ve
+kopyalama durumu temizlenir. Bir kez gösterilen yeni ve yedek anahtar değerleri de temizlenir.
+Yetkili oturumun süresi dolarsa veya iptal edilirse yeniden eşleştirin.
+
Spawn geçersiz kılma garantisi **yerleşik** v2 rehberlik metni için geçerlidir.
Özel bir `injectionPrompt` bu metnin yerini tamamen alır ve `{{model}}` ve
`{{effort}}` yer tutucularını (ve isteğe bağlı olarak `{{roster}}`) içermelidir,
diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
index 598de46bcdd..0d8f285caa4 100644
--- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
@@ -117,6 +117,22 @@ Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以
控制台管理平面与 client→hub 的模型流量相互独立。**连接 → API 密钥** 显示待处理轮换,只显示一次替换密钥,并要求显式提交或中止。浏览器 logout 只使当前会话失效。连接时从 hub 按 `apiKeyId` 过滤用量;断开后使用本地记录,两者不会镜像。
+### 查看已保存的 API 密钥
+
+查看已保存密钥的完整值,需要通过显式配对或可信 Tailscale 身份认证签发的控制台会话。
+自动签发的本地会话仍可查看掩码列表,但不能显示已保存的密钥值;管理员令牌也不能调用
+这项仅限会话的操作。请使用现有的 Remote Hub 配对流程,或在可用时通过可信 Tailscale 身份登录。
+
+本地配对表单仅在通过 HTTP、以字面 IP 地址 `127.0.0.1` 或 `[::1]` 打开的同源独立控制台中提供。
+来源必须与服务器配置的回环 IP 绑定一致。如果通过 `localhost` 或其别名打开,请先检查服务器绑定:
+绑定为 `127.0.0.1` 时重新打开 `http://127.0.0.1:`,绑定为 `::1` 时重新打开
+`http://[::1]:`,然后配对。hostname 配置为 `localhost` 的服务器没有符合条件的本地配对来源。
+控制台无法从别名 URL 判断实际绑定,因此只显示通用拒绝说明,不建议替代 URL。
+
+配对后请再次点击密钥以请求其值;配对本身不会显示密钥。共享会话被清除或替换、开始配对、
+离开面板、隐藏浏览器标签页或桌面窗口、切换服务器时,已显示的值和复制状态都会清除。
+新建或替换密钥的一次性值也会清除。授权会话过期或被撤销后,请重新配对。
+
选择器会列出已启用的原生与路由模型,以及全局 Codex reasoning 阶梯。API 会先验证所选强度是否
属于全局阶梯;Codex 仍会根据目标目录条目再次校验该 spawn 强度。
diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
index c91f953ff4b..5b52203ae71 100644
--- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
@@ -120,6 +120,22 @@ Dashboard 的 **Sub-agent delegation** 選擇器會儲存 `injectionModel`,以
儀表板管理平面與 client→hub 模型流量彼此獨立。**連線 → API 金鑰** 顯示待處理輪替,只顯示一次替代金鑰,並要求明確提交或中止。瀏覽器 logout 只會使目前工作階段失效。連線時從 hub 依 `apiKeyId` 篩選用量;中斷後使用本機記錄,兩者不會鏡像。
+### 查看已儲存的 API 金鑰
+
+查看已儲存金鑰的完整值,需要透過明確配對或受信任的 Tailscale 身分驗證簽發的儀表板工作階段。
+自動簽發的本機工作階段仍可查看遮罩清單,但無法顯示已儲存的金鑰值;管理員權杖也無法呼叫
+這項僅限工作階段的操作。請使用既有的 Remote Hub 配對流程,或在可用時透過受信任的 Tailscale 身分登入。
+
+本機配對表單僅提供於透過 HTTP、以明確的 IP 位址 `127.0.0.1` 或 `[::1]` 開啟的同源獨立儀表板。
+來源必須與伺服器設定的回環 IP 綁定位址一致。如果透過 `localhost` 或其別名開啟,請先檢查伺服器綁定:
+綁定為 `127.0.0.1` 時重新開啟 `http://127.0.0.1:`,綁定為 `::1` 時重新開啟
+`http://[::1]:`,然後配對。hostname 設為 `localhost` 的伺服器沒有符合條件的本機配對來源。
+儀表板無法從別名 URL 判斷實際綁定,因此只顯示一般拒絕說明,不建議替代 URL。
+
+配對後請再次點選金鑰以要求其值;配對本身不會顯示金鑰。共用工作階段遭清除或取代、開始配對、
+離開面板、隱藏瀏覽器分頁或桌面視窗、切換伺服器時,已顯示的值與複製狀態都會清除。
+新建或替代金鑰的一次性值也會清除。已授權的工作階段到期或遭撤銷後,請重新配對。
+
選擇器會列出已啟用的原生與路由模型,以及全域 Codex reasoning 階梯。API 會先驗證所選強度是否
屬於全域階梯;Codex 仍會根據目標目錄條目再次校驗該 spawn 強度。
diff --git a/gui/src/api.ts b/gui/src/api.ts
index 48d47eac70c..503a69e8bd5 100644
--- a/gui/src/api.ts
+++ b/gui/src/api.ts
@@ -7,6 +7,8 @@ import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiT
* its existing readiness state; cancelled callers and newer valid sessions emit no notice.
*/
export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable";
+/** Token identity changed; contains only the plane, never credential material. */
+export const SESSION_CHANGED_EVENT = "opencodex:session-changed";
const LEGACY_TOKEN_KEY = "opencodex-api-token";
// Any guarded route answers 401 for a bad token; this one is a cheap config read. /api/settings
@@ -43,7 +45,7 @@ let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS;
let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS;
const runtimes = new Map();
-function reportSessionUnavailable(plane: ApiPlane): void {
+function reportSessionEvent(plane: ApiPlane, event: string): void {
if (typeof window === "undefined") return;
// Take the constructor off the same window we dispatch on: a test harness (and a
// sandboxed embed) can supply a document without installing CustomEvent globally.
@@ -51,10 +53,16 @@ function reportSessionUnavailable(plane: ApiPlane): void {
?? (typeof CustomEvent === "function" ? CustomEvent : null);
if (!Ctor) return;
try {
- window.dispatchEvent(new Ctor(SESSION_UNAVAILABLE_EVENT, { detail: { plane } }));
+ window.dispatchEvent(new Ctor(event, { detail: { plane } }));
} catch { /* a shell that cannot receive the notice must not break the fetch path */ }
}
+function updateSession(state: TargetRuntime, session: ApiSessionState): void {
+ const changed = state.session.token !== session.token;
+ state.session = session;
+ if (changed) reportSessionEvent(state.target.id, SESSION_CHANGED_EVENT);
+}
+
function blankSession(): ApiSessionState {
return { token: null, csrfToken: null, browserOrigin: null, serverOrigin: null };
}
@@ -72,6 +80,7 @@ export function configureApiTargets(targets: ApiTargets): void {
configuredTargets = targets;
for (const plane of ["machine", "shared"] as const) {
const current = runtimes.get(plane);
+ if (current && !sameTarget(current.target, targets[plane])) updateSession(current, blankSession());
runtimes.set(plane, current && sameTarget(current.target, targets[plane])
? { ...current, target: targets[plane] }
: { target: targets[plane], session: blankSession(), resolutionInFlight: null, promptCancelled: false });
@@ -85,7 +94,7 @@ function runtime(plane: ApiPlane): TargetRuntime {
function clearSessionIfCurrent(plane: ApiPlane, expected: string | null): void {
const state = runtime(plane);
- if (expected !== null && state.session.token === expected) state.session = blankSession();
+ if (expected !== null && state.session.token === expected) updateSession(state, blankSession());
}
function storeSession(
@@ -98,10 +107,10 @@ function storeSession(
const state = runtime(plane);
if (!token?.startsWith("ocx_session_") || !csrfToken
|| browserOrigin !== window.location.origin || serverOrigin !== state.target.serverOrigin) {
- state.session = blankSession();
+ updateSession(state, blankSession());
return false;
}
- state.session = { token, csrfToken, browserOrigin, serverOrigin };
+ updateSession(state, { token, csrfToken, browserOrigin, serverOrigin });
state.promptCancelled = false;
return true;
}
@@ -120,7 +129,7 @@ export async function logoutApiSession(plane: ApiPlane): Promise {
signal: bounded.signal,
});
if (!response.ok) return false;
- state.session = blankSession();
+ updateSession(state, blankSession());
state.promptCancelled = false;
return true;
} catch {
@@ -287,7 +296,7 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null,
}
const prompted = await requestAdminToken(token => verifyAdminToken(plane, token));
if (prompted) {
- state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin };
+ updateSession(state, { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin });
return prompted;
}
state.promptCancelled = true;
@@ -337,14 +346,14 @@ export function installApiAuthFetch(): void {
const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined);
if (!nextToken) {
- if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane);
+ if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionEvent(classified.plane, SESSION_UNAVAILABLE_EVENT);
return response;
}
const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken);
const retry = await originalFetch(retryInput, retryInit);
if (retry.status === 401) {
clearSessionIfCurrent(classified.plane, nextToken);
- if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane);
+ if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionEvent(classified.plane, SESSION_UNAVAILABLE_EVENT);
}
return retry;
};
diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts
index bed565430dc..3b2554d92af 100644
--- a/gui/src/client-resource.ts
+++ b/gui/src/client-resource.ts
@@ -18,6 +18,7 @@ export type ResourceSnapshot = {
};
type Store = {
+ key: string;
snapshot: ResourceSnapshot;
listeners: Set<() => void>;
/** listener → requested poll interval (undefined = no poll from that subscriber) */
@@ -58,6 +59,8 @@ type Store = {
* different resource types (no runtime check — keys are an API contract).
*/
const stores = new Map>();
+// Explicit invalidations survive store eviction and fresh session-cache seeds.
+const invalidatedKeys = new Set();
/**
* Per-attempt deadline for every store fetch. Endpoints documented as slow finish in
@@ -81,6 +84,7 @@ function getStore(key: string): Store {
let store = stores.get(key) as Store | undefined;
if (!store) {
store = {
+ key,
snapshot: {
data: undefined,
error: undefined,
@@ -360,6 +364,7 @@ async function runFetch(
if (gen !== store.generation || controller.signal.aborted) return;
// Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates.
store.seedNeedsRevalidate = false;
+ invalidatedKeys.delete(store.key);
store.lastSettledAt = Date.now();
store.snapshot = {
data,
@@ -458,7 +463,7 @@ function subscribeResource(
const stale = typeof staleAfterMs === "number"
&& store.lastSettledAt !== undefined
&& Date.now() - store.lastSettledAt > staleAfterMs;
- if (store.snapshot.data === undefined || store.seedNeedsRevalidate || stale) {
+ if (store.snapshot.data === undefined || store.seedNeedsRevalidate || invalidatedKeys.has(key) || stale) {
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange, deadlineMs });
}
}
@@ -530,7 +535,7 @@ function seedClientResourceIfEmpty(key: string, data: T, cachedAt?: number |
setClientResourceData(key, data);
// A seed fresher than the staleness window skips the mount revalidation entirely:
// that is the whole request saved on a tab revisit. Unknown age counts as stale.
- if (typeof staleAfterMs === "number" && typeof cachedAt === "number" && Date.now() - cachedAt < staleAfterMs) {
+ if (!invalidatedKeys.has(key) && typeof staleAfterMs === "number" && typeof cachedAt === "number" && Date.now() - cachedAt < staleAfterMs) {
store.seedNeedsRevalidate = false;
store.lastSettledAt = cachedAt;
}
@@ -638,6 +643,18 @@ export function useKeyedClientResource(
return resource;
}
+/** Mark a resource stale without publishing a snapshot. Active subscribers refresh
+ * through runFetch; an inactive/evicted key revalidates on its next subscription. */
+export function invalidateClientResource(key: string): void {
+ invalidatedKeys.add(key);
+ const store = stores.get(key);
+ if (!store) return;
+ const entry = pickFetcherEntry(store);
+ if (entry) {
+ void runFetch(store, entry.fetcher, { replaceInflight: true, owner: entry.owner, deadlineMs: entry.deadlineMs });
+ }
+}
+
/** Publish data for a key and invalidate any in-flight fetch so it cannot stomp this write. */
export function setClientResourceData(key: string, data: T) {
const store = getStore(key);
@@ -670,6 +687,7 @@ export function clearClientResourceStoresForTests(): void {
store.inflightOwner = null;
}
stores.clear();
+ invalidatedKeys.clear();
for (const [intervalMs, bucket] of [...pollBuckets]) {
if (bucket.timer !== null) clearInterval(bucket.timer);
pollBuckets.delete(intervalMs);
diff --git a/gui/src/components/apikeys-workspace/ApiKeysListPanel.tsx b/gui/src/components/apikeys-workspace/ApiKeysListPanel.tsx
index eef15854759..25dbc41ef1d 100644
--- a/gui/src/components/apikeys-workspace/ApiKeysListPanel.tsx
+++ b/gui/src/components/apikeys-workspace/ApiKeysListPanel.tsx
@@ -13,12 +13,16 @@
* concluded a key could not be removed. A hover-only control would repeat that on
* touch screens, so it stays on screen.
*/
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { IconTrash } from "../../icons";
import { useT } from "../../i18n/shared";
-import { formatCreatedDate, type ApiKeyEntry } from "../../pages/api-keys-utils";
+import { formatCreatedDate, type ApiKeyEntry, type RevealKeyResult } from "../../pages/api-keys-utils";
import type { UsageReadMetadata } from "../../usage-summary-resource";
import { UsageIncompleteNotice } from "../usage-incomplete-notice";
+import { Notice } from "../../ui";
+import { isStandaloneRuntime, standaloneApiTargets } from "../../api-targets";
+import { ConnectPairingForm } from "../../connect-pairing";
+import { useKeyDisclosure, type DisclosureResetReason } from "../../use-key-disclosure";
export default function ApiKeysListPanel({
keys,
@@ -27,6 +31,9 @@ export default function ApiKeysListPanel({
attributionSince,
usageMetadata,
localeTag,
+ apiBase,
+ active = true,
+ onPairingStart,
busy,
onSelect,
onDelete,
@@ -39,18 +46,24 @@ export default function ApiKeysListPanel({
attributionSince?: string;
usageMetadata?: UsageReadMetadata;
localeTag?: string;
+ /** Management API origin a reveal denial pairs this dashboard against. */
+ apiBase: string;
+ active?: boolean;
+ onPairingStart?: () => void;
/** A mutation is in flight; its result is bound to one key, so navigation waits. */
busy: boolean;
onSelect: (id: string) => void;
/** Resolves true only when the key is really gone. */
onDelete?: (id: string) => Promise;
- /** The full key, or null when the server would not hand it over. */
- onReveal?: (id: string) => Promise;
+ /** The reveal outcome: the full key, a standing refusal, or a transient failure. */
+ onReveal?: (id: string) => Promise;
}) {
const t = useT();
const [revealed, setRevealed] = useState>({});
const [revealPendingId, setRevealPendingId] = useState(null);
const [revealFailedId, setRevealFailedId] = useState(null);
+ /** A refused row offers pairing; success still needs a fresh reveal click. */
+ const [revealDeniedId, setRevealDeniedId] = useState(null);
const [copiedId, setCopiedId] = useState(null);
const [copyFailedId, setCopyFailedId] = useState(null);
const copiedTimer = useRef(null);
@@ -58,6 +71,20 @@ export default function ApiKeysListPanel({
const [confirmArmed, setConfirmArmed] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const [deleteFailedId, setDeleteFailedId] = useState(null);
+ const pendingReveal = useRef(null);
+ const clearDisclosure = useCallback((reason: DisclosureResetReason) => {
+ const interrupted = pendingReveal.current;
+ pendingReveal.current = null;
+ setRevealed({});
+ setRevealPendingId(null);
+ setRevealFailedId(null);
+ setRevealDeniedId(current => reason === "session" ? interrupted ?? current : reason === "pairing" ? current : null);
+ setCopiedId(null);
+ setCopyFailedId(null);
+ if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
+ copiedTimer.current = null;
+ }, []);
+ const disclosure = useKeyDisclosure(apiBase, active, clearDisclosure);
// Same 300ms fuse as the detail pane: the click that opened the confirmation
// must not be able to land on the button that replaced it.
@@ -79,22 +106,46 @@ export default function ApiKeysListPanel({
const rowLocked = busy || deletingId !== null;
+ // The same gate RemoteLink applies to its local-pairing offer: only the
+ // literal same-origin loopback transport the standalone grant mint accepts.
+ // An alias URL does not reveal the configured bind host, so never invent
+ // a recovery URL from it; keep the generic denial guidance.
+ const localPairingTarget = standaloneApiTargets(apiBase).shared;
+ const canPairLocally = isStandaloneRuntime()
+ && window.location.protocol === "http:"
+ && ["127.0.0.1", "[::1]"].includes(window.location.hostname)
+ && localPairingTarget.serverOrigin === window.location.origin;
+
const toggleReveal = async (k: ApiKeyEntry) => {
+ const generation = disclosure.generation.current;
+ if (!disclosure.current(generation)) return;
const id = k.id;
setRevealFailedId(null);
+ setRevealDeniedId(null);
if (currentReveal(k) !== undefined) {
setRevealed(({ [id]: _hidden, ...rest }) => rest);
return;
}
if (!onReveal || revealPendingId) return;
+ pendingReveal.current = id;
setRevealPendingId(id);
try {
- const full = await onReveal(id);
- if (deletedIds.current.has(id)) return;
- if (full) setRevealed(prev => ({ ...prev, [id]: full }));
+ const result = await onReveal(id);
+ if (!disclosure.current(generation) || deletedIds.current.has(id)) return;
+ if (result.ok) setRevealed(prev => ({ ...prev, [id]: result.key }));
+ else if (result.kind === "denied") {
+ disclosure.invalidate();
+ onPairingStart?.();
+ setRevealDeniedId(id);
+ }
else setRevealFailedId(id);
+ } catch {
+ if (disclosure.current(generation)) setRevealFailedId(id);
} finally {
- setRevealPendingId(null);
+ if (disclosure.current(generation)) {
+ pendingReveal.current = null;
+ setRevealPendingId(null);
+ }
}
};
@@ -104,6 +155,8 @@ export default function ApiKeysListPanel({
};
const copyKey = (id: string, value: string) => {
+ const generation = disclosure.generation.current;
+ if (!disclosure.current(generation)) return;
setCopyFailedId(null);
let write: Promise | undefined;
try {
@@ -116,13 +169,14 @@ export default function ApiKeysListPanel({
return;
}
write.then(() => {
+ if (!disclosure.current(generation) || deletedIds.current.has(id)) return;
setCopiedId(id);
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => {
setCopiedId(current => current === id ? null : current);
copiedTimer.current = null;
}, 2000);
- }, () => copyFailed(id));
+ }, () => { if (disclosure.current(generation)) copyFailed(id); });
};
useEffect(() => () => {
@@ -156,6 +210,27 @@ export default function ApiKeysListPanel({
+ {revealDeniedId !== null && (
+ <>
+ {t("api.key.revealDenied")}
+ {/* Pairing authorizes the browser, but never discloses a value itself. */}
+ {canPairLocally && (
+ {
+ disclosure.invalidate();
+ onPairingStart?.();
+ }}
+ onConnected={() => {
+ disclosure.invalidate();
+ onPairingStart?.();
+ setRevealDeniedId(null);
+ }}
+ />
+ )}
+ >
+ )}
{keysLoading ? (
) : keys.length === 0 ? (
diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
index 745edc84523..0432c0db6bd 100644
--- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
+++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
@@ -16,6 +16,7 @@ import {
type ApiKeyEntry,
type ApiSurfacesInfo,
type ModelTests,
+ type RevealKeyResult,
} from "../../pages/api-keys-utils";
import {
ApiKeysEndpointsPanel,
@@ -35,6 +36,7 @@ export interface ApiKeysWorkspaceProps {
/** Management API origin the client-config panel fetches from. */
apiBase: string;
active?: boolean;
+ onPairingStart?: () => void;
/** Dataset-level. Absent means nothing is attributable yet — a different
* statement from a key whose counters read zero. */
attributionSince?: string;
@@ -74,8 +76,10 @@ export interface ApiKeysWorkspaceProps {
onDismissNewKey: () => void;
onCopyKey: () => void;
onDelete: (id: string) => Promise;
- /** Full key for a click-to-reveal in the key table; absent hides the control. */
- onRevealKey?: (id: string) => Promise;
+ /** Full key for a click-to-reveal in the key table; absent hides the control.
+ * The result discriminates a standing 403 refusal — the list answers it with
+ * the pairing surface, not a retry hint — from a transient failure. */
+ onRevealKey?: (id: string) => Promise;
onRename: (id: string, name: string) => Promise;
onRotationStart?: (id: string) => Promise;
onRotationCommit?: (id: string, rotationId: string) => Promise;
@@ -94,6 +98,7 @@ export default function ApiKeysWorkspace({
keys,
apiBase,
active = true,
+ onPairingStart,
attributionSince,
historyTruncated,
usageMetadata,
@@ -533,6 +538,9 @@ export default function ApiKeysWorkspace({
attributionSince={attributionSince}
usageMetadata={usageMetadata}
localeTag={localeTag}
+ apiBase={apiBase}
+ active={active}
+ onPairingStart={onPairingStart}
busy={mutationPending}
onDelete={onDelete}
onReveal={onRevealKey}
diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx
index e541b6e1cbb..b284482f642 100644
--- a/gui/src/connect-pairing.tsx
+++ b/gui/src/connect-pairing.tsx
@@ -7,10 +7,13 @@ import { useCopyFeedback } from "./components/use-copy-feedback";
export function ConnectPairingForm({
target,
onConnected,
+ onPairingStart,
local = false,
}: {
target: ApiTarget;
onConnected: () => void;
+ /** Let a secret-bearing surface expire its previous disclosure lifetime. */
+ onPairingStart?: () => void;
/** Explicit local pairing for a standalone link join; never an automatic bootstrap. */
local?: boolean;
}) {
@@ -27,6 +30,7 @@ export function ConnectPairingForm({
const submit = async (event: FormEvent) => {
event.preventDefault();
if (busy) return;
+ onPairingStart?.();
setBusy(true);
setError(null);
const controller = new AbortController();
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index e3be8ba278d..79edb9efe70 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -2283,6 +2283,8 @@ export const de: Record = {
"api.key.revealHint": "Klicken, um den vollständigen Schlüssel anzuzeigen",
"api.key.hideHint": "Klicken, um den vollständigen Schlüssel auszublenden",
"api.key.revealFailed": "Der vollständige Schlüssel konnte nicht geladen werden.",
+ "api.key.revealDenied": "Das Anzeigen eines gespeicherten Schlüssels erfordert eine vom Betreiber autorisierte Sitzung. Koppeln Sie diesen Browser, um fortzufahren, oder melden Sie sich über eine vertrauenswürdige Tailscale-Identität an.",
+ "api.key.mutationHidden": "Der Schlüssel wurde erstellt oder seine Rotation gestartet, aber der einmalige Wert wurde ausgeblendet, weil sich die Sitzung oder Ansicht geändert hat.",
"api.key.copyFailedShort": "Kopieren fehlgeschlagen. Markiere den Schlüssel und kopiere ihn manuell.",
"api.key.deleteRowAria": "{name} löschen",
"api.key.deleteShort": "Löschen",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 1dce105005e..fa03097a41d 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2879,6 +2879,8 @@ export const en = {
"api.key.revealHint": "Click to show the full key",
"api.key.hideHint": "Click to hide the full key",
"api.key.revealFailed": "Could not load the full key.",
+ "api.key.revealDenied": "Showing a stored key requires an operator-authorized session. Pair this browser to continue, or sign in through a trusted Tailscale identity.",
+ "api.key.mutationHidden": "The key was created or its rotation started, but the one-time value was hidden because the session or view changed.",
"api.key.copyFailedShort": "Could not copy. Select the key and copy it manually.",
"api.key.deleteRowAria": "Delete {name}",
"api.key.deleteShort": "Delete",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 1295c428bb3..9b4924eccb6 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2795,6 +2795,8 @@ export const fr: Record = {
"api.key.revealHint": "Cliquez pour afficher la clé complète",
"api.key.hideHint": "Cliquez pour masquer la clé complète",
"api.key.revealFailed": "Impossible de charger la clé complète.",
+ "api.key.revealDenied": "Afficher une clé enregistrée nécessite une session autorisée par l'opérateur. Associez ce navigateur pour continuer, ou connectez-vous via une identité Tailscale de confiance.",
+ "api.key.mutationHidden": "La clé a été créée ou sa rotation a démarré, mais la valeur à usage unique a été masquée car la session ou la vue a changé.",
"api.key.copyFailedShort": "Copie impossible. Sélectionnez la clé et copiez-la manuellement.",
"api.key.deleteRowAria": "Supprimer {name}",
"api.key.deleteShort": "Supprimer",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index f134f328e33..68c601aa680 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2722,6 +2722,8 @@ export const ja: Record = {
"api.key.revealHint": "クリックでキー全体を表示",
"api.key.hideHint": "クリックでキー全体を隠す",
"api.key.revealFailed": "キー全体を読み込めませんでした。",
+ "api.key.revealDenied": "保存済みキーの表示には、オペレーターが認可したセッションが必要です。続行するにはこのブラウザーをペアリングするか、信頼された Tailscale ID でサインインしてください。",
+ "api.key.mutationHidden": "キーの作成またはローテーションの開始は完了しましたが、セッションまたは画面が変わったため、一度だけ表示される値を非表示にしました。",
"api.key.copyFailedShort": "コピーできませんでした。キーを選択して手動でコピーしてください。",
"api.key.deleteRowAria": "{name} を削除",
"api.key.deleteShort": "削除",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index fc2ecd9da3a..d582632ceaa 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -2330,6 +2330,8 @@ export const ko: Record = {
"api.key.revealHint": "클릭하면 전체 키가 보입니다",
"api.key.hideHint": "클릭하면 전체 키를 숨깁니다",
"api.key.revealFailed": "전체 키를 불러오지 못했습니다.",
+ "api.key.revealDenied": "저장된 키를 표시하려면 운영자가 승인한 세션이 필요합니다. 이 브라우저를 페어링해 계속하거나, 신뢰된 Tailscale 신원으로 로그인하세요.",
+ "api.key.mutationHidden": "키가 생성되었거나 교체가 시작되었지만, 세션이나 화면이 바뀌어 일회성 값을 숨겼습니다.",
"api.key.copyFailedShort": "복사하지 못했습니다. 키를 선택해 직접 복사하세요.",
"api.key.deleteRowAria": "{name} 삭제",
"api.key.deleteShort": "삭제",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index ed9e0e4d5b2..484fb8bacf2 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2838,6 +2838,8 @@ export const pt: Record = {
"api.key.revealHint": "Clique para mostrar a chave completa",
"api.key.hideHint": "Clique para ocultar a chave completa",
"api.key.revealFailed": "Não foi possível carregar a chave completa.",
+ "api.key.revealDenied": "Mostrar uma chave armazenada exige uma sessão autorizada pelo operador. Emparelhe este navegador para continuar ou entre com uma identidade Tailscale confiável.",
+ "api.key.mutationHidden": "A chave foi criada ou sua rotação foi iniciada, mas o valor de exibição única foi ocultado porque a sessão ou a tela mudou.",
"api.key.copyFailedShort": "Não foi possível copiar. Selecione a chave e copie-a manualmente.",
"api.key.deleteRowAria": "Excluir {name}",
"api.key.deleteShort": "Excluir",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 301e460bf8b..62f35b7ef0e 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2812,6 +2812,8 @@ export const ru: Record = {
"api.key.revealHint": "Нажмите, чтобы показать ключ полностью",
"api.key.hideHint": "Нажмите, чтобы скрыть полный ключ",
"api.key.revealFailed": "Не удалось загрузить полный ключ.",
+ "api.key.revealDenied": "Чтобы показать сохранённый ключ, требуется сеанс, авторизованный оператором. Создайте пару для этого браузера, чтобы продолжить, или войдите с доверенной личностью Tailscale.",
+ "api.key.mutationHidden": "Ключ создан или его ротация начата, но одноразовое значение скрыто, поскольку сеанс или экран изменился.",
"api.key.copyFailedShort": "Не удалось скопировать. Выделите ключ и скопируйте его вручную.",
"api.key.deleteRowAria": "Удалить {name}",
"api.key.deleteShort": "Удалить",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 3a73605cf8b..aa16851efed 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2814,6 +2814,8 @@ export const tr: Record = {
"api.key.revealHint": "Tam anahtarı göstermek için tıklayın",
"api.key.hideHint": "Tam anahtarı gizlemek için tıklayın",
"api.key.revealFailed": "Tam anahtar yüklenemedi.",
+ "api.key.revealDenied": "Kaydedilmiş bir anahtarı göstermek, operatörün yetkilendirdiği bir oturum gerektirir. Devam etmek için bu tarayıcıyı eşleştirin veya güvenilir bir Tailscale kimliğiyle oturum açın.",
+ "api.key.mutationHidden": "Anahtar oluşturuldu veya anahtar rotasyonu başlatıldı, ancak oturum ya da görünüm değiştiği için tek seferlik değer gizlendi.",
"api.key.copyFailedShort": "Kopyalanamadı. Anahtarı seçip elle kopyalayın.",
"api.key.deleteRowAria": "{name} sil",
"api.key.deleteShort": "Sil",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 77677c884fb..d5c0905edde 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2813,6 +2813,8 @@ export const vi: Record = {
"api.key.revealHint": "Nhấp để hiện toàn bộ khóa",
"api.key.hideHint": "Nhấp để ẩn toàn bộ khóa",
"api.key.revealFailed": "Không tải được toàn bộ khóa.",
+ "api.key.revealDenied": "Hiển thị khóa đã lưu yêu cầu phiên được người vận hành cấp quyền. Ghép nối trình duyệt này để tiếp tục, hoặc đăng nhập qua danh tính Tailscale tin cậy.",
+ "api.key.mutationHidden": "Khóa đã được tạo hoặc quá trình xoay khóa đã bắt đầu, nhưng giá trị chỉ hiển thị một lần đã bị ẩn vì phiên hoặc màn hình đã thay đổi.",
"api.key.copyFailedShort": "Không sao chép được. Hãy chọn key và sao chép thủ công.",
"api.key.deleteRowAria": "Xóa {name}",
"api.key.deleteShort": "Xóa",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index dbbd6bb1cd3..4fb66eaa91d 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -2068,6 +2068,8 @@ export const zhTW: Record = {
"api.key.revealHint": "點擊顯示完整金鑰",
"api.key.hideHint": "點擊隱藏完整金鑰",
"api.key.revealFailed": "無法載入完整金鑰。",
+ "api.key.revealDenied": "顯示已儲存的金鑰需要經操作員授權的工作階段。請配對此瀏覽器以繼續,或透過受信任的 Tailscale 身分登入。",
+ "api.key.mutationHidden": "金鑰已建立或已開始輪替,但因工作階段或畫面已變更,一次性值已隱藏。",
"api.key.copyFailedShort": "無法複製。請選取金鑰後手動複製。",
"api.key.deleteRowAria": "刪除 {name}",
"api.key.deleteShort": "刪除",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 50a7c291ad1..bb6a39dac49 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -2303,6 +2303,8 @@ export const zh: Record = {
"api.key.revealHint": "点击显示完整密钥",
"api.key.hideHint": "点击隐藏完整密钥",
"api.key.revealFailed": "无法加载完整密钥。",
+ "api.key.revealDenied": "显示已存储的密钥需要经操作员授权的会话。请配对此浏览器以继续,或通过受信任的 Tailscale 身份登录。",
+ "api.key.mutationHidden": "密钥已创建或已开始轮换,但由于会话或视图发生变化,一次性值已被隐藏。",
"api.key.copyFailedShort": "无法复制。请选中密钥后手动复制。",
"api.key.deleteRowAria": "删除 {name}",
"api.key.deleteShort": "删除",
diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx
index affe4d2684f..d7496433ebd 100644
--- a/gui/src/pages/ApiKeys.tsx
+++ b/gui/src/pages/ApiKeys.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Notice } from "../ui";
import { readUsageMetadata, type UsageReadMetadata } from "../usage-summary-resource";
import { useI18n, LOCALES } from "../i18n/shared";
@@ -13,8 +13,10 @@ import {
} from "../api-access-models";
import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache";
import { createBoundedFetch } from "../bounded-fetch";
+import { invalidateClientResource } from "../client-resource";
import { useDataSurface } from "../data-surface";
import { DataSurfaceSkeleton } from "../components/data-surface";
+import { useKeyDisclosure } from "../use-key-disclosure";
import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace";
import {
DEFAULT_ENDPOINTS,
@@ -29,6 +31,7 @@ import {
type ApiKeyEntry,
type ModelTestResult,
type ModelTests,
+ type RevealKeyResult,
} from "./api-keys-utils";
interface KeysResponse extends UsageReadMetadata {
@@ -140,6 +143,9 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
const cachedModelsEntry = readSessionListCacheEntry(modelsCacheKey);
const cachedKeys = validCachedKeys(cachedKeysEntry?.data ?? null);
const cachedModels = cachedModelsEntry?.data ?? null;
+ const currentApiBase = useRef(apiBase);
+ useLayoutEffect(() => { currentApiBase.current = apiBase; }, [apiBase]);
+ const [hiddenMutationBase, setHiddenMutationBase] = useState(null);
const [actionError, setActionError] = useState(null);
const [modelQuery, setModelQuery] = useState("");
const [copiedModelId, setCopiedModelId] = useState(null);
@@ -151,6 +157,13 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
const [rotationSecret, setRotationSecret] = useState<{ id: string; key: string; rotationId: string } | null>(null);
const [rotationCopied, setRotationCopied] = useState(false);
const creatingRef = useRef(false);
+ const clearDisclosure = useCallback(() => {
+ setNewKey(null);
+ setCopied(false);
+ setRotationSecret(null);
+ setRotationCopied(false);
+ }, []);
+ const disclosure = useKeyDisclosure(apiBase, active, clearDisclosure);
const fetchKeys = useCallback(async (signal: AbortSignal): Promise => {
const res = await fetch(`${apiBase}/api/keys`, { signal });
@@ -185,7 +198,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
authMatrix: data.authMatrix,
};
// Prefixes only — never the secret key material.
- writeSessionListCacheEntry(keysCacheKey, next);
+ if (!signal.aborted) writeSessionListCacheEntry(keysCacheKey, next);
return next;
}, [apiBase, keysCacheKey, t]);
@@ -266,10 +279,12 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
}, [modelQuery, models]);
const handleCreate = async (name?: string): Promise => {
+ const generation = disclosure.generation.current;
if (creatingRef.current) return false;
creatingRef.current = true;
setCreating(true);
setActionError(null);
+ setHiddenMutationBase(null);
try {
const effectiveName = name ?? newName;
const res = await fetch(`${apiBase}/api/keys`, {
@@ -277,14 +292,21 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: effectiveName || "default" }),
});
+ if (res.ok) {
+ if (currentApiBase.current === apiBase) refreshKeys();
+ else invalidateClientResource(keysResourceKey);
+ }
const data = await readJsonOrThrow(res, t("api.createFailed"));
+ if (!disclosure.current(generation)) {
+ setHiddenMutationBase(apiBase);
+ return true;
+ }
if (typeof data?.key !== "string" || data.key.length === 0) {
setActionError(t("api.createFailed"));
return false;
}
setNewKey(data.key);
setNewName("");
- refreshKeys();
return true;
} catch {
setActionError(t("api.createFailed"));
@@ -322,8 +344,10 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
};
/** The full key for one row. Read-only, but bounded like the mutations so a
- * stalled connection releases the cell's pending state. */
- const handleReveal = async (id: string): Promise => {
+ * stalled connection releases the cell's pending state. The 401/403 standing
+ * refusal is reported apart from transient failures so the list can offer
+ * the remedy — pairing — instead of a bare "try again". */
+ const handleReveal = async (id: string): Promise => {
const bounded = createBoundedFetch(MUTATION_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/api/keys/reveal`, {
@@ -333,11 +357,14 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
signal: bounded.signal,
cache: "no-store",
});
- if (!res.ok) return null;
+ if (res.status === 401 || res.status === 403) return { ok: false, kind: "denied" };
+ if (!res.ok) return { ok: false, kind: "failed" };
const body = await res.json() as { key?: unknown };
- return typeof body.key === "string" && body.key ? body.key : null;
+ return typeof body.key === "string" && body.key
+ ? { ok: true, key: body.key }
+ : { ok: false, kind: "failed" };
} catch {
- return null;
+ return { ok: false, kind: "failed" };
} finally {
bounded.clear();
}
@@ -369,7 +396,9 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
};
const handleRotationStart = async (id: string): Promise => {
+ const generation = disclosure.generation.current;
setActionError(null);
+ setHiddenMutationBase(null);
const bounded = createBoundedFetch(MUTATION_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/api/keys/rotate`, {
@@ -378,10 +407,17 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
body: JSON.stringify({ id }),
signal: bounded.signal,
});
+ if (res.ok) {
+ if (currentApiBase.current === apiBase) refreshKeys();
+ else invalidateClientResource(keysResourceKey);
+ }
const data = await readJsonOrThrow(res, t("api.rotation.startFailed"));
+ if (!disclosure.current(generation)) {
+ setHiddenMutationBase(apiBase);
+ return true;
+ }
if (!data || typeof data.key !== "string" || !data.key || typeof data.rotationId !== "string" || !data.rotationId) return false;
setRotationSecret({ id, key: data.key, rotationId: data.rotationId });
- refreshKeys();
return true;
} catch {
return false;
@@ -413,23 +449,28 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
const copyRotationSecret = async () => {
if (!rotationSecret) return;
+ const generation = disclosure.generation.current;
try {
await navigator.clipboard.writeText(rotationSecret.key);
+ if (!disclosure.current(generation)) return;
setRotationCopied(true);
- window.setTimeout(() => setRotationCopied(false), 2000);
+ window.setTimeout(() => { if (disclosure.current(generation)) setRotationCopied(false); }, 2000);
} catch {
- setActionError(t("api.key.copyFailed"));
+ if (disclosure.current(generation)) setActionError(t("api.key.copyFailed"));
}
};
const copyKey = async () => {
if (!newKey) return;
+ const generation = disclosure.generation.current;
setActionError(null);
try {
await navigator.clipboard.writeText(newKey);
+ if (!disclosure.current(generation)) return;
setCopied(true);
- window.setTimeout(() => setCopied(false), 2000);
+ window.setTimeout(() => { if (disclosure.current(generation)) setCopied(false); }, 2000);
} catch {
+ if (!disclosure.current(generation)) return;
// The one-time key is the only string in the product with no second
// chance, so a silent failure here is the worst kind. Keep it on screen.
setCopied(false);
@@ -526,6 +567,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
{subtitleParts[1]}
+ {hiddenMutationBase === apiBase && {t("api.key.mutationHidden")}}
{actionError && {actionError}}
{keysState.showError && keysData && {t("api.keysLoadFailed")}}
{/* The model failure is reported inside the models panel, beside the
@@ -542,7 +584,9 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
) : (
<>
disclosure.invalidate()}
keys={keys}
apiBase={apiBase}
attributionSince={attributionSince}
diff --git a/gui/src/pages/api-keys-utils.ts b/gui/src/pages/api-keys-utils.ts
index 806541b0e78..3a5fe54f044 100644
--- a/gui/src/pages/api-keys-utils.ts
+++ b/gui/src/pages/api-keys-utils.ts
@@ -20,6 +20,17 @@ export interface ApiKeyEntry {
usage: ApiKeyUsage;
}
+/**
+ * Outcome of a stored-key reveal. `denied` is the server's standing answer to a
+ * dashboard session without stored-secret authority — a loopback session may
+ * list masked keys but cannot disclose one until the operator pairs this
+ * browser or signs in through a trusted identity. `failed` is the transient
+ * remainder: timeout, unreachable server, malformed answer.
+ */
+export type RevealKeyResult =
+ | { ok: true; key: string }
+ | { ok: false; kind: "denied" | "failed" };
+
/**
* A usage object the GUI can actually render. Coercing a malformed one to zeroes
* would state "used zero times" about data we could not read — the exact false
diff --git a/gui/src/use-key-disclosure.ts b/gui/src/use-key-disclosure.ts
new file mode 100644
index 00000000000..1e18b463016
--- /dev/null
+++ b/gui/src/use-key-disclosure.ts
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+import { SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api";
+import { hostDocumentHidden, onHostVisibilityChange } from "./host-visibility";
+
+export type DisclosureResetReason = "lifecycle" | "session" | "pairing";
+
+/** Displayed secrets belong to one visible, active shared-session lifetime.
+ * Responses and clipboard callbacks from an earlier lifetime cannot restore them. */
+export function useKeyDisclosure(
+ apiBase: string,
+ active: boolean,
+ clear: (reason: DisclosureResetReason) => void,
+) {
+ const generation = useRef(0);
+ const mounted = useRef(false);
+ const invalidate = useCallback((reason: DisclosureResetReason = "pairing") => {
+ generation.current++;
+ clear(reason);
+ }, [clear]);
+ const expire = useCallback(() => {
+ mounted.current = false;
+ generation.current++;
+ }, []);
+
+ useLayoutEffect(() => {
+ mounted.current = true;
+ invalidate("lifecycle");
+ return expire;
+ }, [apiBase, active, invalidate, expire]);
+
+ useEffect(() => {
+ const unavailable = (event: Event) => {
+ if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared") invalidate("session");
+ };
+ const hidden = () => { if (hostDocumentHidden()) invalidate("lifecycle"); };
+ window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable);
+ window.addEventListener(SESSION_CHANGED_EVENT, unavailable);
+ const stopVisibility = onHostVisibilityChange(hidden);
+ return () => {
+ window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable);
+ window.removeEventListener(SESSION_CHANGED_EVENT, unavailable);
+ stopVisibility();
+ };
+ }, [invalidate]);
+
+ const current = useCallback((expected: number) =>
+ mounted.current && active && !hostDocumentHidden() && generation.current === expected, [active]);
+ return { generation, invalidate, current };
+}
diff --git a/gui/tests/apikeys-actions.test.tsx b/gui/tests/apikeys-actions.test.tsx
index 3024df0d73c..e93a3f52895 100644
--- a/gui/tests/apikeys-actions.test.tsx
+++ b/gui/tests/apikeys-actions.test.tsx
@@ -61,6 +61,7 @@ async function mount(props: Partial): Promise void) | undefined;
+let previousLanguageDescriptor: PropertyDescriptor | undefined;
+let testWindow: Window;
+
+const AUTH_MATRIX = [
+ { endpoint: "/v1/responses", bearer: "rejected", dedicated: "required", xApiKey: "rejected" },
+ { endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
+];
+
+const KEYS_OK = {
+ keys: [
+ { id: "key-1", name: "alpha", prefix: "ocx_data_aaaaaaaa...", createdAt: "2026-01-15T12:00:00.000Z",
+ usage: { requests7d: 3, totalRequests: 8 } },
+ ],
+ attributionSince: "2026-07-20T00:00:00.000Z",
+ authMatrix: AUTH_MATRIX,
+ baseUrl: `${ORIGIN}/v1`,
+ endpoint: `${ORIGIN}/v1/responses`,
+ claudeCodeEnabled: true,
+};
+
+function sessionHtml(token: string): string {
+ return ``
+ + ``
+ + ``
+ + ``;
+}
+
+type RevealMode = "denied" | "failed" | "ok";
+
+/** One stub for both `fetch` (the page's reads) and `window.fetch` (the pairing exchange). */
+function installFetch(mode: { current: RevealMode }): void {
+ const impl = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (url.endsWith("/v1/models")) return Response.json({ data: [] });
+ if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK);
+ if (url.endsWith("/api/keys/reveal")) {
+ if (mode.current === "denied") {
+ return Response.json({ error: "operator-authorized dashboard session required" }, { status: 403 });
+ }
+ if (mode.current === "failed") return Response.json({ error: "internal" }, { status: 500 });
+ return Response.json({ key: FULL_KEY });
+ }
+ if (url.endsWith("/opencodex-session") && method === "POST") {
+ // The freshly paired session can disclose when the operator clicks the key again.
+ mode.current = "ok";
+ return new Response(sessionHtml("ocx_session_paired"), {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ });
+ }
+ return Response.json({}, { status: 404 });
+ }) as typeof fetch;
+ globalThis.fetch = impl;
+ Object.defineProperty(testWindow, "fetch", { configurable: true, value: impl });
+}
+
+beforeEach(() => {
+ clearClientResourceStoresForTests();
+ resetApiAuthFetchForTests();
+ testWindow = new Window({ url: `${ORIGIN}/` });
+ previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language");
+ Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" });
+ const keys = ["document", "window", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
+ const previous = Object.fromEntries(
+ keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
+ ) as Record<(typeof keys)[number], PropertyDescriptor | undefined>;
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ localStorage: { configurable: true, value: testWindow.localStorage },
+ sessionStorage: { configurable: true, value: testWindow.sessionStorage },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+ restoreGlobals = () => {
+ for (const key of keys) {
+ const descriptor = previous[key];
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete (globalThis as Record)[key];
+ }
+ if (previousLanguageDescriptor) {
+ Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor);
+ } else {
+ delete (globalThis.navigator as { language?: string }).language;
+ }
+ };
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ clearClientResourceStoresForTests();
+ resetApiAuthFetchForTests();
+ restoreGlobals?.();
+ testWindow.close();
+});
+
+async function tick(ms = 0): Promise {
+ await act(async () => {
+ await new Promise(resolve => testWindow.setTimeout(resolve, ms));
+ await Promise.resolve();
+ });
+}
+
+async function mountPage(): Promise<{ container: HTMLDivElement; root: Root }> {
+ // The session store checks the shared target's origin, so the module state
+ // must describe the same standalone server the page believes it is on.
+ configureApiTargets(standaloneApiTargets(ORIGIN));
+ const container = testWindow.document.createElement("div") as unknown as HTMLDivElement;
+ testWindow.document.body.appendChild(container);
+ const { createRoot } = await import("react-dom/client");
+ let root!: Root;
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await tick();
+ return { container, root };
+}
+
+const keyButton = (c: HTMLElement): HTMLButtonElement =>
+ c.querySelector(".awi-keylist-key")!;
+
+function standaloneRuntimeTag(): void {
+ const meta = testWindow.document.createElement("meta");
+ meta.setAttribute("name", "opencodex-runtime-role");
+ meta.setAttribute("content", "standalone");
+ testWindow.document.head.append(meta);
+}
+
+async function typePairingCode(container: HTMLDivElement): Promise {
+ const input = container.querySelector("#connect-pairing-code")!;
+ await act(async () => {
+ const setter = Object.getOwnPropertyDescriptor(
+ testWindow.HTMLInputElement.prototype, "value",
+ )!.set!;
+ setter.call(input, `ocx_pair_${"a".repeat(43)}`);
+ input.dispatchEvent(new testWindow.Event("input", { bubbles: true }));
+ });
+}
+
+const DENIED_TEXT = "Showing a stored key requires an operator-authorized session";
+
+test("a refused reveal offers the local pairing form, then requires another click", async () => {
+ standaloneRuntimeTag();
+ const mode = { current: "denied" as RevealMode };
+ installFetch(mode);
+ const { container, root } = await mountPage();
+ try {
+ await act(async () => { keyButton(container).click(); });
+ await tick();
+
+ expect(container.textContent).toContain(DENIED_TEXT);
+ expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
+ const form = container.querySelector(".connect-pairing");
+ expect(form).not.toBeNull();
+ // A standalone install mints locally, so the form shows the local copy.
+ expect(form!.querySelector("h2")?.textContent).toBe("One-time pairing code");
+
+ await typePairingCode(container);
+ await act(async () => {
+ container.querySelector(".connect-pairing button[type=submit]")!.click();
+ });
+ await tick();
+ await tick();
+
+ // Pairing changes authority, while disclosure still requires a fresh click.
+ expect(container.querySelector(".connect-pairing")).toBeNull();
+ expect(container.textContent).not.toContain(DENIED_TEXT);
+ expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
+ await act(async () => { keyButton(container).click(); });
+ await tick();
+ expect(keyButton(container).textContent).toBe(FULL_KEY);
+ } finally {
+ await act(async () => { root.unmount(); });
+ }
+});
+
+test("a refused reveal elsewhere explains the requirement without a pairing form", async () => {
+ // No standalone-runtime tag: nothing this page renders can mint a session,
+ // so the denial is answered with the explanation alone.
+ const mode = { current: "denied" as RevealMode };
+ installFetch(mode);
+ const { container, root } = await mountPage();
+ try {
+ await act(async () => { keyButton(container).click(); });
+ await tick();
+
+ expect(container.textContent).toContain(DENIED_TEXT);
+ expect(container.querySelector(".connect-pairing")).toBeNull();
+ } finally {
+ await act(async () => { root.unmount(); });
+ }
+});
+
+test("a failed reveal reports the failure without offering pairing", async () => {
+ const mode = { current: "failed" as RevealMode };
+ installFetch(mode);
+ const { container, root } = await mountPage();
+ try {
+ await act(async () => { keyButton(container).click(); });
+ await tick();
+
+ expect(container.querySelector(".awi-keylist-keycell [role=\"alert\"]")?.textContent)
+ .toBe("Could not load the full key.");
+ expect(container.querySelector(".connect-pairing")).toBeNull();
+ expect(container.textContent).not.toContain(DENIED_TEXT);
+ } finally {
+ await act(async () => { root.unmount(); });
+ }
+});
diff --git a/gui/tests/apikeys-reveal-session.test.tsx b/gui/tests/apikeys-reveal-session.test.tsx
new file mode 100644
index 00000000000..3d35fc541aa
--- /dev/null
+++ b/gui/tests/apikeys-reveal-session.test.tsx
@@ -0,0 +1,559 @@
+/** @jsxImportSource react */
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, type ComponentProps } from "react";
+import type { Root } from "react-dom/client";
+import ApiKeysListPanel from "../src/components/apikeys-workspace/ApiKeysListPanel";
+import ApiKeys from "../src/pages/ApiKeys";
+import { LanguageProvider } from "../src/i18n/provider";
+import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, resetApiAuthFetchForTests, SESSION_UNAVAILABLE_EVENT } from "../src/api";
+import { clearClientResourceStoresForTests } from "../src/client-resource";
+import { standaloneApiTargets } from "../src/api-targets";
+import { readSessionListCacheEntry } from "../src/session-list-cache";
+import type { RevealKeyResult } from "../src/pages/api-keys-utils";
+
+const origin = "http://127.0.0.1:10100";
+const full = "ocx_data_" + "a".repeat(40);
+const key = { id: "k1", name: "alpha", prefix: "ocx_data_aaaaaaaa...",
+ createdAt: "2026-01-01T00:00:00.000Z", usage: { requests7d: 0, totalRequests: 0 } };
+const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Record;
+let win: Window;
+let root: Root | null;
+let container: HTMLDivElement;
+
+beforeEach(async () => {
+ clearClientResourceStoresForTests();
+ resetApiAuthFetchForTests();
+ previous = Object.fromEntries(globals.map(k => [k, Object.getOwnPropertyDescriptor(globalThis, k)]));
+ win = new Window({ url: origin });
+ for (const k of ["document", "navigator", "localStorage", "sessionStorage"] as const)
+ Object.defineProperty(globalThis, k, { configurable: true, value: win[k] });
+ Object.defineProperty(globalThis, "window", { configurable: true, value: win });
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
+ document.head.innerHTML = '';
+ configureApiTargets(standaloneApiTargets(""));
+ container = document.createElement("div");
+ document.body.append(container);
+ const { createRoot } = await import("react-dom/client");
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root?.unmount());
+ root = null;
+ resetApiAuthFetchForTests();
+ clearClientResourceStoresForTests();
+ win.close();
+ for (const k of globals) {
+ if (previous[k]) Object.defineProperty(globalThis, k, previous[k]!);
+ else Reflect.deleteProperty(globalThis, k);
+ }
+});
+
+type Props = ComponentProps & { active?: boolean };
+async function render(props: Partial = {}) {
+ await act(async () => root!.render( {}} {...props} />));
+}
+const row = () => container.querySelector(".awi-keylist-key")!;
+const click = async () => { await act(async () => row().click()); };
+const loseSession = () => win.dispatchEvent(new win.CustomEvent(SESSION_UNAVAILABLE_EVENT, { detail: { plane: "shared" } }));
+function defer() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+async function pair(response?: () => Promise) {
+ Object.defineProperty(win, "fetch", { configurable: true, value: async (url: string, init: RequestInit) => {
+ expect(url).toBe("/opencodex-session");
+ expect(init.method).toBe("POST");
+ if (response) return response();
+ return new Response(''
+ + ''
+ + ''
+ + '');
+ } });
+ const input = container.querySelector("#connect-pairing-code")!;
+ Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "ocx_pair_" + "a".repeat(43));
+ await act(async () => input.dispatchEvent(new win.Event("input", { bubbles: true })));
+ await act(async () => container.querySelector("form")!.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })));
+}
+
+for (const mode of ["hidden", "host-hidden", "inactive", "session", "apiBase"] as const) {
+ test("existing plaintext and copy feedback are cleared on " + mode, async () => {
+ const props: Partial = { onReveal: async () => ({ ok: true, key: full }) };
+ Object.defineProperty(win.navigator, "clipboard", { configurable: true, value: { writeText: async () => {} } });
+ await render(props);
+ await click();
+ await act(async () => container.querySelector(".awi-keylist-copy")!.click());
+ expect(container.textContent).toContain("Copied");
+ await act(async () => {
+ if (mode === "hidden") {
+ Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
+ document.dispatchEvent(new win.Event("visibilitychange"));
+ } else if (mode === "host-hidden") {
+ expect(document.visibilityState).toBe("visible");
+ win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: false }));
+ } else if (mode === "session") loseSession();
+ });
+ if (mode === "inactive") await render({ ...props, active: false });
+ if (mode === "apiBase") await render({ ...props, apiBase: "http://127.0.0.1:20200" });
+ expect(row().textContent).toBe(key.prefix);
+ expect(container.querySelector(".awi-keylist-copy")).toBeNull();
+ expect(container.textContent).not.toContain("Copied");
+ });
+
+ test("a stale reveal cannot restore plaintext after " + mode, async () => {
+ const pending = defer();
+ const props: Partial = { onReveal: () => pending.promise };
+ await render(props);
+ await click();
+ await act(async () => {
+ if (mode === "hidden") {
+ Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
+ document.dispatchEvent(new win.Event("visibilitychange"));
+ Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
+ } else if (mode === "host-hidden") {
+ expect(document.visibilityState).toBe("visible");
+ win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: false }));
+ win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: true }));
+ } else if (mode === "session") loseSession();
+ });
+ if (mode === "inactive") { await render({ ...props, active: false }); await render(props); }
+ if (mode === "apiBase") await render({ ...props, apiBase: "http://127.0.0.1:20200" });
+ await act(async () => pending.resolve({ ok: true, key: full }));
+ expect(row().textContent).toBe(key.prefix);
+ expect(container.querySelector(".awi-keylist-copy")).toBeNull();
+ });
+}
+
+test("starting pairing clears a different row's previous plaintext", async () => {
+ const second = { ...key, id: "k2", name: "beta" };
+ await render({ keys: [key, second], onReveal: async id => id === "k1"
+ ? { ok: true, key: full } : { ok: false, kind: "denied" } });
+ await click();
+ await act(async () => container.querySelectorAll(".awi-keylist-key")[1]!.click());
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ expect(row().textContent).toBe(key.prefix);
+ expect(container.querySelector(".awi-keylist-copy")).toBeNull();
+});
+
+test("a late clipboard completion cannot recreate copy feedback after session loss", async () => {
+ const pending = defer();
+ Object.defineProperty(win.navigator, "clipboard", { configurable: true, value: { writeText: () => pending.promise } });
+ const props: Partial = { onReveal: async () => ({ ok: true, key: full }) };
+ await render(props);
+ await click();
+ await act(async () => container.querySelector(".awi-keylist-copy")!.click());
+ await act(async () => loseSession());
+ await click();
+ await act(async () => pending.resolve());
+ expect(container.querySelector(".awi-keylist-copy")?.textContent).toBe("Copy");
+});
+
+for (const role of ["hub", "client"]) test(role + " denial shows guidance without a local pairing form", async () => {
+ document.head.innerHTML = '';
+ await render({ onReveal: async () => ({ ok: false, kind: "denied" }) });
+ await click();
+ expect(container.textContent).toContain("operator-authorized session");
+ expect(container.querySelector("#connect-pairing-code")).toBeNull();
+});
+
+test("API-key page treats a reveal 401 as pairing guidance", async () => {
+ const fetcher = (async (input: RequestInfo | URL) => {
+ const path = String(input);
+ if (path.endsWith("/api/keys/reveal")) return new Response(null, { status: 401 });
+ if (path.endsWith("/api/keys")) return Response.json({ keys: [key], attributionSince: "2026-01-01T00:00:00.000Z",
+ authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] });
+ return Response.json([]);
+ }) as typeof fetch;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetcher });
+ await act(async () => root!.render());
+ await click();
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ expect(row().textContent).toBe(key.prefix);
+});
+
+test("session loss during a reveal retains pairing guidance while discarding its answer", async () => {
+ await render({ onReveal: async () => {
+ loseSession();
+ return { ok: false, kind: "denied" };
+ } });
+ await click();
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ expect(row().textContent).toBe(key.prefix);
+});
+
+test("an unrelated machine-session notice leaves the shared reveal intact", async () => {
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await click();
+ await act(async () => win.dispatchEvent(new win.CustomEvent(SESSION_UNAVAILABLE_EVENT, { detail: { plane: "machine" } })));
+ expect(row().textContent).toBe(full);
+});
+
+test("unmount discards a pending reveal and removes its session listener", async () => {
+ const pending = defer();
+ await render({ onReveal: () => pending.promise });
+ await click();
+ await act(async () => root!.unmount());
+ const { createRoot } = await import("react-dom/client");
+ root = createRoot(container);
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await act(async () => pending.resolve({ ok: true, key: full }));
+ expect(row().textContent).toBe(key.prefix);
+ await click();
+ expect(row().textContent).toBe(full);
+});
+
+async function pageWithCreate(created: () => Promise) {
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input).endsWith("/api/keys") && init?.method === "POST") return created();
+ if (String(input).endsWith("/api/keys/reveal")) return new Response(null, { status: 403 });
+ if (String(input).endsWith("/api/keys")) return Response.json({ keys: [key],
+ authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] });
+ return Response.json([]);
+ } });
+ await act(async () => root!.render());
+ const generate = [...container.querySelectorAll("button")].find(b => b.textContent?.includes("Generate"))!;
+ expect(generate).toBeDefined();
+ await act(async () => generate.click());
+}
+
+test("a newly created one-time value is cleared when the page becomes inactive", async () => {
+ await pageWithCreate(async () => Response.json({ key: full }));
+ expect(container.textContent).toContain(full);
+ await act(async () => root!.render());
+ expect(container.textContent).not.toContain(full);
+});
+
+test("a late create response cannot restore a one-time value after session loss", async () => {
+ const pending = defer();
+ await pageWithCreate(() => pending.promise);
+ await act(async () => loseSession());
+ await act(async () => pending.resolve(Response.json({ key: full })));
+ expect(container.textContent).not.toContain(full);
+});
+
+test("pairing submission clears a one-time value created after its form was offered", async () => {
+ await pageWithCreate(async () => Response.json({ key: full }));
+ await click();
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ const generate = [...container.querySelectorAll("button")].find(b => b.textContent?.includes("Generate"))!;
+ await act(async () => generate.click());
+ expect(container.textContent).toContain(full);
+ const pending = defer();
+ await pair(() => pending.promise);
+ expect(container.textContent).not.toContain(full);
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ await act(async () => pending.resolve(new Response(null, { status: 403 })));
+ expect(container.textContent).not.toContain(full);
+});
+
+function sessionHtml(token: string) {
+ return ``
+ + ``
+ + ``
+ + ``;
+}
+
+test("401 recovery and authenticated retry expire an already revealed value", async () => {
+ const bootstrap = defer();
+ const credentials: (string | null)[] = [];
+ let attempts = 0;
+ let unavailable = 0;
+ win.addEventListener(SESSION_UNAVAILABLE_EVENT, () => { unavailable++; });
+ Object.defineProperty(win, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input) === "/opencodex-session") return bootstrap.promise;
+ credentials.push(new Headers(init?.headers).get("X-OpenCodex-API-Key"));
+ return ++attempts === 1 ? new Response(null, { status: 401 }) : Response.json({ ok: true });
+ } });
+ installApiAuthFetch();
+ expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired"))).toBe(true);
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await click();
+ expect(row().textContent).toBe(full);
+ let recovery!: Promise;
+ await act(async () => { recovery = window.fetch("/api/combos"); });
+ expect(row().textContent).toBe(key.prefix);
+ await act(async () => {
+ bootstrap.resolve(new Response(sessionHtml("ocx_session_automatic")));
+ expect((await recovery).status).toBe(200);
+ });
+ expect(credentials).toEqual(["ocx_session_paired", "ocx_session_automatic"]);
+ expect(hasApiSession("shared")).toBe(true);
+ expect(unavailable).toBe(0);
+ expect(row().textContent).toBe(key.prefix);
+});
+
+for (const mode of ["replace", "clear", "logout", "target"] as const) {
+ test("session store " + mode + " expires revealed values without an unavailable notice", async () => {
+ installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired"));
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await click();
+ await act(async () => {
+ if (mode === "replace") installApiSessionFromHtml("shared", sessionHtml("ocx_session_other"));
+ else if (mode === "clear") installApiSessionFromHtml("shared", "");
+ else if (mode === "target") configureApiTargets(standaloneApiTargets("http://127.0.0.1:20200"));
+ else {
+ Object.defineProperty(win, "fetch", { configurable: true, value: async () => new Response(null, { status: 204 }) });
+ expect(await logoutApiSession("shared")).toBe(true);
+ }
+ });
+ expect(row().textContent).toBe(key.prefix);
+ });
+}
+
+test("host hide prevents a late clipboard completion from restoring feedback", async () => {
+ const pending = defer();
+ Object.defineProperty(win.navigator, "clipboard", { configurable: true, value: { writeText: () => pending.promise } });
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await click();
+ await act(async () => container.querySelector(".awi-keylist-copy")!.click());
+ await act(async () => {
+ win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: false }));
+ win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: true }));
+ });
+ expect(document.visibilityState).toBe("visible");
+ await click();
+ await act(async () => pending.resolve());
+ expect(container.querySelector(".awi-keylist-copy")?.textContent).toBe("Copy");
+});
+
+for (const hostname of ["localhost", "dashboard.localhost"]) for (const apiBase of ["", origin, "http://[::1]:10100"]) {
+ test(hostname + " denial at " + (apiBase || "same origin") + " omits an unproven pairing URL and keeps generic guidance", async () => {
+ win.location.href = `http://${hostname}:10100/`;
+ configureApiTargets(standaloneApiTargets(apiBase));
+ await render({ apiBase, onReveal: async () => ({ ok: false, kind: "denied" }) });
+ await click();
+ expect(container.querySelector("#connect-pairing-code")).toBeNull();
+ expect(container.textContent).toContain("operator-authorized session");
+ expect(container.textContent).not.toContain("Reopen the dashboard");
+ expect(container.querySelector('a[href^="http://127.0.0.1"], a[href^="http://[::1]"]')).toBeNull();
+ });
+}
+
+const hiddenMessage = "The key was created or its rotation started, but the one-time value was hidden because the session or view changed.";
+const authMatrix = [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }];
+const button = (label: string) => [...container.querySelectorAll("button")]
+ .find(b => b.textContent?.trim() === label)!;
+
+for (const operation of ["create", "rotate"] as const) for (const change of ["session", "host-hidden", "inactive"] as const) {
+ test(operation + " reconciles successful inventory after " + change + " and explains the hidden value", async () => {
+ const pending = defer();
+ let completed = false;
+ let pendingRotation = false;
+ let reads = 0;
+ let finishBody: unknown;
+ if (operation === "rotate") document.head.innerHTML = '';
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ if ((operation === "create" && path === "/api/keys" && init?.method === "POST")
+ || (path === "/api/keys/rotate" && init?.method === "POST")) return pending.promise;
+ if (path === "/api/keys/rotate/commit") {
+ finishBody = JSON.parse(String(init?.body));
+ pendingRotation = false;
+ return Response.json({ ok: true });
+ }
+ if (path === "/api/keys") {
+ reads++;
+ return Response.json({ authMatrix, keys: operation === "create" && completed ? [key, { ...key, id: "k2", name: "new key" }]
+ : [{ ...key, ...(pendingRotation ? { pendingRotation: { id: "r1", createdAt: key.createdAt, expiresAt: "2026-12-01T00:00:00.000Z" } } : {}) }] });
+ }
+ return Response.json([]);
+ } });
+ await act(async () => root!.render());
+ if (operation === "rotate") await act(async () => container.querySelector(".awi-keylist-name")!.click());
+ await act(async () => button(operation === "create" ? "Generate" : "Start rotation").click());
+ const readsBefore = reads;
+ await act(async () => {
+ if (change === "session") loseSession();
+ else if (change === "host-hidden") win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: false }));
+ else root!.render();
+ });
+ await act(async () => {
+ completed = true;
+ pendingRotation = operation === "rotate";
+ pending.resolve(Response.json({ key: full, rotationId: "r1" }));
+ });
+ expect(reads).toBe(readsBefore + 1);
+ expect(container.textContent).not.toContain(full);
+ expect(container.textContent).toContain(hiddenMessage);
+ if (operation === "create") expect(container.textContent).toContain("new key");
+ else {
+ // Complete rotation only after returning to the active, visible view.
+ if (change === "inactive") await act(async () => root!.render());
+ if (change === "host-hidden") await act(async () => win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: true })));
+ expect(button("Abort rotation")).toBeDefined();
+ expect(container.textContent).not.toContain("The rotation action did not complete");
+ await act(async () => button("Commit rotation").click());
+ expect(finishBody).toEqual({ id: "k1", rotationId: "r1" });
+ expect(button("Start rotation")).toBeDefined();
+ }
+ });
+}
+
+test("a create completed after switching servers revalidates its original inventory on return", async () => {
+ const pending = defer();
+ const reads: string[] = [];
+ let completed = false;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ if (path === "/api/keys" && init?.method === "POST") return pending.promise;
+ if (path.endsWith("/api/keys")) {
+ reads.push(path);
+ return Response.json({ authMatrix, keys: [{ ...key, name: path === "/api/keys" && completed ? "original created" : "current server" }] });
+ }
+ return Response.json([]);
+ } });
+ await act(async () => root!.render());
+ await act(async () => button("Generate").click());
+ await act(async () => root!.render());
+ const before = reads.length;
+ await act(async () => { completed = true; pending.resolve(Response.json({ key: full })); });
+ expect(reads.slice(before)).toEqual([]);
+ expect(container.textContent).toContain("current server");
+ expect(container.textContent).not.toContain("original created");
+ expect(container.textContent).not.toContain(full);
+ await act(async () => root!.render());
+ expect(reads.slice(before)).toEqual(["/api/keys"]);
+ expect(container.textContent).toContain("original created");
+ expect(container.textContent).not.toContain(full);
+});
+
+test("a denied reveal after automatic session recovery keeps the pairing remedy", async () => {
+ let attempts = 0;
+ Object.defineProperty(win, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ if (String(input) === "/opencodex-session") return new Response(sessionHtml("ocx_session_automatic"));
+ return new Response(null, { status: ++attempts === 1 ? 401 : 403 });
+ } });
+ installApiAuthFetch();
+ installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired"));
+ await render({ onReveal: async () => {
+ const response = await window.fetch("/api/keys/reveal", { method: "POST" });
+ return { ok: false, kind: response.status === 403 ? "denied" : "failed" };
+ } });
+ await click();
+ expect(attempts).toBe(2);
+ expect(row().textContent).toBe(key.prefix);
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+});
+
+test("the same shared token and an unrelated machine replacement preserve the revealed value", async () => {
+ installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired"));
+ await render({ onReveal: async () => ({ ok: true, key: full }) });
+ await click();
+ await act(async () => {
+ installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired"));
+ installApiSessionFromHtml("machine", sessionHtml("ocx_session_machine"));
+ });
+ expect(row().textContent).toBe(full);
+});
+
+test("host hide rejects a reveal completion even before the host event arrives", async () => {
+ const pending = defer();
+ await render({ onReveal: () => pending.promise });
+ await click();
+ await act(async () => {
+ Object.assign(win, { __OPENCODEX_HOST_VISIBLE__: false });
+ pending.resolve({ ok: true, key: full });
+ });
+ expect(document.visibilityState).toBe("visible");
+ expect(row().textContent).toBe(key.prefix);
+});
+
+test("a rejected one-time clipboard completion after host hide does not restore an error", async () => {
+ let reject!: (reason: Error) => void;
+ const pending = new Promise((_resolve, fail) => { reject = fail; });
+ Object.defineProperty(win.navigator, "clipboard", { configurable: true, value: { writeText: () => pending } });
+ await pageWithCreate(async () => Response.json({ key: full }));
+ const copy = container.querySelector(".api-newkey-panel button")!;
+ expect(copy.textContent).toBe("Copy");
+ await act(async () => copy.click());
+ await act(async () => win.dispatchEvent(new win.CustomEvent("opencodex:host-visibility", { detail: false })));
+ await act(async () => reject(new Error("clipboard refused")));
+ expect(container.textContent).not.toContain(full);
+ expect(container.textContent).not.toContain("Could not copy");
+});
+
+for (const finish of ["commit", "abort"] as const) {
+ test("a delayed rotation-start inventory cannot resurrect pending controls after " + finish, async () => {
+ document.head.innerHTML = '';
+ const older = defer();
+ const newer = defer();
+ const signals: (AbortSignal | null | undefined)[] = [];
+ let reads = 0;
+ const inventory = (pending: boolean) => Response.json({ authMatrix, keys: [{ ...key,
+ ...(pending ? { pendingRotation: { id: "r1", createdAt: key.createdAt, expiresAt: "2026-12-01T00:00:00.000Z" } } : {}) }] });
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ if (path === "/api/keys/rotate" && init?.method === "POST") return Response.json({ key: full, rotationId: "r1" });
+ if (path === "/api/keys/rotate/commit" || (path === "/api/keys/rotate" && init?.method === "DELETE")) return Response.json({ ok: true });
+ if (path === "/api/keys") {
+ signals.push(init?.signal);
+ return ++reads === 1 ? inventory(false) : reads === 2 ? older.promise : newer.promise;
+ }
+ return Response.json([]);
+ } });
+ await act(async () => root!.render());
+ await act(async () => container.querySelector(".awi-keylist-name")!.click());
+ await act(async () => button("Start rotation").click());
+ expect(container.textContent).toContain(full);
+ expect(reads).toBe(2);
+ await act(async () => button(finish === "commit" ? "Commit rotation" : "Abort rotation").click());
+ expect(reads).toBe(3);
+ await act(async () => newer.resolve(inventory(false)));
+ expect(button("Start rotation")).toBeDefined();
+ await act(async () => older.resolve(inventory(true)));
+ expect(button("Start rotation")).toBeDefined();
+ expect(button("Commit rotation")).toBeUndefined();
+ expect(button("Abort rotation")).toBeUndefined();
+ expect(signals[1]?.aborted).toBe(true);
+ expect(signals[2]?.aborted).toBe(false);
+ const cached = readSessionListCacheEntry<{ keys: { pendingRotation?: unknown }[] }>("ocx.apikeys.list.v2:");
+ expect(cached?.data.keys[0]?.pendingRotation).toBeUndefined();
+ });
+}
+
+test("a delayed first create inventory cannot replace a newer create inventory", async () => {
+ const older = defer();
+ const newer = defer();
+ let reads = 0;
+ let creates = 0;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input) === "/api/keys" && init?.method === "POST") {
+ creates++;
+ return Response.json({ key: full });
+ }
+ if (String(input) === "/api/keys") return ++reads === 1 ? Response.json({ authMatrix, keys: [key] })
+ : reads === 2 ? older.promise : newer.promise;
+ return Response.json([]);
+ } });
+ await act(async () => root!.render());
+ await act(async () => button("Generate").click());
+ await act(async () => button("Generate").click());
+ expect(creates).toBe(2);
+ expect(reads).toBe(3);
+ const first = { ...key, id: "k2", name: "first created" };
+ const second = { ...key, id: "k3", name: "second created" };
+ await act(async () => newer.resolve(Response.json({ authMatrix, keys: [key, first, second] })));
+ expect(container.textContent).toContain("second created");
+ await act(async () => older.resolve(Response.json({ authMatrix, keys: [key, first] })));
+ expect(container.textContent).toContain("second created");
+ const cached = readSessionListCacheEntry<{ keys: { name: string }[] }>("ocx.apikeys.list.v2:");
+ expect(cached?.data.keys.map(row => row.name)).toEqual(["alpha", "first created", "second created"]);
+});
+
+for (const literalOrigin of [origin, "http://[::1]:10100"]) {
+ test("same-origin literal loopback retains its pairing form: " + literalOrigin, async () => {
+ win.location.href = literalOrigin;
+ configureApiTargets(standaloneApiTargets(""));
+ await render({ onReveal: async () => ({ ok: false, kind: "denied" }) });
+ await click();
+ expect(container.querySelector("#connect-pairing-code")).not.toBeNull();
+ expect(container.querySelector(".connect-pairing pre")?.textContent)
+ .toBe(`ocx gui pair --origin "${literalOrigin}"`);
+ expect(container.querySelector("a")).toBeNull();
+ });
+}
diff --git a/gui/tests/apikeys-row-actions.test.tsx b/gui/tests/apikeys-row-actions.test.tsx
index 7925be6dc85..c270acdb15b 100644
--- a/gui/tests/apikeys-row-actions.test.tsx
+++ b/gui/tests/apikeys-row-actions.test.tsx
@@ -5,7 +5,7 @@ import { act } from "react";
import type { Root } from "react-dom/client";
import ApiKeysWorkspace, { type ApiKeysWorkspaceProps } from "../src/components/apikeys-workspace/ApiKeysWorkspace";
import { LanguageProvider } from "../src/i18n/provider";
-import type { ApiEndpointInfo } from "../src/pages/api-keys-utils";
+import type { ApiEndpointInfo, RevealKeyResult } from "../src/pages/api-keys-utils";
// The key table's own row actions: a delete that is visible without hovering
// and confirms in place, and a key that reveals its full value on click.
@@ -61,6 +61,7 @@ async function mount(props: Partial): Promise {
const asked: string[] = [];
const full = "ocx_data_" + "a".repeat(40);
- const container = await mount({ onRevealKey: async id => { asked.push(id); return full; } });
+ const container = await mount({ onRevealKey: async id => { asked.push(id); return { ok: true, key: full }; } });
expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
expect(keyButton(container).getAttribute("aria-expanded")).toBe("false");
@@ -181,12 +182,22 @@ test("clicking the key shows the full value with a copy button, and clicking aga
});
test("a refused reveal keeps the prefix and reports the failure", async () => {
- const container = await mount({ onRevealKey: async () => null });
+ const container = await mount({ onRevealKey: async () => ({ ok: false, kind: "failed" }) });
await act(async () => { keyButton(container).click(); });
expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
expect(container.querySelector(".awi-keylist-keycell [role=\"alert\"]")?.textContent).toBe("Could not load the full key.");
});
+test("a denied reveal leaves the prefix masked and lets the panel offer pairing", async () => {
+ const container = await mount({ onRevealKey: async () => ({ ok: false, kind: "denied" }) });
+ await act(async () => { keyButton(container).click(); });
+ expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
+ // The generic load-failure alert would be a lie — the denial is answered by
+ // the panel's pairing notice, not by this row.
+ expect(container.querySelector(".awi-keylist-keycell [role=\"alert\"]")?.textContent).not.toBe("Could not load the full key.");
+ expect(container.textContent).toContain("Pair this browser to continue");
+});
+
test("without a reveal handler the key stays plain text", async () => {
const container = await mount({});
expect(container.querySelector(".awi-keylist-key")).toBeNull();
@@ -195,11 +206,11 @@ test("without a reveal handler the key stays plain text", async () => {
test("a rotated key does not keep showing the value it replaced", async () => {
const before = "ocx_data_" + "a".repeat(40);
- const container = await mount({ onRevealKey: async () => before });
+ const container = await mount({ onRevealKey: async () => ({ ok: true, key: before }) });
await act(async () => { keyButton(container).click(); });
expect(keyButton(container).textContent).toBe(before);
await rerender({
- onRevealKey: async () => before,
+ onRevealKey: async () => ({ ok: true, key: before }),
keys: [{ id: "k1", name: "alpha", prefix: "ocx_data_bbbbbbbb...", createdAt: "2026-01-01T00:00:00.000Z",
usage: { requests7d: 0, totalRequests: 0 } }],
});
@@ -208,23 +219,23 @@ test("a rotated key does not keep showing the value it replaced", async () => {
});
test("a reveal that answers after the key was deleted stays discarded", async () => {
- let answer: (value: string) => void = () => {};
+ let answer: (value: RevealKeyResult) => void = () => {};
const container = await mount({
- onRevealKey: () => new Promise(resolve => { answer = resolve; }),
+ onRevealKey: () => new Promise(resolve => { answer = resolve; }),
onDelete: async () => true,
});
await act(async () => { keyButton(container).click(); });
await act(async () => { rowDelete(container).click(); });
await act(async () => { await wait(350); });
await act(async () => { confirmButton(container).click(); });
- await act(async () => { answer("ocx_data_" + "a".repeat(40)); });
+ await act(async () => { answer({ ok: true, key: "ocx_data_" + "a".repeat(40) }); });
expect(keyButton(container).textContent).toBe("ocx_data_aaaaaaaa...");
expect(container.querySelector(".awi-keylist-copy")).toBeNull();
});
test("a copy the clipboard refuses says so beside the key", async () => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: () => Promise.reject(new Error("denied")) } });
- const container = await mount({ onRevealKey: async () => "ocx_data_" + "a".repeat(40) });
+ const container = await mount({ onRevealKey: async () => ({ ok: true, key: "ocx_data_" + "a".repeat(40) }) });
await act(async () => { keyButton(container).click(); });
await act(async () => { button(container, "Copy").click(); });
expect(container.querySelector(".awi-keylist-keycell [role=\"alert\"]")?.textContent)
@@ -236,7 +247,7 @@ test("a successful copy shows Copied and then returns to Copy", async () => {
const written: string[] = [];
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (v: string) => { written.push(v); } } });
const full = "ocx_data_" + "a".repeat(40);
- const container = await mount({ onRevealKey: async () => full });
+ const container = await mount({ onRevealKey: async () => ({ ok: true, key: full }) });
await act(async () => { keyButton(container).click(); });
await act(async () => { button(container, "Copy").click(); });
expect(written).toEqual([full]);
diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx
index 4695e09430c..471f2f26446 100644
--- a/gui/tests/apikeys-workspace.test.tsx
+++ b/gui/tests/apikeys-workspace.test.tsx
@@ -56,6 +56,7 @@ async function mountWorkspace(
document.body.append(container);
let latest: ApiKeysWorkspaceProps = {
+ apiBase: "",
keys: sampleKeys,
keysLoading: false,
keysLoadFailed: false,
diff --git a/gui/tests/client-resource-revalidate.test.tsx b/gui/tests/client-resource-revalidate.test.tsx
index f367f2601c7..22c7c41b317 100644
--- a/gui/tests/client-resource-revalidate.test.tsx
+++ b/gui/tests/client-resource-revalidate.test.tsx
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test as bunTest } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import type { Root } from "react-dom/client";
-import { clearClientResourceStoresForTests, useClientResource } from "../src/client-resource";
+import { clearClientResourceStoresForTests, invalidateClientResource, useClientResource } from "../src/client-resource";
import { classifyDataSurface } from "../src/data-surface";
import {
readSessionListCache,
@@ -161,3 +161,38 @@ test("session cache entries round-trip their age and read legacy values", () =>
expect(legacy?.data.rows).toEqual([9]);
expect(legacy?.cachedAt).toBeNull();
});
+
+for (const mode of ["without a store", "before eviction", "after eviction"] as const) {
+ test("explicit invalidation revalidates a fresh seed " + mode, async () => {
+ const key = "invalidated-fresh-seed";
+ if (mode !== "without a store") {
+ const first = await mountSeeded({ key, fetcher: async () => "unused", seed: "old", cachedAt: Date.now(), staleAfterMs: 60_000 });
+ await act(async () => first.root.unmount());
+ if (mode === "before eviction") invalidateClientResource(key);
+ // Flush the resource's documented deferred store eviction.
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
+ }
+ if (mode !== "before eviction") invalidateClientResource(key);
+ let fetches = 0;
+ const { probe, root } = await mountSeeded({ key, fetcher: async () => { fetches++; return "new"; },
+ seed: "old", cachedAt: Date.now(), staleAfterMs: 60_000 });
+ expect(fetches).toBe(1);
+ expect(probe.current?.data).toBe("new");
+ await act(async () => root.unmount());
+ });
+}
+
+test("active invalidation sequences its fetch ahead of a delayed older refresh", async () => {
+ let releaseOld!: (value: string) => void;
+ let reads = 0;
+ const { probe, root } = await mountSeeded({ key: "invalidated-active", fetcher: () => {
+ if (++reads === 1) return new Promise(resolve => { releaseOld = resolve; });
+ return Promise.resolve("new");
+ }, seed: "seed", cachedAt: Date.now(), staleAfterMs: 60_000 });
+ await act(async () => probe.current!.refresh());
+ await act(async () => invalidateClientResource("invalidated-active"));
+ expect(probe.current?.data).toBe("new");
+ await act(async () => releaseOld("old"));
+ expect(probe.current?.data).toBe("new");
+ await act(async () => root.unmount());
+});
diff --git a/gui/tests/i18n-locales.test.ts b/gui/tests/i18n-locales.test.ts
index 5f652b2e7a4..edd17b9a3bc 100644
--- a/gui/tests/i18n-locales.test.ts
+++ b/gui/tests/i18n-locales.test.ts
@@ -85,6 +85,18 @@ describe("i18n locale contracts", () => {
expect(mismatches).toEqual([]);
});
+ test("stored-key reveal notice preserves pairing and trusted Tailscale authorization", () => {
+ const key = "api.key.revealDenied";
+ const missingTailscale = LOCALES
+ .filter(({ code }) => !DICTS[code][key].includes("Tailscale"))
+ .map(({ code }) => code);
+
+ expect(missingTailscale).toEqual([]);
+ expect(en[key]).toContain("operator-authorized session");
+ expect(en[key]).toContain("Pair this browser");
+ expect(en[key]).toContain("trusted Tailscale identity");
+ });
+
test("locale source files contain no duplicate dictionary keys", async () => {
const duplicates: string[] = [];
diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts
index 9b1de78dc23..19104ef1bf8 100644
--- a/src/server/management-auth.ts
+++ b/src/server/management-auth.ts
@@ -279,6 +279,8 @@ export interface ManagementSessionControl {
isCurrent(req: Request, config: OcxConfig): boolean;
/** Prove that the current browser session came from the operator-mediated pairing flow. */
isPaired(req: Request, config: OcxConfig): boolean;
+ /** Revalidate independent session authority before disclosing stored data keys. */
+ canRevealDataKeys?(req: Request, config: OcxConfig): boolean;
}
export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl {
@@ -307,6 +309,10 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana
isPaired(req: Request, config: OcxConfig): boolean {
return currentSession(req, config)?.issuance === "pairing";
},
+ canRevealDataKeys(req: Request, config: OcxConfig): boolean {
+ const issuance = currentSession(req, config)?.issuance;
+ return issuance === "pairing" || issuance === "tailscale-identity";
+ },
revokeCurrent(req: Request): boolean {
if (!state.available) return false;
const credential = requestManagementCredential(req);
diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts
index be9054c7748..37f79d81671 100644
--- a/src/server/management/oauth-account-routes.ts
+++ b/src/server/management/oauth-account-routes.ts
@@ -1168,7 +1168,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
if (principal !== "gui-session") {
return jsonResponse({ error: "dashboard session required" }, 403, req, config);
}
+ // Local page bootstrap is sufficient for the dashboard, not stored-secret disclosure.
+ const authorized = () => ctx.sessionControl?.canRevealDataKeys?.(req, config) === true;
+ const denied = () => jsonResponse({ error: "operator-authorized dashboard session required" }, 403, req, config);
+ if (!authorized()) return denied();
const body = await readJsonBody(req);
+ // Body reception can outlive the session or its revocation; do not trust cached admission.
+ if (!authorized()) return denied();
if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
return jsonResponse({ error: "invalid body" }, 400, req, config);
}
diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts
index c2ad97ec03e..5853ef89385 100644
--- a/src/server/management/route-registry.ts
+++ b/src/server/management/route-registry.ts
@@ -324,7 +324,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
{ method: "PATCH", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
{ method: "PATCH", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
{ method: "POST", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
- { method: "POST", path: "/api/keys/reveal", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only", why: "Revealing a data key requires the gui-session principal; the admin token is refused." } },
+ { method: "POST", path: "/api/keys/reveal", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only", why: "Stored-key reads require a current pairing or trusted Tailscale-identity session; automatic loopback sessions and admin tokens are refused." } },
{ method: "POST", path: "/api/keys/rotate", module: "server/management/oauth-account-routes", mutates: true },
{ method: "POST", path: "/api/keys/rotate/commit", module: "server/management/oauth-account-routes", mutates: true },
{ method: "POST", path: "/api/oauth/accounts/clear-cooldown", module: "server/management/oauth-account-routes", mutates: true },
diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md
index 5936987fa9c..f4c9666f459 100644
--- a/structure/dashboard-and-usage.md
+++ b/structure/dashboard-and-usage.md
@@ -123,7 +123,7 @@ The sidebar exposes eight rows (`gui/src/nav-groups.ts` `NAV_GROUPS`), in order:
| Surface | Shape |
| --- | --- |
| Providers | Rail of configured providers plus a detail pane whose tabs are Overview, Models, Usage, then Accounts or API Keys when the provider has an auth surface, then Settings (`gui/src/components/provider-workspace/ProviderDetails.tsx`). |
-| API keys | Key table plus per-key detail (`gui/src/components/apikeys-workspace/`). The list payload carries masked prefixes only; each row deletes in place behind a two-step confirm, and clicking a key fetches the full value from `POST /api/keys/reveal`, which refuses every principal but a dashboard session and answers `no-store`. |
+| API keys | Key table plus per-key detail (`gui/src/components/apikeys-workspace/`). The list payload carries masked prefixes only; each row deletes in place behind a two-step confirm, and clicking a key fetches the full value from `POST /api/keys/reveal`, which requires a current pairing or trusted Tailscale-identity session and answers `no-store`; automatic loopback sessions and raw admin tokens cannot reveal stored values, and a refusal there answers with the pairing surface — on the same-origin standalone transport the local pairing form, elsewhere the explanation alone — and requires a fresh reveal click after pairing. Displayed secrets and copy feedback are cleared on shared-session loss, pairing, inactive/hidden views, or a target change; stale asynchronous completions cannot restore them. |
| Storage | Rail plus cleanup and trash detail (`gui/src/components/storage-workspace/`). |
| Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). |
| Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). |
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index bdbd0bfa793..71bc6d95876 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -188,7 +188,7 @@ exchange. Pairing accepts no admin/data credential substitute and consumes a gra
full origin predicate succeeds.
The server issues a local in-memory session for five minutes or a remote session for twelve hours,
-with 128 live sessions maximum. Every session is bound to the exact server and browser origins;
+with 128 live sessions maximum. Stored-key reads require a current pairing or trusted Tailscale-identity session, not automatic loopback issuance or admin tokens. `POST /api/keys/reveal` uses `createManagementSessionControl().canRevealDataKeys` before and after body reception; absent controls deny. Every session is bound to the exact server and browser origins;
state-changing requests additionally require the session CSRF token. A raw admin token remains
ordinary management authority only and cannot satisfy consent routes. The dashboard never attaches
its management session to `/v1/*` requests, and pages containing a session bootstrap are served with
diff --git a/tests/gui/gui-pair-http.test.ts b/tests/gui/gui-pair-http.test.ts
index 4144894ed7a..0af7c46d7a3 100644
--- a/tests/gui/gui-pair-http.test.ts
+++ b/tests/gui/gui-pair-http.test.ts
@@ -64,6 +64,8 @@ test("standalone CLI pairing crosses real HTTP admission once and stops before S
const origin = `http://127.0.0.1:${port}`;
writeFileSync(join(ocxHome, "config.json"), JSON.stringify({
port, hostname: "127.0.0.1", runtimeRole: "standalone", providers: {}, defaultProvider: "openai",
+ apiKeys: [{ id: "paired-read-fixture", name: "Fixture", key: "ocx_data_paired_read_fixture",
+ createdAt: "2026-10-06T00:00:00.000Z" }],
codexAutoStart: false, syncResumeHistory: false,
clientIntegrations: { codex: false, grok: false, "claude-desktop": false },
claudeCode: { enabled: false, systemEnv: false },
@@ -119,6 +121,12 @@ test("standalone CLI pairing crosses real HTTP admission once and stops before S
};
await expectStatus(ordinary, false);
await expectError(await joinRequest(headers(ordinary)), 403, "forbidden");
+ const reveal = (session: typeof ordinary) => request("/api/keys/reveal", {
+ method: "POST", headers: headers(session), body: JSON.stringify({ id: "paired-read-fixture" }),
+ });
+ const ordinaryReveal = await reveal(ordinary);
+ expect(ordinaryReveal.status, "automatic session cannot read a stored key").toBe(403);
+ await ordinaryReveal.body?.cancel();
// A process which reads runtime-state and reproduces its HMAC still lacks CLI write intent.
// No approved record is published here; no real user credential or external server is used.
@@ -162,6 +170,10 @@ test("standalone CLI pairing crosses real HTTP admission once and stops before S
await refused.body?.cancel();
}
const paired = await bootstrap(await redeem(), origin);
+ const pairedReveal = await reveal(paired);
+ expect(pairedReveal.status, "config-write-authorized session may read a stored key").toBe(200);
+ expect(pairedReveal.headers.get("cache-control")).toBe("no-store");
+ expect(parseObject(await pairedReveal.text()).key === "ocx_data_paired_read_fixture", "exact fixture key returned").toBe(true);
const replay = await redeem();
expect(replay.status, "single-use grant replay refused").toBe(401);
await replay.body?.cancel();
diff --git a/tests/server/api-keys-routes.test.ts b/tests/server/api-keys-routes.test.ts
index 5c0ddf381ca..dfe4c0df317 100644
--- a/tests/server/api-keys-routes.test.ts
+++ b/tests/server/api-keys-routes.test.ts
@@ -4,6 +4,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig, readConfigDiagnostics, saveConfig } from "../../src/config";
import { startServer } from "../../src/server";
+import { findAvailablePort } from "../../src/server/ports";
+import { createManagementSessionControl, initializeManagementAuthState, issueGuiSession } from "../../src/server/management-auth";
+import { createGuiPairingGrant } from "../../src/server/gui-session";
+import { handleOauthAccountRoutes } from "../../src/server/management/oauth-account-routes";
+import type { ManagementContext } from "../../src/server/management/context";
import { isDataPlaneAdmissionSecret } from "../../src/server/auth-cors";
import { ownAdmissionTokens } from "../../src/claude/auth-detect";
import { commitClientKeyRotation, startClientKeyRotation } from "../../src/client/hub-client";
@@ -327,8 +332,20 @@ describe("GET /api/keys", () => {
});
describe("POST /api/keys/reveal", () => {
- async function dashboardHeaders(server: { url: URL }): Promise> {
- const bootstrap = await fetch(new URL("/opencodex-session", server.url));
+ async function operatorServer() {
+ const config = { ...baseConfig(), hostname: "127.0.0.1", port: await findAvailablePort(0, "127.0.0.1") };
+ saveConfig(config);
+ const state = initializeManagementAuthState(config);
+ if (!state.available) throw new Error("expected fixture management state");
+ return { config, state, server: startServer(config.port, { managementAuthState: state }) };
+ }
+ async function dashboardHeaders(server: { url: URL }, operator?: Awaited>): Promise> {
+ // Only positive fixtures receive an independently issued grant. Anonymous controls use GET.
+ const grant = operator && createGuiPairingGrant(server.url.origin, operator.config, operator.state);
+ const bootstrap = await fetch(new URL("/opencodex-session", server.url), grant ? {
+ method: "POST", headers: { Origin: server.url.origin, "Content-Type": "application/json" },
+ body: JSON.stringify({ grant: grant.grant }),
+ } : {});
expect(bootstrap.status).toBe(200);
const html = await bootstrap.text();
const token = html.match(/name="opencodex-session-token" content="([^"]+)"/)?.[1];
@@ -344,15 +361,15 @@ describe("POST /api/keys/reveal", () => {
};
}
- test("a dashboard session receives the full current key with no-store", async () => {
- saveConfig(baseConfig());
- const server = startServer(0);
+ test("an operator-paired dashboard session receives the full current key with no-store", async () => {
+ const operator = await operatorServer();
+ const { server } = operator;
try {
const created = await keysRequest(server, "POST", { name: "reveal" });
const id = created.json.id as string;
await managementRequest(server, "/api/keys/rotate", "POST", { id });
const response = await fetch(new URL("/api/keys/reveal", server.url), {
- method: "POST", headers: await dashboardHeaders(server), body: JSON.stringify({ id }),
+ method: "POST", headers: await dashboardHeaders(server, operator), body: JSON.stringify({ id }),
});
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
@@ -362,6 +379,134 @@ describe("POST /api/keys/reveal", () => {
}
});
+ test("anonymous loopback sessions cannot export existing keys with valid origin and CSRF", async () => {
+ saveConfig(baseConfig());
+ const server = startServer(0);
+ try {
+ const created = await keysRequest(server, "POST", { name: "retained" });
+ const headers = await dashboardHeaders(server);
+ const listed = await fetch(new URL("/api/keys", server.url), { headers });
+ expect(listed.status).toBe(200);
+ const listing = await listed.text();
+ expect(listing).toContain(created.json.id as string);
+ expect(listing).not.toContain(created.json.key as string);
+ for (const body of [JSON.stringify({ id: created.json.id }), JSON.stringify({ id: "missing" }), "{bad"]) {
+ const response = await fetch(new URL("/api/keys/reveal", server.url), { method: "POST", headers, body });
+ expect(response.status).toBe(403);
+ expect(await response.json()).toEqual({ error: "operator-authorized dashboard session required" });
+ }
+ expect(loadConfig().apiKeys?.[0]?.key).toBe(created.json.key as string);
+ } finally { await server.stop(true); }
+ });
+
+ test("paired key reads still reject wrong CSRF and revoked sessions", async () => {
+ const operator = await operatorServer();
+ const { server, state } = operator;
+ try {
+ const created = await keysRequest(server, "POST", { name: "retained" });
+ const headers = await dashboardHeaders(server, operator);
+ const request = (overrides: Record = {}) => fetch(new URL("/api/keys/reveal", server.url), {
+ method: "POST", headers: { ...headers, ...overrides }, body: JSON.stringify({ id: created.json.id }),
+ });
+ const wrongCsrf = await request({ "x-opencodex-csrf-token": "wrong" });
+ expect(wrongCsrf.status).toBe(401); await wrongCsrf.body?.cancel();
+ state.sessions.delete(headers["x-opencodex-api-key"]!);
+ const revoked = await request();
+ expect(revoked.status).toBe(401); await revoked.body?.cancel();
+ expect(loadConfig().apiKeys?.[0]?.key).toBe(created.json.key as string);
+ } finally { await server.stop(true); }
+ });
+
+ test.each(["revoked", "expired"] as const)("stored-key authority is rechecked after body reception: %s", async reason => {
+ const operator = await operatorServer();
+ const { config, state, server } = operator;
+ try {
+ config.apiKeys = [{ id: "held", name: "Fixture", key: "ocx_data_body_read_fixture", createdAt: "2026-10-06T00:00:00.000Z" }];
+ const headers = await dashboardHeaders(server, operator);
+ const control = createManagementSessionControl(state);
+ let checks = 0;
+ let input!: ReadableStreamDefaultController;
+ const url = new URL("/api/keys/reveal", server.url);
+ const req = new Request(url, { method: "POST", headers: { ...headers, Host: server.url.host },
+ body: new ReadableStream({ start(controller) { input = controller; } }),
+ });
+ const ctx: ManagementContext = { req, url, config, deps: {}, version: "fixture",
+ principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "pairing",
+ sessionControl: { ...control, canRevealDataKeys: (request, current) => {
+ checks += 1; return control.canRevealDataKeys!(request, current);
+ } },
+ convergeCodexCatalog: async () => { throw new Error("must not converge for a read"); },
+ syncClaudeAgentDefsBestEffort: async () => { throw new Error("must not sync for a read"); },
+ };
+ const pending = handleOauthAccountRoutes(ctx);
+ expect(checks).toBe(1); // The real authority check ran before waiting for the body.
+ const token = headers["x-opencodex-api-key"]!;
+ if (reason === "revoked") state.sessions.delete(token);
+ else state.sessions.get(token)!.expiresAt = Date.now() - 1;
+ input.enqueue(new TextEncoder().encode(JSON.stringify({ id: "held" }))); input.close();
+ const response = await pending;
+ expect(response?.status).toBe(403);
+ expect(await response!.json()).toEqual({ error: "operator-authorized dashboard session required" });
+ expect(checks).toBe(2);
+ expect(config.apiKeys[0]!.key).toBe("ocx_data_body_read_fixture");
+ } finally { await server.stop(true); }
+ });
+
+ test("a dispatcher without the independent authority predicate fails closed", async () => {
+ const operator = await operatorServer();
+ const { config, state, server } = operator;
+ try {
+ const headers = await dashboardHeaders(server, operator);
+ const url = new URL("/api/keys/reveal", server.url);
+ const ctx: ManagementContext = {
+ req: new Request(url, { method: "POST", headers: { ...headers, Host: server.url.host }, body: "{bad" }),
+ url, config, deps: {}, version: "fixture", principal: "gui-session",
+ trustedLoopbackIngress: true, guiSessionIssuance: "pairing",
+ sessionControl: { ...createManagementSessionControl(state), canRevealDataKeys: undefined },
+ convergeCodexCatalog: async () => { throw new Error("must not converge for a read"); },
+ syncClaudeAgentDefsBestEffort: async () => { throw new Error("must not sync for a read"); },
+ };
+ const response = await handleOauthAccountRoutes(ctx);
+ expect(response?.status).toBe(403);
+ expect(await response!.json()).toEqual({ error: "operator-authorized dashboard session required" });
+ } finally { await server.stop(true); }
+ });
+
+ test("data-key read authority requires independently issued current sessions", () => {
+ const config: OcxConfig = { ...baseConfig(), hostname: "0.0.0.0", runtimeRole: "hub",
+ hub: { managementPublicOrigin: "https://hub.example.test" },
+ remoteGui: { allowedTailscaleUsers: ["alice@example.test"] },
+ corsAllowOrigins: ["https://dashboard.example.test"],
+ };
+ const state = initializeManagementAuthState(config);
+ if (!state.available) throw new Error("expected management state");
+ const control = createManagementSessionControl(state);
+ const tailscaleRequest = new Request("https://hub.example.test/opencodex-session", {
+ headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test",
+ "Tailscale-User-Login": "alice@example.test" },
+ });
+ expect(issueGuiSession(tailscaleRequest, config, state, { trustedTailscaleIngress: false })).toBeNull();
+ const session = issueGuiSession(tailscaleRequest, config, state, { trustedTailscaleIngress: true })!;
+ expect(session).not.toBeNull();
+ const request = (overrides: Record = {}) => new Request("https://hub.example.test/api/keys/reveal", {
+ method: "POST", headers: { Host: "hub.example.test", Origin: session.browserOrigin,
+ "x-opencodex-gui-origin": session.browserOrigin, "x-opencodex-api-key": session.token,
+ "x-opencodex-csrf-token": session.csrfToken, ...overrides },
+ });
+ const before = session.expiresAt;
+ expect(control.canRevealDataKeys?.(request(), config)).toBe(true);
+ expect(session.expiresAt).toBe(before); // This check cannot extend authority.
+ expect(control.canRevealDataKeys?.(request({ "x-opencodex-csrf-token": "wrong" }), config)).toBe(false);
+ expect(control.canRevealDataKeys?.(request({ Origin: "https://foreign.example.test" }), config)).toBe(false);
+ expect(control.canRevealDataKeys?.(request({ "x-opencodex-api-key": state.token }), config)).toBe(false);
+ session.expiresAt = Date.now() - 1;
+ expect(control.canRevealDataKeys?.(request(), config)).toBe(false);
+ state.sessions.delete(session.token);
+ expect(control.canRevealDataKeys?.(request(), config)).toBe(false);
+ const unavailable = createManagementSessionControl({ available: false, reason: "fixture" });
+ expect(unavailable.canRevealDataKeys?.(request(), config)).toBe(false);
+ });
+
test("an admin token cannot reveal a key even with forged dashboard headers", async () => {
saveConfig(baseConfig());
const server = startServer(0);
@@ -380,11 +525,11 @@ describe("POST /api/keys/reveal", () => {
});
test("an unknown id returns 404 when apiKeys is absent", async () => {
- saveConfig(baseConfig());
- const server = startServer(0);
+ const operator = await operatorServer();
+ const { server } = operator;
try {
const response = await fetch(new URL("/api/keys/reveal", server.url), {
- method: "POST", headers: await dashboardHeaders(server), body: JSON.stringify({ id: "missing" }),
+ method: "POST", headers: await dashboardHeaders(server, operator), body: JSON.stringify({ id: "missing" }),
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: "key not found" });
@@ -394,11 +539,11 @@ describe("POST /api/keys/reveal", () => {
});
test("rejects invalid bodies with 400", async () => {
- saveConfig(baseConfig());
- const server = startServer(0);
+ const operator = await operatorServer();
+ const { server } = operator;
try {
const created = await keysRequest(server, "POST", { name: "keep" });
- const headers = await dashboardHeaders(server);
+ const headers = await dashboardHeaders(server, operator);
const bodies: unknown[] = [{ id: created.json.id, extra: true }, { id: 42 }, { id: "" },
{}, null, [], "plain string", true];
for (const body of [...bodies.map(value => JSON.stringify(value)), "{not json", ""]) {