From 9e8529b4c4d0935f36fd668693019b824d2b2da8 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 27 Sep 2026 05:29:11 +0000 Subject: [PATCH 001/259] 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/259] 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/259] 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/259] 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/259] 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/259] 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/259] 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/259] 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/259] 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/259] 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} -
+
@@ -660,6 +663,120 @@ export default function ClaudeDesktop({ {t("claudeDesktop.emptyHint")} )} + {data.models.length > 0 && ( +
+

{t("claudeDesktop.roles.title")}

+ {effectiveMode === "first-party" &&

{t("claudeDesktop.roles.gatewayScope")}

} +
+
+ {t("claudeDesktop.roles.default")} + {t("claudeDesktop.roles.defaultDesc")} +
+
+ { if (route) moveModel(route, "haiku", true); }} + label={t("claudeDesktop.roles.quick")} + disabled={pending !== null} + style={{ minWidth: 240 }} + align="right" + portal + /> +
+
+
+ )} + + {/* Always mounted: Import/Export must stay reachable with an empty catalog, as they were in the toolbar. */} +
+
+

+ {t("claudeDesktop.roles.listTitle")} + {data.models.length} +

+
+ void importProfile(event)} /> + + +
+
+ {data.models.length > 0 &&

{t("claudeDesktop.roles.listHint")}

} + {data.models.length > 0 && ( +
    + {listed.slice(0, listLimit).map(model => { + const context = formatContextWindow(model.contextWindow, t); + return ( +
  • + + {model.label} + {model.label !== model.route && {model.route}} + + {model.route === defaultRoute && {t("claudeDesktop.defaultBadge")}} + {model.route === quickRoute && {t("claudeDesktop.roles.quickBadge")}} + {context && {context}} + {model.supports1m === true && {t("claudeDesktop.supports1m")}} + {!model.available && {t("claudeDesktop.unavailable")}} +
  • + ); + })} +
+ )} + {listed.length > listLimit && ( + + )} +
+ + {/* The family lanes stay mounted while folded: drag, move, search and per-family defaults + keep working, and the summary carries every warning so nothing actionable hides. */} +
setAdvancedOpen((event.currentTarget as HTMLDetailsElement).open)} + > + + + + + {FAMILIES.map(family => { + const count = modelsByFamily[family].length; + const stored = profile.defaults[family]; + const shown = effectiveDefaults[family]; + return ( + + {t(FAMILY_KEYS[family])} + {shown ? {shown} : {t("claudeDesktop.advanced.empty")}} + {count > 0 && {count}} + {count > 0 && stored === null && {t("claudeDesktop.chooseDefault")}} + {shown && stored !== null && shown !== stored && {t("claudeDesktop.temporaryDefault")}} + + ); + })} + + +

{t("claudeDesktop.advanced.desc")}

