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 159a972b2d0..c3c3fc7f562 100644
--- a/docs-site/src/content/docs/fr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md
@@ -33,9 +33,12 @@ automatiquement `~/.opencodex/admin-api-token`).
Lorsqu'un tableau de bord distant exige cet identifiant, il présente un formulaire de mot de passe standard,
ce qui permet au gestionnaire de mots de passe du navigateur de proposer son enregistrement et son
-remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en mémoire et ne l'écrit ni
-dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du
-gestionnaire de mots de passe.
+remplissage automatique. Par défaut, le tableau de bord ne conserve le jeton qu'en mémoire. Choisir
+**Mémoriser sur cet appareil** autorise le stockage du jeton complet en clair dans `localStorage`. Tout
+script de même origine et toute personne ayant accès à l'appareil peuvent le lire : ne l'activez pas sur un
+appareil partagé. **Oublier le jeton administrateur enregistré**, à côté de Déconnexion, supprime cette valeur.
+La valeur enregistrée est stockée par serveur, selon son origin et son transport, et n'est
+renvoyée qu'au serveur pour lequel elle a été enregistrée.
## Barre de résumé des quotas
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 3645ae7c1d3..f9a7112fb66 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -31,9 +31,12 @@ the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated
`~/.opencodex/admin-api-token` file).
When a remote dashboard needs that credential, it presents a standard password form so a browser
-password manager can offer to save and autofill it. The dashboard itself still keeps the token only
-in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely
-the browser or password manager's decision.
+password manager can offer to save and autofill it. By default, the dashboard keeps the token only
+in memory. Selecting **Remember on this device** opts in to storing the full token as plaintext in
+`localStorage`. Any same-origin script and anyone with access to the device can read it, so do not
+enable it on a shared device. **Forget remembered admin token**, beside Logout, removes that value.
+The remembered value is stored per server, scoped by server origin and transport, and is only
+re-sent to the server it was saved for.
### Finding the admin token
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 b5b14513e46..eb187af9b63 100644
--- a/docs-site/src/content/docs/ja/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md
@@ -25,7 +25,8 @@ bun run dev:gui
`localhost` や `127.0.0.1` などのループバックアドレスで開いたダッシュボードは、短時間有効な GUI セッションを自動的に受け取るため、通常はトークン入力が不要です。ループバック以外のホストで公開する場合は、`OPENCODEX_ADMIN_AUTH_TOKEN`、または自動生成される `~/.opencodex/admin-api-token` ファイルの管理トークンが必要です。
-リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。
+リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。既定では、ダッシュボードはトークンをメモリ内だけに保持します。**この端末で記憶する** を選ぶと、完全なトークンを平文で `localStorage` に保存することに同意します。同一オリジンのスクリプトと端末にアクセスできる人は誰でも読み取れるため、共有端末では有効にしないでください。ログアウトの横にある **保存した管理者トークンを削除** でこの値を削除できます。
+記憶された値はサーバーごとに、サーバーの origin とトランスポートで区切って保存され、保存先のサーバーにのみ再送信されます。
## クォータ概要バー
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 991991dd272..913157e8036 100644
--- a/docs-site/src/content/docs/ko/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md
@@ -25,7 +25,8 @@ bun run dev:gui
`localhost`나 `127.0.0.1` 같은 loopback 주소에서 연 대시보드는 짧게 유지되는 GUI 세션을 자동으로 받으므로 보통 토큰을 입력할 필요가 없습니다. loopback이 아닌 호스트로 공개한 대시보드에는 `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 자동 생성되는 `~/.opencodex/admin-api-token` 파일의 관리자 토큰이 필요합니다.
-원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다.
+원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 기본적으로 대시보드는 토큰을 메모리에만 보관합니다. **이 기기에서 기억하기**를 선택하면 전체 토큰을 평문으로 `localStorage`에 저장하는 데 동의하게 됩니다. 동일 출처 스크립트와 이 기기에 접근할 수 있는 사람은 누구나 읽을 수 있으므로 공유 기기에서는 사용하지 마세요. 로그아웃 옆의 **저장된 관리자 토큰 삭제**로 이 값을 지울 수 있습니다.
+기억된 값은 서버별로 저장되며, 서버 origin과 전송 방식(transport)으로 구분되고, 저장한 서버에만 다시 전송됩니다.
## 사용량 요약 바
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 7e164d5def6..6eecc6dda5d 100644
--- a/docs-site/src/content/docs/ru/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md
@@ -25,7 +25,8 @@ bun run dev:gui
При открытии дашборда через loopback-адрес, например `localhost` или `127.0.0.1`, он автоматически получает краткоживущую GUI-сессию, поэтому ввод токена обычно не требуется. Для дашборда на любом другом хосте нужен административный токен из `OPENCODEX_ADMIN_AUTH_TOKEN` или автоматически созданного файла `~/.opencodex/admin-api-token`.
-Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей.
+Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. По умолчанию дашборд хранит токен только в памяти. Выбор **Запомнить на этом устройстве** означает согласие на хранение полного токена в открытом виде в `localStorage`. Его может прочитать любой скрипт того же origin и любой, у кого есть доступ к устройству, поэтому не включайте это на общем устройстве. **Удалить сохранённый токен администратора** рядом с выходом удаляет это значение.
+Сохранённое значение хранится отдельно для каждого сервера с учётом его origin и транспорта и повторно отправляется только тому серверу, для которого было сохранено.
## Полоса сводки квот
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 947da5d9bf7..50f938bdeff 100644
--- a/docs-site/src/content/docs/tr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md
@@ -34,10 +34,12 @@ oluşturulan `~/.opencodex/admin-api-token` dosyası) gerektirir.
Uzak bir kontrol panelinin bu kimlik bilgisine ihtiyacı olduğunda, bir tarayıcı
şifre yöneticisinin onu kaydetmeyi ve otomatik doldurmayı teklif edebilmesi için
-standart bir şifre formu sunar. Kontrol panelinin kendisi belirteci yine de
-yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz;
-kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin
-kararıdır.
+standart bir şifre formu sunar. Varsayılan olarak kontrol paneli belirteci yalnızca bellekte tutar.
+**Bu cihazda hatırla** seçeneği, tam belirteci düz metin olarak `localStorage` içinde saklamayı kabul eder.
+Aynı origin'deki herhangi bir betik ve cihaza erişebilen herkes bunu okuyabilir; ortak cihazlarda etkinleştirmeyin.
+Çıkışın yanındaki **Kaydedilen yönetici belirtecini unut** bu değeri kaldırır.
+Hatırlanan değer sunucu başına, sunucu origin'i ve taşıma türüne göre ayrı saklanır ve
+yalnızca kaydedildiği sunucuya yeniden gönderilir.
## Kota özeti çubuğu
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 5b1a432af82..5288b808b18 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
@@ -24,7 +24,8 @@ bun run dev:gui
通过 `localhost`、`127.0.0.1` 等 loopback 地址打开仪表盘时,它会自动获得一个短期 GUI session,因此通常无需输入 token。在非 loopback 主机上公开仪表盘时,必须使用 `OPENCODEX_ADMIN_AUTH_TOKEN` 或自动生成的 `~/.opencodex/admin-api-token` 文件中的管理员 token。
-远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。
+远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。默认情况下,仪表盘只在内存中保存 token。选择 **在此设备上记住** 即表示同意将完整 token 以明文保存到 `localStorage`。同源脚本以及任何可以访问该设备的人都可以读取它,因此不要在共享设备上启用。登出旁边的 **忘记已保存的管理员令牌** 会删除该值。
+记住的值按服务器分别存储,以服务器 origin 和传输方式区分,只会重新发送到保存它的服务器。
## 配额摘要栏
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 be921cb1efa..b8527ded10d 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
@@ -28,8 +28,10 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默
`~/.opencodex/admin-api-token` 檔案)。
當遠端儀表板需要該憑證時,它會顯示標準的密碼表單,讓瀏覽器密碼管理員可以提議儲存與自動填入。
-儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全
-由瀏覽器或密碼管理員決定。
+預設情況下,儀表板只在記憶體中保留 token。選擇 **在此裝置上記住** 即表示同意將完整 token 以純文字
+儲存在 `localStorage`。同源指令碼以及任何可存取該裝置的人都能讀取它,因此不要在共用裝置上啟用。
+登出旁邊的 **忘記已儲存的管理員權杖** 會移除此值。
+記住的值以伺服器為單位儲存,依伺服器 origin 與傳輸方式區分,只會重新傳送給當初儲存它的伺服器。
## 配額摘要列
diff --git a/gui/src/App.tsx b/gui/src/App.tsx
index ef9f1a2b245..5c6eacbf792 100644
--- a/gui/src/App.tsx
+++ b/gui/src/App.tsx
@@ -16,7 +16,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import QuotaSummaryBar from "./components/quota-summary-bar/QuotaSummaryBar";
import { SidebarGithubRow } from "./components/sidebar-github-row";
import { DesktopStarOnboarding } from "./components/desktop-star-onboarding";
-import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons";
+import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh, IconTrash} from "./icons";
import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared";
import { Notice, Select, ToastNotice, type NoticeTone } from "./ui";
import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api";
@@ -32,6 +32,7 @@ import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from ".
import { useSidebarCollapse } from "./use-sidebar-collapse";
import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar";
import { watchMacTitlebarMetrics, windowChromeHandlers } from "./lib/window-chrome";
+import { clearAllRememberedAdminTokens, hasAnyRememberedAdminToken, REMEMBERED_ADMIN_TOKEN_CHANGED_EVENT } from "./admin-token-dialog";
type Theme = "light" | "dark" | "system";
@@ -142,6 +143,7 @@ export default function App() {
const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0);
const [remoteWorkspaceAvailableState, setRemoteWorkspaceAvailable] = useState(false);
const [sessionLoggingOut, setSessionLoggingOut] = useState(false);
+ const [rememberedAdminTokenPresent, setRememberedAdminTokenPresent] = useState(() => hasAnyRememberedAdminToken());
/*
* Results from the two sidebar orbs used to be `alert()`, which the app's webview draws
* nowhere, so a refused stop and a completed one looked identical: nothing happened.
@@ -165,6 +167,16 @@ export default function App() {
return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable);
}, []);
+ useEffect(() => {
+ const syncRememberedAdminToken = () => setRememberedAdminTokenPresent(hasAnyRememberedAdminToken());
+ window.addEventListener(REMEMBERED_ADMIN_TOKEN_CHANGED_EVENT, syncRememberedAdminToken);
+ window.addEventListener("storage", syncRememberedAdminToken);
+ return () => {
+ window.removeEventListener(REMEMBERED_ADMIN_TOKEN_CHANGED_EVENT, syncRememberedAdminToken);
+ window.removeEventListener("storage", syncRememberedAdminToken);
+ };
+ }, []);
+
useEffect(() => {
const controller = new AbortController();
void discoverApiTargets(API_BASE, controller.signal).then(async next => {
@@ -359,6 +371,11 @@ export default function App() {
else report(t("connection.sessionLogoutFailed"), "err");
};
+ const handleForgetRememberedAdminToken = () => {
+ clearAllRememberedAdminTokens();
+ setRememberedAdminTokenPresent(false);
+ };
+
/*
* The brand is the control users reach for first when they want out of a deep page,
* and it used to be an inert
: clicking the logo did nothing, so a user on
@@ -416,6 +433,12 @@ export default function App() {
)}
+ {rememberedAdminTokenPresent && (
+
+
+
+ )}
@@ -498,6 +521,12 @@ export default function App() {
)}
+ {rememberedAdminTokenPresent && (
+
+
+
+ )}
Promise;
+const REMEMBERED_ADMIN_TOKEN_KEY_PREFIX = "opencodex.remembered-admin-token";
+export const REMEMBERED_ADMIN_TOKEN_CHANGED_EVENT = "opencodex-remembered-admin-token-changed";
+
+function notifyRememberedAdminTokenChanged(): void {
+ window.dispatchEvent(new window.Event(REMEMBERED_ADMIN_TOKEN_CHANGED_EVENT));
+}
+
+/**
+ * Opt-in plaintext persistence in localStorage: this is what makes sign-in
+ * work in iOS standalone home-screen web apps, where Safari never offers
+ * password AutoFill or save. Readable by any script on this origin; the
+ * dashboard bundles no third-party scripts.
+ */
+/**
+ * Canonical identity of a management target: the server the credential belongs to,
+ * plus the transport it was submitted over. A token remembered for one target is
+ * never read while resolving another, so it can never be transmitted to a different
+ * server (the #4649 credential-storage review).
+ */
+export function rememberedAdminTokenScope(target: { serverOrigin: string; transport: string }): string {
+ return `${target.serverOrigin}|${target.transport}`;
+}
+
+export function rememberedAdminTokenKey(scope: string): string {
+ return `${REMEMBERED_ADMIN_TOKEN_KEY_PREFIX}:${scope}`;
+}
+
+// Only the exact legacy unscoped key and real scoped keys belong to this feature: a plain
+// prefix match would also count and delete decoys like
+// "opencodex.remembered-admin-token.decoy" that no code path here ever wrote (#4649 review).
+function isRememberedAdminTokenKey(key: string): boolean {
+ return key === REMEMBERED_ADMIN_TOKEN_KEY_PREFIX || key.startsWith(`${REMEMBERED_ADMIN_TOKEN_KEY_PREFIX}:`);
+}
+
+// The legacy unscoped key (exactly REMEMBERED_ADMIN_TOKEN_KEY_PREFIX) is never read
+// or migrated: an upgrade must not silently re-target a credential the user saved
+// before targets were distinguished. "Forget remembered admin token" removes it.
+export function getRememberedAdminToken(scope: string): string | null {
+ try { return localStorage.getItem(rememberedAdminTokenKey(scope)); } catch { return null; }
+}
+
+export function clearRememberedAdminToken(scope: string): void {
+ try { localStorage.removeItem(rememberedAdminTokenKey(scope)); } catch { /* storage may be disabled */ }
+ notifyRememberedAdminTokenChanged();
+}
+
+export function hasAnyRememberedAdminToken(): boolean {
+ try {
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i);
+ if (key !== null && isRememberedAdminTokenKey(key)) return true;
+ }
+ } catch { /* storage may be disabled */ }
+ return false;
+}
+
+export function clearAllRememberedAdminTokens(): void {
+ try {
+ const keys: string[] = [];
+ for (let i = 0; i < localStorage.length; i += 1) {
+ const key = localStorage.key(i);
+ if (key !== null && isRememberedAdminTokenKey(key)) keys.push(key);
+ }
+ for (const key of keys) localStorage.removeItem(key);
+ } catch { /* storage may be disabled */ }
+ notifyRememberedAdminTokenChanged();
+}
+
/**
* Ask for the management credential with a real sign-in form so browsers and
- * password managers can offer save/autofill. OpenCodex itself still keeps the
- * submitted token in memory only; persistence remains entirely browser-owned.
+ * password managers can offer save/autofill. OpenCodex keeps the submitted
+ * token in memory only unless the user explicitly opts in to remembering it
+ * on this device (see the remember checkbox below).
*/
export function promptForAdminToken(
verifyToken: AdminTokenVerifier,
+ scope: string,
locale: Locale = getActiveLocale(),
): Promise {
const messages = DICTS[locale];
@@ -91,6 +161,16 @@ export function promptForAdminToken(
help.append(" ", docsLink);
tokenField.append(help);
+ const rememberField = document.createElement("label");
+ rememberField.className = "field-label";
+ rememberField.style.cssText = "display:flex;gap:8px;align-items:center;margin-top:var(--space-4);";
+ const remember = document.createElement("input");
+ remember.id = `${ADMIN_TOKEN_DIALOG_ID}-remember`;
+ remember.name = "remember";
+ remember.type = "checkbox";
+ if (getRememberedAdminToken(scope)) remember.checked = true;
+ rememberField.append(remember, document.createTextNode(messages["auth.adminTokenRemember"]));
+
const validationError = document.createElement("div");
validationError.className = "notice notice-err";
validationError.setAttribute("role", "alert");
@@ -108,7 +188,7 @@ export function promptForAdminToken(
submit.textContent = messages["common.ok"];
actions.append(cancel, submit);
- form.append(heading, accountField, tokenField, validationError, actions);
+ form.append(heading, accountField, tokenField, rememberField, validationError, actions);
dialog.append(form);
/*
@@ -147,6 +227,12 @@ export function promptForAdminToken(
void verifyToken(token).then((result) => {
if (settled) return;
if (result === "accepted") {
+ if (remember.checked) {
+ try { localStorage.setItem(rememberedAdminTokenKey(scope), token); } catch { /* storage may be disabled */ }
+ notifyRememberedAdminTokenChanged();
+ } else {
+ clearRememberedAdminToken(scope);
+ }
finish(token);
return;
}
diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts
index f58d5efbdb7..fbf4cc59978 100644
--- a/gui/src/api-targets.ts
+++ b/gui/src/api-targets.ts
@@ -71,6 +71,13 @@ export interface ApiTarget {
serverOrigin: string;
bootstrapPath: string;
transport: SharedTransport;
+ /**
+ * Relay only: the connection generation this target was discovered from, as
+ * `apiKeyId|connectedAt` from /api/machine/status. Stamped on every relayed request so
+ * the machine listener can refuse before forwarding if the client reconnected to a
+ * different hub (or a newer generation of the same hub) after this target was resolved.
+ */
+ relayGeneration?: string;
}
export interface ApiTargets {
@@ -172,7 +179,10 @@ export function targetsFromMachineStatus(initialBase: string, status: MachineSta
}
const machine = target("machine", trimBase(initialBase), machineOrigin, "same-origin");
const shared = status.managementTransport === "relay"
- ? target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay")
+ ? {
+ ...target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay"),
+ relayGeneration: `${status.apiKeyId}|${status.connectedAt}`,
+ }
: target("shared", sharedOrigin, sharedOrigin, "direct");
return { connected: true, machine, shared, apiKeyId: status.apiKeyId, catalogSyncedAt: status.catalogSyncedAt };
}
diff --git a/gui/src/api.ts b/gui/src/api.ts
index 420c04d3f39..fb9f3c80bc8 100644
--- a/gui/src/api.ts
+++ b/gui/src/api.ts
@@ -1,4 +1,10 @@
-import { promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog";
+import {
+ clearRememberedAdminToken,
+ getRememberedAdminToken,
+ promptForAdminToken,
+ rememberedAdminTokenScope,
+ type AdminTokenVerifier,
+} from "./admin-token-dialog";
import { createBoundedFetch } from "./bounded-fetch";
import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets";
@@ -9,14 +15,16 @@ import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiT
export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable";
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
-// also resolves the Codex runtime and startup health, which made the token prompt hang.
+// Any guarded route answers 401 for a bad token; this one is a cheap config read. /api/combos
+// avoids resolving the Codex runtime and startup health, which made the token prompt hang.
const ADMIN_TOKEN_VALIDATION_PATH = "/api/combos";
const SESSION_REBOOTSTRAP_TIMEOUT_MS = 10_000;
const RESOLUTION_WATCHDOG_MS = 15_000;
const MACHINE_SESSION_HEADER = "X-OpenCodex-Machine-Session";
const MACHINE_GUI_ORIGIN_HEADER = "X-OpenCodex-Machine-GUI-Origin";
const MACHINE_CSRF_HEADER = "X-OpenCodex-Machine-CSRF-Token";
+const RELAY_EXPECTED_ORIGIN_HEADER = "X-OpenCodex-Relay-Expected-Origin";
+const RELAY_EXPECTED_CONNECTION_HEADER = "X-OpenCodex-Relay-Expected-Connection";
interface ApiSessionState {
token: string | null;
@@ -32,7 +40,7 @@ interface TargetRuntime {
promptCancelled: boolean;
}
-type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise;
+type AdminTokenPrompt = (verifyToken: AdminTokenVerifier, scope: string) => Promise;
type RebootstrapResult = { kind: "minted"; token: string } | { kind: "unavailable" } | { kind: "failed" };
let installed = false;
@@ -208,7 +216,13 @@ function classify(input: RequestInfo | URL): { plane: ApiPlane; bootstrap: boole
return null;
}
-function sessionHeaders(plane: ApiPlane, input: RequestInfo | URL, init?: RequestInit, overrideToken?: string | null): Headers {
+function sessionHeaders(
+ plane: ApiPlane,
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ overrideToken?: string | null,
+ relayTarget?: ApiTarget,
+): Headers {
const state = runtime(plane);
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
const token = overrideToken === undefined ? state.session.token : overrideToken;
@@ -218,11 +232,20 @@ function sessionHeaders(plane: ApiPlane, input: RequestInfo | URL, init?: Reques
headers.set("X-OpenCodex-GUI-Origin", state.session.browserOrigin);
if (method !== "GET" && method !== "HEAD") headers.set("X-OpenCodex-CSRF-Token", state.session.csrfToken);
}
- if (plane === "shared" && state.target.transport === "relay") {
+ if (plane === "shared") {
+ // Stamp the connection the credential was resolved against, not whatever is current:
+ // a relay target captured mid-resolution keeps naming its own hub connection, so a
+ // listener rebound to a different hub refuses (409) before the credential is forwarded.
+ const relay = relayTarget ?? state.target;
+ if (relay.transport !== "relay") return headers;
const machine = runtime("machine").session;
if (machine.token) headers.set(MACHINE_SESSION_HEADER, machine.token);
if (machine.browserOrigin) headers.set(MACHINE_GUI_ORIGIN_HEADER, machine.browserOrigin);
if (method !== "GET" && method !== "HEAD" && machine.csrfToken) headers.set(MACHINE_CSRF_HEADER, machine.csrfToken);
+ if (relay.relayGeneration) {
+ headers.set(RELAY_EXPECTED_ORIGIN_HEADER, relay.serverOrigin);
+ headers.set(RELAY_EXPECTED_CONNECTION_HEADER, relay.relayGeneration);
+ }
}
return headers;
}
@@ -232,18 +255,18 @@ function withAuth(
input: RequestInfo | URL,
init?: RequestInit,
overrideToken?: string | null,
+ relayTarget?: ApiTarget,
): [RequestInfo | URL, RequestInit | undefined] {
- const headers = sessionHeaders(plane, input, init, overrideToken);
+ const headers = sessionHeaders(plane, input, init, overrideToken, relayTarget);
if (input instanceof Request) return [new Request(input, { headers }), init ? { ...init, headers } : undefined];
return [input, { ...init, headers }];
}
-async function reBootstrapSessionToken(plane: ApiPlane): Promise {
+async function reBootstrapSessionToken(plane: ApiPlane, target: ApiTarget): Promise {
if (!rawFetch) return { kind: "failed" };
- const state = runtime(plane);
const bounded = createBoundedFetch(rebootstrapTimeoutMs);
try {
- const [input, init] = withAuth(plane, state.target.bootstrapPath, { cache: "no-store", signal: bounded.signal }, null);
+ const [input, init] = withAuth(plane, target.bootstrapPath, { cache: "no-store", signal: bounded.signal }, null, target);
const response = await rawFetch(input, init);
if (!response.ok) return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" };
const html = await response.text();
@@ -253,15 +276,22 @@ async function reBootstrapSessionToken(plane: ApiPlane): Promise {
+async function verifyAdminToken(plane: ApiPlane, target: ApiTarget, token: string): ReturnType {
if (!rawFetch) return "unavailable";
+ const bounded = createBoundedFetch(rebootstrapTimeoutMs);
try {
- const state = runtime(plane);
- const [input, init] = withAuth(plane, `${state.target.baseUrl}${ADMIN_TOKEN_VALIDATION_PATH}`, { cache: "no-store" }, token);
+ const [input, init] = withAuth(
+ plane,
+ `${target.baseUrl}${ADMIN_TOKEN_VALIDATION_PATH}`,
+ { cache: "no-store", signal: bounded.signal },
+ token,
+ target,
+ );
const response = await rawFetch(input, init);
if (response.status === 401) return "rejected";
return response.ok ? "accepted" : "unavailable";
} catch { return "unavailable"; }
+ finally { bounded.clear(); }
}
async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, callerSignal?: AbortSignal): Promise {
@@ -271,9 +301,14 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null,
const body = (async () => {
const current = state.session.token;
if (current && current !== failedToken) return current;
+ // Capture the target this resolution belongs to: the remembered credential is
+ // scoped to it, and both the silent verification and the prompt must send it to
+ // that server even if targets are reconfigured mid-resolution.
+ const target = state.target;
+ const scope = rememberedAdminTokenScope(target);
let watchdog: ReturnType | undefined;
const renewed = await Promise.race([
- reBootstrapSessionToken(plane),
+ reBootstrapSessionToken(plane, target),
new Promise(resolve => { watchdog = setTimeout(() => resolve({ kind: "failed" }), resolutionWatchdogMs); }),
]).finally(() => clearTimeout(watchdog));
if (renewed.kind === "minted") return renewed.token;
@@ -285,9 +320,26 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null,
state.promptCancelled = true;
return null;
}
- const prompted = await requestAdminToken(token => verifyAdminToken(plane, token));
+ const remembered = getRememberedAdminToken(scope);
+ if (remembered) {
+ if (remembered === failedToken) {
+ // The stored token just caused this 401: it is revoked. Clear it
+ // now so it cannot linger until the next visit.
+ clearRememberedAdminToken(scope);
+ } else {
+ const verdict = await verifyAdminToken(plane, target, remembered);
+ if (verdict === "accepted") {
+ state.session = { token: remembered, csrfToken: null, browserOrigin: null, serverOrigin: target.serverOrigin };
+ return remembered;
+ }
+ if (verdict === "rejected") clearRememberedAdminToken(scope);
+ // "unavailable" (network/server error) leaves the stored token
+ // intact: a transient outage must not delete a valid credential.
+ }
+ }
+ const prompted = await requestAdminToken(token => verifyAdminToken(plane, target, token), scope);
if (prompted) {
- state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin };
+ state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: target.serverOrigin };
return prompted;
}
state.promptCancelled = true;
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 204aa1cf580..25a6029638b 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -210,6 +210,7 @@ export const de: Record = {
"auth.adminTokenTitle": "OpenCodex-Admin-Token (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Konto",
"auth.adminTokenFieldLabel": "Admin-Token",
+ "auth.adminTokenRemember": "Auf diesem Gerät merken",
"auth.adminTokenRejected": "Der Admin-Token wurde abgelehnt. Prüfen Sie ihn und versuchen Sie es erneut.",
"auth.adminTokenUnavailable": "Der Admin-Token konnte nicht überprüft werden. Versuchen Sie es erneut.",
"auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.",
@@ -3266,6 +3267,7 @@ export const de: Record = {
"connection.sessionLogout": "Remote-Sitzung abmelden",
"connection.sessionLoggingOut": "Remote-Sitzung wird abgemeldet…",
"connection.sessionLogoutFailed": "Die Remote-Sitzung konnte nicht abgemeldet werden. Die aktuelle Sitzung bleibt bestehen.",
+ "connection.forgetRememberedAdminToken": "Gespeichertes Admin-Token vergessen",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index eb0b4655184..ad3fd38731f 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -145,6 +145,7 @@ export const en = {
"auth.adminTokenTitle": "OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Account",
"auth.adminTokenFieldLabel": "Admin token",
+ "auth.adminTokenRemember": "Remember on this device",
"auth.adminTokenRejected": "That admin token was rejected. Check it and try again.",
"auth.adminTokenUnavailable": "The admin token could not be verified. Try again.",
"auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.",
@@ -3346,6 +3347,7 @@ export const en = {
"connection.sessionLogout": "Log out remote session",
"connection.sessionLoggingOut": "Logging out remote session…",
"connection.sessionLogoutFailed": "Could not log out the remote session. The current session was kept.",
+ "connection.forgetRememberedAdminToken": "Forget remembered admin token",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index c6cdb4e76e6..c5454fb6d1e 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -142,6 +142,7 @@ export const fr: Record = {
"auth.adminTokenTitle": "Jeton d’administration OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Compte",
"auth.adminTokenFieldLabel": "Jeton d’administration",
+ "auth.adminTokenRemember": "Mémoriser sur cet appareil",
"auth.adminTokenRejected": "Ce jeton d’administration a été refusé. Vérifiez-le et réessayez.",
"auth.adminTokenUnavailable": "Le jeton d’administration n’a pas pu être vérifié. Réessayez.",
"auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.",
@@ -3255,6 +3256,7 @@ export const fr: Record = {
"connection.sessionLogout": "Se déconnecter de la session distante",
"connection.sessionLoggingOut": "Déconnexion de la session distante…",
"connection.sessionLogoutFailed": "Impossible de fermer la session distante. La session actuelle a été conservée.",
+ "connection.forgetRememberedAdminToken": "Oublier le jeton administrateur enregistré",
"usage.source.connected": "Source : utilisation du hub",
"usage.source.local": "Source : usage.jsonl local",
"usage.scope.label": "Portée de l'utilisation",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 36d2bfcbe27..a126e401c3b 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -212,6 +212,7 @@ export const ja: Record = {
"auth.adminTokenTitle": "OpenCodex 管理者トークン (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "アカウント",
"auth.adminTokenFieldLabel": "管理者トークン",
+ "auth.adminTokenRemember": "この端末で記憶する",
"auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。",
"auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。",
"auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。",
@@ -3288,6 +3289,7 @@ export const ja: Record = {
"connection.sessionLogout": "リモートセッションからログアウト",
"connection.sessionLoggingOut": "リモートセッションからログアウト中…",
"connection.sessionLogoutFailed": "リモートセッションからログアウトできませんでした。現在のセッションは維持されています。",
+ "connection.forgetRememberedAdminToken": "保存した管理者トークンを削除",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 73fad35e2d9..277b3b561e5 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -210,6 +210,7 @@ export const ko: Record = {
"auth.adminTokenTitle": "OpenCodex 관리자 토큰 (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "계정",
"auth.adminTokenFieldLabel": "관리자 토큰",
+ "auth.adminTokenRemember": "이 기기에서 기억하기",
"auth.adminTokenRejected": "관리자 토큰이 거부되었습니다. 확인한 후 다시 시도하세요.",
"auth.adminTokenUnavailable": "관리자 토큰을 확인할 수 없습니다. 다시 시도하세요.",
"auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.",
@@ -3288,6 +3289,7 @@ export const ko: Record = {
"connection.sessionLogout": "원격 세션 로그아웃",
"connection.sessionLoggingOut": "원격 세션에서 로그아웃하는 중…",
"connection.sessionLogoutFailed": "원격 세션에서 로그아웃하지 못했습니다. 현재 세션은 그대로 유지했습니다.",
+ "connection.forgetRememberedAdminToken": "저장된 관리자 토큰 삭제",
"usage.source.connected": "출처: 허브 사용량",
"usage.source.local": "출처: 로컬 usage.jsonl",
"usage.scope.label": "사용량 범위",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 6a592b041a7..79d7f9e5cc5 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -212,6 +212,7 @@ export const ru: Record = {
"auth.adminTokenTitle": "Токен администратора OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Учётная запись",
"auth.adminTokenFieldLabel": "Токен администратора",
+ "auth.adminTokenRemember": "Запомнить на этом устройстве",
"auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.",
"auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.",
"auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.",
@@ -3289,6 +3290,7 @@ export const ru: Record = {
"connection.sessionLogout": "Выйти из удалённой сессии",
"connection.sessionLoggingOut": "Выход из удалённой сессии…",
"connection.sessionLogoutFailed": "Не удалось выйти из удалённой сессии. Текущая сессия сохранена.",
+ "connection.forgetRememberedAdminToken": "Удалить сохранённый токен администратора",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index f555706cce0..04f5363e058 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -144,6 +144,7 @@ export const tr: Record = {
"auth.adminTokenTitle": "OpenCodex yönetici jetonu (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Hesap",
"auth.adminTokenFieldLabel": "Yönetici jetonu",
+ "auth.adminTokenRemember": "Bu cihazda hatırla",
"auth.adminTokenRejected": "Bu yönetici jetonu reddedildi. Kontrol edip tekrar deneyin.",
"auth.adminTokenUnavailable": "Yönetici jetonu doğrulanamadı. Tekrar deneyin.",
"auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.",
@@ -3289,6 +3290,7 @@ export const tr: Record = {
"connection.sessionLogout": "Uzak oturumdan çık",
"connection.sessionLoggingOut": "Uzak oturumdan çıkılıyor…",
"connection.sessionLogoutFailed": "Uzak oturumdan çıkılamadı. Mevcut oturum korundu.",
+ "connection.forgetRememberedAdminToken": "Kaydedilen yönetici belirtecini unut",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 53909f52acf..b9af6541153 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -142,6 +142,7 @@ export const vi: Record = {
"auth.adminTokenTitle": "Token quản trị OpenCodex (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "Tài khoản",
"auth.adminTokenFieldLabel": "Token quản trị",
+ "auth.adminTokenRemember": "Ghi nhớ trên thiết bị này",
"auth.adminTokenRejected": "Token quản trị đó đã bị từ chối. Hãy kiểm tra lại và thử lại.",
"auth.adminTokenUnavailable": "Không thể xác minh token quản trị. Hãy thử lại.",
"auth.adminTokenHelp": "Đây là token quản trị quản lý OpenCodex, không phải là API key của provider. Proxy ghi nó vào ~/.opencodex/admin-api-token (hoặc $OPENCODEX_HOME/admin-api-token) trong lần khởi động đầu tiên và OPENCODEX_ADMIN_AUTH_TOKEN sẽ ghi đè nó.",
@@ -3270,6 +3271,7 @@ export const vi: Record = {
"connection.sessionLogout": "Đăng xuất phiên từ xa",
"connection.sessionLoggingOut": "Đang đăng xuất phiên từ xa…",
"connection.sessionLogoutFailed": "Không thể đăng xuất phiên từ xa. Phiên hiện tại vẫn được giữ nguyên.",
+ "connection.forgetRememberedAdminToken": "Quên mã thông báo quản trị đã lưu",
"usage.source.connected": "Nguồn: mức sử dụng hub",
"usage.source.local": "Nguồn: usage.jsonl cục bộ",
"usage.scope.label": "Phạm vi sử dụng",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index c30d4386ae8..18b579ee4a1 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -2571,6 +2571,7 @@ export const zhTW: Record = {
"auth.adminTokenTitle": "OpenCodex 管理員金鑰 (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "帳號",
"auth.adminTokenFieldLabel": "管理員金鑰",
+ "auth.adminTokenRemember": "在此裝置上記住",
"auth.adminTokenRejected": "該管理員金鑰被拒絕。請檢查後再試一次。",
"auth.adminTokenUnavailable": "無法驗證管理員金鑰。請再試一次。",
"auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。",
@@ -3252,6 +3253,7 @@ export const zhTW: Record = {
"connection.sessionLogout": "登出遠端工作階段",
"connection.sessionLoggingOut": "正在登出遠端工作階段…",
"connection.sessionLogoutFailed": "無法登出遠端工作階段。目前的工作階段已保留。",
+ "connection.forgetRememberedAdminToken": "忘記已儲存的管理員權杖",
"usage.source.connected": "來源:Hub 使用量",
"usage.source.local": "來源:本機 usage.jsonl",
"usage.scope.label": "使用量範圍",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 36577edd129..885f36a5ecd 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -210,6 +210,7 @@ export const zh: Record = {
"auth.adminTokenTitle": "OpenCodex 管理员令牌 (OPENCODEX_ADMIN_AUTH_TOKEN)",
"auth.adminAccountLabel": "账户",
"auth.adminTokenFieldLabel": "管理员令牌",
+ "auth.adminTokenRemember": "在此设备上记住",
"auth.adminTokenRejected": "管理员令牌被拒绝。请检查后重试。",
"auth.adminTokenUnavailable": "无法验证管理员令牌。请重试。",
"auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。",
@@ -3287,6 +3288,7 @@ export const zh: Record = {
"connection.sessionLogout": "退出远程会话",
"connection.sessionLoggingOut": "正在退出远程会话…",
"connection.sessionLogoutFailed": "无法退出远程会话,当前会话已保留。",
+ "connection.forgetRememberedAdminToken": "忘记已保存的管理员令牌",
"usage.source.connected": "Source: hub usage",
"usage.source.local": "Source: local usage.jsonl",
"usage.scope.label": "Usage scope",
diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts
index 849753833c3..4708356da5d 100644
--- a/gui/tests/admin-token-dialog.test.ts
+++ b/gui/tests/admin-token-dialog.test.ts
@@ -1,8 +1,11 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
-import { promptForAdminToken } from "../src/admin-token-dialog";
+import { clearAllRememberedAdminTokens, hasAnyRememberedAdminToken, promptForAdminToken } from "../src/admin-token-dialog";
import { setActiveLocale } from "../src/i18n/shared";
+const SCOPE = "https://dashboard.example|same-origin";
+const SCOPED_KEY = "opencodex.remembered-admin-token:https://dashboard.example|same-origin";
+
const globals = ["document", "window", "navigator", "localStorage", "HTMLElement"] as const;
let previousGlobals: Record<(typeof globals)[number], unknown>;
let testWindow: Window;
@@ -29,7 +32,7 @@ afterEach(() => {
});
test("renders stable password-manager-compatible sign-in fields", async () => {
- const pending = promptForAdminToken(async () => "accepted");
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
const dialog = document.querySelector("#opencodex-admin-token-dialog");
const form = dialog?.querySelector("form");
const username = form?.elements.namedItem("username") as HTMLInputElement | null;
@@ -63,7 +66,7 @@ test("cancel resolves null and restores the previous focus target", async () =>
document.body.append(focusTarget);
focusTarget.focus();
- const pending = promptForAdminToken(async () => "accepted");
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
const dialog = document.querySelector("#opencodex-admin-token-dialog");
dialog!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true }));
@@ -77,7 +80,7 @@ test("keeps the dialog open for whitespace and rejected tokens until one is acce
const pending = promptForAdminToken(async (token) => {
attempts.push(token);
return token === "valid-token" ? "accepted" : "rejected";
- });
+ }, SCOPE);
void pending.then(() => {
settled = true;
});
@@ -109,11 +112,86 @@ test("keeps the dialog open for whitespace and rejected tokens until one is acce
expect(dialog.isConnected).toBe(false);
});
+test("checked remember box persists the accepted token for standalone sign-in", async () => {
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
+ const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
+ const form = dialog.querySelector("form")!;
+ const password = form.elements.namedItem("password") as HTMLInputElement;
+ const remember = form.elements.namedItem("remember") as HTMLInputElement;
+
+ expect(remember.type).toBe("checkbox");
+ password.value = "stored-token";
+ remember.checked = true;
+ form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true }));
+
+ expect(await pending).toBe("stored-token");
+ expect(localStorage.getItem(SCOPED_KEY)).toBe("stored-token");
+});
+
+test("unchecked remember box clears any stale remembered token", async () => {
+ localStorage.setItem(SCOPED_KEY, "stale-token");
+
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
+ const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
+ const form = dialog.querySelector("form")!;
+ const password = form.elements.namedItem("password") as HTMLInputElement;
+ const remember = form.elements.namedItem("remember") as HTMLInputElement;
+
+ expect(remember.checked).toBe(true); // pre-checked because a stored value exists
+ remember.checked = false;
+ password.value = "fresh-token";
+ form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true }));
+
+ expect(await pending).toBe("fresh-token");
+ expect(localStorage.getItem(SCOPED_KEY)).toBeNull();
+});
+
+/*
+ * #4649 review, P3: "Forget remembered admin token" used a plain prefix match, so a
+ * decoy key like "opencodex.remembered-admin-token.decoy" — which no code path of this
+ * dashboard ever writes — was both counted as a stored credential and deleted by the
+ * Forget control. Only the exact legacy key and real scoped keys belong to it.
+ */
+test("Forget removes remembered-token keys exactly and leaves prefix decoys alone", () => {
+ localStorage.setItem(SCOPED_KEY, "scoped-token");
+ localStorage.setItem("opencodex.remembered-admin-token", "legacy-token");
+ localStorage.setItem("opencodex.remembered-admin-token.decoy", "decoy-value");
+ localStorage.setItem("opencodex.remembered-admin-tokenish", "unrelated");
+
+ expect(hasAnyRememberedAdminToken()).toBe(true);
+
+ clearAllRememberedAdminTokens();
+
+ expect(localStorage.getItem(SCOPED_KEY)).toBeNull();
+ expect(localStorage.getItem("opencodex.remembered-admin-token")).toBeNull();
+ expect(localStorage.getItem("opencodex.remembered-admin-token.decoy")).toBe("decoy-value");
+ expect(localStorage.getItem("opencodex.remembered-admin-tokenish")).toBe("unrelated");
+});
+
+test("a store holding only decoy keys does not count as a remembered admin token", () => {
+ localStorage.setItem("opencodex.remembered-admin-token.decoy", "decoy-value");
+ localStorage.setItem("opencodex.remembered-admin-tokenish", "unrelated");
+ expect(hasAnyRememberedAdminToken()).toBe(false);
+});
+
+test("remember checkbox has a form control name for consistency", async () => {
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
+ const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
+ const form = dialog.querySelector("form")!;
+ const remember = form.elements.namedItem("remember") as HTMLInputElement;
+ const password = form.elements.namedItem("password") as HTMLInputElement;
+
+ expect(remember.name).toBe("remember");
+ password.value = "x";
+ form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true }));
+ await pending;
+});
+
test("uses the active UI locale instead of re-detecting browser storage", async () => {
localStorage.setItem("ocx-lang", "en");
setActiveLocale("ko");
- const pending = promptForAdminToken(async () => "accepted");
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
const form = dialog.querySelector("form")!;
const username = form.elements.namedItem("username") as HTMLInputElement;
@@ -134,7 +212,7 @@ test("uses the active UI locale instead of re-detecting browser storage", async
* "hidden" and "empty" cannot drift apart.
*/
test("the validation alert is hidden and empty until a token is actually rejected", async () => {
- const pending = promptForAdminToken(async () => "rejected");
+ const pending = promptForAdminToken(async () => "rejected", SCOPE);
const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
const form = dialog.querySelector("form")!;
const alert = dialog.querySelector('[role="alert"]')!;
@@ -182,7 +260,7 @@ test("notice display rules are scoped so a hidden notice cannot paint", async ()
/* #3353 — a bare password box explained nothing. */
test("the dialog explains the credential and links the setup guide", async () => {
- const pending = promptForAdminToken(async () => "accepted");
+ const pending = promptForAdminToken(async () => "accepted", SCOPE);
const dialog = document.querySelector("#opencodex-admin-token-dialog")!;
const link = dialog.querySelector('a[target="_blank"]')!;
diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts
index da354ff7153..dab3d879f90 100644
--- a/gui/tests/api-auth-deadline.test.ts
+++ b/gui/tests/api-auth-deadline.test.ts
@@ -10,7 +10,7 @@ import {
} from "../src/api";
import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets";
-const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const;
+const globals = ["document", "window", "navigator", "sessionStorage", "localStorage", "fetch"] as const;
let previousGlobals: Record<(typeof globals)[number], unknown>;
let testWindow: Window;
let promptCalls: number;
@@ -23,6 +23,7 @@ beforeEach(() => {
window: { configurable: true, value: testWindow },
navigator: { configurable: true, value: testWindow.navigator },
sessionStorage: { configurable: true, value: testWindow.sessionStorage },
+ localStorage: { configurable: true, value: testWindow.localStorage },
fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) },
});
promptCalls = 0;
@@ -402,3 +403,42 @@ test("the watchdog never bounds the prompt: slow user input stacks no dialogs an
expect(resB.status).toBe(200);
expect(promptCalls).toBe(1);
});
+
+test("a hung remembered-token verification is bounded and falls back to the prompt", async () => {
+ // verifyAdminToken used to await rawFetch with no bound: an /api/combos request that never
+ // settled wedged resolutionInFlight (and every /api waiter) for the page lifetime —
+ // the whole-resolution watchdog only races reBootstrapSessionToken. The bounded fetch
+ // must turn the hang into "unavailable": stored token preserved, prompt reached.
+ // The seeded key is the exact scoped key for the default standalone targets
+ // (http://localhost, same-origin transport): the legacy unscoped key is never read, so
+ // seeding it left the verification untried and the abort bound below unexercised.
+ declareRuntimeRole("hub");
+ setRebootstrapTimeoutForTests(40);
+ // Bun (Windows) can starve native AbortSignal.timeout timers while the JS timer
+ // queue is empty; the watchdog that normally keeps it non-empty is cleared before
+ // remembered-token verification runs. Keep one timer armed so the bound under
+ // test actually fires instead of hanging the runner.
+ const keepalive = setTimeout(() => {}, 1_000);
+ localStorage.setItem("opencodex.remembered-admin-token:http://localhost|same-origin", "remembered-token");
+ let verifyCalls = 0;
+ const verifySignals: Array = [];
+ const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (pathnameOf(input) === "/api/combos") {
+ verifyCalls += 1;
+ verifySignals.push(init?.signal);
+ return hangUntilAborted(init?.signal);
+ }
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ try {
+ expect((await fetch("/api/config")).status).toBe(401);
+ expect(verifyCalls).toBe(1);
+ expect(verifySignals[0]?.aborted).toBe(true);
+ expect(promptCalls).toBe(1);
+ expect(localStorage.getItem("opencodex.remembered-admin-token:http://localhost|same-origin")).toBe("remembered-token");
+ } finally {
+ clearTimeout(keepalive);
+ }
+});
diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts
index 9645ec80593..5b7943c8617 100644
--- a/gui/tests/api-auth-memory.test.ts
+++ b/gui/tests/api-auth-memory.test.ts
@@ -4,7 +4,8 @@ import { configureApiTargets, fetchAudioUpload, installApiAuthFetch, installApiS
import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets";
const LEGACY_TOKEN_KEY = "opencodex-api-token";
-const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const;
+const STANDALONE_SCOPE_KEY = "opencodex.remembered-admin-token:http://localhost|same-origin";
+const globals = ["document", "window", "navigator", "sessionStorage", "localStorage", "fetch"] as const;
let previousGlobals: Record<(typeof globals)[number], unknown>;
let testWindow: Window;
let originalPrompt: typeof window.prompt;
@@ -17,6 +18,7 @@ beforeEach(() => {
window: { configurable: true, value: testWindow },
navigator: { configurable: true, value: testWindow.navigator },
sessionStorage: { configurable: true, value: testWindow.sessionStorage },
+ localStorage: { configurable: true, value: testWindow.localStorage },
fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) },
});
originalPrompt = window.prompt;
@@ -133,6 +135,267 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage
expect(sessionStorage.length).toBe(0);
});
+test("remembered token is verified and used silently without prompting", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem(STANDALONE_SCOPE_KEY, "remembered-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return "prompt-token"; };
+
+ const seenTokens: Array = [];
+ const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const key = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ seenTokens.push(key);
+ if (key === "remembered-token") return new Response("{}", { status: 200 });
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ const res = await fetch("/api/config");
+ expect(res.status).toBe(200);
+ expect(promptCalls).toBe(0);
+ expect(seenTokens).toContain("remembered-token");
+ expect(localStorage.getItem(STANDALONE_SCOPE_KEY)).toBe("remembered-token");
+});
+
+test("rejected remembered token is cleared and the prompt takes over", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem(STANDALONE_SCOPE_KEY, "stale-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return "fresh-token"; };
+
+ const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const key = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ if (key === "fresh-token") return new Response("{}", { status: 200 });
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ const res = await fetch("/api/config");
+ expect(res.status).toBe(200);
+ expect(promptCalls).toBe(1);
+ expect(localStorage.getItem(STANDALONE_SCOPE_KEY)).toBeNull();
+});
+
+test("unavailable remembered token survives a transient server error", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem(STANDALONE_SCOPE_KEY, "good-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return null; };
+
+ // Validation endpoint returns 503 (unavailable), not 401 (rejected).
+ const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const key = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ const url = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/");
+ if (url.pathname === "/api/combos" && key === "good-token") {
+ return new Response("overloaded", { status: 503 });
+ }
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ await fetch("/api/config");
+ expect(promptCalls).toBe(1); // fell through to prompt
+ expect(localStorage.getItem(STANDALONE_SCOPE_KEY)).toBe("good-token"); // NOT cleared
+});
+
+test("remembered token that caused the current 401 is cleared immediately", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem(STANDALONE_SCOPE_KEY, "revoked-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return "fresh-token"; };
+
+ const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const key = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ if (key === "fresh-token") return new Response("{}", { status: 200 });
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ const res = await fetch("/api/config");
+ expect(res.status).toBe(200);
+ expect(promptCalls).toBe(1);
+ expect(localStorage.getItem(STANDALONE_SCOPE_KEY)).toBeNull();
+});
+
+test("a legacy unscoped remembered token is never sent or migrated", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem("opencodex.remembered-admin-token", "legacy-unscoped-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return null; };
+
+ const seenTokens: Array = [];
+ const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ seenTokens.push(new Headers(init?.headers).get("X-OpenCodex-API-Key"));
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+
+ await fetch("/api/config");
+ expect(promptCalls).toBe(1); // prompted instead of reusing the unscoped value
+ expect(seenTokens).not.toContain("legacy-unscoped-token");
+ expect(localStorage.getItem("opencodex.remembered-admin-token")).toBe("legacy-unscoped-token"); // untouched, unread
+ expect(Object.keys(localStorage).some(key => key.startsWith("opencodex.remembered-admin-token:"))).toBe(false); // not migrated
+});
+
+test("a hub remembered token never reaches the machine plane over direct transport", async () => {
+ declareManagementAuthRequired();
+ localStorage.setItem("opencodex.remembered-admin-token:https://hub.example.test|direct", "hub-token");
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return null; };
+
+ const seen: Array<{ url: string; token: string | null }> = [];
+ const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/");
+ const token = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ seen.push({ url: url.href, token });
+ if (url.origin === "https://hub.example.test" && token === "hub-token") return new Response("{}", { status: 200 });
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+ const status: MachineStatusV1 = {
+ mode: "client", connected: true, machineBase: "http://localhost",
+ sharedBase: "https://hub.example.test", sharedServerOrigin: "https://hub.example.test",
+ managementTransport: "direct", apiKeyId: "client-key-a", protocolVersion: 1,
+ connectedAt: "2026-09-28T00:00:00Z",
+ };
+ configureApiTargets(targetsFromMachineStatus("", status));
+
+ const hub = await fetch("https://hub.example.test/api/config");
+ expect(hub.status).toBe(200); // positive control: the hub credential still works on the hub
+ const machine = await fetch("/api/machine/status");
+ expect(machine.status).toBe(401);
+ expect(promptCalls).toBe(1); // machine plane prompted instead of borrowing the hub credential
+ expect(seen.some(request => request.token === "hub-token")).toBe(true);
+ for (const request of seen) {
+ if (request.token === "hub-token") expect(request.url.startsWith("https://hub.example.test/")).toBe(true);
+ }
+});
+
+test("relay transport keeps machine and hub remembered tokens confined to their planes", async () => {
+ declareManagementAuthRequired();
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return null; };
+
+ const seen: Array<{ path: string; token: string | null }> = [];
+ const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/");
+ const token = new Headers(init?.headers).get("X-OpenCodex-API-Key");
+ seen.push({ path: url.pathname, token });
+ if (url.pathname.startsWith("/api/machine/hub-relay/") && token === "hub-relay-token") return new Response("{}", { status: 200 });
+ if (token === "machine-token") return new Response("{}", { status: 200 });
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+ const relayStatus: MachineStatusV1 = {
+ mode: "client", connected: true, machineBase: "http://localhost",
+ sharedBase: "http://localhost/api/machine/hub-relay", sharedServerOrigin: "https://hub.example.test",
+ managementTransport: "relay", apiKeyId: "client-key-a", protocolVersion: 1,
+ connectedAt: "2026-09-28T00:00:00Z",
+ };
+ configureApiTargets(targetsFromMachineStatus("", relayStatus));
+
+ localStorage.setItem("opencodex.remembered-admin-token:https://hub.example.test|relay", "hub-relay-token");
+ const hub = await fetch("/api/machine/hub-relay/api/config");
+ expect(hub.status).toBe(200);
+ expect(promptCalls).toBe(0);
+
+ localStorage.setItem("opencodex.remembered-admin-token:http://localhost|same-origin", "machine-token");
+ const machine = await fetch("/api/machine/status");
+ expect(machine.status).toBe(200);
+ expect(promptCalls).toBe(0);
+
+ for (const request of seen) {
+ if (request.token === "machine-token") expect(request.path.startsWith("/api/machine/hub-relay/")).toBe(false);
+ if (request.token === "hub-relay-token") expect(request.path.startsWith("/api/machine/hub-relay/")).toBe(true);
+ }
+ expect(seen.some(request => request.token === "machine-token")).toBe(true);
+ expect(seen.some(request => request.token === "hub-relay-token")).toBe(true);
+});
+
+/*
+ * #4649 review, P1 — the A→B reconnect interleaving, GUI half.
+ *
+ * Every relay verification shares one local /api/machine/hub-relay URL, so the request
+ * itself must name the hub connection the credential was resolved against. Here the
+ * client reconnects to hub B (and rediscovery swaps the configured targets) between the
+ * scoped lookup of hub A's remembered token and the verification forward. The verify
+ * request must still carry A's identity headers, so the rebound listener refuses with 409
+ * instead of forwarding A's credential to B — and a 409 is not a rejection, so the stored
+ * A token survives for the rediscovered session to use later.
+ */
+test("a mid-resolution hub reconnect never forwards the previous hub's remembered token", async () => {
+ declareManagementAuthRequired();
+ const hubAOrigin = "https://hub.example.test";
+ const hubBOrigin = "https://hub-b.example.test";
+ const generationA = "client-key-a|2026-09-28T00:00:00.000Z";
+ const generationB = "client-key-b|2026-09-28T01:00:00.000Z";
+ const statusA: MachineStatusV1 = {
+ mode: "client", connected: true, machineBase: "http://localhost",
+ sharedBase: "http://localhost/api/machine/hub-relay", sharedServerOrigin: hubAOrigin,
+ managementTransport: "relay", apiKeyId: "client-key-a", protocolVersion: 1,
+ connectedAt: "2026-09-28T00:00:00.000Z",
+ };
+ const statusB: MachineStatusV1 = {
+ ...statusA,
+ sharedServerOrigin: hubBOrigin, apiKeyId: "client-key-b", connectedAt: "2026-09-28T01:00:00.000Z",
+ };
+ // The simulated machine listener: which hub connection the local relay port is bound to.
+ let currentOrigin = hubAOrigin;
+ let currentGeneration = generationA;
+ let promptCalls = 0;
+ window.prompt = () => { promptCalls += 1; return null; };
+
+ const relaySeen: Array<{
+ path: string;
+ apiKey: string | null;
+ expectedOrigin: string | null;
+ expectedConnection: string | null;
+ }> = [];
+ const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/");
+ if (!url.pathname.startsWith("/api/machine/hub-relay/")) return new Response("{}", { status: 404 });
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
+ const expectedOrigin = headers.get("X-OpenCodex-Relay-Expected-Origin");
+ const expectedConnection = headers.get("X-OpenCodex-Relay-Expected-Connection");
+ relaySeen.push({ path: url.pathname, apiKey: headers.get("X-OpenCodex-API-Key"), expectedOrigin, expectedConnection });
+ if (url.pathname === "/api/machine/hub-relay/api/combos") {
+ // The reconnect lands here: between the scoped lookup and the forward, the client
+ // rebinds to hub B and discovery reconfigures the targets (and the machine session).
+ currentOrigin = hubBOrigin;
+ currentGeneration = generationB;
+ configureApiTargets(targetsFromMachineStatus("", statusB));
+ installApiSessionFromHtml("machine", sessionDocumentHtml("ocx_session_machine_b", "machine-b-csrf", "http://localhost"));
+ }
+ if (expectedOrigin !== currentOrigin || expectedConnection !== currentGeneration) {
+ return new Response(JSON.stringify({ error: "hub relay connection changed" }), { status: 409 });
+ }
+ return new Response("unauthorized", { status: 401 });
+ }) as typeof fetch;
+ await installMockAuthFetch(mockFetch);
+ configureApiTargets(targetsFromMachineStatus("", statusA));
+ installApiSessionFromHtml("machine", sessionDocumentHtml("ocx_session_machine_a", "machine-a-csrf", "http://localhost"));
+ localStorage.setItem("opencodex.remembered-admin-token:https://hub.example.test|relay", "hub-a-token");
+
+ const res = await fetch("/api/machine/hub-relay/api/config");
+ expect(res.status).toBe(401); // resolution failed closed; the original 401 stands in
+
+ const verify = relaySeen.filter(request => request.path === "/api/machine/hub-relay/api/combos");
+ expect(verify).toHaveLength(1);
+ expect(verify[0]!.apiKey).toBe("hub-a-token");
+ expect(verify[0]!.expectedOrigin).toBe(hubAOrigin);
+ expect(verify[0]!.expectedConnection).toBe(generationA);
+ // The A credential never traveled under B's (or no) connection identity.
+ for (const request of relaySeen) {
+ if (request.apiKey === "hub-a-token") {
+ expect(request.expectedOrigin).toBe(hubAOrigin);
+ expect(request.expectedConnection).toBe(generationA);
+ }
+ }
+ // A 409 is a changed connection, not a rejection: the stored A token must survive.
+ expect(localStorage.getItem("opencodex.remembered-admin-token:https://hub.example.test|relay")).toBe("hub-a-token");
+ expect(promptCalls).toBe(1);
+});
+
test("validates prompted tokens with a safe read before retrying the failed request", async () => {
declareManagementAuthRequired();
const validationResults: string[] = [];
diff --git a/gui/tests/app-sidebar-actions.test.ts b/gui/tests/app-sidebar-actions.test.ts
index 15648f65956..1d8ca05941b 100644
--- a/gui/tests/app-sidebar-actions.test.ts
+++ b/gui/tests/app-sidebar-actions.test.ts
@@ -63,6 +63,30 @@ test("mobile orbs keep a 44px touch target", () => {
expect(block).toContain("44px");
});
+test("remembered admin tokens can be forgotten from both chrome surfaces", () => {
+ const mobile = src.slice(src.indexOf('className="mobile-topbar-actions"'), src.indexOf('className="mobile-topbar-actions"') + 2_000);
+ const sidebar = src.slice(src.indexOf('className="sidebar-action-orbs"'), src.indexOf('className="sidebar-action-orbs"') + 2_000);
+ for (const surface of [mobile, sidebar]) {
+ expect(surface).toContain("rememberedAdminTokenPresent");
+ expect(surface).toContain("handleForgetRememberedAdminToken");
+ expect(surface).toContain("connection.forgetRememberedAdminToken");
+ expect(surface).toContain("IconTrash");
+ }
+ expect(src).toContain("clearAllRememberedAdminTokens()");
+});
+
+test("forget remains reachable after remote logout", () => {
+ // Logout is only rendered for a connected session. Forget must not be nested in
+ // that condition, otherwise logging out leaves a persisted credential with no UI
+ // path to remove it.
+ const mobile = src.slice(src.indexOf('className="mobile-topbar-actions"'), src.indexOf('className="mobile-topbar-actions"') + 2_000);
+ const logout = mobile.indexOf("targets.connected && sharedSessionReady");
+ const forget = mobile.indexOf("rememberedAdminTokenPresent");
+ expect(logout).toBeGreaterThan(-1);
+ expect(forget).toBeGreaterThan(logout);
+ expect(mobile.slice(logout, forget)).toContain(")}");
+});
+
/**
* Outcome-message and consent assertions. Kept here rather than in a second file so
diff --git a/src/client/machine-auth.ts b/src/client/machine-auth.ts
index f2aad274997..4d4fc0a895f 100644
--- a/src/client/machine-auth.ts
+++ b/src/client/machine-auth.ts
@@ -8,11 +8,15 @@ import {
export const MACHINE_SESSION_HEADER = "x-opencodex-machine-session";
export const MACHINE_GUI_ORIGIN_HEADER = "x-opencodex-machine-gui-origin";
export const MACHINE_CSRF_HEADER = "x-opencodex-machine-csrf-token";
+export const MACHINE_RELAY_EXPECTED_ORIGIN_HEADER = "x-opencodex-relay-expected-origin";
+export const MACHINE_RELAY_EXPECTED_CONNECTION_HEADER = "x-opencodex-relay-expected-connection";
const MACHINE_AUTH_HEADERS = [
MACHINE_SESSION_HEADER,
MACHINE_GUI_ORIGIN_HEADER,
MACHINE_CSRF_HEADER,
+ MACHINE_RELAY_EXPECTED_ORIGIN_HEADER,
+ MACHINE_RELAY_EXPECTED_CONNECTION_HEADER,
] as const;
function machinePrincipalRequest(req: Request): Request {
diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts
index ffce1086f9d..a60964cf66d 100644
--- a/src/client/machine-listener.ts
+++ b/src/client/machine-listener.ts
@@ -14,7 +14,12 @@ import type { OcxClientConnectionConfig, OcxConfig } from "../types";
import { disconnectClient, syncConnectedClient } from "./connect";
import { isLinkConnection, readClientConnectionState } from "./state";
import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api";
-import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth";
+import {
+ MACHINE_GUI_ORIGIN_HEADER,
+ MACHINE_RELAY_EXPECTED_CONNECTION_HEADER,
+ MACHINE_RELAY_EXPECTED_ORIGIN_HEADER,
+ requireMachineAuth,
+} from "./machine-auth";
import { HUB_RELAY_REQUEST_BODY_MAX_BYTES, relayHubManagementRequest } from "./hub-relay";
import { createLinkKeySource, handleLinkIngress, type LinkIngress, type LinkKeySourceDeps } from "./link-ingress";
import type { LinkTunnelGate } from "./link-relay";
@@ -86,6 +91,29 @@ function readSidecarSafely(read: () => ClientLinkState | null): ClientLinkSideca
try { return read(); } catch { return "invalid"; }
}
+// The browser credential a relayed request carries was resolved against one specific hub
+// connection, but every relay verification shares the same local /api/machine/hub-relay URL.
+// The request must therefore name the connection it expects (hub origin plus the
+// apiKeyId|connectedAt generation from /api/machine/status), and this listener — captured at
+// bind, so it is exactly the connection an upstream fetch would use — refuses before any
+// upstream byte when that no longer matches. Without this, a reconnect to hub B between the
+// A-token lookup and the forward would hand B the A credential, and B's rejection would then
+// delete the still-valid A token (#4649 P1).
+function relayConnectionExpected(
+ headers: Headers,
+ connection: Pick,
+): boolean {
+ const expectedOrigin = headers.get(MACHINE_RELAY_EXPECTED_ORIGIN_HEADER)?.trim();
+ const expectedConnection = headers.get(MACHINE_RELAY_EXPECTED_CONNECTION_HEADER)?.trim();
+ if (!expectedOrigin || !expectedConnection) return false;
+ try {
+ return new URL(connection.managementUrl).origin === expectedOrigin
+ && expectedConnection === `${connection.apiKeyId}|${connection.connectedAt}`;
+ } catch {
+ return false;
+ }
+}
+
export function startMachineListener(
port?: number,
deps: MachineListenerDeps = {},
@@ -149,6 +177,9 @@ export function startMachineListener(
if (!relayEnabled) return json404(req);
const authError = requireMachineAuth(req, managementAuth, config);
if (authError) return authError;
+ if (!relayConnectionExpected(req.headers, connection)) {
+ return Response.json({ error: "hub relay connection changed" }, { status: 409 });
+ }
const prefix = "/api/machine/hub-relay";
const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
const response = await relayHubManagementRequest(req, suffix, {
diff --git a/tests/clients/client-machine-listener.test.ts b/tests/clients/client-machine-listener.test.ts
index 0efcecd5476..1f4fb527dd4 100644
--- a/tests/clients/client-machine-listener.test.ts
+++ b/tests/clients/client-machine-listener.test.ts
@@ -10,6 +10,7 @@ import { RemoteWorkspaceSessionService } from "../../src/remote-control/workspac
import type { RemoteWorkspaceHub } from "../../src/remote-control/workspace-hub";
import { handleManagementAPI } from "../../src/server/management-api";
import { createManagementSessionControl, type ManagementAuthState } from "../../src/server/management-auth";
+import { MACHINE_RELAY_EXPECTED_CONNECTION_HEADER, MACHINE_RELAY_EXPECTED_ORIGIN_HEADER } from "../../src/client/machine-auth";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { repoPath } from "../helpers/repo-root";
import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets";
@@ -83,6 +84,19 @@ async function guiHeaders(server: Server, mutation = false): Promise(bind: () => T): Promise {
+ for (let attempt = 0; ; attempt++) {
+ try {
+ return bind();
+ } catch (error) {
+ if (attempt >= 40 || !/EADDRINUSE|in use/i.test(String(error))) throw error;
+ await Bun.sleep(50);
+ }
+ }
+}
+
describe("client machine listener", () => {
test("relayed workspace prompt acknowledges acceptance before the model turn completes", async () => {
const oldEnabled = process.env.OCX_REMOTE_WORKSPACE_ENABLED;
@@ -126,8 +140,9 @@ describe("client machine listener", () => {
pairingGrants: new Map(),
};
const hubSessionControl = createManagementSessionControl(hubAuth);
+ const relayState = connection("relay");
const server = startMachineListener(0, {
- state: connection("relay"), managementAuthState: authState(),
+ state: relayState, managementAuthState: authState(),
fetchImpl: (async (input, init) => {
const request = new Request(String(input), init);
request.headers.set("Host", new URL(request.url).host);
@@ -154,6 +169,8 @@ describe("client machine listener", () => {
"X-OpenCodex-API-Key": "ocx_session_hub",
"X-OpenCodex-GUI-Origin": local.get("X-OpenCodex-GUI-Origin")!,
"X-OpenCodex-CSRF-Token": "fixture-hub-csrf",
+ [MACHINE_RELAY_EXPECTED_ORIGIN_HEADER]: new URL(relayState.managementUrl).origin,
+ [MACHINE_RELAY_EXPECTED_CONNECTION_HEADER]: `${relayState.apiKeyId}|${relayState.connectedAt}`,
});
const prefix = "/api/machine/hub-relay/api/remote-workspace/sessions";
const acknowledged = await Promise.race([
@@ -181,6 +198,89 @@ describe("client machine listener", () => {
}
}, 15_000);
+ test("a relay request naming a superseded hub connection is refused before the forward", async () => {
+ // #4649 review, P1 — the A→B reconnect interleaving, listener half. The remembered
+ // hub-A credential is resolved against A's discovered status, then the client rebinds
+ // the same loopback port to hub B before the verification is forwarded. The listener
+ // captured connection B at bind, so it must refuse on connection identity before any
+ // upstream byte: forwarding would hand B the A credential, and B's 401 would then
+ // delete the still-valid A token.
+ const hubARequests: Request[] = [];
+ const hubBRequests: Request[] = [];
+ const connectionB: OcxClientConnectionConfig = {
+ ...connection("relay"),
+ serverUrl: "https://hub-b.example.test",
+ managementUrl: "https://hub-b.example.test",
+ apiKeyId: "client-key-b",
+ connectedAt: "2026-08-28T12:00:00.000Z",
+ };
+ const serverA = startMachineListener(0, {
+ state: connection("relay"), managementAuthState: authState(),
+ fetchImpl: (async (input, init) => {
+ hubARequests.push(new Request(String(input), init));
+ return Response.json({ relayed: "hub-a" });
+ }) as typeof fetch,
+ });
+ try {
+ const statusHeaders = await guiHeaders(serverA);
+ const status = await fetch(new URL("/api/machine/status", serverA.url), { headers: statusHeaders });
+ expect(status.status).toBe(200);
+ // Exactly the fields the GUI builds its relay target and credential scope from.
+ const discovered = await status.json() as {
+ sharedServerOrigin: string; apiKeyId: string; connectedAt: string; managementTransport: string;
+ };
+ expect(discovered).toMatchObject({
+ managementTransport: "relay",
+ sharedServerOrigin: "https://hub.example.test",
+ apiKeyId: "client-key-a",
+ });
+ const remembered = "hub-a-remembered-token";
+
+ const port = serverA.port;
+ await serverA.stop(true);
+ const serverB = await rebindLoopbackPort(() => startMachineListener(port, {
+ state: connectionB, managementAuthState: authState(),
+ fetchImpl: (async (input, init) => {
+ hubBRequests.push(new Request(String(input), init));
+ return Response.json({ relayed: "hub-b" });
+ }) as typeof fetch,
+ }));
+ servers.push(serverB);
+ // The reconnect also re-bootstraps the machine session — against B's listener.
+ const localB = await guiHeaders(serverB, true);
+ // The machine session travels in X-OpenCodex-Machine-Session; the plain
+ // API-Key header is the hub credential being relayed, not the local auth.
+ const relayHeaders = (hubCredential: string, expectedOrigin: string, expectedConnection: string): Headers => {
+ const headers = new Headers(localB);
+ headers.set("X-OpenCodex-Machine-Session", localB.get("X-OpenCodex-API-Key")!);
+ headers.set("X-OpenCodex-Machine-GUI-Origin", localB.get("X-OpenCodex-GUI-Origin")!);
+ headers.set("X-OpenCodex-Machine-CSRF-Token", localB.get("X-OpenCodex-CSRF-Token")!);
+ headers.set("X-OpenCodex-API-Key", hubCredential);
+ headers.set(MACHINE_RELAY_EXPECTED_ORIGIN_HEADER, expectedOrigin);
+ headers.set(MACHINE_RELAY_EXPECTED_CONNECTION_HEADER, expectedConnection);
+ return headers;
+ };
+
+ const stale = relayHeaders(remembered, discovered.sharedServerOrigin, `${discovered.apiKeyId}|${discovered.connectedAt}`);
+ const refused = await fetch(new URL("/api/machine/hub-relay/api/combos", serverB.url), { headers: stale });
+ expect(refused.status).toBe(409);
+ expect(await refused.json()).toEqual({ error: "hub relay connection changed" });
+ expect(hubBRequests).toHaveLength(0);
+
+ // Positive control: the same machine session naming B's live connection forwards
+ // exactly once, carrying the hub credential but never the identity headers.
+ const current = relayHeaders("hub-b-remembered-token", "https://hub-b.example.test", `${connectionB.apiKeyId}|${connectionB.connectedAt}`);
+ const forwarded = await fetch(new URL("/api/machine/hub-relay/api/combos", serverB.url), { headers: current });
+ expect(forwarded.status).toBe(200);
+ expect(hubBRequests).toHaveLength(1);
+ expect(hubBRequests[0]!.headers.get("x-opencodex-api-key")).toBe("hub-b-remembered-token");
+ expect(hubBRequests[0]!.headers.get(MACHINE_RELAY_EXPECTED_ORIGIN_HEADER)).toBeNull();
+ expect(hubBRequests[0]!.headers.get(MACHINE_RELAY_EXPECTED_CONNECTION_HEADER)).toBeNull();
+ } finally {
+ await serverA.stop(true).catch(() => { /* already stopped when the reconnect ran */ });
+ }
+ });
+
test("binds IPv4 loopback and default-denies shared/data-plane routes", async () => {
const server = startMachineListener(0, { state: connection(), managementAuthState: authState() });
servers.push(server);