From 9e8529b4c4d0935f36fd668693019b824d2b2da8 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 27 Sep 2026 05:29:11 +0000 Subject: [PATCH 001/145] fix(security): require pairing for child link join --- .../src/content/docs/fr/guides/remote-link.md | 2 +- .../src/content/docs/guides/remote-link.md | 2 +- .../src/content/docs/ja/guides/remote-link.md | 2 +- .../src/content/docs/ko/guides/remote-link.md | 2 +- .../src/content/docs/ru/guides/remote-link.md | 2 +- .../src/content/docs/tr/guides/remote-link.md | 2 +- .../content/docs/zh-cn/guides/remote-link.md | 2 +- .../content/docs/zh-tw/guides/remote-link.md | 2 +- src/server/management/link-routes.ts | 18 +++++++++--------- structure/gui-and-management-api.md | 2 +- structure/remote-link.md | 6 +++--- tests/server/link-join-route.test.ts | 17 +++++++---------- tests/server/link-management-routes.test.ts | 16 ++++++++-------- 13 files changed, 36 insertions(+), 39 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 6a619f8d5dd..90577ee34ab 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -11,7 +11,7 @@ Une liaison entre machines connecte un ordinateur OpenCodex **Home** à un ordin - Pour une liaison initiée par Child, Child peut se connecter à Home avec une clé OpenSSH (la connexion par mot de passe n’est pas prise en charge). - OpenCodex 2.66.0 ou ultérieur est installé sur Child (et sur Home pour une liaison initiée par Child). - Les deux ordinateurs utilisent macOS ou Linux. -- Le tableau de bord qui lance la liaison est ouvert sur cet ordinateur lui-même (navigateur ou application de bureau, installation autonome) ou via une session Hub appairée. +- Le tableau de bord qui ajoute un Child depuis Home est ouvert sur Home ou via une session Hub appairée. Transformer l’ordinateur actuel en Child exige une session de tableau de bord appairée par l’opérateur ; une session locale sans identifiant ne peut pas valider ce changement de routage. SSH par mot de passe et Windows restent hors du flux actuel. Une liaison peut être lancée des deux côtés : depuis Home, comme décrit ci-dessous, ou depuis Child, comme décrit dans la section « Connecter cet ordinateur comme Child ». diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 17bf414b3cb..8a0ee527211 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -11,7 +11,7 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o - For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). - OpenCodex 2.66.0 or later is installed on the Child computer, and on Home for a Child-initiated link. - Both computers run macOS or Linux. -- The dashboard that starts the link is opened on that computer itself (browser or desktop app, standalone install) or through a paired Hub session. +- The dashboard that adds a Child from Home is opened on Home itself or through a paired Hub session. Turning the current computer into a Child requires an operator-paired dashboard session; a credentialless local dashboard session cannot commit that routing change. Password SSH and Windows are outside the current flow. A link can be started from either side: from the Home, as described next, or from the Child, as described in [Connect this computer as a Child](#connect-this-computer-as-a-child). diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index d8878f2fbd8..28ac9a0f447 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -11,7 +11,7 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ - Child から開始するリンクでは、Child から Home に OpenSSH キーでログインできる必要があります(パスワードログインには対応していません)。 - Child に OpenCodex 2.66.0 以降がインストールされていること(Child から開始するリンクでは Home にも)。 - 両方のコンピューターが macOS または Linux であること。 -- リンクを開始するダッシュボードは、そのコンピューター上で直接開いたもの(スタンドアロン環境のブラウザーまたはデスクトップアプリ)か、ペアリング済みの Hub セッションであること。 +- Home から Child を追加するダッシュボードは Home 上で開くか、ペアリング済みの Hub セッションを使用します。現在のコンピューターを Child にするには、オペレーターがペアリングしたダッシュボードセッションが必要です。認証情報なしのローカルセッションでは、このルーティング変更を確定できません。 パスワード SSH と Windows は現在のフローに含まれません。リンクはどちら側からでも開始できます。次の手順のように Home から開始するか、後述の「このコンピューターを Child として接続する」のように Child から開始します。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index 6ac312feeb9..b1942d4af0c 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -11,7 +11,7 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 - 자식이 시작하는 링크에서는 자식에서 OpenSSH 키 로그인으로 홈에 접속할 수 있어야 합니다(비밀번호 로그인은 지원하지 않음). - Child 컴퓨터에 OpenCodex 2.66.0 이상이 설치되어 있어야 합니다(자식이 시작하는 링크에서는 Home에도). - 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. -- 링크를 시작하는 대시보드는 그 컴퓨터에서 직접 열거나(독립형 설치의 브라우저 또는 데스크톱 앱) 페어링된 Hub 세션으로 열어야 합니다. +- Home에서 Child를 추가하는 대시보드는 Home에서 직접 열거나 페어링된 Hub 세션을 사용해야 합니다. 현재 컴퓨터를 Child로 전환하려면 운영자가 페어링한 대시보드 세션이 필요하며, 자격 증명 없이 발급된 로컬 세션은 이 라우팅 변경을 확정할 수 없습니다. 비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 링크는 어느 쪽에서든 시작할 수 있습니다. 아래 순서대로 Home에서 시작하거나, 이어지는 "이 컴퓨터를 Child로 연결하기" 절처럼 Child에서 시작합니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index c53f8b63a5f..5a289596817 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -11,7 +11,7 @@ description: Подключите компьютер OpenCodex Home к комп - Для связи, инициированной со стороны Child, Child должен входить на Home по ключу OpenSSH (вход по паролю не поддерживается). - На Child установлен OpenCodex 2.66.0 или новее (для связи со стороны Child — и на Home). - Оба компьютера работают под macOS или Linux. -- Панель, с которой начинают связь, открыта на самом этом компьютере (браузер или настольное приложение, автономная установка) или через сопряжённую сессию Hub. +- Панель для добавления Child со стороны Home открыта на Home или через сопряжённую сессию Hub. Чтобы превратить текущий компьютер в Child, нужна сессия панели, сопряжённая оператором; локальная сессия без учётных данных не может подтвердить это изменение маршрутизации. SSH с паролем и Windows сейчас не поддерживаются. Связь можно начать с любой стороны: с Home, как описано ниже, или с Child, как описано в разделе «Подключение этого компьютера как Child». diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index 3fe13ce1e89..3c8575de4c5 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -11,7 +11,7 @@ Makine bağlantısı, bir OpenCodex **Home** bilgisayarını bir **Child** bilgi - Child tarafından başlatılan bağlantı için Child, Home bilgisayarına OpenSSH anahtarıyla giriş yapabilmelidir (parola girişi desteklenmez). - Child bilgisayarında OpenCodex 2.66.0 veya sonrası kuruludur (Child tarafından başlatılan bağlantıda Home üzerinde de). - Her iki bilgisayar da macOS veya Linux çalıştırır. -- Bağlantıyı başlatan kontrol paneli o bilgisayarın kendisinde (bağımsız kurulumda tarayıcı veya masaüstü uygulaması) ya da eşleştirilmiş bir Hub oturumu üzerinden açılır. +- Home üzerinden Child ekleyen kontrol paneli Home üzerinde ya da eşleştirilmiş bir Hub oturumunda açılır. Geçerli bilgisayarı Child'a dönüştürmek için operatörün eşleştirdiği bir kontrol paneli oturumu gerekir; kimlik bilgisi olmadan oluşturulan yerel oturum bu yönlendirme değişikliğini onaylayamaz. Parolalı SSH ve Windows mevcut akışın dışındadır. Bağlantı iki taraftan da başlatılabilir: aşağıda anlatıldığı gibi Home tarafından ya da "Bu bilgisayarı Child olarak bağlama" bölümünde anlatıldığı gibi Child tarafından. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 6b092f1b9ab..9f8464cd2c8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -11,7 +11,7 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 - 对于由子机发起的链接,子机必须能使用 OpenSSH 密钥登录主机(不支持密码登录)。 - 子机已安装 OpenCodex 2.66.0 或更高版本(由子机发起的链接还要求主机也满足)。 - 两台电脑运行 macOS 或 Linux。 -- 发起链接的控制台需在那台电脑本机打开(独立安装的浏览器或桌面应用),或通过已配对的 Hub 会话打开。 +- 从主机添加子机的控制台需在主机上打开,或使用已配对的 Hub 会话。将当前电脑转换为子机需要操作员配对的控制台会话;无凭据签发的本地会话不能提交这项路由更改。 密码 SSH 和 Windows 不在当前流程中。链接可以从任意一侧发起:按下文从主机发起,或按“将这台电脑连接为子机”一节从子机发起。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index f30a8987c43..31f312b06bb 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -11,7 +11,7 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 - 對於由 Child 發起的連結,Child 必須能使用 OpenSSH 金鑰登入 Home(不支援密碼登入)。 - Child 已安裝 OpenCodex 2.66.0 或更新版本(由 Child 發起的連結也要求 Home 符合)。 - 兩台電腦執行 macOS 或 Linux。 -- 發起連結的儀表板需在那台電腦本機開啟(獨立安裝的瀏覽器或桌面應用程式),或透過已配對的 Hub 工作階段開啟。 +- 從 Home 新增 Child 的儀表板需在 Home 上開啟,或使用已配對的 Hub 工作階段。將目前電腦轉換為 Child 需要由操作者配對的儀表板工作階段;未經憑證簽發的本機工作階段不能提交這項路由變更。 密碼 SSH 和 Windows 不在目前流程中。連結可以從任一端發起:依下文從 Home 發起,或依「將這台電腦連線為 Child」一節從 Child 發起。 diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 4cec62bb8df..343ac3855c1 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -86,7 +86,7 @@ function port(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } -/** A paired GUI session. Only hub runtimes issue these. */ +/** A GUI session redeemed from an operator-created, one-use pairing grant. */ function pairedSession(ctx: ManagementContext): boolean { return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true; @@ -96,9 +96,8 @@ function pairedSession(ctx: ManagementContext): boolean { * A paired session, or on a standalone runtime the current loopback-issued session that reached * the public listener bound to a loopback hostname. The loopback bootstrap mints that session * without a credential, so this is casual-path protection like POST /api/github/star, not a - * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Join admits this - * session too: turning a Child on from its own dashboard is the point of the route, and the - * dashboard warns first that the restart briefly interrupts running Codex turns. + * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations + * that require operator approval, such as joining this machine as a Child, use pairedSession. */ function dashboardSession(ctx: ManagementContext): boolean { if (pairedSession(ctx)) return true; @@ -113,9 +112,10 @@ function adminLoopback(ctx: ManagementContext): boolean { return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress; } -function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null { +function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null { if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); const allowed = kind === "dashboard" ? dashboardSession(ctx) + : kind === "paired" ? pairedSession(ctx) : kind === "admin" ? adminLoopback(ctx) : dashboardSession(ctx) || adminLoopback(ctx); return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403); @@ -133,7 +133,7 @@ function joinPortMatches(ctx: ManagementContext): boolean { /** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */ function joinAvailable(ctx: ManagementContext): boolean { - return dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx); + return pairedSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx); } function runnerFor(ctx: ManagementContext): SshRunner { @@ -549,9 +549,9 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L if (!isLinkPath(path)) return null; if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); if (url.pathname === "/api/link/join" && req.method === "POST") { - // The same dashboard admission as the Home side. Every refusal below runs before link state - // is read and before any SSH. - const denied = auth(ctx, "dashboard"); + // Joining durably redirects local client traffic, so a credentiallessly bootstrapped loopback + // session is insufficient. Every refusal below runs before link state and before any SSH. + const denied = auth(ctx, "paired"); if (denied) return denied; if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409); if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 56ea1763b01..1083d2549b1 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -223,7 +223,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the five operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged; it never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | -| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | +| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | diff --git a/structure/remote-link.md b/structure/remote-link.md index c2bc2f3b03b..8ab261be4a1 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -16,7 +16,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a ## Client-initiated links -`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits the same dashboard sessions as the Home-side routes (see [Dashboard admission](#dashboard-admission)), so a standalone computer turns itself into a Child from its own dashboard. A Tailscale identity session receives `403 tailscale_session_refused`, any other caller `403 forbidden`, a runtime that is not standalone `409 standalone_required`, and a standalone whose live listener port (`resolveListenPort` in `src/server/management/system-restart.ts`) is not its configured `port`, or cannot be determined, `409 join_port_mismatch`, because the client runtime it restarts into binds exactly the configured port. These gates run before link state is read and before any SSH. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. +`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. Because joining durably redirects local client traffic, the route requires an operator-paired dashboard session; a credentiallessly bootstrapped loopback session may inspect and confirm a host but cannot join. A Tailscale identity session receives `403 tailscale_session_refused`, any other caller `403 forbidden`, a runtime that is not standalone `409 standalone_required`, and a standalone whose live listener port (`resolveListenPort` in `src/server/management/system-restart.ts`) is not its configured `port`, or cannot be determined, `409 join_port_mismatch`, because the client runtime it restarts into binds exactly the configured port. These gates run before link state is read and before any SSH. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. During enrollment, `src/client/link-join.ts` watches the spawned SSH tunnel through its 100 ms spawn grace, readiness checks and the connection attempt. Before an unauthenticated `/readyz` probe and again before the keyed request, the only LISTEN owner serving `127.0.0.1:` must be that tunnel PID. Both requests use `redirect: "manual"`; only a 401 challenge permits the keyed request. A failed, empty, foreign or ambiguous ownership recheck withholds the key and reaches the same 15-second deadline check and up-to-100 ms polling delay as any other not-ready iteration. Repeated recheck failures therefore reach rollback instead of bypassing it. The deadline is checked between operations, not an independent per-fetch cancellation timer. An observed tunnel exit winning the readiness or connection race fails the join and runs compensation. @@ -32,9 +32,9 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio ## Dashboard admission -The dashboard link routes (`GET /api/link/status`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply`, `POST /api/link/join` and `DELETE /api/link/{id}`) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why `src/client/machine-listener.ts` keeps durable machine changes on the connected listener away from that session. The link routes deliberately accept this casual-path trade, the same as `POST /api/github/star` in `src/server/management/sidebar-routes.ts`; it is not a secret-backed boundary like the admin token. For join the trade is the same one apply already makes: another local user who can mint the session can move this machine's Codex and Claude traffic to an SSH host this user's key already reaches, after an explicit fingerprint confirmation, and restart the proxy. +The dashboard link routes (`GET /api/link/status`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `DELETE /api/link/{id}`) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. `POST /api/link/join` is stricter and requires the paired session because it durably redirects this machine's client traffic. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why it cannot authorize joining and why `src/client/machine-listener.ts` keeps durable machine changes on the connected listener away from that session. The remaining link routes accept this casual-path trade, the same as `POST /api/github/star` in `src/server/management/sidebar-routes.ts`; it is not a secret-backed boundary like an operator-created pairing grant or the admin token. -A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, true only for a dashboard session on a standalone runtime whose live port is its configured port. An admin-token caller gets the exact K16 document without that field, because `ocx link status` validates it key by key. The dashboard reads an absent field as false; while it is false the Child role card cannot be selected by pointer or keyboard and a notice names the port mismatch. Before **Connect as Child** the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin `/healthz`, sends the join, then reads `/healthz` once a second, skipping status polls meanwhile, and reloads only when it reports `role: "client"` under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading `/healthz` every 5 seconds until the Child answers or the page is left. +A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, true only for a paired dashboard session on a standalone runtime whose live port is its configured port. An admin-token caller gets the exact K16 document without that field, because `ocx link status` validates it key by key. The dashboard reads an absent field as false; while it is false the Child role card cannot be selected by pointer or keyboard. Before **Connect as Child** the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin `/healthz`, sends the join, then reads `/healthz` once a second, skipping status polls meanwhile, and reloads only when it reports `role: "client"` under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading `/healthz` every 5 seconds until the Child answers or the page is left. A connected Child answers `GET` and `HEAD /api/link/status` on its own listener for a GUI session: `src/client/link-status.ts` projects the client sidecar and the tunnel supervisor into the K16 document with `role: "child"`, the listener off, no links and the child row, plus `joinAvailable: false`. A Home-initiated Child has no sidecar and reports `child: null`. In link mode `/api/machine/status` advertises the machine origin as the shared plane, because the tunnel's hub-link ingress serves no `/api/*` and no session bootstrap. diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index 4736a9607a6..e09b69c607f 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -151,16 +151,13 @@ describe("client initiated link join", () => { } }); - test("admits the current loopback dashboard session of a standalone on trusted loopback ingress", async () => { - // Turning a Child on from its own dashboard: the local session joins, while stale sessions, - // untrusted ingress and other runtime roles are refused before a join starts. + test("requires an operator-paired dashboard session before joining", async () => { let joins = 0; const deps = { joinHome: async () => { joins += 1; return { linkId: LINK_ID, apiKeyId: API_KEY_ID }; } }; const loopback = { principal: "gui-session" as const, issuance: "loopback" as const, deps }; - const joined = await handleLinkRoutes(context({ ...loopback, trustedLoopback: true }), routeState()); - expect(joined?.status).toBe(202); - expect(await joined?.json()).toEqual({ linkId: LINK_ID, alias: "home", restarting: true }); - expect(joins).toBe(1); + const credentialless = await handleLinkRoutes(context({ ...loopback, trustedLoopback: true }), routeState()); + expect(credentialless?.status).toBe(403); + expect(joins).toBe(0); for (const options of [ { role: "standalone" as const, trustedLoopback: true, current: false }, @@ -175,11 +172,11 @@ describe("client initiated link join", () => { const tailscale = await handleLinkRoutes(context({ ...loopback, issuance: "tailscale-identity" }), routeState()); expect(tailscale?.status).toBe(403); expect(await tailscale?.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); - expect(joins).toBe(1); + expect(joins).toBe(0); const paired = await handleLinkRoutes(context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), routeState()); expect(paired?.status).toBe(202); - expect(joins).toBe(2); + expect(joins).toBe(1); }); test("refuses a join whose restart could not bind the configured port, before any SSH", async () => { @@ -191,7 +188,7 @@ describe("client initiated link join", () => { }; for (const livePort of [CONFIG_PORT + 1, undefined]) { const response = await handleLinkRoutes({ - ...context({ principal: "gui-session", issuance: "loopback", deps }), + ...context({ principal: "gui-session", issuance: "pairing", paired: true, deps }), deps: { ...deps, liveListenPort: () => livePort }, }, routeState()); expect(response?.status).toBe(409); diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 3e753bb18d0..a6eb40fe0b4 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -184,7 +184,7 @@ describe("link management routes", () => { const status = await sessionCall(`${base}/api/link/status`, headers, state, cfg, deps, true); expect(status?.status).toBe(200); - expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: false }); const listed = await sessionCall(`${base}/api/link/candidates`, headers, state, cfg, deps, true); expect(listed?.status).toBe(200); expect(await listed!.json()).toEqual({ candidates: [{ alias: "home", source: "ssh_config" }] }); @@ -193,14 +193,15 @@ describe("link management routes", () => { expect((await sessionCall(`${base}/api/link/probe`, headers, state, cfg, deps, true, "POST", { alias: "home" }))?.status).toBe(403); expect((await sessionCall(`${base}/api/link/confirm-host`, mutation, state, cfg, deps, true, "POST", { alias: "home", fingerprint: "SHA256:abcdefghijklmnop" }))?.status).toBe(200); - // The same session may also turn this computer into a Child: join reaches the join step. + // Credentialless loopback sessions may inspect and confirm a host, but cannot commit the + // durable routing change that turns this computer into a Child. let joins = 0; const joinDeps = { ...deps, joinHome: async () => { joins += 1; return { linkId: "lnk_0123456789abcdef", apiKeyId: "key-join" }; } } as ManagementApiDeps; const joined = await sessionCall(`${base}/api/link/join`, mutation, state, cfg, joinDeps, true, "POST", { alias: "home" }); - expect(joined?.status).toBe(202); - expect(joins).toBe(1); + expect(joined?.status).toBe(403); + expect(joins).toBe(0); expect((await sessionCall(`${base}/api/link/join`, headers, state, cfg, joinDeps, true, "POST", { alias: "home" }))?.status).toBe(403); - expect(joins).toBe(1); + expect(joins).toBe(0); // The Home side runs end to end for this session: apply issues and connects, removal disconnects. const applied = await sessionCall(`${base}/api/link/apply`, mutation, state, cfg, deps, true, "POST", { alias: "home" }); @@ -251,10 +252,9 @@ describe("link management routes", () => { expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); const hub = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, { ...standalone, runtimeRole: "hub" } as OcxConfig); expect(await hub!.json()).toMatchObject({ joinAvailable: false }); - // The local dashboard session of a standalone may join, unless this runtime is not on its - // configured port: the client runtime a join restarts into binds only that port. + // A credentialless local session cannot join even on the configured port. const loopback = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "loopback", false, standalone); - expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: false }); const moved = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => 10200 }, "gui-session", true, "loopback", false, standalone); expect(await moved!.json()).toMatchObject({ joinAvailable: false }); const unknownPort = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => undefined }, "gui-session", true, "loopback", false, standalone); From 4fbad2bd6990a0daaa7999345bafb145a45c1f77 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 29 Sep 2026 05:48:13 +0000 Subject: [PATCH 002/145] fix(security): add explicit bounded standalone GUI pairing for link join Keep paired-only join authorization. Allow the existing attested CLI grant flow only for the configured literal loopback origin in standalone mode, with runtime address/port checks, a kernel-local redemption peer, one-use codes, fixed five-minute sessions, and role/origin invalidation. Ordinary automatic sessions remain unpaired. Expose explicit code entry during local link setup. Omit stale shared credentials only for the pairing exchange, retaining separate machine-relay credentials and normal API authentication. Add grant/session negatives, a real CLI-capability/session-control/guarded-route composition test (SSH join stubbed), and browser transport/form regressions. Validation here: complete session/capability modules with relevant auth helper excerpts: original 3 pass/4 fail, patched 7 pass/0 fail. Complete browser API module with a minimal Window adapter: original 0 pass/2 fail, patched 2 pass/0 fail. All changed TS/TSX files syntax-transpiled. Full Bun, React, repository typecheck, full server HTTP transport and native SSH/restart suites were not run locally. Independent security review and exact-head CI remain required; keep this PR in Draft. --- gui/src/api.ts | 6 +- gui/src/connect-pairing.tsx | 11 +- gui/src/pages/RemoteLink.tsx | 12 +- gui/tests/local-link-pairing.test.ts | 102 ++++++++++++++++ src/cli/gui.ts | 11 +- src/lib/gui-pair-capability.ts | 17 +++ src/server/gui-session.ts | 44 +++++-- tests/gui/gui-pair-client.test.ts | 171 ++++++++++++++++++++++++++- 8 files changed, 353 insertions(+), 21 deletions(-) create mode 100644 gui/tests/local-link-pairing.test.ts diff --git a/gui/src/api.ts b/gui/src/api.ts index 420c04d3f39..48d47eac70c 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -320,7 +320,11 @@ export function installApiAuthFetch(): void { if (!classified) return originalFetch(input, init); const state = runtime(classified.plane); const token = state.session.token; - const [firstInput, firstInit] = withAuth(classified.plane, input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + // Pairing exchanges must not carry the old shared-plane credential. The relay's + // separate machine-session headers are still attached by sessionHeaders(). + const pairingExchange = classified.bootstrap && method === "POST"; + const [firstInput, firstInit] = withAuth(classified.plane, input, init, pairingExchange ? null : undefined); const response = await originalFetch(firstInput, firstInit); if (classified.bootstrap || response.status !== 401) return response; const refreshed = state.session.token; diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx index c6c9750b1ab..e541b6e1cbb 100644 --- a/gui/src/connect-pairing.tsx +++ b/gui/src/connect-pairing.tsx @@ -7,9 +7,12 @@ import { useCopyFeedback } from "./components/use-copy-feedback"; export function ConnectPairingForm({ target, onConnected, + local = false, }: { target: ApiTarget; onConnected: () => void; + /** Explicit local pairing for a standalone link join; never an automatic bootstrap. */ + local?: boolean; }) { const t = useT(); const [grant, setGrant] = useState(""); @@ -40,15 +43,15 @@ export function ConnectPairingForm({ }; return
-

{t("connection.pairing.title")}

-

{t("connection.pairing.hub")}: {target.serverOrigin}

-

{t("connection.pairing.getCode")}

+

{t(local ? "connection.pairing.code" : "connection.pairing.title")}

+

{!local && <>{t("connection.pairing.hub")}: }{target.serverOrigin}

+ {!local &&

{t("connection.pairing.getCode")}

}
{command}
{copied === "unavailable" &&

{t("prov.linkCopyUnavailable")}

} -

{t("connection.pairing.askOperator")}

+ {!local &&

{t("connection.pairing.askOperator")}

}

{t("connection.pairing.notApiKey")}

diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index ff20e06451f..580cbca68d7 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -7,7 +7,8 @@ import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { type TKey, useT } from "../i18n/shared"; import { Notice } from "../ui"; -import { isStandaloneRuntime } from "../api-targets"; +import { isStandaloneRuntime, standaloneApiTargets } from "../api-targets"; +import { ConnectPairingForm } from "../connect-pairing"; import "../styles-remote-link.css"; type RemoteLinkRole = "home" | "child"; @@ -248,6 +249,12 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // The server offers the join to this dashboard session only on a standalone runtime that runs // on its configured port, the one port the Child runtime can restart on. const childSelectable = standaloneRuntime && status?.joinAvailable === true; + // Offer code entry only after the operator opens link setup, and only on the + // literal same-origin loopback transport accepted by the standalone mint. + const localPairingTarget = standaloneApiTargets(apiBase).shared; + const canPairLocally = standaloneRuntime && window.location.protocol === "http:" + && ["127.0.0.1", "[::1]"].includes(window.location.hostname) + && localPairingTarget.serverOrigin === window.location.origin; const openSheet = async () => { const attempt = startLinkAttempt(); setSheetOpen(true); setUiState("adding-child"); setCandidates([]); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setBusy("candidates"); @@ -380,6 +387,9 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = {statusError && {t(statusError)}} {statusRows.length === 0 && uiState === "off" &&
{t("link.switch")}

{t("link.switchOffHint")}

} {uiState === "role-select" &&

{t("link.role.title")}

{t("link.role.hint")}

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t("remoteLink.error.join_port_mismatch")}}
} + {uiState === "role-select" && canPairLocally && status !== null && !childSelectable && ( + { void refreshStatus(); }} /> + )} {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

{restartSlow && {t("remoteLink.restart.slow")}}
: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed")))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{actionError && }
} { event.preventDefault(); closeSheet(); }}> diff --git a/gui/tests/local-link-pairing.test.ts b/gui/tests/local-link-pairing.test.ts new file mode 100644 index 00000000000..0488a02eb09 --- /dev/null +++ b/gui/tests/local-link-pairing.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { configureApiTargets, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; +import type { ApiTargets } from "../src/api-targets"; + +const origin = "http://127.0.0.1:10100"; +function sessionHtml(token: string, serverOrigin = origin): string { + return ``; +} + +// TRANSPORT_REGRESSIONS_BEGIN +for (const relay of [false, true]) test(`pairing omits stale shared authentication and preserves the machine boundary (relay: ${relay})`, async () => { + const keys = ["window", "document", "sessionStorage"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }); + const sent: Headers[] = []; + const rawFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sent.push(new Headers(init?.headers)); + return new Response(null, { status: 204 }); + }) as typeof fetch; + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: rawFetch, writable: true }); + const targets: ApiTargets = { connected: relay, + machine: { id: "machine", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + shared: { id: "shared", baseUrl: relay ? "/api/machine/hub-relay" : "", serverOrigin: relay ? "https://hub.example.test" : origin, + bootstrapPath: relay ? "/api/machine/hub-relay/opencodex-session" : "/opencodex-session", transport: relay ? "relay" : "same-origin" }, + }; + try { + resetApiAuthFetchForTests(); configureApiTargets(targets); installApiAuthFetch(); + expect(installApiSessionFromHtml("machine", sessionHtml("ocx_session_machine"))).toBe(true); + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_old", targets.shared.serverOrigin))).toBe(true); + await win.fetch(targets.shared.bootstrapPath, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` }) }); + expect(sent[0]!.get("x-opencodex-api-key")).toBeNull(); + expect(sent[0]!.get("x-opencodex-gui-origin")).toBeNull(); + expect(sent[0]!.get("x-opencodex-csrf-token")).toBeNull(); + expect(sent[0]!.get("x-opencodex-machine-session")).toBe(relay ? "ocx_session_machine" : null); + expect(sent[0]!.get("x-opencodex-machine-csrf-token")).toBe(relay ? "ocx_session_machine-csrf" : null); + await win.fetch(`${targets.shared.baseUrl}/api/link/join`, { method: "POST" }); + expect(sent[1]!.get("x-opencodex-api-key")).toBe("ocx_session_old"); + expect(sent[1]!.get("x-opencodex-csrf-token")).toBe("ocx_session_old-csrf"); + // Explicit mixed credentials are not silently stripped: the server still refuses them. + await win.fetch(targets.shared.bootstrapPath, { method: "POST", headers: { authorization: "Bearer explicit" } }); + expect(sent[2]!.get("authorization")).toBe("Bearer explicit"); + // A successful exchange installs the returned session without reloading into an unpaired bootstrap. + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_paired", targets.shared.serverOrigin))).toBe(true); + await win.fetch(`${targets.shared.baseUrl}/api/link/status`); + expect(sent[3]!.get("x-opencodex-api-key")).toBe("ocx_session_paired"); + } finally { + resetApiAuthFetchForTests(); win.close(); + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); +// TRANSPORT_REGRESSIONS_END + +test("local pairing form names the local origin, makes no automatic exchange, and waits for explicit submission", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }); + let requests = 0; + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, + navigator: { configurable: true, value: win.navigator }, sessionStorage: { configurable: true, value: win.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: async () => { requests++; return new Response(null, { status: 403 }); } }); + const container = document.createElement("div"); document.body.append(container); + const { LanguageProvider } = await import("../src/i18n/provider"); + const { ConnectPairingForm } = await import("../src/connect-pairing"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + try { + await act(async () => root.render(createElement(LanguageProvider, null, createElement(ConnectPairingForm, { + local: true, target: { id: "shared", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + onConnected: () => { throw new Error("unexpected success"); }, + })))); + expect(container.textContent).toContain(`ocx gui pair --origin "${origin}"`); + expect(container.textContent).not.toContain("Connect this dashboard to the hub"); + expect(requests).toBe(0); + expect((container.querySelector('button[type="submit"]') as HTMLButtonElement).disabled).toBe(true); + const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + 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 () => { input.closest("form")!.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); }); + expect(requests).toBe(1); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); container.remove(); win.close(); + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); diff --git a/src/cli/gui.ts b/src/cli/gui.ts index 9308dad496f..dd87cce1e15 100644 --- a/src/cli/gui.ts +++ b/src/cli/gui.ts @@ -1,5 +1,5 @@ import type { OcxConfig } from "../types"; -import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { requestBoundGuiPairingGrant, @@ -23,7 +23,7 @@ export interface GuiCommandDeps extends RuntimeApiDeps { } function allowedPairingOrigin(origin: string, config: OcxConfig): boolean { - if (config.runtimeRole !== "hub") return false; + if (config.runtimeRole !== "hub") return standaloneGuiPairingOrigin(config) === origin; if (canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin) === origin) return true; return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === origin); } @@ -62,7 +62,7 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi } const config = deps.loadConfig(); if (!allowedPairingOrigin(canonicalOrigin, config)) { - console.error("The pairing origin is not enabled by hub.managementPublicOrigin or corsAllowOrigins."); + console.error("Pairing requires the standalone configured literal loopback origin, or an allowed hub origin."); return 1; } const target = await (deps.findLiveProxy ?? findLiveProxy)(); @@ -70,6 +70,11 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi console.error("No running attested OpenCodex proxy is available for GUI pairing."); return 1; } + if ((config.runtimeRole ?? "standalone") === "standalone" + && (target.port !== config.port || (target.hostname ?? "127.0.0.1") !== (config.hostname ?? "127.0.0.1"))) { + console.error("Standalone pairing requires the running proxy to use its configured loopback address and port."); + return 1; + } const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, { ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), }); diff --git a/src/lib/gui-pair-capability.ts b/src/lib/gui-pair-capability.ts index 9acb526258e..8a8c30f97a2 100644 --- a/src/lib/gui-pair-capability.ts +++ b/src/lib/gui-pair-capability.ts @@ -65,6 +65,23 @@ export function canonicalHttpOrigin(value: unknown): string | null { } } +/** + * Standalone pairing is only for this process's configured literal loopback origin. + * No CORS entry, public hub URL, wildcard bind, alias or client role can widen it. + * The CLI separately checks the attested runtime's actual port before requesting a grant. + */ +export function standaloneGuiPairingOrigin(config: { + runtimeRole?: string; + hostname?: string; + port: number; +}): string | null { + if ((config.runtimeRole ?? "standalone") !== "standalone") return null; + const hostname = config.hostname ?? "127.0.0.1"; + if (hostname !== "127.0.0.1" && hostname !== "::1") return null; + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) return null; + return new URL(`http://${hostname === "::1" ? "[::1]" : hostname}:${config.port}`).origin; +} + function capabilityPayload( nonce: string, method: string, diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index 954414639be..daaf5a865f3 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { OcxConfig } from "../types"; -import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability"; import { isAllowedManagementOrigin, isApiAuthRequired, @@ -20,6 +20,8 @@ export interface GuiSessionRecord { csrfToken: string; expiresAt: number; issuance: GuiSessionIssuance; + /** Process-local marker; never accepted from a browser. */ + localPairing?: true; } export interface GuiSessionBootstrap extends GuiSessionRecord { @@ -31,6 +33,7 @@ export interface GuiPairingGrantRecord { browserOrigin: string; expiresAt: number; failedAttempts?: number; + localPairing?: true; } export interface PairingAttemptContext { @@ -131,6 +134,7 @@ function mintSession( issuance: GuiSessionIssuance, state: GuiSessionState, now: number, + localPairing = false, ): GuiSessionBootstrap { pruneExpired(state, now); evictOldestSession(state); @@ -142,8 +146,9 @@ function mintSession( serverOrigin, browserOrigin, csrfToken: randomBytes(32).toString("base64url"), - expiresAt: now + (issuance === "loopback" ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS), + expiresAt: now + (issuance === "loopback" || localPairing ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS), issuance, + ...(localPairing ? { localPairing: true as const } : {}), }; state.sessions.set(token, session); return { @@ -258,13 +263,16 @@ export function createGuiPairingGrant( now = Date.now(), ): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } { const canonicalBrowserOrigin = canonicalGuiBrowserOrigin(browserOrigin); - const serverOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin); + const localOrigin = standaloneGuiPairingOrigin(config); + const localPairing = localOrigin !== null && canonicalBrowserOrigin === localOrigin; + const serverOrigin = config.runtimeRole === "hub" + ? canonicalHttpOrigin(config.hub?.managementPublicOrigin) : localOrigin; if ( - config.runtimeRole !== "hub" - || !canonicalBrowserOrigin + !canonicalBrowserOrigin || canonicalBrowserOrigin !== browserOrigin || !serverOrigin - || !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) + || (config.runtimeRole === "hub" + ? !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) : !localPairing) ) throw new TypeError("remote GUI origin is not allowed"); pruneExpired(state, now); consumeGrantRateSlot(state, now); @@ -276,7 +284,8 @@ export function createGuiPairingGrant( digest = pairingGrantDigest(grant); } while (state.pairingGrants.has(digest)); const expiresAt = now + GUI_PAIRING_GRANT_TTL_MS; - state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }); + state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt, + ...(localPairing ? { localPairing: true as const } : {}) }); return { grant, browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }; } @@ -316,7 +325,8 @@ export function consumeGuiPairingGrant( now = Date.now(), attemptContext?: PairingAttemptContext, ): GuiSessionBootstrap | PairingAttemptRefusal | null { - if (req.method !== "POST" || hasAlternateCredential(req) || config.runtimeRole !== "hub") return null; + const localOrigin = standaloneGuiPairingOrigin(config); + if (req.method !== "POST" || hasAlternateCredential(req) || (config.runtimeRole !== "hub" && !localOrigin)) return null; // Scheme check FIRST, before the grant is parsed or looked up. // // A grant is single-use, so consuming one and then refusing to mint would burn the @@ -333,6 +343,11 @@ export function consumeGuiPairingGrant( const grant = strictPairingGrantBody(body); const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin")); if (!grant || !browserOrigin) return null; + // Unlike the hub path, standalone redemption never accepts a proxy-origin claim: + // both origins must be the configured loopback origin and the kernel peer must be local. + if (localOrigin && (destination !== localOrigin || browserOrigin !== localOrigin + || attemptContext?.ingress !== "public" + || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(attemptContext.peerAddress ?? ""))) return null; const context = attemptContext ?? { ingress: "public", peerAddress: null, @@ -344,11 +359,14 @@ export function consumeGuiPairingGrant( const found = findPairingGrant(grant, state); if (!found) { // Cross-origin browser requests must not create limiter state as a side effect. - if (!isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null; + if (localOrigin ? browserOrigin !== localOrigin : !isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null; const source = recordSourceFailure(state, context, now); return attemptContext && !source.allowed ? source : null; } const [digest, record] = found; + // A live role/config change must not turn a hub grant into local operator consent, or vice versa. + if (record.localPairing ? !localOrigin || record.serverOrigin !== localOrigin + : config.runtimeRole !== "hub") return null; if (record.expiresAt <= now) { state.pairingGrants.delete(digest); return null; @@ -369,7 +387,7 @@ export function consumeGuiPairingGrant( // the session is actually minted from. if (!isPairingTransportPermitted(record.serverOrigin)) return null; state.pairingGrants.delete(digest); - return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now); + return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now, record.localPairing === true); } /** @@ -420,6 +438,10 @@ export function authorizeGuiSessionRequest( state.sessions.delete(token); return { ok: false, reason: "expired" }; } + if (session.localPairing && standaloneGuiPairingOrigin(config) !== session.serverOrigin) { + state.sessions.delete(token); + return { ok: false, reason: "server-origin" }; + } if (managementRequestOrigin(req, config) !== session.serverOrigin) { return { ok: false, reason: "server-origin" }; } @@ -435,6 +457,6 @@ export function authorizeGuiSessionRequest( const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" }; } - if (session.issuance !== "loopback") session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; + if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; return { ok: true, principal: "gui-session", session }; } diff --git a/tests/gui/gui-pair-client.test.ts b/tests/gui/gui-pair-client.test.ts index 7e1809554d4..94fef8e70f1 100644 --- a/tests/gui/gui-pair-client.test.ts +++ b/tests/gui/gui-pair-client.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import type { OcxConfig } from "../../src/types"; +import { runGuiCommand } from "../../src/cli/gui"; +import { createGuiPairingGrant, consumeGuiPairingGrant, issueGuiSession, authorizeGuiSessionRequest } from "../../src/server/gui-session"; +import { createManagementSessionControl, managementPrincipal, managementSessionIssuance, requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; +import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { describe, expect, spyOn, test } from "bun:test"; import { requestBoundGuiPairingGrant } from "../../src/cli/gui-pair-client"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, @@ -160,3 +166,166 @@ describe("GUI pairing client", () => { expect(JSON.stringify(rejected)).not.toContain("secret-must-not-surface"); }); }); + +const localConfig: OcxConfig = { port: 10100, hostname: "127.0.0.1", runtimeRole: "standalone", defaultProvider: "test", providers: {} }; +const localOrigin = "http://127.0.0.1:10100"; +function pairingState(): Extract { + return { available: true, token: `ocx_admin_${"D".repeat(43)}`, source: "environment", sessions: new Map(), pairingGrants: new Map() }; +} +function pairingRequest(origin = localOrigin, extra: Record = {}): Request { + return new Request(`${origin}/opencodex-session`, { method: "POST", headers: { Host: new URL(origin).host, Origin: origin, ...extra } }); +} +const localAttempt = { ingress: "public" as const, peerAddress: "127.0.0.1", tailscaleUser: null, browserOrigin: localOrigin }; + +// PURE_PAIRING_REGRESSIONS_BEGIN + +describe("standalone one-use pairing boundaries", () => { + test("mint, redeem once, enforce CSRF and expire without sliding", () => { + const state = pairingState(), now = Date.now(); + const created = createGuiPairingGrant(localOrigin, localConfig, state, now); + expect(created.serverOrigin).toBe(localOrigin); + expect(state.pairingGrants.has(created.grant)).toBe(false); + const session = consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, localAttempt); + expect(session).toMatchObject({ issuance: "pairing", serverOrigin: localOrigin, browserOrigin: localOrigin }); + if (!session || "allowed" in session) throw new Error("expected a real redeemed session"); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + const request = (csrf?: string) => new Request(`${localOrigin}/api/link/join`, { method: "POST", headers: { + Host: "127.0.0.1:10100", Origin: localOrigin, "x-opencodex-gui-origin": localOrigin, + "x-opencodex-api-key": session.token, ...(csrf ? { "x-opencodex-csrf-token": csrf } : {}), + } }); + expect(authorizeGuiSessionRequest(request(), localConfig, state, now).ok).toBe(false); + expect(authorizeGuiSessionRequest(request(session.csrfToken), localConfig, state, now + 1000).ok).toBe(true); + expect(state.sessions.get(session.token)?.expiresAt).toBe(now + 300000); + expect(authorizeGuiSessionRequest(request(session.csrfToken), localConfig, state, now + 300000).ok).toBe(false); + }); + test("wrong address, alternate credentials, missing peer and nonlocal peer cannot burn a valid code", () => { + const state = pairingState(), now = Date.now(); + const created = createGuiPairingGrant(localOrigin, localConfig, state, now); + for (const peerAddress of [null, "", "192.0.2.3"]) { + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, + { ...localAttempt, peerAddress })).toBeNull(); + } + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now)).toBeNull(); + expect(consumeGuiPairingGrant(pairingRequest("http://127.0.0.1:10101"), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + expect(consumeGuiPairingGrant(pairingRequest(localOrigin, { Origin: "https://foreign.example.test" }), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + for (const name of ["authorization", "x-opencodex-api-key", "x-api-key"]) { + expect(consumeGuiPairingGrant(pairingRequest(localOrigin, { [name]: "old-credential" }), { grant: created.grant }, localConfig, state, now, localAttempt)).toBeNull(); + } + expect(state.pairingGrants.size).toBe(1); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: created.grant }, localConfig, state, now, + { ...localAttempt, peerAddress: "::ffff:127.0.0.1" })).toMatchObject({ issuance: "pairing" }); + }); + test("CORS and hub hints cannot widen the standalone mint", () => { + for (const origin of ["http://localhost:10100", "http://127.0.0.1:10101", "https://127.0.0.1:10100", "https://foreign.example.test", "http://127.0.0.1:10100/"]) { + const state = pairingState(); + expect(() => createGuiPairingGrant(origin, { ...localConfig, corsAllowOrigins: [origin], hub: { managementPublicOrigin: origin } }, state)).toThrow(); + expect(state.pairingGrants.size).toBe(0); + } + for (const config of [{ ...localConfig, hostname: "0.0.0.0" }, { ...localConfig, hostname: "localhost" }, + { ...localConfig, runtimeRole: "client" as const }, { ...localConfig, port: 0 }]) { + expect(() => createGuiPairingGrant(localOrigin, config, pairingState())).toThrow(); + } + }); + test("default standalone and IPv6 use the same exact-origin contract", () => { + for (const [config, origin, peerAddress] of [ + [{ ...localConfig, runtimeRole: undefined, hostname: undefined }, localOrigin, "127.0.0.1"], + [{ ...localConfig, hostname: "::1" }, "http://[::1]:10100", "::1"], + ] as const) { + const state = pairingState(), created = createGuiPairingGrant(origin, config, state); + expect(consumeGuiPairingGrant(pairingRequest(origin), { grant: created.grant }, config, state, Date.now(), + { ...localAttempt, browserOrigin: origin, peerAddress })).toMatchObject({ issuance: "pairing" }); + } + }); + test("role changes cannot convert grants and invalidate local paired sessions", () => { + const state = pairingState(), now = Date.now(); + const hub = { ...localConfig, runtimeRole: "hub" as const, hub: { managementPublicOrigin: localOrigin } }; + const localGrant = createGuiPairingGrant(localOrigin, localConfig, state, now); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: localGrant.grant }, hub, state, now, localAttempt)).toBeNull(); + const hubGrant = createGuiPairingGrant(localOrigin, hub, state, now); + expect(consumeGuiPairingGrant(pairingRequest(), { grant: hubGrant.grant }, localConfig, state, now, localAttempt)).toBeNull(); + const session = consumeGuiPairingGrant(pairingRequest(), { grant: localGrant.grant }, localConfig, state, now, localAttempt); + if (!session || "allowed" in session) throw new Error("expected local pairing"); + const request = new Request(`${localOrigin}/api/link/status`, { headers: { Host: "127.0.0.1:10100", + "x-opencodex-api-key": session.token, "x-opencodex-gui-origin": localOrigin } }); + expect(authorizeGuiSessionRequest(request, hub, state, now).ok).toBe(false); + expect(state.sessions.has(session.token)).toBe(false); + }); + test("remote hub pairing remains origin-bound with its existing lifetime", () => { + const state = pairingState(), now = Date.now(), origin = "https://hub.example.test"; + const config: OcxConfig = { ...localConfig, runtimeRole: "hub", hostname: "0.0.0.0", hub: { managementPublicOrigin: origin } }; + const created = createGuiPairingGrant(origin, config, state, now); + const session = consumeGuiPairingGrant(pairingRequest(origin), { grant: created.grant }, config, state, now); + expect(session).toMatchObject({ issuance: "pairing", expiresAt: now + 12 * 60 * 60000 }); + }); + test("ordinary local bootstrap remains unpaired", () => { + const state = pairingState(); + const session = issueGuiSession(new Request(`${localOrigin}/opencodex-session`, { + headers: { Host: "127.0.0.1:10100", Origin: localOrigin }, + }), localConfig, state); + expect(session).toMatchObject({ issuance: "loopback" }); + expect(state.pairingGrants.size).toBe(0); + }); +}); + +// PURE_PAIRING_REGRESSIONS_END + +test("operator CLI proof -> one-use grant -> real paired session -> guarded join, without an isPaired stub", async () => { + const state = pairingState(), output: string[] = []; + const log = spyOn(console, "log").mockImplementation(value => { output.push(String(value)); }); + const local = { attestationSecret: secret, pid: target.pid!, port: target.port }; + let joins = 0; + try { + const result = await runGuiCommand(["pair", "--origin", localOrigin, "--json"], { + loadConfig: () => localConfig, findLiveProxy: async () => target, openDefaultGui: async () => 0, + requestPairingGrant: (runtime, origin) => requestBoundGuiPairingGrant(runtime, origin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + fetchImpl: (async (input, init) => { + if (!init?.method) return proofResponse(init); + const req = new Request(input, init); + expect(requireManagementAuth(req, state, localConfig, local)).toBeNull(); + expect(managementPrincipal(req, state, localConfig, local)).toBe("gui-pair-capability"); + return Response.json(createGuiPairingGrant(req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER)!, localConfig, state), { status: 201 }); + }) as typeof fetch, + }), + }); + expect(result).toBe(0); expect(output).toHaveLength(1); + const grant = JSON.parse(output[0]!).grant; + const paired = consumeGuiPairingGrant(pairingRequest(), { grant }, localConfig, state, Date.now(), localAttempt); + if (!paired || "allowed" in paired) throw new Error("operator grant was not redeemed"); + const ordinary = issueGuiSession(new Request(`${localOrigin}/`, { headers: { Host: "127.0.0.1:10100" } }), localConfig, state)!; + const sessionControl = createManagementSessionControl(state); + const routeState: LinkRouteState = { pendingHosts: new Map(), confirmedHosts: new Map([["home", { + alias: "home", fingerprint: `SHA256:${"a".repeat(32)}`, keyType: "ed25519", knownHostLine: "home ssh-ed25519 AAAA", probedAt: Date.now(), ocxVersion: "2.71.0", + }]]), supervisor: {} as LinkRouteState["supervisor"], listener: {} as LinkRouteState["listener"] }; + for (const [session, expected] of [[ordinary, 403], [paired, 202]] as const) { + const req = new Request(`${localOrigin}/api/link/join`, { method: "POST", headers: { + Host: "127.0.0.1:10100", Origin: localOrigin, "content-type": "application/json", "x-opencodex-api-key": session.token, + "x-opencodex-gui-origin": localOrigin, "x-opencodex-csrf-token": session.csrfToken, + }, body: JSON.stringify({ alias: "home" }) }); + expect(requireManagementAuth(req, state, localConfig)).toBeNull(); + const ctx: ManagementContext = { req, url: new URL(req.url), config: localConfig, version: "test", + principal: managementPrincipal(req, state, localConfig) ?? undefined, sessionControl, + trustedLoopbackIngress: true, guiSessionIssuance: managementSessionIssuance(req, state), + deps: { liveListenPort: () => localConfig.port, linkKnownHostsPath: () => "/unused/known_hosts", + sshRunner: { run: async () => { throw new Error("unexpected SSH"); } } as never, ...{ joinHome: async () => { joins++; return { linkId: "lnk_0123456789abcdef", apiKeyId: "link-key-1" }; } } }, + convergeCodexCatalog: async () => ({ status: "unchanged" } as never), syncClaudeAgentDefsBestEffort: async () => {}, + }; + expect((await handleLinkRoutes(ctx, routeState))?.status).toBe(expected); + } + expect(joins).toBe(1); + } finally { log.mockRestore(); } +}); + +test("standalone CLI rejects actual runtime address/port mismatch before requesting a grant", async () => { + let requests = 0; + const log = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const runtime of [{ ...target, port: 10101 }, { ...target, hostname: "0.0.0.0" }]) { + expect(await runGuiCommand(["pair", "--origin", localOrigin], { loadConfig: () => localConfig, + findLiveProxy: async () => runtime, openDefaultGui: async () => 0, + requestPairingGrant: async () => { requests++; return { kind: "unavailable", reason: "rejected" }; }, + })).toBe(1); + } + expect(requests).toBe(0); + } finally { log.mockRestore(); } +}); From 1a20825e91d0531a360d13ffc5cec620e2335712 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 1 Oct 2026 05:54:17 -0700 Subject: [PATCH 003/145] fix(link): explain dashboard join denial causes --- .../src/content/docs/fr/guides/remote-link.md | 4 +- .../src/content/docs/guides/remote-link.md | 4 +- .../src/content/docs/ja/guides/remote-link.md | 4 +- .../src/content/docs/ko/guides/remote-link.md | 4 +- .../src/content/docs/ru/guides/remote-link.md | 4 +- .../src/content/docs/tr/guides/remote-link.md | 4 +- .../content/docs/zh-cn/guides/remote-link.md | 4 +- .../content/docs/zh-tw/guides/remote-link.md | 4 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/RemoteLink.tsx | 12 +++- gui/src/remote-link-api.ts | 9 ++- gui/tests/remote-link.test.tsx | 67 ++++++++++++++++++- src/server/management/link-routes.ts | 17 +++-- structure/gui-and-management-api.md | 2 +- structure/remote-link.md | 2 +- tests/server/link-management-routes.test.ts | 27 +++++--- 25 files changed, 158 insertions(+), 30 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 90577ee34ab..11363609697 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -36,7 +36,9 @@ Sur l’ordinateur qui doit utiliser les fournisseurs de Home : La connexion redémarre OpenCodex sur cet ordinateur. Les tours Codex déjà en cours se terminent d’abord, et les nouvelles requêtes peuvent échouer pendant une minute au plus pendant le redémarrage. Le tableau de bord se recharge ensuite de lui-même et affiche la liaison Child. Codex continue d’utiliser `http://127.0.0.1:/v1` sur cet ordinateur, sans jeton ni variable d’environnement à définir : l’OpenCodex local relaie chaque requête vers Home, qui y répond avec ses propres fournisseurs et comptes. -Le rôle **Child** n’est disponible que lorsque OpenCodex tourne sur son port configuré, car Child redémarre exactement sur ce port. Si le tableau de bord indique qu’OpenCodex ne tourne pas sur son port configuré, redémarrez-le d’abord sur ce port. +Si **Child** demande de jumeler d’abord cet ordinateur, ouvrez son tableau de bord HTTP à l’adresse IP de bouclage configurée, par exemple `http://127.0.0.1:`. Le formulaire de jumelage local apparaît uniquement lorsque le jumelage manque et que le tableau de bord et l’API utilisent la même origine de bouclage. Copiez la commande `ocx gui pair --origin "http://127.0.0.1:"` du formulaire, exécutez-la dans un terminal sur cet ordinateur, puis collez le code à usage unique dans le formulaire. Utilisez exactement l’origine affichée ; une clé API de fournisseur ou un jeton d’administration n’est pas un code de jumelage. L’absence de jumelage et un port différent du port configuré sont deux causes distinctes. + +Le rôle **Child** exige également qu’OpenCodex fonctionne en mode autonome sur son port configuré, car Child redémarre exactement sur ce port. Si le tableau de bord indique qu’OpenCodex ne tourne pas sur son port configuré, redémarrez-le d’abord sur ce port. ## État de la liaison diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 4832ff3b5ea..02272acc780 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -38,7 +38,9 @@ Connecting restarts OpenCodex on this computer. Codex turns that are already run The Child waits for its configured port while the old process releases it. If a CLI-managed restart still fails, run `ocx start` on the Child and check `~/.opencodex/restart-handoff.log`. In the desktop app, the app starts and supervises the replacement automatically. -The **Child** role is available only while OpenCodex runs on its configured port, because the Child restarts on exactly that port. If the dashboard says OpenCodex is not running on its configured port, restart it there first. +If **Child** says to pair this machine first, open this computer's configured literal-loopback HTTP dashboard, for example `http://127.0.0.1:`. The local pairing form appears only when pairing is missing and the dashboard and API use the same loopback origin. Copy the form's `ocx gui pair --origin "http://127.0.0.1:"` command, run it in a terminal on this computer, and paste the one-use code into the form. Use the exact origin shown in the form; a provider API key or admin token is not a pairing code. Missing pairing is separate from a configured-port mismatch. + +The **Child** role also requires a standalone OpenCodex runtime running on its configured port, because the Child restarts on exactly that port. If the dashboard says OpenCodex is not running on its configured port, restart it there first. ## Link status diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 28ac9a0f447..9088e5f36e4 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -36,7 +36,9 @@ Home のプロバイダーを使うコンピューターで次の操作を行い 接続すると、このコンピューターの OpenCodex が再起動します。すでに実行中の Codex ターンは先に完了し、再起動中の最大 1 分間は新しいリクエストが失敗することがあります。その後ダッシュボードは自動的に再読み込みされ、Child のリンクを表示します。Codex はこのコンピューターの `http://127.0.0.1:/v1` を使い続け、トークンや環境変数の設定は不要です。ローカルの OpenCodex が各リクエストを Home に中継し、Home が自身のプロバイダーとアカウントで応答します。 -**Child** の役割は、OpenCodex が設定されたポートで動作している間だけ選択できます。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 +**Child** にこのコンピューターを先にペアリングするよう表示された場合は、このコンピューターに設定された HTTP ループバック IP アドレスのダッシュボード(例: `http://127.0.0.1:`)を開いてください。ローカルのペアリングフォームは、ペアリングが必要で、ダッシュボードと API が同じループバックオリジンを使う場合にのみ表示されます。フォームの `ocx gui pair --origin "http://127.0.0.1:"` コマンドをコピーしてこのコンピューターのターミナルで実行し、使い捨てコードをフォームに貼り付けてください。フォームに表示された正確なオリジンを使ってください。プロバイダーの API キーや管理者トークンはペアリングコードではありません。ペアリングの不足と設定されたポートの不一致は別の原因です。 + +**Child** の役割には、OpenCodex がスタンドアロンモードで設定されたポートで動作していることも必要です。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 ## リンクの状態 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index b1942d4af0c..3342001ab61 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -36,7 +36,9 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. 연결하면 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 이미 실행 중인 Codex 작업은 먼저 끝나고, 다시 시작되는 동안 최대 1분 정도 새 요청이 실패할 수 있습니다. 그 뒤 대시보드가 스스로 새로 고쳐지고 Child 링크를 보여 줍니다. Codex는 이 컴퓨터의 `http://127.0.0.1:/v1`을 그대로 사용하며 토큰이나 환경 변수를 설정할 필요가 없습니다. 로컬 OpenCodex가 각 요청을 Home으로 전달하고, Home은 자신의 프로바이더와 계정으로 응답합니다. -**Child** 역할은 OpenCodex가 설정된 포트에서 실행 중일 때만 선택할 수 있습니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. +**Child**에 이 컴퓨터를 먼저 페어링하라는 안내가 나오면, 이 컴퓨터에 설정된 IP 주소 형식의 HTTP 루프백 대시보드(예: `http://127.0.0.1:`)를 여세요. 로컬 페어링 양식은 페어링이 필요하고 대시보드와 API가 같은 루프백 출처를 사용할 때만 표시됩니다. 양식의 `ocx gui pair --origin "http://127.0.0.1:"` 명령을 복사해 이 컴퓨터의 터미널에서 실행한 뒤, 일회용 코드를 양식에 붙여 넣으세요. 양식에 표시된 정확한 출처를 사용해야 하며, 프로바이더 API 키나 관리자 토큰은 페어링 코드가 아닙니다. 페어링 누락과 설정된 포트의 불일치는 서로 다른 사유입니다. + +**Child** 역할을 선택하려면 OpenCodex가 독립형 런타임으로 설정된 포트에서 실행 중이어야 합니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. ## 링크 상태 diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index 5a289596817..fa6052dc383 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -36,7 +36,9 @@ SSH с паролем и Windows сейчас не поддерживаются. При подключении OpenCodex на этом компьютере перезапустится. Уже идущие запросы Codex сначала завершатся, а новые запросы могут не проходить до минуты, пока идёт перезапуск. Затем панель перезагрузится сама и покажет связь Child. Codex продолжит использовать `http://127.0.0.1:/v1` на этом компьютере без токена и без переменных окружения: локальный OpenCodex передаёт каждый запрос на Home, а Home обслуживает его своими провайдерами и учётными записями. -Роль **Child** доступна, только пока OpenCodex работает на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. +Если **Child** просит сначала выполнить сопряжение этого компьютера, откройте на нём HTTP-панель по настроенному IP-адресу обратной петли, например `http://127.0.0.1:`. Локальная форма сопряжения появляется только при отсутствии сопряжения, когда панель и API используют один и тот же origin обратной петли. Скопируйте из формы команду `ocx gui pair --origin "http://127.0.0.1:"`, выполните её в терминале на этом компьютере и вставьте одноразовый код в форму. Используйте точно тот origin, который показан в форме; API-ключ провайдера или токен администратора не является кодом сопряжения. Отсутствие сопряжения и несовпадение настроенного порта — разные причины. + +Роль **Child** также требует, чтобы OpenCodex работал в автономном режиме на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. ## Состояние связи diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index 3c8575de4c5..2ca1201bf9c 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -36,7 +36,9 @@ Home'un sağlayıcılarını kullanacak bilgisayarda: Bağlanmak bu bilgisayardaki OpenCodex'i yeniden başlatır. Zaten çalışan Codex istekleri önce tamamlanır ve yeniden başlatma sırasında yeni istekler bir dakikaya kadar başarısız olabilir. Ardından kontrol paneli kendiliğinden yeniden yüklenir ve Child bağlantısını gösterir. Codex bu bilgisayarda `http://127.0.0.1:/v1` adresini kullanmaya devam eder ve belirteç veya ortam değişkeni ayarlamanız gerekmez: yerel OpenCodex her isteği Home'a aktarır, Home da kendi sağlayıcıları ve hesaplarıyla yanıt verir. -**Child** rolü yalnızca OpenCodex yapılandırılmış bağlantı noktasında çalışırken kullanılabilir, çünkü Child tam olarak o bağlantı noktasında yeniden başlar. Kontrol paneli OpenCodex'in yapılandırılmış bağlantı noktasında çalışmadığını söylerse önce onu o bağlantı noktasında yeniden başlatın. +**Child** önce bu bilgisayarı eşleştirmenizi istiyorsa bu bilgisayarın yapılandırılmış HTTP geri döngü IP adresindeki kontrol panelini açın; örneğin `http://127.0.0.1:`. Yerel eşleştirme formu yalnızca eşleştirme eksik olduğunda ve kontrol paneli ile API aynı geri döngü kaynağını kullandığında görünür. Formdaki `ocx gui pair --origin "http://127.0.0.1:"` komutunu kopyalayıp bu bilgisayarın terminalinde çalıştırın, ardından tek kullanımlık kodu forma yapıştırın. Formda gösterilen kaynak adresini aynen kullanın; sağlayıcı API anahtarı veya yönetici belirteci eşleştirme kodu değildir. Eksik eşleştirme ile yapılandırılmış bağlantı noktası uyuşmazlığı farklı nedenlerdir. + +**Child** rolü ayrıca OpenCodex'in bağımsız çalışma modunda, yapılandırılmış bağlantı noktasında çalışmasını gerektirir, çünkü Child tam olarak o bağlantı noktasında yeniden başlar. Kontrol paneli OpenCodex'in yapılandırılmış bağlantı noktasında çalışmadığını söylerse önce onu o bağlantı noktasında yeniden başlatın. ## Bağlantı durumu diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 9f8464cd2c8..53b6d69b991 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -36,7 +36,9 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 连接会重启这台电脑上的 OpenCodex。已在运行的 Codex 请求会先完成,重启期间新的请求可能在最多一分钟内失败。随后控制台会自动重新加载并显示子机链接。Codex 继续使用这台电脑上的 `http://127.0.0.1:/v1`,无需设置令牌或环境变量:本地 OpenCodex 会把每个请求转发给主机,由主机用它自己的提供商和账户提供服务。 -只有当 OpenCodex 在其配置的端口上运行时,才能选择 **Child** 角色,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 +如果 **Child** 提示先配对这台电脑,请在这台电脑上打开配置的 HTTP 回环 IP 地址控制台,例如 `http://127.0.0.1:`。只有在缺少配对且控制台与 API 使用同一个回环源时,才会显示本地配对表单。复制表单中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在这台电脑的终端中运行,再将一次性代码粘贴到表单中。必须使用表单显示的确切源;提供商 API 密钥或管理员令牌不是配对代码。缺少配对与配置端口不匹配是不同的原因。 + +选择 **Child** 角色还要求 OpenCodex 以独立运行模式在其配置的端口上运行,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 ## 链接状态 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index 31f312b06bb..473328ea190 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -36,7 +36,9 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 連線會重新啟動這台電腦上的 OpenCodex。已在執行的 Codex 請求會先完成,重新啟動期間新的請求可能在最多一分鐘內失敗。之後儀表板會自動重新載入並顯示 Child 連結。Codex 會繼續使用這台電腦上的 `http://127.0.0.1:/v1`,不需要設定權杖或環境變數:本機 OpenCodex 會把每個請求轉送給 Home,由 Home 以它自己的供應商與帳戶提供服務。 -只有當 OpenCodex 在其設定的連接埠上執行時,才能選擇 **Child** 角色,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 +如果 **Child** 提示先配對這台電腦,請在這台電腦上開啟設定的 HTTP 回環 IP 位址儀表板,例如 `http://127.0.0.1:`。只有在缺少配對且儀表板與 API 使用相同的回環來源時,才會顯示本機配對表單。複製表單中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在這台電腦的終端機中執行,再將一次性代碼貼到表單中。必須使用表單顯示的確切來源;供應商 API 金鑰或管理員權杖不是配對代碼。缺少配對與設定的連接埠不符是不同的原因。 + +選擇 **Child** 角色還要求 OpenCodex 以獨立執行模式在其設定的連接埠上執行,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 ## 連結狀態 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 204aa1cf580..b270c3345ad 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3448,6 +3448,8 @@ export const de: Record = { "remoteLink.error.join_in_progress": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.join_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.join_port_mismatch": "OpenCodex läuft nicht auf seinem konfigurierten Port und kann daher nicht als Kind neu starten. Starten Sie OpenCodex auf dem konfigurierten Port neu und versuchen Sie es dann erneut.", + "remoteLink.joinDenied.pairing_required": "Koppeln Sie diesen Computer zuerst, um ihn als Kind zu verbinden. Öffnen Sie das Dashboard dieses Computers über seine Loopback-Adresse und geben Sie einen vom Betreiber erstellten einmaligen Kopplungscode ein.", + "remoteLink.joinDenied.unavailable": "Eine Verbindung als Kind ist in dieser Dashboard-Sitzung nicht verfügbar. Aktualisieren Sie den Status und prüfen Sie die Kopplungs- und Laufzeiteinstellungen dieses Computers.", "remoteLink.error.join_rollback_failed": "Die Verbindung ist fehlgeschlagen und der Link auf Home konnte nicht entfernt werden. Wiederholen Sie die Bereinigung oder führen Sie auf Home ocx link revoke aus.", "remoteLink.error.join_restart_failed": "Der Link ist bereit. Starten Sie OpenCodex auf diesem Computer neu, um die Verbindung als Child abzuschließen.", "remoteLink.error.join_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index eb0b4655184..a7cd24172c5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3482,6 +3482,8 @@ export const en = { "remoteLink.error.join_in_progress": "Remote link request could not be completed.", "remoteLink.error.join_port_failed": "Remote link request could not be completed.", "remoteLink.error.join_port_mismatch": "OpenCodex is not running on its configured port, so it cannot restart as a Child. Restart OpenCodex on its configured port, then try again.", + "remoteLink.joinDenied.pairing_required": "Pair this machine first to join as a Child. Open this computer's loopback dashboard and enter an operator-created one-use pairing code.", + "remoteLink.joinDenied.unavailable": "Joining as a Child is unavailable for this dashboard session. Refresh the status and check this machine's pairing and runtime settings.", "remoteLink.error.join_rollback_failed": "Joining failed and the link on Home could not be removed. Retry the cleanup, or run ocx link revoke on Home.", "remoteLink.error.join_restart_failed": "The link is ready. Restart OpenCodex on this computer to finish connecting as a Child.", "remoteLink.error.join_connect_failed": "Remote link request could not be completed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c6cdb4e76e6..e7e47b9b940 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3439,6 +3439,8 @@ export const fr: Record = { "remoteLink.error.join_restart_failed": "Le lien est prêt. Redémarrez OpenCodex sur cet ordinateur pour terminer la connexion en tant qu’Enfant.", "remoteLink.error.join_port_failed": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.join_port_mismatch": "OpenCodex ne tourne pas sur son port configuré et ne peut donc pas redémarrer comme Enfant. Redémarrez OpenCodex sur son port configuré, puis réessayez.", + "remoteLink.joinDenied.pairing_required": "Associez d’abord cet ordinateur pour le connecter en tant qu’Enfant. Ouvrez son tableau de bord via son adresse de bouclage et saisissez un code d’association à usage unique créé par l’opérateur.", + "remoteLink.joinDenied.unavailable": "La connexion en tant qu’Enfant n’est pas disponible dans cette session du tableau de bord. Actualisez l’état et vérifiez les paramètres d’association et d’exécution de cet ordinateur.", "remoteLink.error.join_connect_failed": "La demande de lien distant n’a pas pu aboutir.", "link.noChildren": "Aucun ordinateur enfant connecté.", "remoteLink.status.connecting": "Connexion", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 36d2bfcbe27..bd17c113ebd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3472,6 +3472,8 @@ export const ja: Record = { "remoteLink.error.join_restart_failed": "リンクの準備ができました。このコンピューターで OpenCodex を再起動して、子としての接続を完了してください。", "remoteLink.error.join_port_failed": "リモートリンク要求を完了できませんでした。", "remoteLink.error.join_port_mismatch": "OpenCodex が設定されたポートで動作していないため、子として再起動できません。設定されたポートで OpenCodex を再起動してから、もう一度お試しください。", + "remoteLink.joinDenied.pairing_required": "子として接続するには、まずこのコンピューターをペアリングしてください。このコンピューターの loopback アドレスでダッシュボードを開き、管理者が作成したワンタイムペアリングコードを入力してください。", + "remoteLink.joinDenied.unavailable": "このダッシュボードセッションでは、子として接続できません。状態を更新し、このコンピューターのペアリングとランタイムの設定を確認してください。", "remoteLink.error.join_connect_failed": "リモートリンク要求を完了できませんでした。", "link.noChildren": "接続された子コンピューターはありません。", "remoteLink.status.connecting": "接続中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 73fad35e2d9..6ceaa5ba6fd 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3472,6 +3472,8 @@ export const ko: Record = { "remoteLink.error.join_restart_failed": "링크가 준비되었습니다. 이 컴퓨터에서 OpenCodex를 다시 시작해 자식 연결을 완료하세요.", "remoteLink.error.join_port_failed": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.join_port_mismatch": "OpenCodex가 설정된 포트에서 실행되고 있지 않아 자식으로 다시 시작할 수 없습니다. 설정된 포트에서 OpenCodex를 다시 시작한 뒤 다시 시도하세요.", + "remoteLink.joinDenied.pairing_required": "자식으로 연결하려면 먼저 이 컴퓨터를 페어링하세요. 루프백 주소로 이 컴퓨터의 대시보드를 열고 운영자가 생성한 일회용 페어링 코드를 입력하세요.", + "remoteLink.joinDenied.unavailable": "이 대시보드 세션에서는 자식으로 연결할 수 없습니다. 상태를 새로고침하고 이 컴퓨터의 페어링 및 런타임 설정을 확인하세요.", "remoteLink.error.join_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", "remoteLink.status.connecting": "연결 중", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6a592b041a7..f4010b4dc16 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3473,6 +3473,8 @@ export const ru: Record = { "remoteLink.error.join_restart_failed": "Связь готова. Перезапустите OpenCodex на этом компьютере, чтобы завершить подключение в роли дочернего компьютера.", "remoteLink.error.join_port_failed": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.join_port_mismatch": "OpenCodex работает не на настроенном порту, поэтому не может перезапуститься как Child. Перезапустите OpenCodex на настроенном порту и повторите попытку.", + "remoteLink.joinDenied.pairing_required": "Сначала выполните сопряжение этого компьютера, чтобы подключить его в роли дочернего. Откройте дашборд этого компьютера по loopback-адресу и введите одноразовый код сопряжения, созданный оператором.", + "remoteLink.joinDenied.unavailable": "Подключение в роли дочернего компьютера недоступно в этой сессии дашборда. Обновите статус и проверьте настройки сопряжения и среды выполнения на этом компьютере.", "remoteLink.error.join_connect_failed": "Не удалось завершить запрос удалённой связи.", "link.noChildren": "Дочерние компьютеры не подключены.", "remoteLink.status.connecting": "Подключение", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f555706cce0..bc0d3640775 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3473,6 +3473,8 @@ export const tr: Record = { "remoteLink.error.join_restart_failed": "Bağlantı hazır. Çocuk olarak bağlanmayı tamamlamak için bu bilgisayarda OpenCodex'i yeniden başlatın.", "remoteLink.error.join_port_failed": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.join_port_mismatch": "OpenCodex yapılandırılmış bağlantı noktasında çalışmıyor, bu yüzden Çocuk olarak yeniden başlatılamıyor. OpenCodex'i yapılandırılmış bağlantı noktasında yeniden başlatın ve tekrar deneyin.", + "remoteLink.joinDenied.pairing_required": "Çocuk olarak bağlanmak için önce bu bilgisayarı eşleştirin. Bu bilgisayarın geri döngü (loopback) adresindeki gösterge panelini açın ve operatörün oluşturduğu tek kullanımlık eşleştirme kodunu girin.", + "remoteLink.joinDenied.unavailable": "Bu gösterge paneli oturumunda Çocuk olarak bağlanılamıyor. Durumu yenileyin ve bu bilgisayarın eşleştirme ve çalışma zamanı ayarlarını kontrol edin.", "remoteLink.error.join_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", "link.noChildren": "Bağlı çocuk bilgisayarı yok.", "remoteLink.status.connecting": "Bağlanıyor", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 53909f52acf..9fca4fc214c 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3408,6 +3408,8 @@ export const vi: Record = { "remoteLink.error.join_restart_failed": "Liên kết đã sẵn sàng. Hãy khởi động lại OpenCodex trên máy tính này để hoàn tất kết nối với vai trò máy con.", "remoteLink.error.join_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.join_port_mismatch": "OpenCodex không chạy trên cổng đã cấu hình nên không thể khởi động lại với vai trò máy con. Hãy khởi động lại OpenCodex trên cổng đã cấu hình rồi thử lại.", + "remoteLink.joinDenied.pairing_required": "Hãy ghép nối máy tính này trước để kết nối với vai trò máy con. Mở bảng điều khiển của máy tính này qua địa chỉ loopback rồi nhập mã ghép nối dùng một lần do người vận hành tạo.", + "remoteLink.joinDenied.unavailable": "Không thể kết nối với vai trò máy con trong phiên bảng điều khiển này. Hãy làm mới trạng thái và kiểm tra cài đặt ghép nối và runtime của máy tính này.", "remoteLink.error.join_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "link.noChildren": "Chưa có máy con nào được kết nối.", "remoteLink.status.connecting": "Đang kết nối", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c30d4386ae8..e87b98fabe3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3436,6 +3436,8 @@ export const zhTW: Record = { "remoteLink.error.join_restart_failed": "連線已準備就緒。請在此電腦上重新啟動 OpenCodex,以完成作為子裝置的連線。", "remoteLink.error.join_port_failed": "無法完成遠端連線要求。", "remoteLink.error.join_port_mismatch": "OpenCodex 未在其設定的連接埠上執行,因此無法以子裝置身分重新啟動。請在設定的連接埠上重新啟動 OpenCodex,然後再試一次。", + "remoteLink.joinDenied.pairing_required": "請先配對此電腦,再以子裝置身分連線。透過此電腦的 loopback 位址開啟儀表板,並輸入管理員建立的一次性配對碼。", + "remoteLink.joinDenied.unavailable": "此儀表板工作階段無法以子裝置身分連線。請重新整理狀態,並檢查此電腦的配對與執行環境設定。", "remoteLink.error.join_connect_failed": "無法完成遠端連線要求。", "link.noChildren": "沒有已連線的子裝置。", "remoteLink.status.connecting": "連線中", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 36577edd129..8ba06fc3fc2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3471,6 +3471,8 @@ export const zh: Record = { "remoteLink.error.join_restart_failed": "连接已准备就绪。请在此电脑上重启 OpenCodex,以完成作为子设备的连接。", "remoteLink.error.join_port_failed": "无法完成远程连接请求。", "remoteLink.error.join_port_mismatch": "OpenCodex 未在其配置的端口上运行,因此无法以子设备身份重启。请在配置的端口上重启 OpenCodex,然后重试。", + "remoteLink.joinDenied.pairing_required": "请先配对此电脑,再以子设备身份连接。通过此电脑的 loopback 地址打开仪表盘,并输入管理员创建的一次性配对码。", + "remoteLink.joinDenied.unavailable": "此仪表盘会话无法以子设备身份连接。请刷新状态,并检查此电脑的配对与运行时设置。", "remoteLink.error.join_connect_failed": "无法完成远程连接请求。", "link.noChildren": "没有已连接的子设备。", "remoteLink.status.connecting": "连接中", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 580cbca68d7..5019ee0ed93 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactElement } from "react"; import { LinkApiError, parseRemoteLinkStatus, readRuntimeHealth, requestLinkJson, waitForChildRuntime, type ChildRestartWaitDeps, type LinkCandidateView, type LinkConfirmHostView, - type LinkErrorCode, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, + type LinkErrorCode, type LinkJoinDenied, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, } from "../remote-link-api"; import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; import { Trans } from "../i18n/provider"; @@ -85,6 +85,11 @@ const REASON_TKEY: Record = { compensation_failed: "remoteLink.reason.compensation_failed", "stale tunnel may hold the port": "remoteLink.reason.staleTunnel", }; +const JOIN_DENIED_TKEY: Record = { + pairing_required: "remoteLink.joinDenied.pairing_required", + standalone_required: "remoteLink.childDisabled", + join_port_mismatch: "remoteLink.error.join_port_mismatch", +}; type FailedAction = { phase: "probe" | "apply" | "join"; alias: string }; type LinkAttempt = { controller: AbortController; sequence: number }; @@ -249,6 +254,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // The server offers the join to this dashboard session only on a standalone runtime that runs // on its configured port, the one port the Child runtime can restart on. const childSelectable = standaloneRuntime && status?.joinAvailable === true; + const joinDeniedKey = status?.joinDenied ? JOIN_DENIED_TKEY[status.joinDenied] : "remoteLink.joinDenied.unavailable"; // Offer code entry only after the operator opens link setup, and only on the // literal same-origin loopback transport accepted by the standalone mint. const localPairingTarget = standaloneApiTargets(apiBase).shared; @@ -386,8 +392,8 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = {workspaceAvailable &&
{t("remoteLink.workspaceMoved.title")}

{t("remoteLink.workspaceMoved.body")}

} {statusError && {t(statusError)}} {statusRows.length === 0 && uiState === "off" &&
{t("link.switch")}

{t("link.switchOffHint")}

} - {uiState === "role-select" &&

{t("link.role.title")}

{t("link.role.hint")}

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t("remoteLink.error.join_port_mismatch")}}
} - {uiState === "role-select" && canPairLocally && status !== null && !childSelectable && ( + {uiState === "role-select" &&

{t("link.role.title")}

{t("link.role.hint")}

{!standaloneRuntime && {t("remoteLink.childDisabled")}}{standaloneRuntime && status !== null && !status.joinAvailable && {t(joinDeniedKey)}}
} + {uiState === "role-select" && canPairLocally && status?.joinAvailable === false && status.joinDenied === "pairing_required" && ( { void refreshStatus(); }} /> )} {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

{restartSlow && {t("remoteLink.restart.slow")}}
: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed")))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{actionError && }
} diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index 3f8ed4b3c99..9dec2e1cea6 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -43,6 +43,8 @@ export type LinkErrorCode = typeof LINK_ERROR_CODES[number]; export type LinkWireDirection = "hub-initiated" | "client-initiated"; export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle"; export type LinkListenerState = "off" | "listening" | "failed"; +export const LINK_JOIN_DENIALS = ["pairing_required", "standalone_required", "join_port_mismatch"] as const; +export type LinkJoinDenied = typeof LINK_JOIN_DENIALS[number]; export interface LinkCandidateView { alias: string; source: string } export interface LinkProbeView { alias: string; fingerprint: string; keyType: string } @@ -54,11 +56,13 @@ export interface RemoteLinkStatusWire { links: LinkRowWire[]; child: null | { alias: string; state: LinkWireState; since: string; reason: string | null }; /** - * Whether this dashboard session may join a Home as a Child: a dashboard session on a + * Whether this dashboard session may join a Home as a Child: a paired dashboard session on a * standalone runtime that listens on its configured port. The server omits the field for * non-dashboard callers, read as false. */ joinAvailable: boolean; + /** GUI-only gate explanation. Older servers may omit it; unknown reasons normalize to null. */ + joinDenied?: LinkJoinDenied | null; } const LINK_STATES: readonly LinkWireState[] = ["connecting", "connected", "reconnecting", "failed", "idle"]; @@ -120,7 +124,8 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { if (!isRecord(value.child) || !nonEmpty(value.child.alias) || !isLinkState(value.child.state) || !nonEmpty(value.child.since) || (value.child.reason !== null && typeof value.child.reason !== "string")) throw new Error("invalid child"); child = { alias: value.child.alias, state: value.child.state, since: value.child.since, reason: value.child.reason as string | null }; } - return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true }; + const joinDenied = LINK_JOIN_DENIALS.includes(value.joinDenied as LinkJoinDenied) ? value.joinDenied as LinkJoinDenied : null; + return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true, joinDenied }; } /** Read link-route JSON and preserve the server's machine-readable error code. */ diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 649a56f359c..5dcbdb08ee5 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { createRoot, type Root } from "react-dom/client"; import { act } from "react"; import RemoteLink from "../src/pages/RemoteLink"; -import { boundLinkHint, CHILD_RESTART_NOTICE_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_SLOW_POLL_MS, LINK_ERROR_CODES, LinkApiError, parseRemoteLinkStatus, readLinkJson, waitForChildRuntime, type RemoteLinkStatusWire } from "../src/remote-link-api"; +import { boundLinkHint, CHILD_RESTART_NOTICE_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_SLOW_POLL_MS, LINK_ERROR_CODES, LINK_JOIN_DENIALS, LinkApiError, parseRemoteLinkStatus, readLinkJson, waitForChildRuntime, type RemoteLinkStatusWire } from "../src/remote-link-api"; import { LanguageProvider } from "../src/i18n/provider"; import { LOCALES } from "../src/i18n/shared"; @@ -74,6 +74,69 @@ test("joinAvailable is true only when the server says exactly true", () => { expect(parseRemoteLinkStatus({ ...baseStatus, joinAvailable: true }).joinAvailable).toBe(true); }); +test("joinDenied accepts only known causes and tolerates legacy or future status responses", () => { + for (const joinDenied of LINK_JOIN_DENIALS) { + expect(parseRemoteLinkStatus({ ...baseStatus, joinDenied }).joinDenied).toBe(joinDenied); + } + for (const joinDenied of [undefined, null, "future_gate", true, 1, {}, []]) { + const parsed = parseRemoteLinkStatus({ ...baseStatus, joinDenied }); + expect(parsed.joinDenied).toBeNull(); + expect(parsed.joinAvailable).toBe(false); + } + expect(parseRemoteLinkStatus(joinableStatus).joinAvailable).toBe(true); +}); + +test.each([ + ["pairing_required", "Pair this machine first to join as a Child.", true], + ["standalone_required", "Child links can only be started from a standalone runtime.", false], + ["join_port_mismatch", "OpenCodex is not running on its configured port", false], + [undefined, "Joining as a Child is unavailable for this dashboard session.", false], + ["future_gate", "Joining as a Child is unavailable for this dashboard session.", false], +] as const)("disabled Child explains %s without issuing a mutation", async (joinDenied, message, showPairing) => { + win.happyDOM.setURL("http://127.0.0.1:10100/#remote"); + declareRuntimeRole("standalone"); + const calls: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + calls.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET" }); + return response({ ...baseStatus, role: "standalone", joinAvailable: false, joinDenied }); + }) as typeof fetch; + const host = await mount({ apiBase: win.location.origin }); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const radios = [...host.querySelectorAll('[role="radio"]')] as HTMLButtonElement[]; + expect(radios[1]?.getAttribute("aria-disabled")).toBe("true"); + expect(host.textContent).toContain(message); + if (joinDenied !== "join_port_mismatch") expect(host.textContent).not.toContain("OpenCodex is not running on its configured port"); + expect(host.querySelector(".connect-pairing") !== null).toBe(showPairing); + await act(async () => { radios[1]?.click(); }); + for (const key of ["ArrowRight", "ArrowDown", "End"]) { + await act(async () => { radios[0]?.dispatchEvent(new win.KeyboardEvent("keydown", { key, bubbles: true })); }); + } + expect(radios[1]?.getAttribute("aria-checked")).toBe("false"); + expect(radios[1]?.tabIndex).toBe(-1); + expect(calls.every(call => call.path === "/api/link/status" && call.method === "GET")).toBe(true); +}); + +test("refreshing a now-paired status removes pairing guidance without automatically joining", async () => { + win.happyDOM.setURL("http://127.0.0.1:10100/#remote"); + declareRuntimeRole("standalone"); + let current: RemoteLinkStatusWire = { ...baseStatus, role: "standalone", joinDenied: "pairing_required" }; + const calls: Array<{ path: string; method: string }> = []; + globalThis.fetch = (async (input, init) => { + calls.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET" }); + return response(current); + }) as typeof fetch; + const host = await mount({ apiBase: win.location.origin }); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + expect(host.querySelector(".connect-pairing")).not.toBeNull(); + current = { ...joinableStatus, joinDenied: null }; + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Refresh")?.click(); }); + await flush(); + expect(host.textContent).not.toContain("Pair this machine first"); + expect(host.querySelector(".connect-pairing")).toBeNull(); + expect(host.querySelectorAll('[role="radio"]')[1]?.getAttribute("aria-disabled")).toBe("false"); + expect(calls.every(call => call.path === "/api/link/status" && call.method === "GET")).toBe(true); +}); + test("session gate makes no link request", async () => { const calls: string[] = []; globalThis.fetch = (async input => { calls.push(String(input)); return response(baseStatus); }) as typeof fetch; @@ -125,7 +188,7 @@ test("a standalone off its configured port keeps Child disabled, explains why, a if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.66.0" }); if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); if (path === "/api/link/apply") return response({ linkId: "lnk_1234567890abcdef" }, 202); - return response({ ...baseStatus, role: "standalone", joinAvailable: false }); + return response({ ...baseStatus, role: "standalone", joinAvailable: false, joinDenied: "join_port_mismatch" }); }) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 343ac3855c1..5c1cf203c01 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -131,9 +131,14 @@ function joinPortMatches(ctx: ManagementContext): boolean { return live !== undefined && live === ctx.config.port; } -/** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */ -function joinAvailable(ctx: ManagementContext): boolean { - return pairedSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx); +type LinkJoinDenied = "pairing_required" | "standalone_required" | "join_port_mismatch"; + +/** Read-only explanation of the join gates, in the same order as `POST /api/link/join`. */ +function joinDenied(ctx: ManagementContext): LinkJoinDenied | null { + if (!pairedSession(ctx)) return "pairing_required"; + if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return "standalone_required"; + if (!joinPortMatches(ctx)) return "join_port_mismatch"; + return null; } function runnerFor(ctx: ManagementContext): SshRunner { @@ -578,9 +583,11 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L const failure = state.compensationFailures?.get(store.links[0].id); if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason }); } - // A dashboard session also learns whether it may join as a Child. The admin-token answer stays + // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays // the exact K16 document that `ocx link status` validates key by key. - const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto; + const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null; + const body: LinkStatusDto & { joinAvailable?: boolean; joinDenied?: LinkJoinDenied | null } = ctx.principal === "gui-session" + ? { ...dto, joinAvailable: denial === null, joinDenied: denial } : dto; return Response.json(body, { headers: { "cache-control": "no-store" } }); } if (url.pathname === "/api/link/candidates" && req.method === "GET") { diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 6327062efaf..06c8efafd45 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -256,7 +256,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the eight operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` accepts a boolean or `null` to clear it. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | -| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | +| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | diff --git a/structure/remote-link.md b/structure/remote-link.md index 565ce43f17b..348c2850e38 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -34,7 +34,7 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio The dashboard link routes (`GET /api/link/status`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `DELETE /api/link/{id}`) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. `POST /api/link/join` is stricter and requires the paired session because it durably redirects this machine's client traffic. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why it cannot authorize joining and why `src/client/machine-listener.ts` keeps durable machine changes on the connected listener away from that session. The remaining link routes accept this casual-path trade, the same as `POST /api/github/star` in `src/server/management/sidebar-routes.ts`; it is not a secret-backed boundary like an operator-created pairing grant or the admin token. -A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, true only for a paired dashboard session on a standalone runtime whose live port is its configured port. An admin-token caller gets the exact K16 document without that field, because `ocx link status` validates it key by key. The dashboard reads an absent field as false; while it is false the Child role card cannot be selected by pointer or keyboard. Before **Connect as Child** the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin `/healthz`, sends the join, then reads `/healthz` once a second, skipping status polls meanwhile, and reloads only when it reports `role: "client"` under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading `/healthz` every 5 seconds until the Child answers or the page is left. +A successful join restarts this proxy (a 503 drain of up to a minute while running turns finish, then a closed listener) into the client runtime on the configured port. `GET /api/link/status` tells a GUI-session caller whether it may join: the response gains `joinAvailable`, true only for a paired dashboard session on a standalone runtime whose live port is its configured port, and `joinDenied`, which is `null` when allowed or the first failed gate (`pairing_required`, `standalone_required`, then `join_port_mismatch`). An admin-token caller gets the exact K16 document without either field, because `ocx link status` validates it key by key. The dashboard reads an absent availability field as false and an absent or unknown denial as generic unavailability; while unavailable the Child role card cannot be selected by pointer or keyboard. A pairing refusal says to pair this machine first and offers code entry only on the existing same-origin literal-loopback transport. A port refusal instead asks the operator to restart on the configured port; neither status reads nor displaying this guidance mint a grant or change pairing, keys, routing, or runtime state. Before **Connect as Child** the confirmation panel says that the restart briefly interrupts Codex and that Codex keeps its local address. The dashboard reads the standalone's pid from same-origin `/healthz`, sends the join, then reads `/healthz` once a second, skipping status polls meanwhile, and reloads only when it reports `role: "client"` under another pid, so the reloaded document carries the client role and a fresh session. It never gives up by itself: past the server's own handoff budget (60 s drain plus 70 s replacement readiness, plus a margin: 145 seconds) it says the restart is slow and keeps reading `/healthz` every 5 seconds until the Child answers or the page is left. A connected Child answers `GET` and `HEAD /api/link/status` on its own listener for a GUI session: `src/client/link-status.ts` projects the client sidecar and the tunnel supervisor into the K16 document with `role: "child"`, the listener off, no links and the child row, plus `joinAvailable: false`. A Home-initiated Child has no sidecar and reports `child: null`. In link mode `/api/machine/status` advertises the machine origin as the shared plane, because the tunnel's hub-link ingress serves no `/api/*` and no session bootstrap. diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index a6eb40fe0b4..63f4b721c02 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -184,7 +184,7 @@ describe("link management routes", () => { const status = await sessionCall(`${base}/api/link/status`, headers, state, cfg, deps, true); expect(status?.status).toBe(200); - expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: false }); + expect(await status!.json()).toMatchObject({ role: "standalone", joinAvailable: false, joinDenied: "pairing_required" }); const listed = await sessionCall(`${base}/api/link/candidates`, headers, state, cfg, deps, true); expect(listed?.status).toBe(200); expect(await listed!.json()).toEqual({ candidates: [{ alias: "home", source: "ssh_config" }] }); @@ -243,25 +243,36 @@ describe("link management routes", () => { expect(await refused!.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); }); - test("status tells a dashboard session whether it may join and keeps the admin-token DTO exact", async () => { + test("status explains join gates without mutations and keeps the admin-token DTO exact", async () => { temp = mkdtempSync(join(tmpdir(), "ocx-link-join-available-")); const h = harness(); const standalone = { ...h.config, runtimeRole: "standalone" } as OcxConfig; const paired = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, standalone); expect(paired?.status).toBe(200); - expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true }); + expect(await paired!.json()).toMatchObject({ role: "standalone", joinAvailable: true, joinDenied: null }); + expect(paired!.headers.get("cache-control")).toBe("no-store"); const hub = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, { ...standalone, runtimeRole: "hub" } as OcxConfig); - expect(await hub!.json()).toMatchObject({ joinAvailable: false }); + expect(await hub!.json()).toMatchObject({ joinAvailable: false, joinDenied: "standalone_required" }); + const client = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "pairing", true, { ...standalone, runtimeRole: "client" } as OcxConfig); + expect(await client!.json()).toMatchObject({ joinAvailable: false, joinDenied: "standalone_required" }); // A credentialless local session cannot join even on the configured port. const loopback = await call("/api/link/status", "GET", undefined, h.deps, "gui-session", true, "loopback", false, standalone); - expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: false }); + expect(await loopback!.json()).toMatchObject({ role: "standalone", joinAvailable: false, joinDenied: "pairing_required" }); const moved = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => 10200 }, "gui-session", true, "loopback", false, standalone); - expect(await moved!.json()).toMatchObject({ joinAvailable: false }); + expect(await moved!.json()).toMatchObject({ joinAvailable: false, joinDenied: "pairing_required" }); const unknownPort = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort: () => undefined }, "gui-session", true, "loopback", false, standalone); - expect(await unknownPort!.json()).toMatchObject({ joinAvailable: false }); - // `ocx link status` validates the admin-token answer key by key, so it never gains the field. + expect(await unknownPort!.json()).toMatchObject({ joinAvailable: false, joinDenied: "pairing_required" }); + // Pairing takes precedence; only a paired standalone is told to fix the listening port. + for (const liveListenPort of [() => 10200, () => undefined]) { + const portDenied = await call("/api/link/status", "GET", undefined, { ...h.deps, liveListenPort }, "gui-session", true, "pairing", true, standalone); + expect(await portDenied!.json()).toMatchObject({ joinAvailable: false, joinDenied: "join_port_mismatch" }); + } + // `ocx link status` validates the admin-token answer key by key, so it gains neither field. const admin = await call("/api/link/status", "GET", undefined, h.deps, "admin-token", true, null, true, standalone); expect(Object.keys(await admin!.json()).sort()).toEqual(["child", "links", "listener", "role"]); + expect(h.events).toEqual([]); + expect(standalone.apiKeys).toEqual([]); + expect(h.store.links).toEqual([]); }); test("confirm-host keeps the parsed remote version to a bounded semver shape", async () => { From b4616be1e4db9e7178fd28cb19d4c2269abc2ba7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:55:19 +0900 Subject: [PATCH 004/145] chore(release): open dev at 2.77.0 before releasing 2.76.0 (#6462) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 42f4b24ce4c..28ea7a672f8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.76.0" +version = "2.77.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c788432f9f3..96d14b98e3a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.76.0" +version = "2.77.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 688678b4f69..2e5a7813710 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.76.0", + "version": "2.77.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index b2a3048d0fc..c851664acc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.76.0", + "version": "2.77.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 4b74668332acf0d320fd0831aa64edfdc615b1e0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 09:48:15 +0900 Subject: [PATCH 005/145] fix(gui): remove blank tail from account-page scrolling (#6475) * fix(gui): keep dashboard scrolling on the document * test(gui): cover shared scroll geometry and drawer locking * test(gui): normalize preserved build directory paths --- .../261003_dashboard_scroll_gap/000_plan.md | 38 +++ .../010_scroll_boundary.md | 20 ++ .../090_summary.md | 20 ++ gui/package.json | 3 +- gui/src/styles.css | 3 +- gui/tests/shared-scroll-browser.ts | 241 ++++++++++++++++++ gui/tests/viewport-scroll-caps.test.ts | 8 + structure/dashboard-and-usage.md | 2 +- 8 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 devlog/_fin/261003_dashboard_scroll_gap/000_plan.md create mode 100644 devlog/_fin/261003_dashboard_scroll_gap/010_scroll_boundary.md create mode 100644 devlog/_fin/261003_dashboard_scroll_gap/090_summary.md create mode 100644 gui/tests/shared-scroll-browser.ts diff --git a/devlog/_fin/261003_dashboard_scroll_gap/000_plan.md b/devlog/_fin/261003_dashboard_scroll_gap/000_plan.md new file mode 100644 index 00000000000..6a249d05669 --- /dev/null +++ b/devlog/_fin/261003_dashboard_scroll_gap/000_plan.md @@ -0,0 +1,38 @@ +# Shared dashboard scroll boundary + +Codex and Claude account pages can keep scrolling after their visible content ends, moving the sidebar upward and exposing a blank lower area. Preserve the existing page layout while giving normal dashboard pages one document scroller. This single C2 work-phase includes repair, rendered regression checks, and a normal PR merged into dev. + +## Loop specification + +- Archetype: satisfy-spec bug repair. Trigger: user screenshot and explicit request to fix with Sol subagents and merge. +- Goal: no second outer scroll or blank strip below the sidebar after reaching the end of account pages. +- Non-goals: provider/settings behavior, dependency updates, release/deployment, installed-app replacement, wholesale fixed-shell redesign. +- Verifier: real Chrome measurements and screenshots; maintained built-CSS browser regression; GUI suite/lint/build; repository typecheck, structure check and required current-head CI. Browser script is new and must first fail on unchanged CSS. Existing GUI tests do not calculate layout. +- Stop: merged PR, recorded passing required CI, rendered evidence and honest native-runtime coverage limits. +- Memory artifact: this unit, ignored .tmp/scroll-gap evidence, durable .codexclaw goalplan. +- Outcomes: DONE only after all criteria; unresolved external permission/runtime failures remain unmet. No invented budget exhaustion. +- Escalation: broader shell redesign or unavailable merge authority; main owns decisions and git. No token/cost/time bound was supplied. Tool/credential scope is local GUI, existing browser tooling, and repository GitHub PR/CI/merge authority. + +## Evidence and hypotheses + +H1: body overflow-axis coupling creates two vertical scrollers. Falsifier: body is not scrollable or removing its horizontal scroll container does not remove the defect. +H2: page bottom padding/min-height is excessive. Falsifier: existing content bottom aligns with viewport after changing only body scroll ownership. +H3: sidebar/background painting alone fails. Falsifier: measured sidebar rectangle itself leaves the viewport. + +Chrome on the actual Vite GUI, /#codex-set/multiauth at 1280x800, gave body scrollTop=704, then window scrollY=352 after a second downward wheel. Sidebar top=-352/bottom=448; .app bottom=448.375. Changing only body overflow-x to clip gave body scrollTop=0, window scrollY=704, sidebar top=0/bottom=800 and .app bottom=800.375. This rules out H2/H3 as primary causes. Generic tall shell without an escaped absolute descendant did not reproduce the outer overflow; regression must drive the second scroll. + +## Consultation + +V1 multi_agent_v1 transport; architect 01a0ff1b-efd3-7c61-bcae-715a83b10fda requested gpt-6.1-sol. D1 accepted: body-only overflow clipping; D2 accepted: keep document scrolling and existing height model; D3 accepted: preserve titlebar/mobile/Combos relationships; D4 accepted: rendered geometry before/after, not CSS strings as visual proof. Same-architect reflection: ALIGNED with D1-D4; accepted clarifications: fixture is mechanism coverage, actual Codex and Claude routes are mandatory; production mobile drawer effect must lock/restore without scroll jumps. Independent A follows reflection. + +## Scope and delivery + +See 010_scroll_boundary.md for exact changes. Existing source owners and test patterns reused; no runtime abstraction or dependency needed. One ordinary PR targets dev, no native stack. Full root suite is disproportionate for a CSS-only runtime delta; focused GUI checks and browser regression run locally, broad runtime suite stays with required hosted CI. Screenshot evidence is uploaded to pr-assets, never committed on the feature branch. + +Toggle proof also reproduced Claude: body=811, window=537, sidebar bottom=263 before; body=0, window=811, sidebar bottom=800 with clip; removing override restored the same 537px gap. Built unchanged GUI successfully; baseline bundle retained in ignored scratch. + +Mobile amendment: body-only lock allowed document wheel movement after clipping. A narrow-screen html:has(.sidebar.open) overflow-y:hidden rule repaired it in the actual App (open=482, after wheel=482, close restores prior 500). Keep the existing body effect; root lock follows the real open class. Architect D3 recheck completed ALIGNED after stable-anchor measurement. + +A review accepted the browser-harness correction: built entry CSS alone misses the lazy App chunk, so tests must load both in production order. Architect D3 final reflection ALIGNED: actual anchor top stayed 396.4375px before/open/wheel/close; scrollY adjusted with reflow. Existing scrollbar-width horizontal shift is outside this vertical gap repair. All D1-D4 remain aligned. Baseline focused titlebar/viewport tests: 15 pass, 0 fail. + +B/C evidence so far: actual Codex/Claude browser and desktop-UA surfaces at 1280/1024/768/390/320: 20/20 geometry cases passed. Actual mobile Escape/navigation/resize dismissal and wheel/touch locks passed. Native WKWebView offline probe reproduced hidden -> clip -> hidden and passed 16 observations; packaged Tauri itself was not launched. GUI suite: 2756 pass/0 fail; root typecheck and GUI build passed. diff --git a/devlog/_fin/261003_dashboard_scroll_gap/010_scroll_boundary.md b/devlog/_fin/261003_dashboard_scroll_gap/010_scroll_boundary.md new file mode 100644 index 00000000000..d3070f2b8cd --- /dev/null +++ b/devlog/_fin/261003_dashboard_scroll_gap/010_scroll_boundary.md @@ -0,0 +1,20 @@ +# WP1: shared document scroll ownership + +Dependency: existing shell in gui/src/styles.css, App.tsx and app-titlebar.css. No new types, enums or enforcement layer. + +1. MODIFY gui/src/styles.css: change only body overflow-x from hidden to clip. Keep html horizontal guard, html/body/root heights, sidebar sticky/100dvh and main sizing. Add a short rationale for avoiding a second vertical scroller. Do not alter page padding. In the existing max-width:760px media block, add html:has(.sidebar.open) { overflow-y: hidden; } so the production drawer also locks the document scroller. Class removal restores the normal root overflow automatically; keep App effect and cleanup unchanged. +2. NEW gui/tests/shared-scroll-browser.ts: reuse the standalone isolated Chromium/CDP pattern from sidebar-version-browser.ts. Read both entry CSS and lazy App CSS in production order (titlebar, collapsed-sidebar and mobile Combos rules are in App CSS); record their hashes; render representative Codex/Claude account content with an absolute descendant outside a positioned ancestor to drive outer overflow. Test short/long content, browser and desktop chrome, normal/collapsed rail, desktop/tablet/mobile widths, themes, and final-control reachability. Scroll body and window successively; fail on blank beyond app or displaced desktop rail. Include mobile drawer lock/restore and Combos bounds mandatory. Write only ignored artifacts; expose deliberate old-CSS baseline mode or run against a baseline build for red proof. +3. MODIFY gui/tests/viewport-scroll-caps.test.ts: extend the existing effective-declaration guards for body clipping and mobile root lock; this default-CI source check complements rendered tests. MODIFY gui/package.json: add test:shared-scroll script for the standalone browser regression. It is explicit execution, not part of bun test tests discovery. +4. MODIFY owning structure GUI/layout documentation: state normal pages use document scrolling and clipping must not create a body scroller. Review dashboard/desktop owners; no provider/API doc changes. +5. MODIFY unit 090_summary.md at completion and move unit to _fin. Publish reviewed screenshot through pr-assets and open normal dev PR with full template. + +## Acceptance activation + +- Long Codex and Claude account content: two downward scroll gestures (or equivalent body+document scrolling) end with sidebar top approximately 0 and bottom viewport height; no blank tail beyond app. Toggle old body hidden on/off restores/removes defect. +- Short content: no vertical overflow introduced; normal bottom whitespace is not a failure. +- Browser/desktop chrome and both themes: no titlebar displacement or rail discontinuity. +- 1280/1024/768 wide views and 390/320 mobile: horizontal clipping retained, final control reachable. Fixed drawer remains viewport-bound; drive the actual App drawer effect, confirm wheel/touch document lock and restoration with stable content viewport position and restored document position after close; test Escape, navigation dismissal and resize past 760px. Recheck actual Codex and Claude routes after the patch. +- Combos: existing explicit viewport scroller remains within its available height; no outer blank tail. +- GUI suite/lint/build, root typecheck, structure gate and required current-head CI succeed. Standalone browser regression is run explicitly. Do not represent emulated desktop classes as packaged WKWebView execution. + +Test implementation is delegated to a Sol worker owning only gui/tests/shared-scroll-browser.ts, gui/tests/viewport-scroll-caps.test.ts and gui/package.json after A passes. Main owns CSS, docs, browser diagnosis, commits, PR and merge. Independent Sol reviewer owns read-only A and separate fresh final review. diff --git a/devlog/_fin/261003_dashboard_scroll_gap/090_summary.md b/devlog/_fin/261003_dashboard_scroll_gap/090_summary.md new file mode 100644 index 00000000000..530f5967ec6 --- /dev/null +++ b/devlog/_fin/261003_dashboard_scroll_gap/090_summary.md @@ -0,0 +1,20 @@ +# Scroll repair verification + +The shared shell now has one document scroller. Body horizontal clipping no longer creates a second vertical scrollport; the mobile drawer locks the document while open. The runtime delta is two CSS rules, with no settings/API changes. + +The actual Codex and Claude pages reproduced the blank tail after exhausting body scrolling and then scrolling the document. Changing only body overflow removed the gap; restoring the previous rule restored it. A native WKWebView fixture independently reproduced the same mechanism, including the effect of escaped screen-reader-only descendants. Padding and background painting were not the primary cause. + +## Evidence + +- GUI suite: 2,756 passed, 0 failed across 316 files; root typecheck, GUI lint and production build passed. +- Actual account routes: 20/20 combinations passed across 1280, 1024, 768, 390 and 320 widths, browser and desktop user-agent mounts. +- Actual mobile drawer: wheel/touch lock plus Escape, navigation and resize dismissal passed; vertical anchor stayed in place through open/close. +- Built-CSS standalone regression: old bundle 34/62 failures; patched bundle 0/62. Entry and lazy App CSS are loaded in production order and hashed. It covers themes, short/long content, collapsed navigation, mobile drawer lock/restore and synthetic Combos containment. +- Native macOS WKWebView: 16 observations passed with hidden/clip/hidden reversal. Probe window/process were closed. +- Independent final Sol review: PASS, no blocking findings. Plan review also passed after requiring the lazy App stylesheet in the browser harness. + +Raw geometry and screenshots stay in ignored scratch; the PR uses only synthetic, non-account screenshots on pr-assets. The source guard runs under ordinary GUI tests; the rendered regression is explicitly invoked with test:shared-scroll. + +## Limits and integration + +Packaged Tauri installation, older WebKit and zoom are not verified. Real mobile Combos keeps its existing natural page layout; synthetic containment does not prove that real page is viewport-sized. The broad root runtime suite is deferred to hosted CI because this is a shared-CSS repair; the full GUI suite and direct renderer checks ran locally. Required current-head CI and the merge outcome are recorded in the PR and goal ledger before task completion. No release, deployment or app installation is part of this change. diff --git a/gui/package.json b/gui/package.json index d7000ade3d2..33b37040cfb 100644 --- a/gui/package.json +++ b/gui/package.json @@ -13,7 +13,8 @@ "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry", "preview": "vite preview", "test:sidebar-version": "bun tests/sidebar-version-browser.ts", - "test:quota-hover": "bun tests/quota-summary-hover-browser.ts" + "test:quota-hover": "bun tests/quota-summary-hover-browser.ts", + "test:shared-scroll": "bun tests/shared-scroll-browser.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.9", diff --git a/gui/src/styles.css b/gui/src/styles.css index 490ba545428..3932c985d91 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -161,7 +161,7 @@ html { overflow-x: hidden; background: var(--bg); } body { margin: 0; - overflow-x: hidden; + overflow-x: clip; /* Keep vertical scrolling on the document, not a second body scrollport. */ background: transparent; color: var(--text); font-family: var(--font-ui); @@ -2380,6 +2380,7 @@ button.prov-account-row.active { cursor: default; } reused as an off-canvas drawer — 10 destinations no longer fit an always-visible strip or chip grid without eating half the viewport. */ @media (max-width: 760px) { + html:has(.sidebar.open) { overflow-y: hidden; } /* Lock the document behind the drawer. */ /* rows: topbar auto + main fills — without this, align-content stretch splits the leftover viewport height between the two rows and inflates the top bar */ .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } diff --git a/gui/tests/shared-scroll-browser.ts b/gui/tests/shared-scroll-browser.ts new file mode 100644 index 00000000000..74ae20a1912 --- /dev/null +++ b/gui/tests/shared-scroll-browser.ts @@ -0,0 +1,241 @@ +/** Isolated Chromium regression using production entry + lazy App CSS, in load order. + * Build separately, then run `bun run test:shared-scroll [ignored-output-dir]`. + * GUI_DIST selects a preserved build (absolute path); CHROME_BIN selects Chromium. + * This mechanism fixture does not run React, account APIs, or a native WebView. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve, sep } from "node:path"; + +const gui = resolve(import.meta.dir, ".."); +const inputDist = process.env.GUI_DIST ?? join(gui, "dist"); +if (!isAbsolute(inputDist)) throw new Error("GUI_DIST must be an absolute built GUI path."); +const dist = resolve(inputDist); +const output = resolve(process.argv[2] ?? join(gui, ".tmp/shared-scroll-browser")); +// Evidence must never become part of the shipped feature branch. +if (!output.split(sep).includes(".tmp")) throw new Error("Output must be inside an ignored .tmp directory."); +const chrome = process.env.CHROME_BIN || ["chromium", "chromium-browser", "google-chrome", "chrome"] + .map(name => Bun.which(name)).find(Boolean); +if (!chrome) throw new Error("Set CHROME_BIN to an existing Chrome/Chromium executable."); +function assetPath(path: string) { + const file = resolve(dist, path.replace(/^\/+/, "")); + if (!file.startsWith(`${dist}${sep}`)) throw new Error(`Built asset escapes GUI_DIST: ${path}`); + return file; +} +const index = await readFile(join(dist, "index.html"), "utf8"); +const entryPath = index.match(/]*type="module"[^>]*src="([^"]+)"/)?.[1]; +const entryCss = [...index.matchAll(/]*rel="stylesheet"[^>]*href="([^"]+\.css)"/g)].map(m => m[1]); +if (!entryPath || !entryCss.length) throw new Error("Missing built entry assets; ask the build owner to build GUI_DIST."); +const entry = await readFile(assetPath(entryPath), "utf8"); +// Follow the dependency indices for App, rather than guessing App-*.css from a +// directory glob (which could silently select stale files or miss shared chunks). +const dependencies = entry.match(/m\.f\|\|\(m\.f=(\[[^\]]+\])/)?.[1]; +const appIndices = entry.match(/import\([`"']\.\/App-[^`"']+\.js[`"']\),__vite__mapDeps\((\[[\d,\s]+\])/)?.[1]; +if (!dependencies || !appIndices) throw new Error("Cannot resolve Vite lazy App CSS dependency order; update the harness for the new bundle format."); +const dependencyPaths = JSON.parse(dependencies) as string[]; +const appDependencies = (JSON.parse(appIndices) as number[]).map(i => { + if (!dependencyPaths[i]) throw new Error(`Missing App dependency index ${i}`); + return dependencyPaths[i]; +}); +const lazyCss = appDependencies.filter(path => path.endsWith(".css")); +if (!lazyCss.length) throw new Error("Lazy App has no CSS dependencies; entry-only testing is insufficient."); +const cssPaths = [...new Set([...entryCss, ...lazyCss])]; +const styles: string[] = []; +const assets: { path: string; sha256: string }[] = []; +for (const path of cssPaths) { + const css = await readFile(assetPath(path), "utf8"); + if (/@import\s/i.test(css)) throw new Error(`Unresolved CSS import in ${path}; load it explicitly before testing.`); + styles.push(css); + assets.push({ path, sha256: new Bun.CryptoHasher("sha256").update(css).digest("hex") }); +} + +type Scenario = { page: "Codex" | "Claude" | "Combos"; long: boolean; desktop: boolean; collapsed: boolean; width: number; theme: string }; +const height = 800; +function fixture(s: Scenario) { + const brand = 'opencodex'; + const cards = Array.from({ length: s.long ? 12 : 1 }, (_, i) => `

${s.page} account ${i + 1}

Available quota and model selection

`).join(""); + const final = ''; + const combos = `

Combos

Selected combo
`; + // The late static paragraph is deliberately OUTSIDE .main-inner. Its absolute + // sr-only child must escape to the document, not a positioned/container-query + // ancestor. A generic tall block alone does not reproduce the outer overflow. + const ordinary = `

${s.page} accounts

${cards}

Account configuration Settings refreshed

${final}`; + return `
${brand}
${s.desktop ? '
Quota available
' : '
Quota available
'}${s.page === "Combos" ? `
${combos}
` : ordinary}
`; +} +const profile = await mkdtemp(join(tmpdir(), "ocx-shared-scroll-chrome-")); +const browser = Bun.spawn([chrome, "--headless", "--disable-gpu", "--disable-background-networking", "--no-first-run", "--no-default-browser-check", "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", `--user-data-dir=${profile}`, ...(process.env.CHROME_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), "about:blank"], { stdout: "ignore", stderr: "pipe" }); +let socket: WebSocket | undefined; +const delay = (ms: number) => new Promise(done => setTimeout(done, ms)); +const rows: { name: string; checks: Record; measurements: unknown }[] = []; +await mkdir(output, { recursive: true }); +try { + let port = ""; + const deadline = Date.now() + 10_000; + while (!port && Date.now() < deadline) { + try { port = (await readFile(join(profile, "DevToolsActivePort"), "utf8")).split("\n")[0]; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (browser.exitCode !== null) throw new Error(`Chrome exited ${browser.exitCode}: ${await new Response(browser.stderr).text()}`, { cause: error }); + await delay(50); + } + } + if (!/^\d+$/.test(port)) throw new Error("Chrome did not expose its debugging port within 10 seconds."); + const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT", signal: AbortSignal.timeout(5_000) }); + if (!response.ok) throw new Error(`Cannot create Chromium target: ${response.status}`); + const target = await response.json() as { webSocketDebuggerUrl: string }; + socket = new WebSocket(target.webSocketDebuggerUrl); + const ws = socket; + await new Promise((done, fail) => { + const timer = setTimeout(() => fail(new Error("CDP connection timed out")), 5_000); + ws.addEventListener("open", () => { clearTimeout(timer); done(); }, { once: true }); + ws.addEventListener("error", () => { clearTimeout(timer); fail(new Error("CDP connection failed")); }, { once: true }); + }); + let id = 0; + const pending = new Map void; reject: (error: Error) => void }>(); + ws.addEventListener("message", event => { + const message = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } }; + if (message.id === undefined) return; + const call = pending.get(message.id); + if (!call) return; + pending.delete(message.id); + if (message.error) call.reject(new Error(message.error.message)); else call.resolve(message.result); + }); + function cdp(method: string, params: Record = {}): Promise { + return new Promise((done, fail) => { + const next = ++id; + const timer = setTimeout(() => { pending.delete(next); fail(new Error(`CDP timeout: ${method}`)); }, 10_000); + pending.set(next, { resolve: value => { clearTimeout(timer); done(value as T); }, reject: error => { clearTimeout(timer); fail(error); } }); + ws.send(JSON.stringify({ id: next, method, params })); + }); + } + async function evaluate(expression: string): Promise { + const result = await cdp<{ result: { value: T }; exceptionDetails?: unknown }>("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(`Browser evaluation failed: ${JSON.stringify(result.exceptionDetails)}`); + return result.result.value; + } + const paint = () => evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))'); + await cdp("Page.enable"); + await cdp("Network.enable"); + await cdp("Network.setBlockedURLs", { urls: ["*"] }); // no account APIs, fonts, or external assets + await cdp("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); + const { frameTree } = await cdp<{ frameTree: { frame: { id: string } } }>("Page.getFrameTree"); + async function render(s: Scenario) { + await cdp("Emulation.setDeviceMetricsOverride", { width: s.width, height, deviceScaleFactor: 1, mobile: false }); + await cdp("Page.setDocumentContent", { frameId: frameTree.frame.id, html: fixture(s) }); + await evaluate('window.scrollTo(0,0); document.body.scrollTop=0'); + await paint(); + } + async function screenshot(name: string, width: number) { + const scroll = await evaluate('scrollY'); + const png = await cdp<{ data: string }>("Page.captureScreenshot", { format: "png", captureBeyondViewport: false, clip: { x: 0, y: scroll, width, height, scale: 0.5 } }); + await writeFile(join(output, `${name}.png`), Buffer.from(png.data, "base64")); + } + const measure = () => evaluate<{ + app: { bottom: number }; rail: { top: number; bottom: number; width: number }; stripTop: number; + bodyScroll: number; windowScroll: number; documentHeight: number; documentWidth: number; finalReachable: boolean; + statusPosition: string; srPosition: string; srOffsetParent: string | null; + }>(`(() => { + const box = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return {top:r.top,bottom:r.bottom,width:r.width}; }; + const final = document.querySelector('#final-control'), r = final.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left+r.width/2,r.top+r.height/2); + const sr = document.querySelector('.sr-only'), status = document.querySelector('#late-status'); + return {app:box('.app'),rail:box('.sidebar'),stripTop:box('.sidebar-top').top, + bodyScroll:document.body.scrollTop,windowScroll:scrollY,documentHeight:document.documentElement.scrollHeight, + documentWidth:document.documentElement.scrollWidth, + finalReachable:r.top>=0 && r.bottom<=innerHeight+1 && !!hit && (hit===final || final.contains(hit)), + statusPosition:status ? getComputedStyle(status).position : '',srPosition:sr ? getComputedStyle(sr).position : '',srOffsetParent:sr?.offsetParent?.className ?? null}; + })()`); + function record(name: string, checks: Record, measurements: unknown) { + rows.push({ name, checks, measurements }); + const failed = Object.keys(checks).filter(key => !checks[key]); + if (failed.length) console.error(`FAIL ${name}: ${failed.join(", ")}`); + } + const variants = [ + { page: "Codex", long: true, desktop: false, collapsed: false }, + { page: "Claude", long: true, desktop: true, collapsed: false }, + { page: "Claude", long: true, desktop: false, collapsed: true }, + { page: "Codex", long: false, desktop: true, collapsed: true }, + { page: "Claude", long: false, desktop: false, collapsed: false }, + ] as const; + for (const theme of ["light", "dark"]) for (const width of [1280, 1024, 768, 390, 320]) for (const variant of variants) { + const s = { ...variant, theme, width }; + await render(s); + // Critical sequence: exhaust BODY first, then WINDOW. One scroll misses the + // second scroller and can leave the rail looking correctly viewport-bound. + await evaluate('document.body.scrollTop=document.body.scrollHeight'); + await paint(); + const afterBody = await measure(); + await evaluate('window.scrollTo(0,document.documentElement.scrollHeight)'); + await paint(); + const end = await measure(); + const expandedRail = width > 760 && !s.collapsed; + record(`${theme}-${width}-${s.page}-${s.long ? "long" : "short"}-${s.desktop ? "desktop" : "web"}-${s.collapsed ? "collapsed" : "expanded"}`, { + appCoversViewport: end.app.bottom >= height - 1, + singleDocumentScroller: afterBody.bodyScroll === 0 && end.bodyScroll === 0, + railViewportBound: !expandedRail || (Math.abs(end.rail.top) <= 1 && Math.abs(end.rail.bottom - height) <= 1), + collapsedRailHidden: width <= 760 || !s.collapsed || end.rail.width === 0, + titlebarBound: width <= 760 || Math.abs(end.stripTop) <= 1, + finalControlReachable: end.finalReachable, + horizontalGuard: end.documentWidth <= width, + contentLength: s.long ? end.windowScroll > 0 || afterBody.bodyScroll > 0 : end.documentHeight <= height + 1, + escapedStatusFixture: end.statusPosition === "static" && end.srPosition === "absolute" && end.srOffsetParent !== "main-inner", + }, { afterBody, end }); + if (width === 1280 && theme === "dark" && s.long && !s.collapsed) await screenshot(`${s.page.toLowerCase()}-end`, width); + } + + // Mirrors only App's existing body-overflow effect. Actual React dismissal, + // Escape, navigation and resize handling are separately exercised by main QA. + for (const width of [390, 320]) for (const desktop of [false, true]) { + await render({ page: "Codex", long: true, desktop, collapsed: false, width, theme: "dark" }); + await evaluate('window.scrollTo(0,400)'); + await paint(); + const before = await evaluate('scrollY'); + const previousOverflow = await evaluate('document.body.style.overflow'); + await evaluate('document.body.style.overflow="hidden";document.querySelector(".sidebar").classList.add("open")'); + await paint(); + const opened = await evaluate('scrollY'); + await cdp("Input.synthesizeScrollGesture", { x: width - 10, y: 500, yDistance: -400, speed: 1000, gestureSourceType: "mouse" }); + await paint(); + const locked = await evaluate<{ scroll: number; overflow: string; railTop: number; railBottom: number }>('({scroll:scrollY,overflow:getComputedStyle(document.documentElement).overflowY,railTop:document.querySelector(".sidebar").getBoundingClientRect().top,railBottom:document.querySelector(".sidebar").getBoundingClientRect().bottom})'); + await evaluate(`document.querySelector('.sidebar').classList.remove('open');document.body.style.overflow=${JSON.stringify(previousOverflow)}`); + await paint(); + const restored = await evaluate<{ scroll: number; overflow: string; bodyInline: string }>('({scroll:scrollY,overflow:getComputedStyle(document.documentElement).overflowY,bodyInline:document.body.style.overflow})'); + await cdp("Input.synthesizeScrollGesture", { x: width - 10, y: 500, yDistance: -300, speed: 1000, gestureSourceType: "mouse" }); + await paint(); + const closedScroll = await evaluate('scrollY'); + record(`drawer-${width}-${desktop ? "desktop" : "web"}`, { + rootLocked: locked.overflow === "hidden", wheelLocked: Math.abs(locked.scroll - opened) <= 1, + drawerBound: Math.abs(locked.railTop) <= 1 && Math.abs(locked.railBottom - height) <= 1, + rootRestored: restored.overflow !== "hidden", bodyEffectRestored: restored.bodyInline === previousOverflow, + positionRestored: Math.abs(restored.scroll - before) <= 1, wheelRestored: closedScroll > restored.scroll + 10, + }, { before, opened, locked, restored, closedScroll }); + } + for (const width of [1280, 768, 390, 320]) for (const desktop of [false, true]) { + await render({ page: "Combos", long: true, desktop, collapsed: false, width, theme: "light" }); + await evaluate('document.querySelector(".combos-workspace-rail-list").scrollTop=1e6;document.body.scrollTop=1e6;window.scrollTo(0,1e6)'); + await paint(); + const end = await measure(); + const workspace = await evaluate<{ top: number; bottom: number; scrolled: number }>('(() => {const r=document.querySelector(".combos-workspace-shell").getBoundingClientRect();return {top:r.top,bottom:r.bottom,scrolled:document.querySelector(".combos-workspace-rail-list").scrollTop};})()'); + record(`combos-${width}-${desktop ? "desktop" : "web"}`, { + // Mobile Combos can intentionally use natural document height. Assert its + // bottom leaves no blank tail, without imposing desktop's viewport cap. + workspaceContained: width > 760 ? workspace.top >= 0 && workspace.bottom <= height + 1 : workspace.bottom >= 0 && workspace.bottom <= height + 1, + internalScrollerWorks: workspace.scrolled > 0, finalControlReachable: end.finalReachable, + noOuterScroll: end.bodyScroll === 0 && (width <= 760 || end.windowScroll === 0), + appCoversViewport: end.app.bottom >= height - 1, + }, { end, workspace }); + } + const failures = rows.filter(row => Object.values(row.checks).includes(false)); + await writeFile(join(output, "results.json"), JSON.stringify({ scope: "Offline synthetic shell geometry with production entry and lazy App CSS; drawer effect mirrored; no real React/account/native execution", dist, assets, browser: await cdp("Browser.getVersion"), total: rows.length, failed: failures.length, cases: rows }, null, 2)); + console.log(`${failures.length ? "FAIL" : "PASS"}: ${rows.length} shared-scroll cases, ${failures.length} failures; ${output}`); + if (failures.length) process.exitCode = 1; +} catch (error) { + const fatal = error instanceof Error ? error.stack ?? error.message : String(error); + await writeFile(join(output, "error.json"), JSON.stringify({ dist, assets, fatal, cases: rows }, null, 2)); + throw error; +} finally { + socket?.close(); + browser.kill(); + await Promise.race([browser.exited, delay(2_000)]); + if (browser.exitCode === null) { browser.kill("SIGKILL"); await browser.exited; } + await rm(profile, { recursive: true, force: true }); +} diff --git a/gui/tests/viewport-scroll-caps.test.ts b/gui/tests/viewport-scroll-caps.test.ts index a77c09c48b6..f32962df307 100644 --- a/gui/tests/viewport-scroll-caps.test.ts +++ b/gui/tests/viewport-scroll-caps.test.ts @@ -12,6 +12,14 @@ import { allRuleBodies, effectiveDeclaration, ruleBodies, withoutComments } from const cssUrl = new URL("../src/styles.css", import.meta.url); +test("ordinary pages keep one document scroller and the mobile drawer locks it", async () => { + const css = withoutComments(await Bun.file(cssUrl).text()); + // hidden on one axis computes auto on the other and creates the second body + // scroller. The standalone built-CSS Chromium test proves the geometry. + expect(effectiveDeclaration(css, "body", "overflow-x")).toBe("clip"); + expect(effectiveDeclaration(css, "html:has(.sidebar.open)", "overflow-y")).toBe("hidden"); +}); + test("the log table caps its scroll height against the dynamic viewport", async () => { const css = withoutComments(await Bun.file(cssUrl).text()); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 90536160170..0cf669cb3b2 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -292,7 +292,7 @@ surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibil bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. -The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. +Normal dashboard pages use the document as their vertical scroller. In `gui/src/styles.css`, body horizontal overflow is clipped without creating a second scrollport; the mobile open-drawer state also locks document overflow. The shared sidebar stays viewport-height through the end of Codex and Claude account pages. `gui/tests/shared-scroll-browser.ts` checks the built entry and App styles with short/long content, both themes, desktop chrome, collapsed navigation and mobile widths. The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) From 11bbc17d9ed40ed27121a526dbe80039c2b951cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:26:17 +0900 Subject: [PATCH 006/145] docs(providers): update TokenLab documentation links (carry #6474) Replace six relocated guide and API-reference URLs with their direct destinations. Provider endpoints, routing, and configuration remain unchanged. Carries #6474 by @hedging8563. Co-authored-by: hedging8563 <45883076+hedging8563@users.noreply.github.com> --- docs-site/src/content/docs/guides/providers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 18f52701c82..1e358755184 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -602,9 +602,9 @@ OpenAI-compatible API gateway at [tokenlab.sh](https://tokenlab.sh/r/OPENCODEX), operated by TOKENLAB AI INC. Create a workspace [API key](https://tokenlab.sh/dashboard/api?tab=keys), then run `ocx provider add tokenlab` or select **TokenLab** in the dashboard's **Add provider** picker. -TokenLab maintains a step-by-step [OpenCodex integration guide](https://docs.tokenlab.sh/integrations/opencodex) -([한국어](https://docs.tokenlab.sh/ko/integrations/opencodex)) covering setup and per-model routing. -The preset uses [Chat Completions](https://docs.tokenlab.sh/quickstart) and discovers models at +TokenLab maintains a step-by-step [OpenCodex integration guide](https://tokenlab.sh/docs/en/integrations/opencodex) +([한국어](https://tokenlab.sh/docs/ko/integrations/opencodex)) covering setup and per-model routing. +The preset uses [Chat Completions](https://tokenlab.sh/docs/en/quickstart) and discovers models at `GET /v1/models?category=chat`, keeping only entries that declare `tool-use` capability. Image, video, audio, embedding and decision models are excluded from this chat preset. @@ -613,8 +613,8 @@ Each model then uses the request format TokenLab declares for it | Models | Codex (Responses clients) | Chat clients | Claude Code (Anthropic clients) | | --- | --- | --- | --- | -| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://docs.tokenlab.sh/api-reference/responses/create-response) | Chat Completions | Chat Completions | -| `claude-*` | [Messages](https://docs.tokenlab.sh/api-reference/messages/create-message) | Messages | Messages | +| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions | +| `claude-*` | [Messages](https://tokenlab.sh/docs/en/api-reference/messages/create-message) | Messages | Messages | | Every other model, including `gemini-3.8-flash` | Chat Completions | Chat Completions | Chat Completions | To keep a model on Chat Completions, add it to the provider's `modelAdapters`, for example @@ -622,7 +622,7 @@ To keep a model on Chat Completions, add it to the provider's `modelAdapters`, f provider points at `https://api.tokenlab.sh/v1`. OpenCodex sends no delivery-policy header, so your API key's own delivery policy decides how TokenLab serves each request. -The [model catalog](https://docs.tokenlab.sh/api-reference/models/list-models) is public without +The [model catalog](https://tokenlab.sh/docs/en/api-reference/models/list-models) is public without a key, but a supplied key is validated and scopes results to its model permissions and delivery policy. Use a valid key with a funded workspace for inference. `gpt-5.6-terra` is the seeded default; choose another discovered model if your key does not allow it. The provider and model From f5572a0031471b933a4bafe0236fb509ab78ddfd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:26:47 +0900 Subject: [PATCH 007/145] fix(chatgpt): recognize snake-case quota usage windows (carry #6463) Read used_percent as exhaustion evidence alongside usedPercent. Preserve usage values and existing spend-control and non-quota blockers. Carries #6463 by @lcxhh521. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- src/chatgpt/app-server-shim/gate-rewrite.ts | 3 ++ structure/clients/chatgpt-desktop.md | 3 +- tests/clients/desktop-app-server-shim.test.ts | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index 0ae8dcfe8c7..4864a7bece3 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -38,7 +38,10 @@ export function unlockRateLimitGate(value: unknown): boolean { return { cleared, exhausted, blocked }; } if (!isRecord(node)) return { cleared, exhausted, blocked }; + // A usage window at 100%, in either spelling the gate fields come in: `usedPercent` in the + // app-server's JSON-RPC, `used_percent` in the web usage snapshot. if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true; + if (typeof node.used_percent === "number" && node.used_percent >= 100) exhausted = true; if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true; if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true; const reachedType = node.rate_limit_reached_type; diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 9f3f1328e54..deae2e8b88e 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -27,7 +27,8 @@ The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC rate-limit notifications and top-level rate-limit results. Workspace, credit, unknown reached-type and spend-control restrictions preserve closed gate flags. Both the rate-limit flags and `ordinaryUsageAllowed` open only where the subtree shows -plain-quota evidence: a cleared plain reached type or a usage window at 100%. +plain-quota evidence: a cleared plain reached type or a usage window at 100% (`usedPercent` in the +RPC, `used_percent` in the web usage snapshot; every gate field is read in both spellings). Usage percentages, resets and window durations remain accurate. Unrelated messages and malformed lines remain byte-identical; changed lines are reserialized. A per-line rewrite exception preserves that line. A failure in the framing/rewrite diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index d0974969cb8..b801884fbaf 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { rewriteAppServerLine } from "../../src/chatgpt/app-server-shim/app-server-rewrite"; +import { unlockRateLimitGate } from "../../src/chatgpt/app-server-shim/gate-rewrite"; import { createRpcLineFilter, runStdoutFilter, runChatgptAppServerFilter } from "../../src/chatgpt/app-server-shim/filter"; /** @@ -126,6 +127,43 @@ describe("app-server line rewrite", () => { }); }); +describe("gate rewrite on the web usage snapshot's spelling", () => { + // The rewrite reads every gate field in both spellings (rate_limit/rateLimit, + // limit_reached/limitReached, spend_control/spendControlReached, ...). The usage window must be + // read in both too, or a snake_case snapshot never shows the plain quota as the reason. + const snapshot = (usedPercent: number, spendReached = false) => ({ + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: usedPercent, limit_window_seconds: 604800, reset_after_seconds: 205162 }, + }, + spend_control: { reached: spendReached }, + }, + }); + + test("a window at used_percent 100 is plain-quota evidence: the flags open, the window stays as sent", () => { + const value = snapshot(100); + expect(unlockRateLimitGate(value)).toBe(true); + expect(value.usage.rate_limit.allowed).toBe(true); + expect(value.usage.rate_limit.limit_reached).toBe(false); + expect(value.usage.rate_limit.primary_window).toEqual(snapshot(100).usage.rate_limit.primary_window); + }); + + test("below 100% the payload shows no quota reason, so the flags stay closed", () => { + const value = snapshot(42); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(42)); + }); + + test("a reached spend control keeps the flags closed even with the window at 100%", () => { + const value = snapshot(100, true); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(100, true)); + }); +}); + describe("app-server line filter", () => { const collect = (chunks: Uint8Array[], filter = createRpcLineFilter()) => { const parts: Uint8Array[] = []; From 763dda2117e6d1817f62e0c142d8a45c3b5c9c1a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:18 +0900 Subject: [PATCH 008/145] fix(cli): escape terminal controls in human output (carry #6459) Suggestion rationale could emit terminal controls in human-readable output. Escape shared human lines while preserving JSON values and multiline exports. Add public guidance alongside the contributor regression tests. Carries #6459 by @luvs01. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../src/content/docs/reference/cli/agents.md | 3 +++ src/cli/export-command.ts | 4 +++- src/cli/runtime-api.ts | 2 +- structure/runtime.md | 2 +- tests/cli/cli-export-command.test.ts | 4 ++++ tests/cli/cli-headless-parity.test.ts | 17 +++++++++++++++-- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 727a5699fc8..9b3e6e8821b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -34,6 +34,9 @@ prints a proposed model and effort per role without writing anything. `--apply` through the same write as `set`, skipping and naming the roles whose model and effort already match. See [Auto-assign](/guides/integrations/#auto-assign). +Human-readable suggestion output displays terminal control characters as visible escapes. +Use `--json` when you need the original suggestion values without presentation escaping. + `ocx agent injection suggest ` does the same for the delegation model: it sizes the described work, proposes the cheapest sufficient model and an effort from the delegation picker's list, and writes nothing unless `--apply` is given, which saves through the same write as `injection set`. See diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index ca9eeba54ea..d1dd3e9c6ca 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -194,7 +194,9 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep // `--out` is the path that writes the selected client's native format. // Format metadata rides in the human lines below. printData(clientConfig, wantsJson, [ - text.trimEnd(), + // `lines` entries print one console line each and are control-escaped, so + // the document goes in as individual lines rather than one multi-line blob. + ...text.trimEnd().split("\n"), "", ...(out !== undefined ? [`Wrote ${out}`] : []), `Destination: ${spec.destination(process.env)}`, diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index 570e60680a1..478c2424878 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -471,7 +471,7 @@ export async function readSecretBytes( export function printData(value: unknown, wantsJson: boolean, lines?: string[]): void { if (wantsJson || !lines) console.log(JSON.stringify(value, null, 2)); - else for (const line of lines) console.log(line); + else for (const line of lines) console.log(terminalSafeText(line)); } /** diff --git a/structure/runtime.md b/structure/runtime.md index f988c76a0f6..4eb406d2a4a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -53,7 +53,7 @@ this wire projection does not change the usage ledger. ## CLI readiness diagnostics -Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. +Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. `printData` in `src/cli/runtime-api.ts` escapes each human-output line at the shared renderer, including role and delegation-model suggestion rationale. JSON output retains the exact original values. `src/cli/export-command.ts` passes native serialized documents as individual lines so their multiline layout is preserved. `tests/cli/cli-headless-parity.test.ts` checks both suggestion paths independently from their parsed JSON output; `tests/cli/cli-export-command.test.ts` covers export framing. ## CLI resolve and stop contracts for embedding shells diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index b5a8571b768..1ef331c59df 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -249,6 +249,10 @@ describe("ocx export human output (accept criterion 2)", () => { expect(result.code).toBe(0); expect(result.stdout.startsWith("{\n")).toBe(true); + logs.length = 0; + const structured = await run(["--client", "opencode", "--json"], { baseUrl: proxy.baseUrl }); + expect(structured.code).toBe(0); + expect(JSON.parse(result.stdout.split("\n\n")[0]!)).toEqual(JSON.parse(structured.stdout)); expect(result.stdout).toContain(join("opencode", "opencode.json")); expect(result.stdout).toContain("Merge this generated configuration into that file; do not replace it."); expect(result.stdout).toContain("export OPENCODEX_OPENCODE_API_KEY="); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 2d1177c2297..c613ab3d3c9 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1000,15 +1000,19 @@ describe("headless GUI parity CLI", () => { sizingModel: "gpt-5.5", proposals: [ { role: "explorer", model: "gpt-5.5", status: "proposed", tier: "fast", effortIntent: "glance", proposedModel: "a/small", proposedEffort: "low" }, - { role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null }, + { role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", rationale: "Use \x1b]52;c;cG9pc29uZWQ=\x07 carefully.", proposedModel: "a/mid", proposedEffort: null }, { role: "vague", model: null, status: "unsized", reason: "no JSON" }, ], }; const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true }); const logSpy = spyOn(console, "log").mockImplementation(() => {}); + let output = ""; try { expect(await handleAgentCommand(["roles", "suggest", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0); + expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(proposals); + logSpy.mockClear(); expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0); + output = logSpy.mock.calls.flat().join("\n"); } finally { logSpy.mockRestore(); } @@ -1018,6 +1022,8 @@ describe("headless GUI parity CLI", () => { { path: "/api/codex-agent-roles/explorer", method: "PUT", body: { model: "a/small", effort: "low" } }, { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } }, ]); + expect(output).not.toMatch(/[\x07\x1b]/); + expect(output).toContain("Use \\x1b]52;c;cG9pc29uZWQ=\\x07 carefully."); }); test("agent roles suggest --apply skips proposals that already match the role's pin and says so", async () => { @@ -1102,13 +1108,20 @@ describe("headless GUI parity CLI", () => { test("agent injection suggest prints the proposal, and --apply writes it through PUT /api/injection-model", async () => { const suggestion = { sizingModel: "gpt-5.5", - proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded edits.", moveUpIf: "It crosses modules.", moveDownIf: "Never.", proposedModel: "a/small", proposedEffort: "low", reason: null }, + proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded\x1b]52;c;payload\x07 edits.", moveUpIf: "It crosses\rmodules.", moveDownIf: "Never\u009b31m.", proposedModel: "a/small", proposedEffort: "low", reason: null }, }; const runtime = fakeRuntime(req => req.method === "POST" ? suggestion : { ok: true }); const logSpy = spyOn(console, "log").mockImplementation(() => {}); try { expect(await handleAgentCommand(["injection", "suggest", "rename", "symbols", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0); + expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(suggestion); + logSpy.mockClear(); expect(await handleAgentCommand(["injection", "suggest", "rename symbols", "--apply"], runtime.deps)).toBe(0); + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).not.toMatch(/[\x07\x1b\r\u009b]/); + expect(output).toContain("Bounded\\x1b]52;c;payload\\x07 edits."); + expect(output).toContain("It crosses\\x0dmodules."); + expect(output).toContain("Never\\u009b31m."); expect(await handleAgentCommand(["injection", "suggest"], runtime.deps)).not.toBe(0); } finally { logSpy.mockRestore(); From 10975564724b93789493522154919f2f6d5e8e19 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:49 +0900 Subject: [PATCH 009/145] fix(providers): declare MiniMax M3 image input for combos (carry #6445) Seed image capabilities for M3 and M3.1 in both Coding Plan regions. Exercise saved overrides, ID-only discovery, static fallback, and Combo gating. Carries #6445 by @xianhongtao. Co-authored-by: xianhongtao <74975356+xianhongtao@users.noreply.github.com> --- docs-site/src/content/docs/guides/minimax.md | 5 + .../src/content/docs/zh-cn/guides/minimax.md | 4 + scripts/test-layout/layout.json | 1 + src/providers/registry/entries-extended.ts | 3 + src/providers/registry/model-seeds.ts | 5 + structure/providers-and-adapters.md | 7 +- tests/fixtures/test-layout-expected.json | 1 + .../minimax-combo-image-input.test.ts | 101 ++++++++++++++++++ 8 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/providers/minimax-combo-image-input.test.ts diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md index b09247dabfc..03a837406e8 100644 --- a/docs-site/src/content/docs/guides/minimax.md +++ b/docs-site/src/content/docs/guides/minimax.md @@ -12,6 +12,11 @@ the protocol boundary it actually exposes: ## MiniMax Code +In the OpenCodex Models page, both MiniMax Coding Plan regions advertise image input for +`MiniMax-M3` and `MiniMax-M3.1-Flash-Preview`. A Combo's **Image / multimodal** switch is +available only when every selected model supports image input. The switch controls image +attachments; it does not enable video uploads. + Install and sign in to MiniMax Code using MiniMax's instructions first. Then start OpenCodex and connect the reversible file integration: diff --git a/docs-site/src/content/docs/zh-cn/guides/minimax.md b/docs-site/src/content/docs/zh-cn/guides/minimax.md index 48e633e63e7..4ab74ee3a00 100644 --- a/docs-site/src/content/docs/zh-cn/guides/minimax.md +++ b/docs-site/src/content/docs/zh-cn/guides/minimax.md @@ -10,6 +10,10 @@ MiniMax 发布两种不同的命令行产品。OpenCodex 在它们实际提供 ## MiniMax Code +在 OpenCodex 的模型管理页面,MiniMax Coding Plan 两个区域的 `MiniMax-M3` 和 +`MiniMax-M3.1-Flash-Preview` 均声明支持图片输入。只有所有选定模型均支持图片输入时, +Combo 的“图片 / 多模态”开关才可用。该开关控制图片附件,不提供视频上传能力。 + 先按照 MiniMax 的说明安装并登录 MiniMax Code,然后启动 OpenCodex 并连接可逆的文件集成: ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fde377d7131..fa1ccf01bc1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,6 +11,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index a463dba9bf4..b001339a1bc 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -35,6 +35,7 @@ import { ZAI_GLM_5X_REASONING_EFFORTS, MINIMAX_MODELS, MINIMAX_MODEL_CONTEXT_WINDOWS, + MINIMAX_MODEL_INPUT_MODALITIES, MINIMAX_M3_REASONING_EFFORTS, MINIMAX_M3_REASONING_EFFORT_MAP, MINIMAX_M31_FLASH_PREVIEW, @@ -1024,6 +1025,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, @@ -1049,6 +1051,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 9b480828a4a..b5004e81e5c 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -172,6 +172,11 @@ export const MINIMAX_REASONING_SPLIT_MODELS = MINIMAX_MODELS_BEFORE_M31; export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH_PREVIEW ? 1_000_000 : 204_800]), ); +/** MiniMax's OpenAI-compatible M3 endpoints accept image_url; keep video off Codex's input enum. */ +export const MINIMAX_MODEL_INPUT_MODALITIES: Record = { + "MiniMax-M3": ["text", "image"], + [MINIMAX_M31_FLASH_PREVIEW]: ["text", "image"], +}; export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** Identity efforts on the wire; no map, so none omits the field instead of disabling thinking. */ export const MINIMAX_M31_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 4a46a1b674f..91b15f681af 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -366,10 +366,9 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. -Roster additions share the blind spot when the vendor's `/models` omits the new id (MiniMax-M3.1-Flash-Preview): -`src/providers/stale-model-roster-migration.ts` replaces a saved roster only while it is byte-for-byte the previous -seed, filling the added id's window and default effort only inside records the row already has, in the same startup -pass; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) keeps it in the live catalog. +When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. + +Both MiniMax Coding Plan presets declare `text` and `image` for M3 and M3.1 in `src/providers/registry/model-seeds.ts`; registry enrichment fills missing saved declarations, so id-only live rows enable the Combo image switch when every target supports images. Explicit overrides still win; video is outside Codex's catalog input enum. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46c16b4174a..e6d4a5665be 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/tests/providers/minimax-combo-image-input.test.ts b/tests/providers/minimax-combo-image-input.test.ts new file mode 100644 index 00000000000..9949fbd37c9 --- /dev/null +++ b/tests/providers/minimax-combo-image-input.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { comboImagesSupported } from "../../gui/src/combo-capabilities"; +import { applyProviderConfigHints, deriveComboCatalogModel, gatherRoutedModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; +import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; +import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; + +const M3 = "MiniMax-M3"; +const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +const REGIONS = ["minimax", "minimax-cn"] as const; +const originalFetch = globalThis.fetch; + +function seeded(region: typeof REGIONS[number]): OcxProviderConfig { + const entry = getProviderRegistryEntry(region); + if (!entry) throw new Error(`missing ${region} registry entry`); + return { ...providerConfigSeed(entry), apiKey: "test-key" }; +} + +function config(region: typeof REGIONS[number], provider = seeded(region)): OcxConfig { + return { port: 10100, defaultProvider: region, providers: { [region]: provider } } as OcxConfig; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); +}); + +describe("MiniMax Coding Plan Combo image input", () => { + test("both presets seed M3 image input and enrich missing saved declarations without replacing overrides", () => { + for (const region of REGIONS) { + const fresh = seeded(region); + expect(fresh.modelInputModalities?.[M3]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.[PREVIEW]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.["MiniMax-M2.7"]).toBeUndefined(); + + const saved: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: fresh.baseUrl, + modelInputModalities: { "MiniMax-M2.7": ["text"] }, + modelCapabilities: { [M3]: { inputModalities: ["text"] } }, + }; + enrichProviderFromRegistry(region, saved); + expect(saved.modelInputModalities).toMatchObject({ + [M3]: ["text", "image"], [PREVIEW]: ["text", "image"], "MiniMax-M2.7": ["text"], + }); + expect(saved.modelCapabilities?.[M3]?.inputModalities).toEqual(["text"]); + expect(isModelVisionSidecarConsumer(saved, M3)).toBe(true); + expect(applyProviderConfigHints(region, saved, { provider: region, id: M3 }).inputModalities) + .toEqual(["text", "image"]); // explicit text-only uses the existing vision sidecar + expect(isModelVisionSidecarConsumer(fresh, M3)).toBe(false); + } + }); + + test("id-only discovery and a warm cache yield management rows accepted by the Combo picker", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (!String(input).includes("/models")) throw new Error("unexpected request"); + return Response.json({ data: [{ id: M3 }] }); + }) as typeof fetch; + + for (const region of REGIONS) { + const live = config(region); + const first = await gatherRoutedModels(live); + const cached = await gatherRoutedModels(live); + for (const models of [first, cached]) { + const m3 = models.find(model => model.provider === region && model.id === M3); + expect(m3?.inputModalities).toEqual(["text", "image"]); + const rows = await listManagementModelRows(live, { models }); + const row = rows.find(model => model.provider === region && model.id === M3); + expect(row?.inputModalities).toEqual(["text", "image"]); + expect(comboImagesSupported([{ provider: region, model: M3 }], rows)).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "vision" }], + [...rows, { provider: "other", id: "vision", inputModalities: ["text", "image"] }], + )).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "blind" }], + [...rows, { provider: "other", id: "blind", inputModalities: ["text"] }], + )).toBe(false); + } + } + }); + + test("static fallback and Combo catalog retain image unless the operator disables it", async () => { + for (const region of REGIONS) { + const provider = seeded(region); + provider.liveModels = false; + const models = await gatherRoutedModels(config(region, provider)); + const member = models.find(model => model.provider === region && model.id === M3); + expect(member?.inputModalities).toEqual(["text", "image"]); + const combo = (imageInput: "auto" | "disabled") => ({ + targets: [{ provider: region, model: M3 }], imageInput, defaultEffort: "medium", + }) as never; + expect(deriveComboCatalogModel("minimax-image", combo("auto"), [member as CatalogModel])?.inputModalities) + .toContain("image"); + expect(deriveComboCatalogModel("minimax-image", combo("disabled"), [member as CatalogModel])?.inputModalities) + .toEqual(["text"]); + } + }); +}); From 6d4e40443c451039f7d215743aea925852565c49 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:51 +0900 Subject: [PATCH 010/145] fix(gui): queue optimistic model visibility saves (carry #6426) Model toggles now respond immediately while writes retain click order. Keep newer intent through reconciliation and cancel stale target observations. Carries #6426 by @andrew05060414. Closes #6425 Co-authored-by: andrew05060414 <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/web-dashboard.md | 5 + .../docs/zh-cn/guides/web-dashboard.md | 4 + gui/src/model-visibility.ts | 2 + gui/src/pages/Models.tsx | 63 +++--- gui/src/use-model-visibility.ts | 109 +++++++++ gui/tests/models-visibility-queue.test.tsx | 207 ++++++++++++++++++ structure/gui-and-management-api.md | 11 + 7 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 gui/src/use-model-visibility.ts create mode 100644 gui/tests/models-visibility-queue.test.tsx diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 3a4c01848ff..f3593a1644d 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -201,6 +201,11 @@ new or that every upstream measurement was refreshed. The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. +Switches respond immediately so you can keep changing models while saves run in the background in +click order. Saved feedback appears after the queue finishes and the list is reconciled with the +server. Failed saves restore the server's state when it can be read and show an error. Wait for that +feedback before leaving Models or changing servers: unsent queued changes are discarded on departure. + ### Managing models in a provider workspace In a provider’s **Models** tab, **Delete** removes the stored custom definition. An underlying 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..e2235e720bb 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 @@ -78,6 +78,10 @@ Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、 ## 模型可见性 +开关会立即响应,保存则按点击顺序在后台执行,因此可以连续调整多个模型。队列处理完并与服务器 +核对列表后,才显示保存结果。保存失败会提示错误,并在能读取服务器状态时恢复实际选择。 +请等保存结果出现后再离开模型页或切换服务器;离开时会丢弃尚未发送的排队修改。 + **Models** 开关表示 Codex 中的最终可见状态。路由模型只有在 provider allowlist 中(或未设置 allowlist)且未被禁用时才会开启。开启模型会原子地协调两个过滤条件;**全部开启** 会清除 allowlist,因此以后新发现的模型也会开启。 ### 在提供方工作区管理模型 diff --git a/gui/src/model-visibility.ts b/gui/src/model-visibility.ts index df73ff51862..2cece6b7115 100644 --- a/gui/src/model-visibility.ts +++ b/gui/src/model-visibility.ts @@ -82,11 +82,13 @@ export async function putModelVisibility( targets: ModelVisibilityTarget[], enabled: boolean, fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, ): Promise { return fetchImpl(`${apiBase}/api/model-visibility`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, provider, targets, enabled }), + ...(signal ? { signal } : {}), }); } diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index cfd1d89f057..6c0a446fb81 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -26,6 +26,7 @@ import { type ModelPickerOrderMode, type PickerOrderSettings, type PickerOrderSaved, type ModelPickerUsage, } from "../model-picker-order"; import { startVisibilityPoll } from "../visibility-poll"; +import { useModelVisibility } from "../use-model-visibility"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import ErrorBoundary from "../components/ErrorBoundary"; @@ -48,8 +49,7 @@ import { } from "../models-groups"; import { fetchSelectedModels, - modelVisible, - putModelVisibility, + modelVisible as savedModelVisible, clientCatalogRefreshFailures, type ClientCatalogRefreshFailure, shouldApplyLoadGeneration, @@ -340,6 +340,18 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const catalogMutationRef = useRef(false); const loadGenerationRef = useRef(0); const loadPendingRef = useRef(false); + const visibility = useModelVisibility(apiBase, { + onQueued: () => { ++loadGenerationRef.current; setStatus(""); }, + onBusy: value => { ++loadGenerationRef.current; loadPendingRef.current = false; catalogMutationRef.current = value; busyRef.current = value; setBusy(value); }, + onResponse: body => { + const failures = clientCatalogRefreshFailures(body); + if (failures !== undefined) setIntegrationFailures(failures); + }, + refresh: signal => load(true, signal), + onSettled: error => { setOk(!error); setStatus(t(error ?? "models.applied")); }, + }); + const modelVisible = (selected: ProviderModelMap, provider: string, id: string, native: boolean, blocked: boolean) => + visibility.visible(provider, id, native, savedModelVisible(selected, provider, id, native, blocked)); // multi_agent_v2 / ultra gate. null = endpoint unavailable (older proxy build) -> section hidden. const [v2, setV2] = useState(null); // #2465: per-provider model-preset state. Keyed by provider so one card's busy state cannot @@ -497,6 +509,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c }, [apiBase]); const fetchCatalog = useCallback(async (signal: AbortSignal): Promise => { + const generation = loadGenerationRef.current; const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([ // Every request carries the resource signal, so leaving the catalog tab cancels // the work rather than only discarding its result. @@ -528,7 +541,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c contextCapValues: capsData.values ?? capsData.caps ?? {}, contextCapValue: nextCapValue, } satisfies CachedModelsPage; - writeSessionListCache(cacheKey, next); + if (generation === loadGenerationRef.current) writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey]); @@ -552,11 +565,12 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c cacheKey, [apiBase], async (signal) => { + const generation = loadGenerationRef.current; const next = await fetchCatalog(signal); // A manual mutation refresh may have invalidated this request while its JSON was decoding. // Do not let the aborted catalog repaint controls after the newer result is applied. if (signal.aborted) throw new Error("models request aborted"); - applyCatalog(next); + if (!catalogMutationRef.current && generation === loadGenerationRef.current) applyCatalog(next); return next; }, // Gated on the catalog tab: a 10-second poll that keeps running while the user @@ -888,7 +902,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c model.native === true, disabled.has(model.namespaced), )).length; - }, [disabled, models, selectedModels]); + }, [disabled, models, selectedModels, visibility.overrides]); /* * Quiet per-tab counts. A count is omitted, never zeroed, while it is unknown: the @@ -910,35 +924,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c targets: ModelVisibilityTarget[], enabled: boolean, ) => { - if (catalogMutationRef.current) return; - catalogMutationRef.current = true; - ++loadGenerationRef.current; - setBusy(true); - busyRef.current = true; - setStatus(""); - let errorKey: "models.saveFailed" | "models.networkError" | null = null; - try { - const response = await putModelVisibility(apiBase, scope, provider, targets, enabled); - if (!response.ok) errorKey = "models.saveFailed"; - else { - const failures = clientCatalogRefreshFailures(await response.json()); - if (failures !== undefined) setIntegrationFailures(failures); - } - } catch { - errorKey = "models.networkError"; - } finally { - const refreshed = await load(true); - if (errorKey) { - setOk(false); - setStatus(t(errorKey)); - } else if (refreshed) { - setOk(true); - setStatus(t("models.applied")); - } - setBusy(false); - busyRef.current = false; - catalogMutationRef.current = false; - } + if (busyRef.current && !visibility.isRunning()) return; + visibility.enqueue(scope, provider, targets, enabled); }; const toggleProviderCap = async (provider: string) => { @@ -1589,8 +1576,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c ); })()} - - + +
{/* The label names the FUNCTION. It used to be `models.capValue` - "기본 128k" - which is a value masquerading as a name: even a @@ -1730,7 +1717,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c }} >
- void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> + void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={(busy && !visibility.pending) || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> {m.initialSelectionPending && {t("models.initialSelectionPending")}} {/* #1711: listed and selectable, but every usable target is out of credit. Not a visibility change and not the operator's disable flag — the row is @@ -2568,7 +2555,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c pickerMode: modelPickerOrderMode(pickerSettings?.pickerAvailable ?? [], pickerSettings?.pickerOrder ?? [], pickerSettings?.pickerOrderMode) })}> {controlsBlock} -
+