{FAMILIES.map(family => { // Render-only narrowing: the lane header, effectiveDefaults and every assignment keep @@ -849,6 +966,7 @@ export default function ClaudeDesktop({ ); })}
+
); } diff --git a/gui/src/pages/claude-code-save.ts b/gui/src/pages/claude-code-save.ts new file mode 100644 index 00000000000..e24f7a2d147 --- /dev/null +++ b/gui/src/pages/claude-code-save.ts @@ -0,0 +1,137 @@ +import { serializeSidecarOverride } from "./claude-code-sidecar"; +import type { ClaudeCodeState, MapRow } from "./claude-code-types"; +import { newClientId } from "./claude-code-types"; + +/** + * The Claude Code fields the Save bar owns. Everything else on ClaudeCodeState is either + * server-derived (aliases, availability, diagnostics) or committed immediately by its own + * control (`enabled`, the CLI 1P switch), so a read may always refresh it. + */ +export const EDITABLE_KEYS = [ + "authMode", + "systemEnv", + "fastMode", + "autoContext", + "autoCompactWindow", + "injectAgents", + "smallFastModel", + "webSearchSidecar", + "visionSidecar", +] as const satisfies readonly (keyof ClaudeCodeState)[]; + +export type ClaudeCodeEditable = { state: ClaudeCodeState; rows: MapRow[] }; + +/** Trimmed, non-empty rows; the last duplicate wins, as the server would apply it. Sorted keys. */ +export function normalizedModelMap(rows: readonly MapRow[]): Record { + const map = new Map(); + for (const row of rows) { + const from = row.from.trim(); + const to = row.to.trim(); + if (from && to) map.set(from, to); + } + return Object.fromEntries([...map.entries()].sort(([a], [b]) => a.localeCompare(b))); +} + +/** + * The PUT body for Save. `enabled` is deliberately absent: the connection switch commits on + * its own, and the server only writes `enabled` when the body carries it, so a Save built + * from a stale draft can no longer switch Claude back on or off. + */ +export function claudeCodeSaveBody(state: ClaudeCodeState, rows: readonly MapRow[]) { + return { + authMode: state.authMode, + systemEnv: state.systemEnv, + fastMode: state.fastMode, + autoContext: state.autoContext, + autoCompactWindow: state.autoCompactWindow, + injectAgents: state.injectAgents, + smallFastModel: state.smallFastModel, + modelMap: normalizedModelMap(rows), + webSearchSidecar: serializeSidecarOverride(state.webSearchSidecar), + visionSidecar: serializeSidecarOverride(state.visionSidecar), + }; +} + +/** + * A comparable identity for an editable draft: the Save body plus the raw row text, so a + * blank or half-typed row keeps Revert available instead of vanishing on the next read. + */ +export function claudeCodeDraftKey(draft: ClaudeCodeEditable): string { + return JSON.stringify([claudeCodeSaveBody(draft.state, draft.rows), draft.rows.map(row => [row.from, row.to])]); +} + +/** Whether the draft differs from what the server last confirmed. */ +export function isClaudeCodeDraftDirty(draft: ClaudeCodeEditable, baseline: ClaudeCodeEditable): boolean { + return claudeCodeDraftKey(draft) !== claudeCodeDraftKey(baseline); +} + +function pickEditable(state: ClaudeCodeState): Partial { + return Object.fromEntries(EDITABLE_KEYS.map(key => [key, state[key]])) as Partial; +} + +/** A fresh server read with the user's unsaved editable fields laid back on top. */ +export function mergeServerRead(draft: ClaudeCodeState, next: ClaudeCodeState): ClaudeCodeState { + return { ...next, ...pickEditable(draft) }; +} + +/** The draft with every editable field restored from the baseline; live fields stay. */ +export function revertEditable(draft: ClaudeCodeState, baseline: ClaudeCodeState): ClaudeCodeState { + return { ...draft, ...pickEditable(baseline) }; +} + +export type ClaudeCodeEditState = { + draft: ClaudeCodeEditable; + baseline: ClaudeCodeEditable; + /** + * Set when a Save succeeded and nothing was edited since it was sent. The next read is that + * Save's acknowledgement and replaces the draft outright, so rows the server normalized + * (trimmed, blank, duplicate) settle instead of reading as unsaved forever. + */ + adoptNextRead: boolean; + /** + * A Save is out. Until it answers, a read never replaces the draft wholesale: an edit made + * after submission can equal the old baseline and would otherwise read as clean and vanish. + */ + savePending?: boolean; +}; + +/** Fold a successful read into the edit state. */ +export function applyServerRead(current: ClaudeCodeEditState | null, next: ClaudeCodeEditable): ClaudeCodeEditState { + if (!current || current.adoptNextRead || (!current.savePending && !isClaudeCodeDraftDirty(current.draft, current.baseline))) { + return { draft: next, baseline: next, adoptNextRead: false, savePending: current?.savePending }; + } + return { + draft: { state: mergeServerRead(current.draft.state, next.state), rows: current.draft.rows }, + baseline: next, + adoptNextRead: false, + savePending: current.savePending, + }; +} + +/** + * What the server holds after a successful Save: the last server copy with the submitted + * editable fields, and the normalized map (trimmed, no blanks, last duplicate wins). + */ +export function savedCopy(serverState: ClaudeCodeState, submitted: ClaudeCodeEditable): ClaudeCodeEditable { + return { + state: { ...serverState, ...pickEditable(submitted.state) }, + rows: Object.entries(normalizedModelMap(submitted.rows)).map(([from, to]) => ({ id: newClientId(), from, to })), + }; +} + +/** + * A successful PUT makes the submitted draft the new baseline right away, before the + * refresh lands. Otherwise two things go wrong in that window: an edit that happens to + * return to the OLD baseline reads as clean and is replaced by the saved value, and Revert + * restores settings the server has already overwritten. The following read still gets the + * final word through applyServerRead (adoptNextRead when nothing changed since submission). + */ +export function acknowledgeSave(current: ClaudeCodeEditState | null, submitted: ClaudeCodeEditable): ClaudeCodeEditState | null { + if (!current) return current; + // The saved copy is the baseline even if the refresh that would show it fails. + const baseline = savedCopy(current.baseline.state, submitted); + const rows = baseline.rows; + const unchanged = claudeCodeDraftKey(current.draft) === claudeCodeDraftKey(submitted); + // Untouched since submission: show what was stored. Edited meanwhile: keep the edits, dirty. + return { draft: unchanged ? { state: current.draft.state, rows } : current.draft, baseline, adoptNextRead: unchanged }; +} diff --git a/gui/src/pages/claude-code-sections.tsx b/gui/src/pages/claude-code-sections.tsx index 423ad77a02b..072624b7c1f 100644 --- a/gui/src/pages/claude-code-sections.tsx +++ b/gui/src/pages/claude-code-sections.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import { IconPlus, IconX } from "../icons"; import { useT } from "../i18n/shared"; import { Trans } from "../i18n/provider"; @@ -30,28 +29,21 @@ export function ClaudeCodeSettingsCard({ autoCompactOptions, availableModels, onStateChange, - footer, }: { state: ClaudeCodeState; autoCompactOptions: { value: string; label: string }[]; availableModels: string[]; onStateChange: (next: ClaudeCodeState) => void; - /** - * The last row of the card. ClaudeCode puts the master Claude connection switch here: - * it commits immediately through the native-integration route, unlike the Save-gated - * rows above, so the parent owns it and this card only places it. - */ - footer?: ReactNode; }) { const t = useT(); return (
{/* - The connection toggle is the footer row, and it commits immediately. A - Save-gated draft copy once lived here too: one setting with two controls and - two commit semantics, so a user who flipped it and navigated away had changed - nothing. Keep exactly one control, owned by ClaudeCode. + The connection toggle is not in this card: it commits immediately, so ClaudeCode + keeps it in its own card at the bottom of the page. A Save-gated draft copy once + lived here too: one setting with two controls and two commit semantics, so a user + who flipped it and navigated away had changed nothing. Keep exactly one control. */}
@@ -217,14 +209,13 @@ export function ClaudeCodeSettingsCard({
); })} - {footer}
); } export function ClaudeCodeQuickstartSection({ manualEnv }: { manualEnv: string }) { const t = useT(); - // Title lives in ClaudeCode's shared ccw-main-head so every rail pane shares one top inset. + // The section title lives in ClaudeCode's CcwSection heading. return ( <>

@@ -245,11 +236,11 @@ export function ClaudeCodeModelMapSection({ onRowsChange: (rows: MapRow[]) => void; }) { const t = useT(); - // Title + count live in ClaudeCode's shared ccw-main-head (stable across rail panes). + // Title + count live in ClaudeCode's CcwSection heading. return ( <>

{t("claude.modelMapHint")}

-
+ {rows.length > 0 &&
{rows.map((row, i) => (
))} -
-
+
} +
0 ? 8 : 0 }}> @@ -304,7 +295,7 @@ function groupAliasesByProvider(aliases: AliasRow[]): Array<[string, AliasRow[]] export function ClaudeCodeAliasesSection({ aliases }: { aliases: AliasRow[] }) { const t = useT(); - // Title + count live in ClaudeCode's shared ccw-main-head (stable across rail panes). + // Title + count live in ClaudeCode's CcwSection heading. return (

{t("claude.aliasesHint")}

diff --git a/gui/src/pages/claude-desktop-roles.ts b/gui/src/pages/claude-desktop-roles.ts new file mode 100644 index 00000000000..d4773438e48 --- /dev/null +++ b/gui/src/pages/claude-desktop-roles.ts @@ -0,0 +1,95 @@ +/** + * Desktop model roles over the existing family profile (design D1). + * + * Claude Desktop reads one family tier per model. Most users only care about two of them: + * the Opus default, which the gateway lists first, and the Haiku default. These helpers let + * the Models card set those two without exposing the tier taxonomy, and keep the exact + * move rules the Advanced lanes use, so both surfaces produce the same profile. + */ + +export const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; +export type Family = typeof FAMILIES[number]; + +export interface Assignment { + family: Family; + alias: string; +} + +export interface DesktopProfile { + version: 1; + assignments: Record; + defaults: Record; + appliedFingerprint?: string; + appliedAt?: string; +} + +export interface RoleModel { + route: string; + label: string; + available: boolean; +} + +/** + * Move `route` into `family`. The old family's default falls to its first remaining member + * (sorted) when the moved route held it; the new family's default becomes `route` when it is + * empty, or always when `makeDefault` is set — even if the route already lived there. + * Aliases and applied markers are carried over untouched. + */ +export function assignFamily(profile: DesktopProfile, route: string, family: Family, makeDefault: boolean): DesktopProfile { + const previous = profile.assignments[route]; + if (!previous) return profile; + if (previous.family === family) { + if (!makeDefault || profile.defaults[family] === route) return profile; + return { ...profile, defaults: { ...profile.defaults, [family]: route } }; + } + const assignments = { ...profile.assignments, [route]: { ...previous, family } }; + const defaults = { ...profile.defaults }; + if (defaults[previous.family] === route) { + defaults[previous.family] = Object.keys(assignments) + .filter(key => key !== route && assignments[key]!.family === previous.family) + .sort()[0] ?? null; + } + if (makeDefault || defaults[family] === null) defaults[family] = route; + return { ...profile, assignments, defaults }; +} + +/** Stored default when it is an available member, else the first available member (sorted). */ +export function effectiveFamilyDefaults(models: readonly RoleModel[], profile: DesktopProfile): Record { + const activeByFamily = Object.fromEntries(FAMILIES.map(family => [family, [] as string[]])) as Record; + for (const model of models) { + if (model.available) activeByFamily[profile.assignments[model.route]?.family ?? "opus"].push(model.route); + } + const result = {} as Record; + for (const family of FAMILIES) { + const active = activeByFamily[family].toSorted(); + const stored = profile.defaults[family]; + result[family] = stored && active.includes(stored) ? stored : (active[0] ?? null); + } + return result; +} + +/** How many assignments a family holds, available or not. */ +export function familySize(models: readonly RoleModel[], profile: DesktopProfile, family: Family): number { + return models.filter(model => (profile.assignments[model.route]?.family ?? "opus") === family).length; +} + +/** + * The value a role select shows: the stored default, so an unavailable stored choice stays + * visible; the effective default only when nothing is stored. + */ +export function roleValue(profile: DesktopProfile, effective: Record, family: Family): string { + return profile.defaults[family] ?? effective[family] ?? ""; +} + +/** Available routes sorted by label, minus the route the other role holds. */ +export function roleOptions(models: readonly RoleModel[], exclude: string | null): RoleModel[] { + return models + .filter(model => model.available && model.route !== exclude) + .sort((a, b) => a.label.localeCompare(b.label) || a.route.localeCompare(b.route)); +} + +/** The compact list: default first, quick second, the rest by label. A GUI overview only. */ +export function roleListOrder(models: readonly T[], defaultRoute: string | null, quickRoute: string | null): T[] { + const rank = (route: string) => (route === defaultRoute ? 0 : route === quickRoute ? 1 : 2); + return models.toSorted((a, b) => rank(a.route) - rank(b.route) || a.label.localeCompare(b.label) || a.route.localeCompare(b.route)); +} diff --git a/gui/src/styles-claudecode-workspace.css b/gui/src/styles-claudecode-workspace.css index 63904cc8134..47f60fb11df 100644 --- a/gui/src/styles-claudecode-workspace.css +++ b/gui/src/styles-claudecode-workspace.css @@ -1,5 +1,5 @@ /* ============================================================================ - claudecode-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + claudecode-workspace — Claude Code settings as one vertical page Namespace: claudecode-workspace- | widgets: ccw- Uses only design tokens from styles.css. No gradients. ============================================================================ */ @@ -13,15 +13,6 @@ min-width: 0; } -.claudecode-workspace-root { - display: grid; - grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); - gap: var(--space-4); - width: 100%; - max-width: 100%; - min-height: 0; -} - .claude-effective-auth { display: flex; flex-direction: column; @@ -46,114 +37,6 @@ font-size: 10.5px; } -/* ── Rail ─────────────────────────────────────────────── */ - -.claudecode-workspace-rail { - display: flex; - flex-direction: column; - gap: var(--space-3); - border-right: 1px solid var(--border); - padding-right: var(--space-3); - min-width: 0; -} - -.claudecode-workspace-rail-list { - display: flex; - flex-direction: column; - gap: 2px; - min-height: 0; -} - -.claudecode-workspace-rail-row { - display: flex; - flex-direction: column; - justify-content: center; - gap: var(--space-0-5); - width: 100%; - min-height: 46px; - appearance: none; - background: none; - border: none; - border-radius: var(--radius-sm); - padding: var(--space-1-5) var(--space-2); - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; - overflow: hidden; -} - -.claudecode-workspace-rail-row:hover { - background: var(--raised); -} - -.claudecode-workspace-rail-row--selected { - background: var(--raised); - box-shadow: inset 0 0 0 1px var(--border); -} - -.claudecode-workspace-rail-name { - font-size: var(--text-body); - font-weight: var(--weight-medium); - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Main pane ────────────────────────────────────────── */ - -.claudecode-workspace-main { - display: flex; - flex-direction: column; - gap: var(--space-3); - min-width: 0; -} - -/* - One pane head for every rail tab: same title baseline under the Code/Desktop selector, - and a permanently reserved Save slot so editable panes cannot shove the rail down. -*/ -.ccw-main-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - min-height: 32px; - min-width: 0; -} - -.ccw-main-title { - margin: 0; - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; - font-size: var(--text-control); - font-weight: var(--weight-semibold); - line-height: var(--leading-ui); - color: var(--text); -} - -.ccw-main-title .count { - color: var(--muted); - font-weight: var(--weight-medium); - font-family: var(--font-code); - font-size: var(--text-label); -} - -.claudecode-workspace-save[data-visible="false"] { - visibility: hidden; - pointer-events: none; -} - -.ccw-body { - display: flex; - flex-direction: column; - gap: var(--space-3); - min-width: 0; -} - /* Available models — dense chip grid instead of one card per alias. */ .claude-aliases { min-width: 0; @@ -240,23 +123,81 @@ max-width: 100%; } -.claudecode-workspace-save { +/* ── Single page ──────────────────────────────────────── */ + +.claudecode-doc { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* The flex gap owns the spacing between blocks now. */ +.claudecode-doc > .claudecode-connection-card { + margin-bottom: 0; +} + +.ccw-section { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.ccw-section-title { + margin: 0; + display: flex; + align-items: baseline; + gap: 8px; + font-size: var(--text-control); + font-weight: var(--weight-semibold); + line-height: var(--leading-ui); + color: var(--text); +} + +.ccw-section-title .count { + color: var(--muted); + font-weight: var(--weight-medium); + font-family: var(--font-code); + font-size: var(--text-label); +} + +/* Immediate-commit master switch: last on the page, set apart from the Save-gated sections. */ +.claudecode-master-card { + overflow: hidden; + border-color: color-mix(in srgb, var(--red) 35%, var(--border)); +} + +/* + One Save bar for the whole page. Opaque so content scrolling under it never shows + through; it stays in flow, so the page ends with it rather than hiding the last card. +*/ +.ccw-savebar { + position: sticky; + bottom: 0; + z-index: 6; display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-2); + padding: var(--space-2) 0 var(--space-3); + background: var(--bg); + border-top: 1px solid var(--border-soft, var(--border)); } -/* ── Responsive ───────────────────────────────────────── */ +.ccw-savebar-state { + flex: 1; + min-width: 0; + color: var(--muted); + font-size: var(--text-label); +} -@media (max-width: 768px) { - .claudecode-workspace-root { - grid-template-columns: 1fr; - } +.ccw-savebar-state.dirty { + color: var(--amber); + font-weight: var(--weight-medium); +} - .claudecode-workspace-rail { - border-right: none; - border-bottom: 1px solid var(--border); - padding-right: 0; - padding-bottom: var(--space-3); - } +/* A focused control near the bottom must not end up under the sticky bar. */ +html:has(.ccw-savebar) { + scroll-padding-bottom: 72px; } + diff --git a/gui/src/styles/claude-page.css b/gui/src/styles/claude-page.css index f888420f18c..dc4e61d094f 100644 --- a/gui/src/styles/claude-page.css +++ b/gui/src/styles/claude-page.css @@ -50,3 +50,160 @@ gap: var(--space-3); margin-left: auto; } + +/* ── Desktop tab: Models card, compact list, Advanced tier lanes (design D1) ── */ +.claude-desktop-roles { + overflow: hidden; + margin-bottom: var(--space-4); +} +.claude-desktop-section-title { + margin: 0; + display: flex; + align-items: baseline; + gap: 8px; + font-size: var(--text-control); + font-weight: var(--weight-semibold); + line-height: var(--leading-ui); + color: var(--text); +} +.claude-desktop-section-title .count { + color: var(--muted); + font-weight: var(--weight-medium); + font-family: var(--font-code); + font-size: var(--text-label); +} +.claude-desktop-roles > .claude-desktop-section-title { + padding: 12px 16px 4px; +} +.claude-desktop-roles-scope { + margin: 0; + padding: 0 16px 8px; + color: var(--muted); + font-size: var(--text-label); +} +.claude-desktop-roles .setting-controls > .custom-select { + text-align: right; +} +.claude-desktop-list { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-4); +} +.claude-desktop-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.claude-desktop-list-hint { + margin: 0; + color: var(--muted); + font-size: var(--text-label); +} +.claude-desktop-list-rows { + list-style: none; + margin: 0; + padding: 0; + overflow: hidden; +} +.claude-desktop-list-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 8px 14px; + border-top: 1px solid var(--border-soft, var(--border)); +} +.claude-desktop-list-row:first-child { + border-top: none; +} +.claude-desktop-list-names { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; +} +.claude-desktop-list-names strong, +.claude-desktop-list-names code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.claude-desktop-list-names strong { + font-weight: var(--weight-medium); +} +.claude-desktop-list-names code { + color: var(--muted); + font-size: 11px; +} +.claude-row-quick { + color: var(--accent); +} +.claude-desktop-advanced { + margin-bottom: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} +.claude-desktop-advanced > summary { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + cursor: pointer; + list-style: none; +} +.claude-desktop-advanced > summary::-webkit-details-marker { + display: none; +} +.claude-desktop-advanced-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: var(--weight-semibold); + color: var(--text); +} +.claude-desktop-advanced-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.claude-desktop-advanced-chip { + display: inline-flex; + align-items: baseline; + gap: 6px; + max-width: 100%; + padding: 2px 10px; + border-radius: var(--radius-pill); + background: var(--raised); + color: var(--muted); + font-size: var(--text-label); +} +.claude-desktop-advanced-chip b { + color: var(--text); + font-weight: var(--weight-medium); +} +.claude-desktop-advanced-chip code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 220px; + font-size: 11px; +} +.claude-desktop-advanced-count { + font-family: var(--font-code); + font-size: 11px; + color: var(--faint); +} +.claude-desktop-advanced-desc { + margin: 0; + padding: 0 14px 10px; + color: var(--muted); + font-size: var(--text-label); +} +.claude-desktop-advanced > .ocx-group-stack { + padding: 0 10px 10px; +} diff --git a/gui/tests/claude-code-save.test.ts b/gui/tests/claude-code-save.test.ts new file mode 100644 index 00000000000..10c4a2c04e6 --- /dev/null +++ b/gui/tests/claude-code-save.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { + acknowledgeSave, + applyServerRead, + claudeCodeSaveBody, + isClaudeCodeDraftDirty, + mergeServerRead, + normalizedModelMap, + revertEditable, + type ClaudeCodeEditable, +} from "../src/pages/claude-code-save"; +import type { ClaudeCodeState, MapRow } from "../src/pages/claude-code-types"; + +const STATE = { + enabled: true, + cliFirstParty: false, + cliFirstPartyApplied: false, + desktopFirstParty: false, + interceptRunning: true, + interceptEligible: true, + sharedProxy: "none", + authMode: "auto", + autoConnectSupported: true, + systemEnv: false, + fastMode: null, + maxContextTokens: null, + autoContext: true, + autoCompactWindow: null, + injectAgents: true, + smallFastModel: "", + effectiveModelEnv: {}, + available: ["a/model"], + aliases: [], + port: 10100, +} satisfies ClaudeCodeState; + +const row = (from: string, to: string, id = `${from}->${to}`): MapRow => ({ id, from, to }); +const editable = (state: Partial = {}, rows: MapRow[] = []): ClaudeCodeEditable => ({ state: { ...STATE, ...state }, rows }); + +test("the Save body never carries the immediate connection switch", () => { + expect("enabled" in claudeCodeSaveBody(STATE, [])).toBe(false); +}); + +test("modelMap is trimmed, drops blank rows, keeps the last duplicate and sorts keys", () => { + expect(normalizedModelMap([row(" b ", " x "), row("", "y"), row("a", ""), row("b", "z"), row("a", "w")])) + .toEqual({ a: "w", b: "z" }); + expect(Object.keys(normalizedModelMap([row("z", "1"), row("a", "2")]))).toEqual(["a", "z"]); +}); + +test("dirty ignores row ids and live fields but counts blank rows", () => { + const base = editable({}, [row("a", "b", "1")]); + expect(isClaudeCodeDraftDirty(editable({}, [row("a", "b", "2")]), base)).toBe(false); + expect(isClaudeCodeDraftDirty(editable({ enabled: false, aliases: [{ id: "x", display_name: "x" }] }, [row("a", "b")]), base)).toBe(false); + expect(isClaudeCodeDraftDirty(editable({}, [row("a", "b"), row("", "")]), base)).toBe(true); + expect(isClaudeCodeDraftDirty(editable({ authMode: "proxy" }, [row("a", "b")]), base)).toBe(true); +}); + +test("a read refreshes server fields of a dirty draft and keeps its edits", () => { + const baseline = editable(); + const draft = editable({ authMode: "proxy" }, [row("x", "y")]); + const next = editable({ enabled: false, available: ["new/model"] }); + const merged = applyServerRead({ draft, baseline, adoptNextRead: false }, next); + expect(merged.draft.state.authMode).toBe("proxy"); + expect(merged.draft.state.enabled).toBe(false); + expect(merged.draft.state.available).toEqual(["new/model"]); + expect(merged.draft.rows).toEqual([row("x", "y")]); + expect(merged.baseline).toBe(next); +}); + +test("a clean draft, or a Save acknowledgement, adopts the server copy outright", () => { + const next = editable({}, [row("a", "b")]); + const clean = applyServerRead({ draft: editable(), baseline: editable(), adoptNextRead: false }, next); + expect(clean.draft).toBe(next); + // Saved untrimmed text: the server normalizes it, and the acknowledgement must settle it. + const acked = applyServerRead({ draft: editable({}, [row(" a ", "b"), row("", "")]), baseline: editable(), adoptNextRead: true }, next); + expect(acked.draft).toBe(next); + expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(false); +}); + +test("merge and revert move exactly the editable fields", () => { + const draft = { ...STATE, authMode: "proxy" as const, enabled: false }; + expect(mergeServerRead(draft, { ...STATE, enabled: true }).authMode).toBe("proxy"); + const reverted = revertEditable(draft, STATE); + expect(reverted.authMode).toBe("auto"); + expect(reverted.enabled).toBe(false); +}); + +test("an edit back to the old value during Save survives the acknowledging read", () => { + const old = editable({ authMode: "proxy" }); + const submitted = editable({ authMode: "auto" }); + // Saved auto, then switched back to proxy while the PUT was in flight. + const acked = acknowledgeSave({ draft: editable({ authMode: "proxy" }), baseline: old, adoptNextRead: false }, submitted)!; + expect(acked.adoptNextRead).toBe(false); + expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(true); + const read = applyServerRead(acked, editable({ authMode: "auto" })); + expect(read.draft.state.authMode).toBe("proxy"); +}); + +test("after a successful Save, Revert restores the saved values", () => { + const submitted = editable({ authMode: "auto" }); + const acked = acknowledgeSave({ draft: submitted, baseline: editable({ authMode: "proxy" }), adoptNextRead: false }, submitted)!; + expect(acked.adoptNextRead).toBe(true); + expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(false); + expect(revertEditable(editable({ authMode: "subscription" }).state, acked.baseline.state).authMode).toBe("auto"); +}); + +test("a successful Save shows the normalized rows even if the refresh never lands", () => { + const submitted = editable({}, [row(" a ", " b "), row("", ""), row("c", "d"), row("c", "e")]); + const acked = acknowledgeSave({ draft: submitted, baseline: editable(), adoptNextRead: false }, submitted)!; + expect(acked.draft.rows.map(r => [r.from, r.to])).toEqual([["a", "b"], ["c", "e"]]); + expect(acked.baseline.rows.map(r => [r.from, r.to])).toEqual([["a", "b"], ["c", "e"]]); + expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(false); + // An edit made while the PUT was in flight stays on screen and stays dirty. + const edited = editable({}, [row(" a ", " b "), row("x", "y")]); + const kept = acknowledgeSave({ draft: edited, baseline: editable(), adoptNextRead: false }, submitted)!; + expect(kept.draft).toBe(edited); + expect(isClaudeCodeDraftDirty(kept.draft, kept.baseline)).toBe(true); +}); diff --git a/gui/tests/claude-desktop-role-card.test.tsx b/gui/tests/claude-desktop-role-card.test.tsx new file mode 100644 index 00000000000..de28126509f --- /dev/null +++ b/gui/tests/claude-desktop-role-card.test.tsx @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * Design D1: the Models card sets the Opus and Haiku defaults without showing tiers, and + * the family lanes live under a folded Advanced disclosure. Mounted, because the contract is + * the profile the card produces and the warnings the folded summary must still show. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +function model(route: string, label: string, family: string, available = true) { + return { route, label, available, contextWindow: 200_000, effortSupported: true, assignment: { family, alias: `alias-${label}` } }; +} + +// Eight models so the lane pager and the search input are both in play (LANE_PAGE = 6, +// LANE_SEARCH_MIN = 4). +const MODELS = [ + ...Array.from({ length: 7 }, (_, i) => model(`prov/opus-${i}`, `Opus Model ${i}`, "opus")), + model("prov/only-sonnet", "Sonnet Model", "sonnet"), +]; + +function payload() { + return { + profile: { + version: 1, + assignments: Object.fromEntries(MODELS.map(m => [m.route, m.assignment])), + defaults: { opus: "prov/opus-0", fable: null, sonnet: "prov/only-sonnet", haiku: null }, + }, + models: MODELS, + rendered: [], + port: 10100, + }; +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const body = String(url).includes("/status") + ? { desiredEnabled: true, applied: true, appliedAt: null, stale: false, health: { lastRequestAt: null, requestCount: 0, errorCount: 0 } } + : payload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function combobox(name: string): HTMLButtonElement { + const found = container.querySelector(`[role="combobox"][aria-label="${name}"]`); + if (!found) throw new Error(`combobox not found: ${name}`); + return found as unknown as HTMLButtonElement; +} + +async function choose(name: string, optionText: string) { + await act(async () => { combobox(name).click(); }); + const option = Array.from(testWindow.document.querySelectorAll('[role="option"]')) + .find(o => (o.textContent ?? "").trim() === optionText) as unknown as HTMLElement | undefined; + if (!option) throw new Error(`option not found: ${optionText}`); + await act(async () => { option.click(); }); +} + +test("the lanes sit inside a folded Advanced disclosure", async () => { + await mount(); + const advanced = container.querySelector("details.claude-desktop-advanced") as unknown as HTMLDetailsElement; + expect(advanced).not.toBeNull(); + expect(advanced.open).toBe(false); + expect(advanced.querySelector(".ocx-group-stack")).not.toBeNull(); + // The folded summary still names every family and its default. + const summary = advanced.querySelector("summary")!.textContent ?? ""; + for (const family of ["Opus", "Fable", "Sonnet", "Haiku"]) expect(summary).toContain(family); + expect(summary).toContain("prov/opus-0"); +}); + +test("choosing a Quick task model moves it into Haiku as its default", async () => { + await mount(); + expect(combobox("Quick task model").textContent).toContain("Not set"); + await choose("Quick task model", "Opus Model 3"); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); + const haiku = Array.from(container.querySelectorAll(".claude-desktop-advanced-chip")) + .find(chip => chip.textContent?.startsWith("Haiku")); + expect(haiku?.textContent).toContain("prov/opus-3"); + const quickRow = Array.from(container.querySelectorAll(".claude-desktop-list-row")) + .find(row => row.textContent?.includes("Opus Model 3")); + expect(quickRow?.textContent).toContain("Quick tasks"); +}); + +test("each role select leaves out the model the other role holds", async () => { + await mount(); + await act(async () => { combobox("Quick task model").click(); }); + const options = Array.from(testWindow.document.querySelectorAll('[role="option"]')).map(o => (o.textContent ?? "").trim()); + expect(options).not.toContain("Opus Model 0"); + expect(options).toContain("Sonnet Model"); +}); + +test("choosing a Default model from another family moves it into Opus", async () => { + await mount(); + await choose("Default model", "Sonnet Model"); + const opus = Array.from(container.querySelectorAll(".claude-desktop-advanced-chip")) + .find(chip => chip.textContent?.startsWith("Opus")); + expect(opus?.textContent).toContain("prov/only-sonnet"); + const sonnet = Array.from(container.querySelectorAll(".claude-desktop-advanced-chip")) + .find(chip => chip.textContent?.startsWith("Sonnet")); + expect(sonnet?.textContent).toContain("empty"); +}); diff --git a/gui/tests/claude-desktop-roles.test.ts b/gui/tests/claude-desktop-roles.test.ts new file mode 100644 index 00000000000..d9c3e0f096c --- /dev/null +++ b/gui/tests/claude-desktop-roles.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { + assignFamily, + effectiveFamilyDefaults, + roleListOrder, + roleOptions, + roleValue, + type DesktopProfile, +} from "../src/pages/claude-desktop-roles"; + +const models = [ + { route: "p/a", label: "Alpha", available: true }, + { route: "p/b", label: "Bravo", available: true }, + { route: "p/c", label: "Charlie", available: false }, + { route: "p/d", label: "Delta", available: true }, +]; + +function profile(): DesktopProfile { + return { + version: 1, + assignments: { + "p/a": { family: "opus", alias: "claude-opus-a" }, + "p/b": { family: "opus", alias: "claude-opus-b" }, + "p/c": { family: "haiku", alias: "claude-haiku-c" }, + "p/d": { family: "sonnet", alias: "claude-sonnet-d" }, + }, + defaults: { opus: "p/a", fable: null, sonnet: "p/d", haiku: "p/c" }, + appliedFingerprint: "fp", + }; +} + +test("making a model the default moves it and repairs the family it left", () => { + const next = assignFamily(profile(), "p/d", "opus", true); + expect(next.assignments["p/d"]).toEqual({ family: "opus", alias: "claude-sonnet-d" }); + expect(next.defaults.opus).toBe("p/d"); + expect(next.defaults.sonnet).toBeNull(); + expect(next.appliedFingerprint).toBe("fp"); +}); + +test("a same-family pick still changes the default", () => { + expect(assignFamily(profile(), "p/b", "opus", true).defaults.opus).toBe("p/b"); + // A lane move into the same family is a no-op, as before. + expect(assignFamily(profile(), "p/b", "opus", false)).toEqual(profile()); +}); + +test("moving the default away hands it to the first remaining member", () => { + const next = assignFamily(profile(), "p/a", "haiku", true); + expect(next.defaults.opus).toBe("p/b"); + expect(next.defaults.haiku).toBe("p/a"); +}); + +test("a plain lane move only claims an empty family's default", () => { + const next = assignFamily(profile(), "p/b", "fable", false); + expect(next.defaults.fable).toBe("p/b"); + expect(assignFamily(profile(), "p/b", "sonnet", false).defaults.sonnet).toBe("p/d"); +}); + +test("effective defaults skip unavailable members; the select keeps the stored one", () => { + const p = profile(); + const effective = effectiveFamilyDefaults(models, p); + expect(effective.haiku).toBeNull(); + expect(roleValue(p, effective, "haiku")).toBe("p/c"); + expect(roleValue({ ...p, defaults: { ...p.defaults, opus: null } }, effective, "opus")).toBe("p/a"); +}); + +test("role options are available models minus the other role", () => { + expect(roleOptions(models, "p/a").map(m => m.route)).toEqual(["p/b", "p/d"]); +}); + +test("the compact list leads with default then quick", () => { + expect(roleListOrder(models, "p/d", "p/b").map(m => m.route)).toEqual(["p/d", "p/b", "p/a", "p/c"]); +}); diff --git a/gui/tests/claudecode-fetch-errors.test.tsx b/gui/tests/claudecode-fetch-errors.test.tsx index 9830e6f059a..f38993966ee 100644 --- a/gui/tests/claudecode-fetch-errors.test.tsx +++ b/gui/tests/claudecode-fetch-errors.test.tsx @@ -192,14 +192,7 @@ test("ClaudeCode helper model options render icon-backed model names", async () const { container, root, testWindow } = await mountClaudeCode(); try { - const helperSection = [...container.querySelectorAll(".claudecode-workspace-rail-row")] - .find(button => button.textContent?.includes("Background helper model")); - expect(helperSection).toBeTruthy(); - await act(async () => { - helperSection!.click(); - await Promise.resolve(); - }); - + // One page: the helper model is on screen without picking a section first. const helperModel = container.querySelector( '[role="combobox"][aria-label="Background helper model"]', ); diff --git a/gui/tests/claudecode-layout.test.ts b/gui/tests/claudecode-layout.test.ts index bec73307c66..88fdcbed337 100644 --- a/gui/tests/claudecode-layout.test.ts +++ b/gui/tests/claudecode-layout.test.ts @@ -1,19 +1,19 @@ import { expect, test } from "bun:test"; -test("ClaudeCode renders the denser workspace rail layout", async () => { +test("ClaudeCode renders every section on one page without an inner rail", async () => { const page = await Bun.file(new URL("../src/pages/ClaudeCode.tsx", import.meta.url)).text(); const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); // The Claude nav entry mounts a Code/Desktop tab wrapper; ClaudeCode itself is - // the Code tab body with a section rail to cut scroll. + // the Code tab body, laid out as one vertical page. const claude = await Bun.file(new URL("../src/pages/Claude.tsx", import.meta.url)).text(); - expect(page).toContain("claudecode-workspace"); - expect(page).toContain("ccw-body"); - expect(page).toContain("ccw-main-head"); - expect(page).toContain("selectedSection"); - expect(page).toContain("claude.workspace.settings"); - // Save stays in the pane head (visibility-toggled) so the Code/Desktop chrome does not jump. - expect(page).toContain('data-visible={sectionEditable ? "true" : "false"}'); + expect(page).toContain("claudecode-workspace-shell claudecode-doc"); + // The inner section rail made a second sidebar beside the app's own; it must not return. + expect(page).not.toContain("claudecode-workspace-rail"); + expect(page).not.toContain("selectedSection"); + // One Save bar for the page, with its own polite status line. + expect(page).toContain('className="ccw-savebar" role="region"'); + expect(page).toContain('aria-live="polite" aria-atomic="true"'); // Claude lives as a tab inside Connect (Integrations), not as its own App slot. expect(app).not.toContain(" { expect(integrations).toContain(""); }); -test("ClaudeCode workspace sections remain available in source order", async () => { +test("ClaudeCode sections follow the page order, master switch and Save bar last", async () => { const src = await Bun.file(new URL("../src/pages/ClaudeCode.tsx", import.meta.url)).text(); + const render = src.slice(src.indexOf('
')); const order = [ - " void) | undefined; + +beforeEach(() => { + clearClientResourceStoresForTests(); + const language = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" }); + const previous = (["document", "window", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const) + .map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const); + restoreGlobals = () => { + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + if (language) Object.defineProperty(globalThis.navigator, "language", language); + else delete (globalThis.navigator as { language?: string }).language; + }; +}); + +test("an edit back to the old value survives a read that lands before Save answers", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = { systemEnv: false, cliFirstParty: false }; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record; + Object.assign(server, body); + // The server commits the Save at once, but its answer is late. + if ("systemEnv" in body) await gate; + return Response.json({ ok: true }); + } + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + const autoConnect = () => page.container.querySelector('input[aria-label="Auto-connect"]')!; + try { + await page.click(autoConnect()); + await act(async () => { page.button("Save").click(); }); + await page.click(autoConnect()); + await page.click(page.button("Toggle Claude Code CLI first-party")); + expect(autoConnect().checked).toBe(false); + release(); + await settle(page.testWindow); + expect(server.systemEnv).toBe(true); + expect(autoConnect().checked).toBe(false); + expect(page.barState()).toBe("Unsaved changes"); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a late Save from an unmounted page reaches the page on screen even when its refresh fails", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = { systemEnv: false }; + let offline = false; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { systemEnv: boolean }; + await gate; + server.systemEnv = body.systemEnv; + offline = true; + return Response.json({ ok: true }); + } + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + const autoConnect = () => page.container.querySelector('input[aria-label="Auto-connect"]')!; + try { + await page.click(autoConnect()); + await act(async () => { page.button("Save").click(); }); + await act(async () => { page.root.render(); }); + await settle(page.testWindow); + expect(autoConnect().checked).toBe(false); + release(); + await settle(page.testWindow); + expect(server.systemEnv).toBe(true); + expect(autoConnect().checked).toBe(true); + expect(page.barState()).toBe("No changes"); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a late connection acknowledgement from an unmounted page reaches the page on screen", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let enabled = true; + let offline = false; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/native-integrations/claude")) { + await gate; + enabled = (JSON.parse(String(init?.body)) as { enabled: boolean }).enabled; + offline = true; + return Response.json({ desiredEnabled: enabled }); + } + if (!url.endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, enabled, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + try { + await act(async () => { page.button("Toggle Claude connection").click(); }); + await act(async () => { page.root.render(); }); + await settle(page.testWindow); + release(); + await settle(page.testWindow); + expect(enabled).toBe(false); + expect(readSessionListCacheEntry<{ state: { enabled: boolean } }>("ocx.claude-code.v1:http://localhost")?.data?.state.enabled).toBe(false); + expect(page.button("Toggle Claude connection").getAttribute("aria-pressed")).toBe("false"); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a late Save refresh from an unmounted page reaches the page on screen", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = { systemEnv: false }; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { systemEnv: boolean }; + await gate; + server.systemEnv = body.systemEnv; + return Response.json({ ok: true }); + } + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + const autoConnect = () => page.container.querySelector('input[aria-label="Auto-connect"]')!; + try { + await page.click(autoConnect()); + await act(async () => { page.button("Save").click(); }); + await act(async () => { page.root.render(); }); + await settle(page.testWindow); + release(); + await settle(page.testWindow); + expect(server.systemEnv).toBe(true); + expect(readSessionListCacheEntry<{ state: { systemEnv: boolean } }>("ocx.claude-code.v1:http://localhost")?.data?.state.systemEnv).toBe(true); + expect(autoConnect().checked).toBe(true); + expect(page.barState()).toBe("No changes"); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a late Save from an unmounted page keeps a newer page's connection switch", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = { systemEnv: false, enabled: true }; + let offline = false; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/native-integrations/claude")) { + server.enabled = (JSON.parse(String(init?.body)) as { enabled: boolean }).enabled; + offline = true; + return Response.json({ desiredEnabled: server.enabled }); + } + if (!url.endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + await gate; + Object.assign(server, JSON.parse(String(init.body))); + return Response.json({ ok: true }); + } + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + const cached = () => readSessionListCacheEntry<{ state: { systemEnv: boolean; enabled: boolean } }>("ocx.claude-code.v1:http://localhost")?.data?.state; + try { + await page.click(page.container.querySelector('input[aria-label="Auto-connect"]')!); + await act(async () => { page.button("Save").click(); }); + // The page is replaced while its Save is out, and the new page turns Claude off. + await act(async () => { page.root.render(); }); + await settle(page.testWindow); + await page.click(page.button("Toggle Claude connection")); + expect(server.enabled).toBe(false); + release(); + await settle(page.testWindow); + expect(server.systemEnv).toBe(true); + expect(cached()?.systemEnv).toBe(true); + expect(cached()?.enabled).toBe(false); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a 1P reread from an unmounted page cannot overwrite a newer page's Save", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = { systemEnv: false, cliFirstParty: false }; + let hold = false; + let offline = false; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record; + Object.assign(server, body); + if ("systemEnv" in body) offline = true; + return Response.json({ ok: true }); + } + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + const snapshot = { ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }; + if (hold) { + hold = false; + await gate; + } + return Response.json(snapshot); + }) as typeof fetch; + const page = await mount(); + const cachedSystemEnv = () => readSessionListCacheEntry<{ state: { systemEnv: boolean } }>("ocx.claude-code.v1:http://localhost")?.data?.state.systemEnv; + try { + hold = true; + await act(async () => { page.button("Toggle Claude Code CLI first-party").click(); }); + await settle(page.testWindow); + // A new page replaces the old one while its reread is still out. + await act(async () => { page.root.render(); }); + await settle(page.testWindow); + await page.click(page.container.querySelector('input[aria-label="Auto-connect"]')!); + await page.click(page.button("Save")); + expect(server.systemEnv).toBe(true); + expect(cachedSystemEnv()).toBe(true); + release(); + await settle(page.testWindow); + expect(cachedSystemEnv()).toBe(true); + } finally { + release(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +for (const control of ["first-party", "connection"] as const) { + test(`a successful ${control} switch survives a failed reread, on screen and in the session copy`, async () => { + const server = { cliFirstParty: false, enabled: true }; + let offline = false; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (init?.method === "PUT") { + if (url.endsWith("/api/native-integrations/claude")) { + server.enabled = (JSON.parse(String(init.body)) as { enabled: boolean }).enabled; + offline = true; + return Response.json({ desiredEnabled: server.enabled }); + } + server.cliFirstParty = (JSON.parse(String(init.body)) as { cliFirstParty: boolean }).cliFirstParty; + offline = true; + return Response.json({ ok: true }); + } + if (!url.endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + try { + const label = control === "first-party" ? "Toggle Claude Code CLI first-party" : "Toggle Claude connection"; + await page.click(page.button(label)); + const cached = readSessionListCacheEntry<{ state: typeof server }>("ocx.claude-code.v1:http://localhost"); + if (control === "first-party") { + expect(server.cliFirstParty).toBe(true); + expect(page.button(label).getAttribute("aria-pressed")).toBe("true"); + expect(cached?.data?.state.cliFirstParty).toBe(true); + } else { + expect(server.enabled).toBe(false); + expect(page.button(label).getAttribute("aria-pressed")).toBe("false"); + expect(cached?.data?.state.enabled).toBe(false); + } + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } + }); +} + +for (const control of ["first-party", "connection"] as const) { + test(`a ${control} switch confirmed during Save survives in the session copy`, async () => { + let releaseSave!: () => void; + const saveGate = new Promise(resolve => { releaseSave = resolve; }); + const server = { systemEnv: false, cliFirstParty: false, enabled: true }; + let offline = false; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/native-integrations/claude")) { + server.enabled = (JSON.parse(String(init?.body)) as { enabled: boolean }).enabled; + return Response.json({ desiredEnabled: server.enabled }); + } + if (!url.endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record; + if ("cliFirstParty" in body) { + server.cliFirstParty = body.cliFirstParty as boolean; + return Response.json({ ok: true }); + } + await saveGate; + Object.assign(server, { systemEnv: body.systemEnv }); + offline = true; + return Response.json({ ok: true }); + } + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + try { + await page.click(page.container.querySelector('input[aria-label="Auto-connect"]')!); + await act(async () => { page.button("Save").click(); }); + await page.click(page.button(control === "first-party" ? "Toggle Claude Code CLI first-party" : "Toggle Claude connection")); + expect(control === "first-party" ? server.cliFirstParty : server.enabled).toBe(control === "first-party"); + releaseSave(); + await settle(page.testWindow); + const cached = readSessionListCacheEntry<{ state: { systemEnv: boolean; cliFirstParty: boolean; enabled: boolean } }>("ocx.claude-code.v1:http://localhost"); + expect(cached?.data?.state.systemEnv).toBe(true); + if (control === "first-party") expect(cached?.data?.state.cliFirstParty).toBe(true); + else expect(cached?.data?.state.enabled).toBe(false); + } finally { + releaseSave(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } + }); +} + +test("a successful Save replaces the session copy even when its refresh fails", async () => { + const server = { systemEnv: false }; + let offline = false; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + Object.assign(server, JSON.parse(String(init.body))); + offline = true; + return Response.json({ ok: true }); + } + if (offline) return Response.json({ error: "offline" }, { status: 503 }); + return Response.json({ ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }); + }) as typeof fetch; + const page = await mount(); + try { + await page.click(page.container.querySelector('input[aria-label="Auto-connect"]')!); + await page.click(page.button("Save")); + expect(server.systemEnv).toBe(true); + const cached = readSessionListCacheEntry<{ state: { systemEnv: boolean } }>("ocx.claude-code.v1:http://localhost"); + expect(cached?.data?.state.systemEnv).toBe(true); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("a read begun before a successful Save cannot undo it", async () => { + // The 1P switch's GET is held until after Save is acknowledged and refreshed; every read + // after it fails, so only the read epoch keeps the stale response out of the draft. + let releaseStale!: () => void; + const stale = new Promise(resolve => { releaseStale = resolve; }); + const server = { systemEnv: false, cliFirstParty: false }; + let holdNextGet = false; + let staleReleased = false; + globalThis.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + Object.assign(server, JSON.parse(String(init.body))); + return Response.json({ ok: true }); + } + const snapshot = { ...SERVER, autoConnectSupported: true, ...server, modelMap: {} }; + // Once the stale read is released, the network fails: nothing later can paper over it. + if (staleReleased) return Response.json({ error: "offline" }, { status: 503 }); + if (holdNextGet) { + holdNextGet = false; + await stale; + staleReleased = true; + } + return Response.json(snapshot); + }) as typeof fetch; + const page = await mount(); + try { + const autoConnect = () => page.container.querySelector('input[aria-label="Auto-connect"]')!; + await page.click(autoConnect()); + expect(page.barState()).toBe("Unsaved changes"); + holdNextGet = true; + await act(async () => { page.button("Toggle Claude Code CLI first-party").click(); }); + await page.click(page.button("Save")); + expect(server.systemEnv).toBe(true); + expect(autoConnect().checked).toBe(true); + releaseStale(); + await settle(page.testWindow); + expect(autoConnect().checked).toBe(true); + expect(page.barState()).toBe("No changes"); + } finally { + releaseStale(); + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +afterEach(() => { + clearClientResourceStoresForTests(); + globalThis.fetch = originalFetch; + restoreGlobals?.(); +}); + +const SERVER = { + enabled: true, + cliFirstParty: false, + cliFirstPartyApplied: false, + desktopFirstParty: false, + interceptRunning: true, + interceptEligible: true, + sharedProxy: "none", + authMode: "proxy", + autoConnectSupported: false, + systemEnv: false, + fastMode: null, + maxContextTokens: null, + autoContext: true, + autoCompactWindow: null, + injectAgents: true, + smallFastModel: "", + effectiveModelEnv: {}, + available: ["mock/model"], + aliases: [], + port: 10100, +}; + +const FROM_INPUT = 'input[aria-label="Original model (e.g. claude-sonnet-4-5)"]'; + +type Server = { modelMap: Record; cliFirstParty: boolean; puts: unknown[]; holdPut?: Promise }; + +function serve(server: Server) { + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (!url.endsWith("/api/claude-code")) return new Response(null, { status: 404 }); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { modelMap?: Record; cliFirstParty?: boolean }; + server.puts.push(body); + if (server.holdPut) await server.holdPut; + if (body.modelMap) server.modelMap = body.modelMap; + if (typeof body.cliFirstParty === "boolean") server.cliFirstParty = body.cliFirstParty; + return Response.json({ ok: true }); + } + return Response.json({ ...SERVER, cliFirstParty: server.cliFirstParty, modelMap: server.modelMap }); + }) as typeof fetch; +} + +async function settle(testWindow: Window) { + for (let i = 0; i < 4; i++) { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + } +} + +async function mount() { + const testWindow = new Window({ url: "http://localhost/" }); + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await settle(testWindow); + const button = (label: string) => [...container.querySelectorAll("button")] + .find(b => b.textContent?.trim() === label || b.getAttribute("aria-label") === label)!; + const barState = () => container.querySelector(".ccw-savebar-state")?.textContent; + const click = async (element: HTMLElement) => { + await act(async () => { element.click(); }); + await settle(testWindow); + }; + return { container, root, testWindow, button, barState, click }; +} + +test("an edit marks the page unsaved and Revert restores the server copy", async () => { + serve({ modelMap: {}, cliFirstParty: false, puts: [] }); + const page = await mount(); + try { + expect(page.barState()).toBe("No changes"); + expect(page.button("Revert").disabled).toBe(true); + await page.click(page.button("Add rule")); + expect(page.barState()).toBe("Unsaved changes"); + await page.click(page.button("Revert")); + expect(page.barState()).toBe("No changes"); + expect(page.container.querySelectorAll(FROM_INPUT).length).toBe(0); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("saving a blank row settles to clean once the server answers", async () => { + const server: Server = { modelMap: {}, cliFirstParty: false, puts: [] }; + serve(server); + const page = await mount(); + try { + await page.click(page.button("Add rule")); + expect(page.barState()).toBe("Unsaved changes"); + await page.click(page.button("Save")); + expect(server.puts).toHaveLength(1); + expect(server.puts[0]).not.toHaveProperty("enabled"); + expect(page.barState()).toBe("No changes"); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("an edit made while Save is in flight survives the acknowledging read", async () => { + let release!: () => void; + const server: Server = { modelMap: {}, cliFirstParty: false, puts: [], holdPut: new Promise(resolve => { release = resolve; }) }; + serve(server); + const page = await mount(); + try { + await page.click(page.button("Add rule")); + await act(async () => { page.button("Save").click(); }); + expect(page.button("Save").disabled).toBe(true); + await page.click(page.button("Add rule")); + release(); + await settle(page.testWindow); + expect(page.container.querySelectorAll(FROM_INPUT).length).toBe(2); + expect(page.barState()).toBe("Unsaved changes"); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("the 1P switch's re-read keeps an unsaved draft", async () => { + const server: Server = { modelMap: {}, cliFirstParty: false, puts: [] }; + serve(server); + const page = await mount(); + try { + await page.click(page.button("Add rule")); + const firstParty = page.button("Toggle Claude Code CLI first-party"); + await page.click(firstParty); + expect(server.cliFirstParty).toBe(true); + expect(page.container.querySelectorAll(FROM_INPUT).length).toBe(1); + expect(page.barState()).toBe("Unsaved changes"); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); + +test("the connection switch is the last setting row, right above the Save bar", async () => { + serve({ modelMap: {}, cliFirstParty: false, puts: [] }); + const page = await mount(); + try { + const rows = page.container.querySelectorAll(".setting-row"); + const last = rows[rows.length - 1]!; + expect(last.classList.contains("claudecode-connection-row")).toBe(true); + expect(last.closest(".claudecode-master-card")?.nextElementSibling?.classList.contains("ccw-savebar")).toBe(true); + } finally { + await act(async () => page.root.unmount()); + page.testWindow.close(); + } +}); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index feaf481ff8f..1b67b1e5b8e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -283,7 +283,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`, `PUT /api/model-settings`. Visibility writes trigger catalog sync through the owning server path. `PUT /api/model-settings` edits one routed row's capability axes in place — context window, declared input modalities, reasoning ladder and its default — through the provider-level per-model maps the runtime already reads, so the row keeps its discovery provenance instead of being replaced by a custom model. Every field is optional and `null` clears the declaration back to the registry/catalog answer; display name is deliberately absent because `PUT /api/providers/{provider}/model-display-names` already owns it. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. It leads with Default model and Quick task model, which set the `opus` and `haiku` family defaults through `gui/src/pages/claude-desktop-roles.ts` (same move rules as the family lanes, which sit under a folded Advanced disclosure); the profile wire shape is unchanged. `gui/src/pages/ClaudeCode.tsx` is one page with a single Save bar; its Save body (`gui/src/pages/claude-code-save.ts`) omits `enabled`, which only the immediate connection switch writes. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. | The model-settings route rejects unknown fields, non-exact or reserved model IDs, and context windows outside the positive safe integers. It computes per-model maps on copies and publishes From 8ef6ad949e1c59127b408d7ff85692874b7325d7 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 06:10:51 +0900 Subject: [PATCH 238/259] test(oauth): make the startup-proxy refresh test hold on Windows (#6600) * test(oauth): remove proxy keys case-insensitively in startup-proxy children The startup-proxy refresh cases failed on Windows (dev dadc2328f1, CI run 37227252582, shard 6/9): after the child deleted the six fixed proxy key spellings, outboundProxyConfigured() still returned true. Windows environment names are case-insensitive, so an inherited spelling outside that fixed list survives. Both the parent env handed to the child and the child's own cleanup now remove every key whose upper-cased name is HTTPS_PROXY, HTTP_PROXY or ALL_PROXY, and the precondition assertion names any proxy key still visible. The production startup-proxy guard is unchanged. * test(oauth): only require an observably proxy-free env where deletion is observable Windows CI run 37229867142 showed the real cause of the shard 6/9 failure: on win32 Bun 1.4.0, deleting a process.env variable removes it from enumeration (no proxy key remains in Object.keys(process.env)), but property reads still return its value, so outboundProxyConfigured() stays true. The "deleted" child therefore keeps its strict current-env precondition on every other platform, and the "empty" child keeps it everywhere. The startup snapshot and intent retention assertions run on all platforms. Production behavior is unchanged; on Windows a runtime deletion simply leaves the guard closed. --- tests/oauth/oauth-refresh.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/oauth/oauth-refresh.test.ts b/tests/oauth/oauth-refresh.test.ts index 2fecda99c44..879ac4bbc33 100644 --- a/tests/oauth/oauth-refresh.test.ts +++ b/tests/oauth/oauth-refresh.test.ts @@ -591,6 +591,7 @@ describe("oauth refresh hardening", () => { describe("Anthropic real-adapter transport intent", () => { const proxyKeys = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]; + const proxyKeyNames = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"]; const dns = { code: "ENOTFOUND", syscall: "getaddrinfo", hostname: "api.anthropic.com" }; const scenarios: Array<{ name: string; outcome: "retry" | "blocked" | "reauth"; @@ -686,7 +687,8 @@ describe("oauth refresh hardening", () => { `; const runChild = async (startupProxy: string | undefined, body: string) => { const env: Record = { ...process.env }; - for (const key of proxyKeys) delete env[key]; + // Windows environment names are case-insensitive; remove every spelling the runner has. + for (const key of Object.keys(env)) if (proxyKeyNames.includes(key.toUpperCase())) delete env[key]; if (startupProxy !== undefined) env.HTTPS_PROXY = startupProxy; const child = Bun.spawn([process.execPath, "--eval", `${childPrelude}${body}\nprocess.exit(0);`], { cwd: repoRoot(), env, stdout: "pipe", stderr: "pipe", @@ -711,10 +713,12 @@ describe("oauth refresh hardening", () => { `)); for (const current of [undefined, ""]) test(`startup proxy with current HTTPS_PROXY ${current === undefined ? "deleted" : "empty"}`, () => runChild("http://proxy.invalid:8080", ` - for (const key of ${JSON.stringify(proxyKeys)}) delete process.env[key]; + for (const key of Object.keys(process.env)) if (${JSON.stringify(proxyKeyNames)}.includes(key.toUpperCase())) delete process.env[key]; ${current === undefined ? "" : 'process.env.HTTPS_PROXY = "";'} assert.equal(startupOutboundProxyConfigured, true); - assert.equal(outboundProxyConfigured(), false); + // On win32 Bun 1.4.0 a deleted variable stops enumerating but property reads still return it + // (CI run 37229867142), so only an emptied value is observably proxy-free there. + if (process.platform !== "win32" || process.env.HTTPS_PROXY === "") assert.equal(outboundProxyConfigured(), false); await assert.rejects(refresh, error => error === dns); const pending = readOAuthRefreshIntent("anthropic", id); assert.equal(pending?.generation, credentialGeneration(credential)); From e53c8077e40b6f540bf1e5d5791a55282466bfac Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 07:22:31 +0900 Subject: [PATCH 239/259] test: make catalog audit and provider proxy fixture robust on Windows; state unhealthy in-place shim status (#6602) * test: make catalog audit and provider proxy fixture robust on Windows runners Compare the audit opencodexHome with redactUserPath of the same path, so long, 8.3 and non-home temp roots all hold. Fail only the audit file ACL in the Windows audit privacy cases; the catalog writer legitimately hardens its own backup on real Windows. Bound the provider proxy fixture child at 45s on Windows (12s elsewhere) under a deadline 3s above it, and report signal, elapsed time and bound when it is killed. * fix(codex): state the unhealthy verdict in in-place shim status diagnoseCodexShim computed healthy:false for a damaged Windows in-place wrapper, but its summary only said "shim present", so ocx codex-shim status read a broken wrapper as fine. Append the unhealthy verdict and repair command when the diagnosis is unhealthy, matching the overlay summary. A host-independent regression records a legacy win32 in-place state; it fails without the fix. --- src/codex/shim-diagnostics.ts | 4 ++- structure/runtime.md | 2 +- .../codex-catalog-write-audit.test.ts | 13 +++++++-- .../codex-shim-readiness.test.ts | 28 +++++++++++++++++++ tests/providers/provider-outbound.test.ts | 13 ++++++--- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/codex/shim-diagnostics.ts b/src/codex/shim-diagnostics.ts index 37909fe211f..28a405b441e 100644 --- a/src/codex/shim-diagnostics.ts +++ b/src/codex/shim-diagnostics.ts @@ -67,7 +67,9 @@ export function diagnoseCodexShim(): CodexShimDiagnostic { const backup = existsSync(file.backupPath) ? "present" : "missing"; return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`; }).join("\n"); - return { installed: true, healthy, summary }; + // Presence alone is not health: a damaged wrapper still reads "shim present", so state the verdict. + return { installed: true, healthy, summary: healthy ? summary + : `${summary}\nCodex autostart shim is unhealthy. Run ocx codex-shim install to repair it.` }; } export function codexShimStatus(): string { diff --git a/structure/runtime.md b/structure/runtime.md index 253e9bb8939..45aaab353e2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -143,7 +143,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. CLI proxy update restart: `src/cli/system-restart-client.ts` preserves a private newer-CLI candidate with its original runtime record and physical home snapshot. `src/cli/update-restart.ts` accepts only known versions and positively classified standalone POSIX targets: PID-1 parent, no installed/active manager, no ownership record, no client/sibling role. It holds the existing ownership mutation lease through stop and spawn and revalidates the captured target before stop. `src/cli/update-restart-transport.ts` proves identity and sends stop on one direct TCP connection, refuses reconnects and requires complete successful teardown acknowledgement. It never signals a PID or reclaims a port. Confirmed predecessor exit, runtime-record removal, port availability and definitive endpoint absence precede the one launch. The executable that launch spawns must pass the shared `REAL_BUN_MIN_BYTES` gate: an incomplete runtime refuses before any stop byte, and after a confirmed stop the transaction waits for it within the deadline, then rechecks deadline and physical home synchronously before spawning. `src/cli/update-restart-child.ts` strips inherited lease delegation, acquires its own lease before CLI preflight and checks the frozen physical homes, ownership revision, CLI version, endpoint and deadline before startup, each bind and publication. It keeps custody through rollback; its deadline ends an unfinished child. `src/cli/update-restart-home.ts` owns physical-home and unclaimed-state checks. Success requires that launched child PID, fresh attestation and the exact known version; all incomplete outcomes terminate without generic restart recovery. Supervised, Windows and uncertain cases refuse this update path. | -| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status and distinguishes runnable from active overlays. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index 377c1c8ed33..f8f4cee61f5 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -28,6 +28,7 @@ import { CODEX_HOME_JOURNAL_FILE } from "../../src/codex/codex-home-owner"; import { initializeConfigOwnership, CONFIG_UNINSTALL_MANIFEST } from "../../src/lib/config-ownership"; import type { CatalogWriteAuditEvent } from "../../src/codex/catalog/write-audit-contract"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { redactUserPath } from "../../src/lib/redact"; let root = ""; let codexHome = ""; @@ -108,7 +109,9 @@ describe("catalog write audit (#6529)", () => { expect(first).toMatchObject({ target: "catalog", writer: "other", routedBefore: 2, routedAfter: 3, pid: process.pid }); expect(typeof first!.at).toBe("string"); expect(typeof first!.command).toBe("string"); - expect(first!.opencodexHome).toBe(realpathSync.native(opencodexHome)); + // The record masks the account name under a Windows or POSIX home (long or 8.3 profile name + // alike) and keeps other paths verbatim, so compare against the same projection. + expect(first!.opencodexHome).toBe(redactUserPath(realpathSync.native(opencodexHome))); }); test("keeps the newest records once the file passes its budget", () => { @@ -556,19 +559,23 @@ describe("Windows audit privacy (r4176128642)", () => { for (const code of ["EICACLS", "ETIMEDOUT", "returned-failure"]) { test(`${code} leaves a blank file and preserves catalog publication`, () => { + // Only the audit file's ACL fails. On real Windows the catalog writer also hardens its own + // backup, which this case must not turn into a publication failure. harden.mockImplementation(path => { + if (path !== auditPath()) return { ok: true }; expect(readFileSync(path)).toHaveLength(0); if (code === "returned-failure") return { ok: false }; throw Object.assign(new Error("ACL failure"), { code }); }); + const auditHardens = () => harden.mock.calls.filter(([path]) => path === auditPath()).length; expect(appendUnderK()).toBe("skipped"); - expect(harden).toHaveBeenCalledTimes(1); + expect(auditHardens()).toBe(1); expect(readFileSync(auditPath())).toHaveLength(0); // A skipped diagnostic must not alter the real operation's outcome. expect(replaceAs("restore", catalogBytes(native))).toEqual({ kind: "written" }); expect(readFileSync(catalogPath(), "utf8")).toBe(catalogBytes(native)); expect(readFileSync(auditPath())).toHaveLength(0); - expect(harden).toHaveBeenCalledTimes(1); + expect(auditHardens()).toBe(1); }); } diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index 46c55cfea52..354887f8f9d 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { codexShimReadinessWarnings } from "../../src/cli/codex-shim-readiness"; +import { diagnoseCodexShim } from "../../src/codex/shim-diagnostics"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -289,3 +290,30 @@ describe("Codex shim install readiness", () => { } }, SHIM_INSTALL_CASE_MS * 3); }); + +describe("in-place Windows shim status verdict", () => { + test("a damaged in-place wrapper says unhealthy instead of only 'shim present'", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-inplace-verdict-")); + const previous = process.env.OPENCODEX_HOME; + try { + const home = join(root, "ocx"); const bin = join(root, "bin"); mkdirSync(home); mkdirSync(bin); + const wrapperPath = join(bin, "codex.cmd"); const backupPath = join(bin, "codex.opencodex-real.cmd"); + writeFileSync(backupPath, "@echo off\r\n"); + // Legacy in-place record, as Windows installs write it; readable on every host. + writeFileSync(join(home, "codex-shim.json"), JSON.stringify({ platform: "win32", wrapperPath, originalPath: wrapperPath, backupPath })); + process.env.OPENCODEX_HOME = home; + const body = `@echo off\r\nrem opencodex codex autostart shim${"\r\nrem padding".repeat(20)}\r\n`; + writeFileSync(wrapperPath, `${body}ocx codex-shim ensure\r\n`); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: true }); + expect(diagnoseCodexShim().summary).not.toContain("unhealthy"); + writeFileSync(wrapperPath, `${body}ocx codex-shim broken\r\n`); + const damaged = diagnoseCodexShim(); + expect(damaged).toMatchObject({ installed: true, healthy: false }); + expect(damaged.summary).toContain("wrapper shim present"); + expect(damaged.summary).toContain("Codex autostart shim is unhealthy. Run ocx codex-shim install to repair it."); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index f0e0ff4c91c..6f596dd63c3 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -493,7 +493,11 @@ describe("provider outbound GET transport", () => { expect(override).toHaveBeenCalledTimes(1); }); + // A cold Bun start plus the fixture's imports can exceed 12s on slow Windows runners. The child + // stays bounded and SIGKILLed below the case deadline, so a real hang still fails with its timing. + const PROXY_E2E_CHILD_MS = process.platform === "win32" ? 45_000 : 12_000; test("proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths", async () => { + const startedAt = performance.now(); const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-")); const child = Bun.spawn([ process.execPath, @@ -506,8 +510,8 @@ describe("provider outbound GET transport", () => { }, stdout: "pipe", stderr: "pipe", - // Terminate and reap the fixture before the existing 15s test deadline. - timeout: 12_000, + // Terminate and reap the fixture before the case deadline. + timeout: PROXY_E2E_CHILD_MS, killSignal: "SIGKILL", }); @@ -518,7 +522,8 @@ describe("provider outbound GET transport", () => { child.exited, ]); if (exitCode !== 0) { - throw new Error(`provider outbound fixture exited ${exitCode}: ${stderr.trim()}`); + const bound = child.signalCode ? ` (${child.signalCode} after ${Math.round(performance.now() - startedAt)} ms; bound ${PROXY_E2E_CHILD_MS} ms)` : ""; + throw new Error(`provider outbound fixture exited ${exitCode}${bound}: ${stderr.trim()}`); } const result = JSON.parse(stdout.trim()) as { dnsLookups: string[]; @@ -569,7 +574,7 @@ describe("provider outbound GET transport", () => { await child.exited; removeTreeWithRetry(childHome); } - }, 15_000); + }, PROXY_E2E_CHILD_MS + 3_000); }); describe("provider outbound POST transport", () => { From 829a18ba94941aa7997d34cbd88f97390844d79b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 12:27:35 +0900 Subject: [PATCH 240/259] docs(devlog): close the CLI update restart unit with its delivery outcome (#6595) * docs(devlog): close the CLI update restart unit with its delivery outcome #6556 merged as 7cf1b7624a, so its devlog unit moves from _plan to _fin with the recorded outcome: the final-head unmocked POSIX acceptance and its limits, the runtime-readiness gate and #6548 message carry, the test isolation fix, and the #6548 disposition. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> * docs(devlog): record #6548 closure in the restart unit delivery outcome --------- Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- .../261004_cli_update_restart/000_plan.md | 0 .../261004_cli_update_restart/010_delivery.md | 16 ++++++++++++++++ .../261004_cli_update_restart/010_delivery.md | 7 ------- 3 files changed, 16 insertions(+), 7 deletions(-) rename devlog/{_plan => _fin}/261004_cli_update_restart/000_plan.md (100%) create mode 100644 devlog/_fin/261004_cli_update_restart/010_delivery.md delete mode 100644 devlog/_plan/261004_cli_update_restart/010_delivery.md diff --git a/devlog/_plan/261004_cli_update_restart/000_plan.md b/devlog/_fin/261004_cli_update_restart/000_plan.md similarity index 100% rename from devlog/_plan/261004_cli_update_restart/000_plan.md rename to devlog/_fin/261004_cli_update_restart/000_plan.md diff --git a/devlog/_fin/261004_cli_update_restart/010_delivery.md b/devlog/_fin/261004_cli_update_restart/010_delivery.md new file mode 100644 index 00000000000..3d488d8a036 --- /dev/null +++ b/devlog/_fin/261004_cli_update_restart/010_delivery.md @@ -0,0 +1,16 @@ +# wp1 — Publish the CLI restart replacement + +One implementation cycle produces an ordinary draft PR against dev. Preserve the newer-CLI upgrade intent and proxy-newer/incomparable refusal from #6548. Add focused lifecycle regression coverage, keep index composition minimal, and synchronize runtime and user lifecycle documentation. + +The scoped implementation and security audit remain in ignored scratch until the patch is public, per repository policy. Public delivery records source coverage, attribution, verification commands and native/live limits. No source PR is closed by this lane. + +Expected changed areas: src/cli restart siblings and index wiring; tests/cli lifecycle regressions; both test-layout registries; structure/runtime.md; docs-site lifecycle reference. No integration branch or version file changes. + +## Delivery outcome + +#6556 merged into dev as `7cf1b7624a` from final PR head `87d8123137`, after leaving draft. That head already incorporated #6569 (`accd69444c`), which landed first and added the same runtime-readiness wait to the package-tree restart. + +- **Unmocked POSIX acceptance (macOS arm64, Bun 1.4.0) passed on the final PR head `87d8123137`.** Two standalone builds of that tree (`2.78.0-acceptance.1` and `.2`) ran inside one temporary root holding every state directory, on a random loopback port, after a preflight confirmed each resolved state path stayed inside it. The newer CLI's `ocx restart` stopped the detached old proxy and launched exactly one replacement: parent PID 1, same port, version `.2`. The older CLI's restart against the newer proxy was refused. Teardown confirmed both processes gone and the port closed. Not exercised end to end: Linux with a real user systemd manager, npm-global installs (the runtime gate has unit coverage only), and Windows or supervised runtimes, which refuse this path by design. +- **Added while carrying #6548:** the launched executable must pass the shared `REAL_BUN_MIN_BYTES` gate. While it is still the npm `bun` placeholder, restart refuses before sending any stop byte. After a confirmed stop it waits within the deadline, then rechecks deadline and home before the single launch. #6548's progress line and per-step failure explanations were carried with a `Co-authored-by` trailer for agentHits. +- **Test isolation:** the transport test's HTTP-proxy case now runs in a child process. Bun cannot unset an exported variable, so the in-process version leaked the proxy into the next file of a `BUN_TEST_PARALLEL=1` batch and failed the macOS shard. +- **#6548:** superseded by #6556 for the newer-CLI update. Its attempt to restart foreground and service-managed proxies was deliberately not carried, because that path cannot verify the replacement it launches. It was closed without merging on 2026-10-04 (UTC). diff --git a/devlog/_plan/261004_cli_update_restart/010_delivery.md b/devlog/_plan/261004_cli_update_restart/010_delivery.md deleted file mode 100644 index 0f95a7e31e9..00000000000 --- a/devlog/_plan/261004_cli_update_restart/010_delivery.md +++ /dev/null @@ -1,7 +0,0 @@ -# wp1 — Publish the CLI restart replacement - -One implementation cycle produces an ordinary draft PR against dev. Preserve the newer-CLI upgrade intent and proxy-newer/incomparable refusal from #6548. Add focused lifecycle regression coverage, keep index composition minimal, and synchronize runtime and user lifecycle documentation. - -The scoped implementation and security audit remain in ignored scratch until the patch is public, per repository policy. Public delivery records source coverage, attribution, verification commands and native/live limits. No source PR is closed by this lane. - -Expected changed areas: src/cli restart siblings and index wiring; tests/cli lifecycle regressions; both test-layout registries; structure/runtime.md; docs-site lifecycle reference. No integration branch or version file changes. From e0238355da893b45471287f03ad6ed7b15716ed2 Mon Sep 17 00:00:00 2001 From: lcxhh521 Date: Mon, 5 Oct 2026 13:21:59 +0800 Subject: [PATCH 241/259] feat(integrations): show the missing-store remedy in the dashboard (#6597) * fix(integrations): write DSH routes to the Desktop profile patch it reads DSH 0.1.7+ imports $DSH_HOME/settings.yaml once into the first profile that boots and renames it to settings.yaml.imported. From then on it reads provider routes from the llm-pi-ai row of a profile patch (a top-level YAML list of loader rows) and hot reloads that file. The integration kept writing settings.yaml, so after that first import a catalog refresh, a model change or a disable reached nobody, and status read the renamed file as absent. Declare the Desktop profile's cordis.patch.yml as DSH's currentStore (the seam from #5348), addressed through a leading [id=llm-pi-ai] selector. The home patch is not an alternative: a home row replaces the profile row's whole config, the user's own routes with it. - merge.ts keeps a sequence root, and prunes an element it seeded once only its selector fields remain. - The source-preserving YAML patcher edits one top-level list entry as the block map it holds and restores its "- " and two-space indent byte for byte. DSH's empty `[]` is the only flow form adopted, and a disable that empties the list writes it back. - IntegrationTarget carries its own sourcePreservingYaml, so the legacy file and the store are patched along their own paths, and a coordinated write also holds DSH's config-editor lock (the profile's package.json.lock) whenever the profile directory exists. - Without a Desktop profile the legacy settings.yaml stays the target. - The dashboard accepts the new plan path; GUI text, docs and the integrations structure doc describe the new location. * test(gui): pin the DSH ownership copy to the Desktop profile path The locale-parity fixture still held the settings.yaml sentence in every locale, and the DSH surface test asserted the old llm-pi-ai.providers spelling. Both now follow the copy: the llm-pi-ai row, the Desktop profile patch, and the settings.yaml fallback. * fix(integrations): keep DSH profile refresh order and lock late profiles Refreshing a Desktop profile row that OpenCodex created no longer deletes and re-appends it in the expected merge, so a row DSH appended afterwards no longer turns a routine catalog refresh into an "unsafe" refusal. The created-row provenance is kept so disable still removes it. The profile manifest lock is now chosen after the settings lock is held and re-checked after every revalidation await, so a profile that appears mid-write is locked and revalidated before it is read or written. Removing the last managed row also keeps the file's original final-newline and CRLF convention. * fix(integrations): refuse restoring into a missing DSH profile directory A confirmed restore of a journaled Desktop profile write used to recreate a removed profiles/desktop directory and write it without the profile's package.json.lock. Preview and restore now refuse as unsafe when the declared locked store's directory is missing, and the structure doc records the lock re-probe, row-order, and restore contracts. * docs(structure): fit the DSH store paragraph inside the integrations budget Merging #6577 put structure/clients/integrations.md four lines over its 600-line budget. Tighten the DSH paragraph without dropping any contract. * fix(integrations): report a booted DSH profile without a patch instead of writing settings.yaml With profiles/desktop/package.json present and cordis.patch.yml missing, the target fell back to $DSH_HOME/settings.yaml with no ineffective marker. DSH imported that file when it booted the profile and never reads it again, so the write was lost without a word. A store declaration can now name a missing store the client still reads; DSH's does, and the write is refused as an ineffective one with the remedy (create the patch as `[]`). * docs(integrations): state why a DSH profile without a patch is refused The comments, structure doc, and refusal message said DSH imports settings.yaml once and never reads it again. Upstream DSH's importLegacyDocument renames settings.yaml to settings.yaml.imported and imports it on every startup where it exists. The refusal stays: a block written there would be moved out from under opencodex's ownership record. Only wording changes; behavior and tests' assertions are unchanged. * feat(integrations): show the missing-store remedy in the dashboard A DSH Desktop profile whose cordis.patch.yml is missing is refused as superseded_store, and the CLI names the fix: create the patch containing `[]`. The dashboard only had the generic superseded-store copy, which says the client reads a file opencodex does not write and offers no way out. - The missingStore declaration publishes its empty document (`[]` for DSH) beside the remedy text, and IneffectiveWrite carries it. - Status rows carry supersededReason beside supersededBy, plus missingStoreDocument for a missing store. - A superseded_store plan carries the same two fields. The plan still names no file (the path stays on the status row), and the fingerprint does not change: the bound ineffective-write token already covers this finding. - The GUI accepts the fields only on a superseded_store refusal, the document only for missing-store and only as one short line, and renders copy that names the file and the document in all eleven locales. * fix(integrations): name the missing store in the dialog and decode it in the CLI - The apply dialog covers the page's status notice, so a refused DSH preview now names the patch path as well as `[]`. The path comes from the status row; the plan still names no file. Bulk dialogs, which have no single status row, keep the pathless copy. - The Turkish copy names the legacy settings file explicitly instead of "that file". - The CLI plan decoder from #6542 accepts supersededReason and missingStoreDocument under the same contract as the GUI, and `ocx integration client preview` prints the remedy. Both decoders now reject a non-string reason and a document that is not one short printable-ASCII line, since the CLI echoes it to a terminal. * fix(gui): wrap long missing-store paths in the DSH notice and plan dialog --------- Co-authored-by: JUN --- 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/pt.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 + .../pages/integrations/ConsequenceDialog.tsx | 5 ++- .../integrations/FileIntegrationPage.tsx | 7 +++- .../integrations/IntegrationPlanDetails.tsx | 18 +++++++-- gui/src/pages/integrations/integration-api.ts | 28 +++++++++++++- gui/src/styles-integrations.css | 5 ++- ...integration-preview-confirmations.test.tsx | 32 ++++++++++++++++ gui/tests/integrations-api.test.ts | 38 +++++++++++++++++++ gui/tests/integrations-surfaces.test.tsx | 35 +++++++++++++++++ src/cli/integration-plan-dto.ts | 15 +++++++- src/cli/integration-preview.ts | 13 ++++++- src/integrations/mutation-plan.ts | 21 +++++++++- src/integrations/registry.ts | 7 ++++ src/integrations/state.ts | 15 +++++++- src/integrations/target.ts | 10 +++-- structure/clients/integrations.md | 2 +- tests/cli/cli-integration-preview.test.ts | 31 +++++++++++++++ .../integrations-dsh-profile-patch.test.ts | 19 +++++++++- .../integrations-superseded-store.test.ts | 4 ++ 29 files changed, 307 insertions(+), 20 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 254a77afcf4..bc4b1041c70 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1632,6 +1632,7 @@ export const de: Record = { "integrations.status.notInstalled": "Nicht installiert", "integrations.status.appliedAt": "Angewendet", "integrations.status.supersededStore": "Dieser Client liest seine Provider jetzt aus {path}, und opencodex schreibt diese Datei nicht. Ein Aktivieren hier ändert nichts an dem, was der Client lädt.", + "integrations.status.missingStore": "Dieser Client speichert seine Provider in {path}, und diese Datei fehlt. opencodex schreibt nicht stattdessen in die alte Einstellungsdatei des Clients, weil der Client sie beim nächsten Start verschiebt und importiert. Lege {path} mit dem Inhalt {document} an und klicke dann erneut auf „Anwenden“.", "integrations.status.candidateConflict": "Eine weitere Kilo-Konfigurationsdatei, {path}, definiert ebenfalls provider.opencodex. Entfernen Sie provider.opencodex aus dieser Datei, bevor Sie die Integration anwenden.", "integrations.status.backup": "Sicherung", "integrations.status.lastRestore": "Letzte Wiederherstellung", @@ -1694,6 +1695,7 @@ export const de: Record = { "integrations.plan.refusal.unsafe": "Die Client-Konfiguration kann nicht sicher geändert werden. Repariere oder ersetze die Datei und versuche es erneut.", "integrations.plan.refusal.nonLoopback": "Dieser Client benötigt eine Loopback-Proxyadresse. Verwende localhost, einen Tunnel oder eine lokale Weiterleitung.", "integrations.plan.refusal.supersededStore": "Dieser Client liest seine Provider aus einer Datei, die opencodex nicht schreibt. Dieser Vorgang würde nichts an dem ändern, was der Client lädt.", + "integrations.plan.refusal.missingStore": "Die Datei, in der dieser Client seine Provider speichert, fehlt. opencodex schreibt nicht stattdessen in die alte Einstellungsdatei des Clients, weil der Client sie beim nächsten Start verschiebt. Lege die Datei mit dem Inhalt {document} an und klicke dann erneut auf „Anwenden“.", "integrations.plan.refusal.driftRequiresConfirm": "Die Datei wurde nach diesem Rollback-Punkt geändert. Prüfe vor der Wiederherstellung den Drift-Plan.", "integrations.plan.refusal.snapshotExpired": "Die Sicherung für diesen Rollback-Punkt ist abgelaufen und kann nicht mehr wiederhergestellt werden.", "integrations.plan.refusal.writeFailed": "Der Server konnte keinen sicheren Schreibvorgang vorbereiten. Aktualisiere den Integrationsstatus und versuche es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cecfbdc73b2..73b43500e1c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2243,6 +2243,7 @@ export const en = { "integrations.status.notInstalled": "Not installed", "integrations.status.appliedAt": "Applied", "integrations.status.supersededStore": "This client now reads its providers from {path}, which opencodex does not write, so enabling it here would change nothing it loads.", + "integrations.status.missingStore": "This client keeps its providers in {path}, which is missing. opencodex will not write the client's old settings file instead, because the client moves that file aside and imports it on its next start. Create {path} containing {document}, then press Apply again.", "integrations.status.candidateConflict": "Another Kilo config file, {path}, also defines provider.opencodex. Remove provider.opencodex from that file before applying.", "integrations.status.backup": "Backup", "integrations.status.lastRestore": "Last restore", @@ -2305,6 +2306,7 @@ export const en = { "integrations.plan.refusal.unsafe": "The client configuration cannot be changed safely. Repair or replace the file before trying again.", "integrations.plan.refusal.nonLoopback": "This client requires a loopback proxy address. Use localhost access, a tunnel, or a local forwarder before trying again.", "integrations.plan.refusal.supersededStore": "This client now reads its providers from a file opencodex does not write, so this operation would change nothing it loads.", + "integrations.plan.refusal.missingStore": "The file this client keeps its providers in is missing, and opencodex will not write the client's old settings file instead, because the client moves that file aside on its next start. Create the file containing {document}, then press Apply again.", "integrations.plan.refusal.driftRequiresConfirm": "The file changed after this rollback point. Review the drift-aware plan before restoring.", "integrations.plan.refusal.snapshotExpired": "The backup for this rollback point has expired, so it can no longer be restored.", "integrations.plan.refusal.writeFailed": "The server could not prepare a safe write. Refresh the integration state before trying again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index d24dfd7227b..1698eb95c61 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2170,6 +2170,7 @@ export const fr: Record = { "integrations.status.notInstalled": "Non installé", "integrations.status.appliedAt": "Appliqué", "integrations.status.supersededStore": "Ce client lit désormais ses fournisseurs depuis {path}, un fichier qu'opencodex n'écrit pas : l'activer ici ne changerait rien à ce qu'il charge.", + "integrations.status.missingStore": "Ce client conserve ses fournisseurs dans {path}, et ce fichier est absent. opencodex n'écrit pas à la place dans l'ancien fichier de réglages du client, car le client le déplace et l'importe à son prochain démarrage. Créez {path} avec le contenu {document}, puis cliquez de nouveau sur « Appliquer ».", "integrations.status.candidateConflict": "Un autre fichier de configuration Kilo, {path}, définit aussi provider.opencodex. Supprimez provider.opencodex de ce fichier avant d'appliquer.", "integrations.status.backup": "Sauvegarde", "integrations.status.lastRestore": "Dernière restauration", @@ -2232,6 +2233,7 @@ export const fr: Record = { "integrations.plan.refusal.unsafe": "La configuration du client ne peut pas être modifiée en toute sécurité. Réparez ou remplacez le fichier avant de réessayer.", "integrations.plan.refusal.nonLoopback": "Ce client exige une adresse proxy en boucle locale. Utilisez localhost, un tunnel ou un transfert local avant de réessayer.", "integrations.plan.refusal.supersededStore": "Ce client lit ses fournisseurs depuis un fichier qu'opencodex n'écrit pas : cette opération ne changerait rien à ce qu'il charge.", + "integrations.plan.refusal.missingStore": "Le fichier où ce client conserve ses fournisseurs est absent, et opencodex n'écrit pas à la place dans l'ancien fichier de réglages du client, car le client le déplace à son prochain démarrage. Créez ce fichier avec le contenu {document}, puis cliquez de nouveau sur « Appliquer ».", "integrations.plan.refusal.driftRequiresConfirm": "Le fichier a changé après ce point de restauration. Vérifiez le plan tenant compte des changements avant de restaurer.", "integrations.plan.refusal.snapshotExpired": "La sauvegarde de ce point de restauration a expiré et ne peut plus être restaurée.", "integrations.plan.refusal.writeFailed": "Le serveur n’a pas pu préparer une écriture sûre. Actualisez l’état de l’intégration avant de réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 340a65593c3..f50ac3ffe19 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2065,6 +2065,7 @@ export const ja: Record = { "integrations.status.notInstalled": "未インストール", "integrations.status.appliedAt": "適用", "integrations.status.supersededStore": "このクライアントは現在 {path} からプロバイダーを読み込みます。opencodex はこのファイルを書き込まないため、ここで有効にしても読み込む内容は変わりません。", + "integrations.status.missingStore": "このクライアントはプロバイダーを {path} に保存していますが、このファイルがありません。クライアントは次回起動時に古い設定ファイルを移動して取り込むため、opencodex は代わりにその設定ファイルへ書き込むことはしません。{path} を内容 {document} で作成してから、もう一度「適用」を押してください。", "integrations.status.candidateConflict": "別の Kilo 設定ファイル {path} にも provider.opencodex が定義されています。適用する前に、そのファイルから provider.opencodex を削除してください。", "integrations.status.backup": "バックアップ", "integrations.status.lastRestore": "最終復元", @@ -2127,6 +2128,7 @@ export const ja: Record = { "integrations.plan.refusal.unsafe": "クライアント設定を安全に変更できません。ファイルを修復または置き換えてから再試行してください。", "integrations.plan.refusal.nonLoopback": "このクライアントにはループバックのプロキシアドレスが必要です。localhost、トンネル、またはローカル転送を使用してください。", "integrations.plan.refusal.supersededStore": "このクライアントは opencodex が書き込まないファイルからプロバイダーを読み込むため、この操作では読み込む内容は変わりません。", + "integrations.plan.refusal.missingStore": "このクライアントがプロバイダーを保存するファイルがありません。クライアントは次回起動時に古い設定ファイルを移動するため、opencodex は代わりにその設定ファイルへ書き込むことはしません。このファイルを内容 {document} で作成してから、もう一度「適用」を押してください。", "integrations.plan.refusal.driftRequiresConfirm": "このロールバック地点以降にファイルが変更されました。変更を考慮したプランを確認してから復元してください。", "integrations.plan.refusal.snapshotExpired": "このロールバック地点のバックアップは期限切れのため、復元できません。", "integrations.plan.refusal.writeFailed": "サーバーは安全な書き込みを準備できませんでした。統合状態を更新してから再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a4f9993543d..41808761b7a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1676,6 +1676,7 @@ export const ko: Record = { "integrations.status.notInstalled": "설치되지 않음", "integrations.status.appliedAt": "적용", "integrations.status.supersededStore": "이 클라이언트는 이제 {path}에서 프로바이더를 읽습니다. opencodex는 이 파일을 쓰지 않으므로 여기서 켜도 클라이언트가 불러오는 내용은 달라지지 않습니다.", + "integrations.status.missingStore": "이 클라이언트는 프로바이더를 {path}에 보관하지만 이 파일이 없습니다. 클라이언트가 다음 시작 때 이전 설정 파일을 옮겨서 가져오므로, opencodex는 대신 그 설정 파일에 쓰지 않습니다. {path} 파일을 {document} 내용으로 만든 뒤 '적용'을 다시 누르세요.", "integrations.status.candidateConflict": "다른 Kilo 설정 파일 {path}에도 provider.opencodex가 정의되어 있습니다. 적용하려면 그 파일에서 provider.opencodex를 제거하세요.", "integrations.status.backup": "백업", "integrations.status.lastRestore": "마지막 복원", @@ -1738,6 +1739,7 @@ export const ko: Record = { "integrations.plan.refusal.unsafe": "클라이언트 구성을 안전하게 변경할 수 없습니다. 파일을 복구하거나 교체한 뒤 다시 시도하세요.", "integrations.plan.refusal.nonLoopback": "이 클라이언트에는 루프백 프록시 주소가 필요합니다. localhost, 터널 또는 로컬 포워더를 사용한 뒤 다시 시도하세요.", "integrations.plan.refusal.supersededStore": "이 클라이언트는 opencodex가 쓰지 않는 파일에서 프로바이더를 읽으므로, 이 작업으로는 클라이언트가 불러오는 내용이 달라지지 않습니다.", + "integrations.plan.refusal.missingStore": "이 클라이언트가 프로바이더를 보관하는 파일이 없습니다. 클라이언트가 다음 시작 때 이전 설정 파일을 옮기므로, opencodex는 대신 그 설정 파일에 쓰지 않습니다. 이 파일을 {document} 내용으로 만든 뒤 '적용'을 다시 누르세요.", "integrations.plan.refusal.driftRequiresConfirm": "이 롤백 시점 이후 파일이 변경되었습니다. 변경 상태를 반영한 계획을 검토한 뒤 복원하세요.", "integrations.plan.refusal.snapshotExpired": "이 롤백 시점의 백업이 만료되어 더 이상 복원할 수 없습니다.", "integrations.plan.refusal.writeFailed": "서버가 안전한 쓰기 작업을 준비하지 못했습니다. 통합 상태를 새로 고친 뒤 다시 시도하세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index f704e8ebdf0..27f12c5e5f2 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2217,6 +2217,7 @@ export const pt: Record = { "integrations.status.notInstalled": "Não instalado", "integrations.status.appliedAt": "Aplicado", "integrations.status.supersededStore": "Este cliente agora lê seus provedores de {path}, que o opencodex não grava, então ativá-lo aqui não mudaria nada do que ele carrega.", + "integrations.status.missingStore": "Este cliente guarda seus provedores em {path}, e esse arquivo não existe. O opencodex não grava no antigo arquivo de configurações do cliente no lugar dele, porque o cliente move esse arquivo e o importa na próxima inicialização. Crie {path} com o conteúdo {document} e clique em \"Aplicar\" de novo.", "integrations.status.candidateConflict": "Outro arquivo de configuração do Kilo, {path}, também define provider.opencodex. Remova provider.opencodex desse arquivo antes de aplicar.", "integrations.status.backup": "Backup", "integrations.status.lastRestore": "Última restauração", @@ -2279,6 +2280,7 @@ export const pt: Record = { "integrations.plan.refusal.unsafe": "A configuração do cliente não pode ser alterada com segurança. Repare ou substitua o arquivo antes de tentar de novo.", "integrations.plan.refusal.nonLoopback": "Este cliente exige um endereço de proxy loopback. Use acesso por localhost, um túnel ou um encaminhador local antes de tentar de novo.", "integrations.plan.refusal.supersededStore": "Este cliente agora lê seus provedores de um arquivo que o opencodex não grava, então esta operação não mudaria nada do que ele carrega.", + "integrations.plan.refusal.missingStore": "O arquivo em que este cliente guarda seus provedores não existe, e o opencodex não grava no antigo arquivo de configurações do cliente no lugar dele, porque o cliente move esse arquivo na próxima inicialização. Crie o arquivo com o conteúdo {document} e clique em \"Aplicar\" de novo.", "integrations.plan.refusal.driftRequiresConfirm": "O arquivo mudou depois deste ponto de rollback. Revise o plano com as diferenças antes de restaurar.", "integrations.plan.refusal.snapshotExpired": "O backup deste ponto de rollback expirou, então ele não pode mais ser restaurado.", "integrations.plan.refusal.writeFailed": "O servidor não conseguiu preparar uma gravação segura. Atualize o estado da integração antes de tentar de novo.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4260be9a684..fc050a82f67 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2156,6 +2156,7 @@ export const ru: Record = { "integrations.status.notInstalled": "Не установлен", "integrations.status.appliedAt": "Применено", "integrations.status.supersededStore": "Этот клиент теперь читает провайдеров из {path}, а opencodex этот файл не пишет, поэтому включение здесь ничего не изменит в том, что клиент загружает.", + "integrations.status.missingStore": "Этот клиент хранит провайдеров в {path}, а этого файла нет. opencodex не будет писать вместо него в старый файл настроек клиента: при следующем запуске клиент переносит этот файл и импортирует его. Создайте {path} с содержимым {document}, затем снова нажмите «Применить».", "integrations.status.candidateConflict": "Другой файл конфигурации Kilo, {path}, тоже определяет provider.opencodex. Перед применением удалите provider.opencodex из этого файла.", "integrations.status.backup": "Резервная копия", "integrations.status.lastRestore": "Последнее восстановление", @@ -2218,6 +2219,7 @@ export const ru: Record = { "integrations.plan.refusal.unsafe": "Конфигурацию клиента нельзя безопасно изменить. Исправьте или замените файл перед повторной попыткой.", "integrations.plan.refusal.nonLoopback": "Этому клиенту нужен адрес прокси в loopback. Используйте localhost, туннель или локальную переадресацию.", "integrations.plan.refusal.supersededStore": "Этот клиент читает провайдеров из файла, который opencodex не пишет, поэтому операция ничего не изменит в том, что он загружает.", + "integrations.plan.refusal.missingStore": "Файла, в котором этот клиент хранит провайдеров, нет, и opencodex не будет писать вместо него в старый файл настроек клиента: при следующем запуске клиент переносит этот файл. Создайте файл с содержимым {document}, затем снова нажмите «Применить».", "integrations.plan.refusal.driftRequiresConfirm": "Файл изменился после этой точки отката. Проверьте план с учётом изменений перед восстановлением.", "integrations.plan.refusal.snapshotExpired": "Резервная копия для этой точки отката истекла и больше не может быть восстановлена.", "integrations.plan.refusal.writeFailed": "Сервер не смог подготовить безопасную запись. Обновите состояние интеграции перед повторной попыткой.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1c1b3ec2320..a2debe0d782 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2183,6 +2183,7 @@ export const tr: Record = { "integrations.status.notInstalled": "Yüklü değil", "integrations.status.appliedAt": "Uygulandı", "integrations.status.supersededStore": "Bu istemci sağlayıcılarını artık {path} dosyasından okuyor; opencodex bu dosyayı yazmadığı için buradan etkinleştirmek istemcinin yüklediklerini değiştirmez.", + "integrations.status.missingStore": "Bu istemci sağlayıcılarını {path} dosyasında tutuyor, ancak bu dosya yok. İstemci eski ayar dosyasını bir sonraki açılışta taşıyıp içe aktarır; bu nedenle opencodex eski ayar dosyasına yazmaz. {path} dosyasını {document} içeriğiyle oluşturun, ardından \"Uygula\"ya yeniden basın.", "integrations.status.candidateConflict": "Başka bir Kilo yapılandırma dosyası olan {path} da provider.opencodex tanımlıyor. Uygulamadan önce bu dosyadan provider.opencodex değerini kaldırın.", "integrations.status.backup": "Yedek", "integrations.status.lastRestore": "Son geri yükleme", @@ -2245,6 +2246,7 @@ export const tr: Record = { "integrations.plan.refusal.unsafe": "İstemci yapılandırması güvenle değiştirilemiyor. Yeniden denemeden önce dosyayı onarın veya değiştirin.", "integrations.plan.refusal.nonLoopback": "Bu istemci bir geri döngü proxy adresi gerektiriyor. localhost, tünel veya yerel yönlendirme kullanın.", "integrations.plan.refusal.supersededStore": "Bu istemci sağlayıcılarını opencodex'in yazmadığı bir dosyadan okuyor; bu işlem istemcinin yüklediklerini değiştirmez.", + "integrations.plan.refusal.missingStore": "Bu istemcinin sağlayıcılarını tuttuğu dosya yok. İstemci eski ayar dosyasını bir sonraki açılışta taşır; bu nedenle opencodex eski ayar dosyasına yazmaz. Dosyayı {document} içeriğiyle oluşturun, ardından \"Uygula\"ya yeniden basın.", "integrations.plan.refusal.driftRequiresConfirm": "Dosya bu geri alma noktasından sonra değişti. Geri yüklemeden önce değişikliği dikkate alan planı inceleyin.", "integrations.plan.refusal.snapshotExpired": "Bu geri alma noktasının yedeği sona erdiği için artık geri yüklenemez.", "integrations.plan.refusal.writeFailed": "Sunucu güvenli bir yazma işlemi hazırlayamadı. Yeniden denemeden önce entegrasyon durumunu yenileyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index be623d39883..9bd13a477f9 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2198,6 +2198,7 @@ export const vi: Record = { "integrations.status.notInstalled": "Chưa cài đặt", "integrations.status.appliedAt": "Đã áp dụng", "integrations.status.supersededStore": "Máy khách này hiện đọc danh sách nhà cung cấp từ {path}, tệp mà opencodex không ghi, nên bật ở đây sẽ không thay đổi những gì nó tải.", + "integrations.status.missingStore": "Máy khách này lưu danh sách nhà cung cấp trong {path}, nhưng tệp này không tồn tại. opencodex sẽ không ghi vào tệp cài đặt cũ của máy khách để thay thế, vì máy khách sẽ di chuyển và nhập tệp đó ở lần khởi động tiếp theo. Hãy tạo {path} với nội dung {document}, rồi nhấn \"Áp dụng\" lần nữa.", "integrations.status.candidateConflict": "Một tệp cấu hình Kilo khác, {path}, cũng định nghĩa provider.opencodex. Hãy xóa provider.opencodex khỏi tệp đó trước khi áp dụng.", "integrations.status.backup": "Sao lưu", "integrations.status.lastRestore": "Lần khôi phục cuối", @@ -2260,6 +2261,7 @@ export const vi: Record = { "integrations.plan.refusal.unsafe": "Không thể thay đổi cấu hình máy khách một cách an toàn. Hãy sửa hoặc thay tệp trước khi thử lại.", "integrations.plan.refusal.nonLoopback": "Máy khách này cần địa chỉ proxy loopback. Hãy dùng localhost, đường hầm hoặc chuyển tiếp cục bộ.", "integrations.plan.refusal.supersededStore": "Máy khách này đọc danh sách nhà cung cấp từ một tệp mà opencodex không ghi, nên thao tác này sẽ không thay đổi những gì nó tải.", + "integrations.plan.refusal.missingStore": "Tệp mà máy khách này dùng để lưu danh sách nhà cung cấp không tồn tại, và opencodex sẽ không ghi vào tệp cài đặt cũ của máy khách để thay thế, vì máy khách sẽ di chuyển tệp đó ở lần khởi động tiếp theo. Hãy tạo tệp với nội dung {document}, rồi nhấn \"Áp dụng\" lần nữa.", "integrations.plan.refusal.driftRequiresConfirm": "Tệp đã thay đổi sau điểm khôi phục này. Hãy xem kế hoạch có tính đến thay đổi trước khi khôi phục.", "integrations.plan.refusal.snapshotExpired": "Bản sao lưu cho điểm khôi phục này đã hết hạn nên không thể khôi phục nữa.", "integrations.plan.refusal.writeFailed": "Máy chủ không thể chuẩn bị thao tác ghi an toàn. Hãy làm mới trạng thái tích hợp trước khi thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a5e63c9d0c1..81e803de462 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3067,6 +3067,7 @@ export const zhTW: Record = { "integrations.status.notInstalled": "未安裝", "integrations.status.appliedAt": "已套用", "integrations.status.supersededStore": "此用戶端現在從 {path} 讀取供應商,而 opencodex 不會寫入該檔案,因此在這裡啟用不會改變它載入的內容。", + "integrations.status.missingStore": "此用戶端將供應商保存在 {path},但該檔案不存在。用戶端下次啟動時會把舊的設定檔移走並匯入,所以 opencodex 不會改為寫入那個設定檔。請建立 {path},內容為 {document},然後再按一次「套用」。", "integrations.status.candidateConflict": "另一個 Kilo 設定檔 {path} 也定義了 provider.opencodex。套用之前,請從該檔案移除 provider.opencodex。", "integrations.status.backup": "備份", "integrations.status.lastRestore": "上次還原", @@ -3129,6 +3130,7 @@ export const zhTW: Record = { "integrations.plan.refusal.unsafe": "無法安全地變更用戶端設定。請修復或替換檔案後再試一次。", "integrations.plan.refusal.nonLoopback": "此用戶端需要迴路代理位址。請使用 localhost、通道或本機轉送後再試一次。", "integrations.plan.refusal.supersededStore": "此用戶端從 opencodex 不會寫入的檔案讀取供應商,因此這項操作不會改變它載入的內容。", + "integrations.plan.refusal.missingStore": "此用戶端保存供應商的檔案不存在。用戶端下次啟動時會把舊的設定檔移走,所以 opencodex 不會改為寫入那個設定檔。請建立該檔案,內容為 {document},然後再按一次「套用」。", "integrations.plan.refusal.driftRequiresConfirm": "檔案在此回復點後已變更。請先檢視包含變更的計畫再還原。", "integrations.plan.refusal.snapshotExpired": "此回復點的備份已過期,無法再還原。", "integrations.plan.refusal.writeFailed": "伺服器無法準備安全寫入。請重新整理整合狀態後再試一次。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b99ce665365..e88939401bd 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1649,6 +1649,7 @@ export const zh: Record = { "integrations.status.notInstalled": "未安装", "integrations.status.appliedAt": "已应用", "integrations.status.supersededStore": "此客户端现在从 {path} 读取提供方,而 opencodex 不写入该文件,因此在这里启用不会改变它加载的内容。", + "integrations.status.missingStore": "此客户端把提供方保存在 {path},但该文件不存在。客户端下次启动时会把旧的设置文件移走并导入,所以 opencodex 不会改为写入那个设置文件。请创建 {path},内容为 {document},然后再点一次“应用”。", "integrations.status.candidateConflict": "另一个 Kilo 配置文件 {path} 也定义了 provider.opencodex。应用之前,请从该文件中移除 provider.opencodex。", "integrations.status.backup": "备份", "integrations.status.lastRestore": "上次恢复", @@ -1711,6 +1712,7 @@ export const zh: Record = { "integrations.plan.refusal.unsafe": "无法安全地更改客户端配置。请修复或替换文件后重试。", "integrations.plan.refusal.nonLoopback": "此客户端需要环回代理地址。请使用 localhost、隧道或本地转发后重试。", "integrations.plan.refusal.supersededStore": "此客户端从 opencodex 不写入的文件读取提供方,因此该操作不会改变它加载的内容。", + "integrations.plan.refusal.missingStore": "此客户端保存提供方的文件不存在。客户端下次启动时会把旧的设置文件移走,所以 opencodex 不会改为写入那个设置文件。请创建该文件,内容为 {document},然后再点一次“应用”。", "integrations.plan.refusal.driftRequiresConfirm": "文件在此回滚点后发生了变化。请先查看包含变化的计划再恢复。", "integrations.plan.refusal.snapshotExpired": "此回滚点的备份已过期,无法再恢复。", "integrations.plan.refusal.writeFailed": "服务器无法准备安全写入。请刷新集成状态后重试。", diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx index 2a2e85ebcea..e3608af5f86 100644 --- a/gui/src/pages/integrations/ConsequenceDialog.tsx +++ b/gui/src/pages/integrations/ConsequenceDialog.tsx @@ -35,6 +35,7 @@ export default function ConsequenceDialog({ planStale = false, planLoading = false, planFailure = null, + missingStorePath, onConfirm, onClose, }: { @@ -45,6 +46,8 @@ export default function ConsequenceDialog({ planStale?: boolean; planLoading?: boolean; planFailure?: string | null; + /** Passed through to the plan details; see IntegrationPlanDetails. */ + missingStorePath?: string; onConfirm: (plan?: IntegrationMutationPlan) => Promise | void; onClose: () => void; }) { @@ -146,7 +149,7 @@ export default function ConsequenceDialog({ {(stale || planStale) && {t("integrations.preview.stale")}} {planFailure && {planFailure}}
- + {failure && {failure}}