diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 06a7a01380a..0218832defb 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -14,6 +14,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; const PKG = "@bitkyc08/opencodex"; const require = createRequire(import.meta.url); @@ -90,6 +91,27 @@ function repairCodexShimIfNeeded() { } } +function trayInstallState() { + const statePath = join(configDir(), "tray-state.json"); + if (!existsSync(statePath)) return { installed: false, running: false }; + let running = false; + try { + const heartbeat = JSON.parse(readFileSync(join(configDir(), "tray-heartbeat.json"), "utf8")); + if (Number.isSafeInteger(heartbeat.pid) && Date.now() - Number(heartbeat.timestamp) < 15_000) { + process.kill(heartbeat.pid, 0); + running = true; + } + } catch { /* installed but not running */ } + return { installed: true, running }; +} + +function runTrayLifecycle(launcher, action) { + return spawnSync(process.execPath, [launcher, "tray", action], { + stdio: "inherit", + windowsHide: true, + }); +} + function runNpmSelfUpdate() { const current = currentPackageVersion(); const tag = updateTag(current); @@ -115,6 +137,9 @@ function runNpmSelfUpdate() { // unloads it permanently, so a successful update must reinstall it afterwards. const serviceStatePath = join(configDir(), "service-state.json"); const serviceWasInstalled = existsSync(serviceStatePath); + const trayBeforeUpdate = planWindowsTrayUpdate( + process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, + ); /** Read the backend from service-state.json so the update reinstalls the same one. */ function serviceReinstallArgs() { try { @@ -160,6 +185,21 @@ function runNpmSelfUpdate() { // and the runtime-port record too: a service-managed or orphaned proxy can be live // while ocx.pid is stale/missing. const launcher = fileURLToPath(import.meta.url); + if (trayBeforeUpdate.stopBeforeReplacement) { + console.log("⏹ Handing off the Windows tray before updating..."); + try { + handoffWindowsTrayForUpdate(trayBeforeUpdate, { + stop: () => { + const stopped = runTrayLifecycle(launcher, "stop"); + return { exitStatus: stopped.status, running: trayInstallState().running }; + }, + start: () => runTrayLifecycle(launcher, "start"), + }); + } catch { + console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + process.exit(1); + } + } const hasRuntimeState = existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); if (serviceWasInstalled || hasRuntimeState) { @@ -168,6 +208,7 @@ function runNpmSelfUpdate() { const stillHasRuntimeState = existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); if (stopRes.status !== 0 || stillHasRuntimeState) { + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); console.error("opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); process.exit(1); } @@ -190,6 +231,16 @@ function runNpmSelfUpdate() { if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); repairCodexShimIfNeeded(); + if (trayBeforeUpdate.refreshAfterReplacement) { + const tray = spawnSync(process.execPath, [launcher, ...trayBeforeUpdate.installArgs], { + stdio: "inherit", + windowsHide: true, + }); + if (tray.status !== 0) { + console.warn("opencodex: Windows tray refresh failed. Run: ocx tray install"); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + } + } // The stop above unloaded any managed service; reinstall via the freshly-installed // launcher so the new files write the baked paths and the service restarts. if (serviceWasInstalled) { @@ -226,6 +277,7 @@ function runNpmSelfUpdate() { } process.exit(0); } + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); console.error(`\nUpdate failed (${npm} exit ${res.status ?? "?"}). Try manually: ${npm} install -g ${PKG}@${tag}`); process.exit(1); } diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 378fc287916..5ab85e46f6a 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -29,7 +29,9 @@ bun run dev:gui | **Sub-agent delegation** | Choose a native or routed guidance model and an optional reasoning effort for v1 delegation prompts. This is not a per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | -| **Codex autostart** | Enable or disable the Codex launcher shim that runs `ocx ensure` before Codex CLI/App starts. | +| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | +| **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | +| **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | | **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, and configure quota auto-switch and transient-failure failover. | @@ -86,6 +88,9 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | Endpoint | Purpose | | --- | --- | | `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | +| `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | +| `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | +| `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | | `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index d4a82d9a592..3cb6bd4dd47 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -29,7 +29,9 @@ bun run dev:gui | **サブエージェント委任** | v1 委任プロンプトに入れるネイティブまたはルーティングモデルとオプションの推論強度を選びます。スポーンごとのルーターではありません。下記の説明を確認してください。 | | **サイドカー** | ウェブ検索モデルと強度、画像説明モデルを選択します。次回リクエストから適用されます。 | | **メンテナンス** | Codex モデルカタログを再同期し、プロジェクトローカル設定のバイパス警告を確認し、latest/preview 更新を照会またはオプションのプロキシ再起動と共にインストールします。 | -| **Codex 自動起動** | Codex CLI/App 実行前に `ocx ensure` を呼ぶランチャーシムをオン/オフします。 | +| **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | +| **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | +| **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | | **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えと一時的失敗フェイルオーバーを設定します。 | @@ -81,6 +83,8 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | エンドポイント | 用途 | --- | --- | | `GET` / `PUT /api/settings` | 設定を読むか Codex 自動起動をオン/オフします。 | +| `GET /api/startup-health` | 秘密情報を含まないルーティング、サービス、shim、再起動安全性診断を読み取ります。 | +| `GET` / `POST /api/windows-tray` | Windows トレイの導入・表示状態を読み取り、`install`、`start`、`stop`、`uninstall` を実行します。 | | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 自己更新作業を確認、実行、追跡します。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 69952fe9e77..21a0c4c57ca 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -29,7 +29,9 @@ bun run dev:gui | **Sub-agent delegation** | v1 위임 프롬프트에 넣을 네이티브 또는 라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | | **사이드카** | 웹 검색 모델과 강도, 이미지 설명 모델을 선택합니다. 다음 요청부터 적용됩니다. | | **Maintenance** | Codex 모델 카탈로그를 다시 동기화하고, 프로젝트 로컬 설정의 우회 경고를 확인하고, latest/preview 업데이트를 조회하거나 선택적 프록시 재시작과 함께 설치합니다. | -| **Codex 자동 시작** | Codex CLI/App 실행 전에 `ocx ensure`를 호출하는 launcher shim을 켜거나 끕니다. | +| **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | +| **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | +| **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | | **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환과 일시적 실패 failover를 설정합니다. | @@ -83,6 +85,8 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | 엔드포인트 | 용도 | | --- | --- | | `GET` / `PUT /api/settings` | 설정을 읽거나 Codex 자동 시작을 켜고 끕니다. | +| `GET /api/startup-health` | 비밀값 없이 라우팅, 서비스, shim, 재부팅 안전성 진단을 읽습니다. | +| `GET` / `POST /api/windows-tray` | Windows 트레이 설치 및 표시 상태를 읽거나 `install`, `start`, `stop`, `uninstall` 작업을 수행합니다. | | `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 자체 업데이트 작업을 확인, 실행, 추적합니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 63bd25640a9..2c831e87ec9 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -29,7 +29,9 @@ bun run dev:gui | **Sub-agent delegation** | Выбор нативной или маршрутизируемой модели для инструкции и необязательного уровня рассуждений для v1-промптов делегирования. Это не маршрутизатор для каждого порождения; см. ниже. | | **Сайдкары** | Выбор модели и уровня рассуждений для веб-поиска, а также модели описания изображений. Изменения применяются со следующего запроса. | | **Maintenance** | Пересинхронизация каталога моделей Codex, просмотр предупреждений об обходе через проектную локальную конфигурацию, проверка последнего или предварительного выпуска и запуск обновления с необязательным перезапуском прокси. | -| **Автозапуск Codex** | Включение и отключение launcher-шима Codex, который выполняет `ocx ensure` перед запуском Codex CLI/App. | +| **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | +| **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | +| **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | | **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, настройка автопереключения по квоте и failover при временных сбоях. | @@ -86,6 +88,8 @@ GUI — это тонкий клиент поверх JSON-API управлен | Эндпоинт | Назначение | | --- | --- | | `GET` / `PUT /api/settings` | Чтение настроек или переключение автозапуска Codex. | +| `GET /api/startup-health` | Чтение безопасной диагностики маршрутизации, службы, shim и устойчивости к перезагрузке. | +| `GET` / `POST /api/windows-tray` | Чтение или изменение установки и видимости трея Windows; POST поддерживает `install`, `start`, `stop`, `uninstall`. | | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Проверка, запуск и мониторинг задач самообновления. | | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | 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 9e651ff2d73..2d910dc118f 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 @@ -28,7 +28,9 @@ bun run dev:gui | **Sub-agent delegation** | 为 v1 委派 prompt 选择原生或路由模型,并可指定 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录,查看项目级配置绕过警告,检查 latest/preview 版本,并可在更新后重启代理。 | -| **Codex 自动启动** | 启用或禁用 Codex launcher shim;该 shim 会在 Codex CLI/App 启动前运行 `ocx ensure`。 | +| **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | +| **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | +| **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | | **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,并设置配额自动切换和临时故障 failover。 | @@ -78,6 +80,8 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | Endpoint | 用途 | | --- | --- | | `GET` / `PUT /api/settings` | 读取设置或切换 Codex 自动启动。 | +| `GET /api/startup-health` | 读取不含秘密信息的路由、服务、shim 和重启安全诊断。 | +| `GET` / `POST /api/windows-tray` | 读取或更改 Windows 托盘安装和显示状态;POST 支持 `install`、`start`、`stop`、`uninstall`。 | | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 检查、运行和监控自更新任务。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index eb9d5b806a5..f5b327cc1a0 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,6 +10,7 @@ import Storage from "./pages/Storage"; import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; +import Startup from "./pages/Startup"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select } from "./ui"; @@ -17,10 +18,10 @@ import { installApiAuthFetch } from "./api"; installApiAuthFetch(); -type Page = "dashboard" | "providers" | "models" | "combos" | "subagents" | "logs" | "usage" | "storage" | "codex-auth" | "api" | "claude"; +type Page = "dashboard" | "startup" | "providers" | "models" | "combos" | "subagents" | "logs" | "usage" | "storage" | "codex-auth" | "api" | "claude"; type Theme = "light" | "dark" | "system"; -const VALID_PAGES = new Set(["dashboard", "providers", "models", "combos", "subagents", "logs", "usage", "storage", "codex-auth", "api", "claude"]); +const VALID_PAGES = new Set(["dashboard", "startup", "providers", "models", "combos", "subagents", "logs", "usage", "storage", "codex-auth", "api", "claude"]); function readPageFromHash(): Page { const raw = location.hash.replace(/^#\/?/, ""); @@ -63,6 +64,7 @@ function providersHashForPage(): string { const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, + { id: "startup", tkey: "nav.startup", Icon: IconPower }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, @@ -92,6 +94,7 @@ export default function App() { const [page, setPageState] = useState(readPageFromHash); const [theme, setTheme] = useState(readStoredTheme); const [runtimeVersion, setRuntimeVersion] = useState(null); + const [startupAtRisk, setStartupAtRisk] = useState(false); const { locale, setLocale } = useI18n(); const t = useT(); @@ -135,6 +138,35 @@ export default function App() { return () => window.removeEventListener("hashchange", onHash); }, []); + useEffect(() => { + let cancelled = false; + let latestRequest = 0; + const readStartupHealth = async () => { + const request = ++latestRequest; + try { + const res = await fetch(`${API_BASE}/api/startup-health`); + if (!res.ok) { + if (!cancelled && request === latestRequest) setStartupAtRisk(true); + return; + } + const data = await res.json() as { status?: unknown; diagnosticStale?: unknown }; + const valid = data.status === "native" || data.status === "protected" || data.status === "at-risk"; + if (!cancelled && request === latestRequest) { + setStartupAtRisk(!valid || data.status === "at-risk" || data.diagnosticStale === true); + } + } catch { + // A failed refresh cannot preserve a previously green claim. + if (!cancelled && request === latestRequest) setStartupAtRisk(true); + } + }; + void readStartupHealth(); + const timer = window.setInterval(() => { void readStartupHealth(); }, 30_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + useEffect(() => { const rawHash = window.location.hash.replace(/^#\/?/, ""); // Legacy #debug deep links must resolve before generic normalization @@ -291,6 +323,9 @@ export default function App() { }} aria-current={page === id ? "page" : undefined}> {t(tkey)} + {id === "startup" && startupAtRisk && ( + + )} ))} @@ -330,6 +365,7 @@ export default function App() {
{page === "dashboard" && } + {page === "startup" && } {page === "providers" && } {page === "models" && } {page === "combos" && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 287b83e9758..b7b5d5fe77f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1,6 +1,7 @@ // German — generated from en.ts. Must match TKey set (compile-checked). export const de = { "nav.dashboard": "Übersicht", + "nav.startup": "Startsicherheit", "nav.providers": "Anbieter", "nav.models": "Modelle", "nav.combos": "Combos", @@ -19,6 +20,79 @@ export const de = { "theme.dark": "Dunkel", "theme.system": "System", "lang.label": "Sprache", + "startup.title": "Startsicherheit", + "startup.subtitle": "Prüft, ob Codex opencodex nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.", + "startup.refresh": "Aktualisieren", + "startup.loading": "Startschutz wird geprüft…", + "startup.error": "Startschutz konnte nicht gelesen werden.", + "startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.", + "startup.status.native": "Natives Routing", + "startup.status.protected": "Neustartgeschützt", + "startup.status.atRisk": "Aktion erforderlich", + "startup.summary.native": "Codex ist nicht vom lokalen Proxy abhängig", + "startup.summary.protected": "opencodex ist nach einem Neustart verfügbar", + "startup.summary.atRisk": "Codex kann nach einem Neustart den Modellzugriff verlieren", + "startup.riskDetail": "Codex ist auf den lokalen Proxy festgelegt, aber weder ein dauerhafter Dienst noch ein intakter Launcher-Shim startet ihn erneut.", + "startup.riskDetailCustomLocal": "Codex verwendet ein benutzerdefiniertes lokales Gateway. opencodex kann dessen Neustart-Lebenszyklus weder verwalten noch prüfen.", + "startup.riskDetailWindowsShim": "Der Launcher-Shim schützt unterstützte CLI-Skripte, aber Codex Desktop und direkte codex.exe-Aufrufe können ihn unter Windows umgehen.", + "startup.safeDetail": "Routing und Startmechanismus stimmen überein. Nach einem Neustart sollte kein manuelles ocx start nötig sein.", + "startup.routing": "Codex-Routing", + "startup.routing.proxy": "Lokaler Proxy", + "startup.routing.native": "Natives OpenAI", + "startup.routing.customLocal": "Benutzerdefiniertes lokales Gateway", + "startup.routing.customRemote": "Benutzerdefiniertes Remote-Gateway", + "startup.routing.unknown": "Unbekanntes oder ungültiges Routing", + "startup.restartProtection": "Neustartschutz", + "startup.preference": "Start bei Bedarf", + "startup.enabled": "Aktiviert", + "startup.disabled": "Deaktiviert", + "startup.protection.service": "Hintergrunddienst", + "startup.protection.shim": "Launcher-Shim", + "startup.protection.none": "Nicht installiert", + "startup.details": "Schutzdetails", + "startup.service": "Hintergrunddienst", + "startup.serviceHint": "Startet bei der Anmeldung und startet den Proxy nach einem Absturz neu.", + "startup.installed": "Installiert", + "startup.notInstalled": "Nicht installiert", + "startup.unsupported": "Nicht unterstützt", + "startup.shim": "Codex-Launcher-Shim", + "startup.shimHint": "Führt ocx ensure aus, wenn ein unterstützter Codex-Skript-Launcher startet.", + "startup.healthy": "Intakt", + "startup.cliOnly": "Nur CLI", + "startup.stale": "Veraltet", + "startup.viable": "Einsatzbereit", + "startup.unhealthy": "Installiert, aber fehlerhaft", + "startup.conflict": "Dienstkonflikt", + "startup.installedDisabled": "Installiert, aber deaktiviert", + "startup.install": "Installieren", + "startup.installing": "Wird installiert…", + "startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.", + "startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.", + "startup.installFailed": "Installation fehlgeschlagen:", + "startup.tray.title": "Windows-Infobereich", + "startup.tray.hint": "Installiert ein Anmeldesymbol für Proxy-Start, Stopp, Neustart, Dashboard und Status per Klick.", + "startup.tray.login": "Infobereich bei Windows-Anmeldung starten", + "startup.tray.notProtection": "Das Symbol ist nur eine Steuerung, kein Neustartschutz. Für unbeaufsichtigte Wiederherstellung bleibt ein funktionsfähiger Hintergrunddienst nötig.", + "startup.tray.running": "Wird ausgeführt", + "startup.tray.stopped": "Installiert, ausgeblendet", + "startup.tray.stale": "Reparatur erforderlich", + "startup.tray.notInstalled": "Nicht installiert", + "startup.tray.loading": "Wird geprüft…", + "startup.tray.unavailable": "Status nicht verfügbar", + "startup.tray.install": "Installieren und anzeigen", + "startup.tray.start": "Symbol anzeigen", + "startup.tray.stop": "Symbol beenden", + "startup.tray.uninstall": "Anmeldesymbol entfernen", + "startup.tray.error": "Die Windows-Infobereichsaktion ist fehlgeschlagen. Details: ocx tray status.", + "startup.recovery": "Reparaturoptionen", + "startup.recoveryHint": "Nutze die Ein-Klick-Installation oben oder kopiere einen Befehl für die manuelle Reparatur. Für Codex Desktop und Windows-Programme wird der Hintergrunddienst empfohlen.", + "startup.command.service": "Empfohlen: dauerhafter Hintergrunddienst", + "startup.command.shim": "Alternative: CLI-Launcher-Shim", + "startup.command.native": "Ausfallsicher: natives Codex-Routing wiederherstellen", + "startup.copy": "Kopieren", + "startup.copied": "Kopiert", + "startup.recommended": "Empfohlene Reparatur: {cmd}", + "startup.navRisk": "Der Startschutz erfordert Aufmerksamkeit", "dash.subtitle": "Live-Status des lokalen opencodex-Proxys, seiner Anbieter und der in Codex gerouteten Modelle.", "dash.status": "Status", "dash.online": "Online", @@ -42,7 +116,7 @@ export const de = { "dash.stopConfirm": "Proxy stoppen und natives Codex wiederherstellen?", "dash.stopping": "Wird gestoppt…", "dash.codexAutoStart": "opencodex mit Codex starten", - "dash.codexAutoStartHint": "Wenn aktiviert, führt das Codex-Shim vor jedem Codex-CLI-/App-Start „ocx ensure\" aus. Deaktiviere dies, um Codex unverändert zu lassen.", + "dash.codexAutoStartHint": "Erlaubt einem installierten Launcher-Shim, ocx ensure auszuführen. Diese Einstellung installiert keinen Neustartschutz; prüfe den effektiven Zustand unter Startsicherheit.", "dash.searchModel": "Such-Sidecar-Modell", "dash.searchModelHint": "Modell für web_search bei nicht über OpenAI gerouteten Modellen. Erfordert ChatGPT-Login.", "dash.searchReasoning": "Such-Reasoning-Aufwand", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 3430c5ba0ba..b5ba13e26a7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3,6 +3,7 @@ export const en = { // sidebar / nav / common "nav.dashboard": "Dashboard", + "nav.startup": "Startup", "nav.providers": "Providers", "nav.models": "Models", "nav.combos": "Combos", @@ -27,6 +28,81 @@ export const en = { "theme.system": "System", "lang.label": "Language", + // startup health + "startup.title": "Startup safety", + "startup.subtitle": "Verify that Codex can reach opencodex after a restart, before local proxy routing becomes a reconnect loop.", + "startup.refresh": "Refresh", + "startup.loading": "Checking startup protection…", + "startup.error": "Could not read startup protection.", + "startup.staleData": "The latest startup check failed. The values below are stale and must not be treated as proof of protection.", + "startup.status.native": "Native routing", + "startup.status.protected": "Restart protected", + "startup.status.atRisk": "Action required", + "startup.summary.native": "Codex does not depend on the local proxy", + "startup.summary.protected": "opencodex will be available after restart", + "startup.summary.atRisk": "Codex can lose model access after restart", + "startup.riskDetail": "Codex is pinned to the local proxy, but no persistent service or healthy launcher shim will start it again.", + "startup.riskDetailCustomLocal": "Codex points to a custom local gateway. opencodex cannot manage or verify that gateway's restart lifecycle.", + "startup.riskDetailWindowsShim": "The launcher shim protects supported CLI scripts, but Codex Desktop and direct codex.exe launches can bypass it on Windows.", + "startup.safeDetail": "The current routing and startup mechanism are consistent. No manual ocx start should be required after restart.", + "startup.routing": "Codex routing", + "startup.routing.proxy": "Local proxy", + "startup.routing.native": "Native OpenAI", + "startup.routing.customLocal": "Custom local gateway", + "startup.routing.customRemote": "Custom remote gateway", + "startup.routing.unknown": "Unknown or invalid routing", + "startup.restartProtection": "Restart protection", + "startup.preference": "On-demand startup", + "startup.enabled": "Enabled", + "startup.disabled": "Disabled", + "startup.protection.service": "Background service", + "startup.protection.shim": "Launcher shim", + "startup.protection.none": "Not installed", + "startup.details": "Protection details", + "startup.service": "Background service", + "startup.serviceHint": "Starts at login and restarts the proxy after a crash.", + "startup.installed": "Installed", + "startup.notInstalled": "Not installed", + "startup.unsupported": "Unsupported", + "startup.shim": "Codex launcher shim", + "startup.shimHint": "Runs ocx ensure when a supported Codex script launcher starts.", + "startup.healthy": "Healthy", + "startup.cliOnly": "CLI only", + "startup.stale": "Stale", + "startup.viable": "Ready", + "startup.unhealthy": "Installed but unhealthy", + "startup.conflict": "Service conflict", + "startup.installedDisabled": "Installed but disabled", + "startup.install": "Install", + "startup.installing": "Installing…", + "startup.serviceInstalled": "Background service installed successfully.", + "startup.shimInstalled": "Codex launcher shim installed successfully.", + "startup.installFailed": "Installation failed:", + "startup.tray.title": "Windows system tray", + "startup.tray.hint": "Install a login tray icon for one-click proxy start, stop, restart, dashboard, and status controls.", + "startup.tray.login": "Start tray at Windows login", + "startup.tray.notProtection": "The tray is a controller, not restart protection. A viable background service is still required for unattended proxy recovery.", + "startup.tray.running": "Running", + "startup.tray.stopped": "Installed, hidden", + "startup.tray.stale": "Repair required", + "startup.tray.notInstalled": "Not installed", + "startup.tray.loading": "Checking…", + "startup.tray.unavailable": "Status unavailable", + "startup.tray.install": "Install and show tray", + "startup.tray.start": "Show tray icon", + "startup.tray.stop": "Exit tray icon", + "startup.tray.uninstall": "Remove login tray", + "startup.tray.error": "The Windows tray action failed. Check ocx tray status for details.", + "startup.recovery": "Repair options", + "startup.recoveryHint": "Use the one-click installers above, or copy a command for manual repair. The background service is recommended for Codex Desktop and Windows executables.", + "startup.command.service": "Recommended: persistent background service", + "startup.command.shim": "Alternative: CLI launcher shim", + "startup.command.native": "Fail-safe: restore native Codex routing", + "startup.copy": "Copy", + "startup.copied": "Copied", + "startup.recommended": "Recommended repair: {cmd}", + "startup.navRisk": "Startup protection requires attention", + // dashboard "dash.subtitle": "Live status of the local opencodex proxy, its providers, and the models routed into Codex.", "dash.status": "Status", @@ -51,7 +127,7 @@ export const en = { "dash.stopConfirm": "Stop the proxy and restore native Codex?", "dash.stopping": "Stopping…", "dash.codexAutoStart": "Start opencodex with Codex", - "dash.codexAutoStartHint": "When enabled, the Codex shim runs ocx ensure before every Codex CLI/App launch. Disable this to leave Codex untouched.", + "dash.codexAutoStartHint": "Allows an installed launcher shim to run ocx ensure. This setting does not install restart protection; check Startup safety for the effective state.", "dash.searchModel": "Search sidecar model", "dash.searchModelHint": "Model used for web_search on non-OpenAI routed models. Requires ChatGPT login.", "dash.searchReasoning": "Search reasoning effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0babac0bffc..c2505fec665 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3,6 +3,7 @@ import type { TKey } from "./en"; export const ja: Record = { // sidebar / nav / common "nav.dashboard": "ダッシュボード", + "nav.startup": "起動安全性", "nav.providers": "プロバイダー", "nav.models": "モデル", "nav.combos": "コンボ", @@ -27,6 +28,81 @@ export const ja: Record = { "theme.system": "システム", "lang.label": "言語", + // startup health + "startup.title": "起動安全性", + "startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が opencodex へ到達できるか確認します。", + "startup.refresh": "更新", + "startup.loading": "起動保護を確認中…", + "startup.error": "起動保護を読み取れませんでした。", + "startup.staleData": "最新の確認に失敗しました。以下は古い値であり、保護の証明にはなりません。", + "startup.status.native": "ネイティブルーティング", + "startup.status.protected": "再起動保護済み", + "startup.status.atRisk": "対応が必要", + "startup.summary.native": "Codex はローカルプロキシに依存していません", + "startup.summary.protected": "再起動後も opencodex を利用できます", + "startup.summary.atRisk": "再起動後に Codex がモデルへ接続できなくなる可能性があります", + "startup.riskDetail": "Codex はローカルプロキシを参照していますが、再起動する永続サービスまたは正常な launcher shim がありません。", + "startup.riskDetailCustomLocal": "Codex はカスタムローカルゲートウェイを参照しています。opencodex はその再起動ライフサイクルを管理・検証できません。", + "startup.riskDetailWindowsShim": "Launcher shim は対応する CLI スクリプトのみを保護し、Windows の Codex Desktop と codex.exe の直接起動はこれを迂回できます。", + "startup.safeDetail": "現在のルーティングと起動方式は整合しています。再起動後に ocx start を手動実行する必要はありません。", + "startup.routing": "Codex ルーティング", + "startup.routing.proxy": "ローカルプロキシ", + "startup.routing.native": "OpenAI ネイティブ", + "startup.routing.customLocal": "カスタムローカルゲートウェイ", + "startup.routing.customRemote": "カスタム遠隔ゲートウェイ", + "startup.routing.unknown": "不明または無効なルーティング", + "startup.restartProtection": "再起動保護", + "startup.preference": "オンデマンド起動", + "startup.enabled": "有効", + "startup.disabled": "無効", + "startup.protection.service": "バックグラウンドサービス", + "startup.protection.shim": "Launcher shim", + "startup.protection.none": "未インストール", + "startup.details": "保護の詳細", + "startup.service": "バックグラウンドサービス", + "startup.serviceHint": "ログイン時に起動し、クラッシュ後にプロキシを再起動します。", + "startup.installed": "インストール済み", + "startup.notInstalled": "未インストール", + "startup.unsupported": "未対応", + "startup.shim": "Codex launcher shim", + "startup.shimHint": "対応する Codex スクリプトランチャーの起動時に ocx ensure を実行します。", + "startup.healthy": "正常", + "startup.cliOnly": "CLI のみ", + "startup.stale": "要更新", + "startup.viable": "利用可能", + "startup.unhealthy": "インストール済み・異常", + "startup.conflict": "サービス競合", + "startup.installedDisabled": "インストール済み・無効", + "startup.install": "インストール", + "startup.installing": "インストール中…", + "startup.serviceInstalled": "バックグラウンドサービスをインストールしました。", + "startup.shimInstalled": "Codex ランチャー shim をインストールしました。", + "startup.installFailed": "インストールに失敗しました:", + "startup.tray.title": "Windows システムトレイ", + "startup.tray.hint": "ログイン時にトレイを起動し、プロキシの開始・停止・再起動・ダッシュボード・状態をクリックで操作します。", + "startup.tray.login": "Windows ログイン時にトレイを開始", + "startup.tray.notProtection": "トレイは操作画面であり再起動保護ではありません。無人復旧には正常なバックグラウンドサービスが必要です。", + "startup.tray.running": "実行中", + "startup.tray.stopped": "インストール済み・非表示", + "startup.tray.stale": "修復が必要", + "startup.tray.notInstalled": "未インストール", + "startup.tray.loading": "確認中…", + "startup.tray.unavailable": "状態を確認できません", + "startup.tray.install": "トレイをインストールして表示", + "startup.tray.start": "トレイアイコンを表示", + "startup.tray.stop": "トレイアイコンを終了", + "startup.tray.uninstall": "ログイントレイを削除", + "startup.tray.error": "Windows トレイ操作に失敗しました。ocx tray status で詳細を確認してください。", + "startup.recovery": "修復方法", + "startup.recoveryHint": "上のワンクリックインストールを使うか、手動修復用のコマンドをコピーできます。Codex Desktop と Windows 実行ファイルにはバックグラウンドサービスを推奨します。", + "startup.command.service": "推奨: 永続バックグラウンドサービス", + "startup.command.shim": "代替: CLI launcher shim", + "startup.command.native": "安全策: Codex ネイティブルーティングを復元", + "startup.copy": "コピー", + "startup.copied": "コピー済み", + "startup.recommended": "推奨修復: {cmd}", + "startup.navRisk": "起動保護に対応が必要です", + // dashboard "dash.subtitle": "ローカル opencodex プロキシ、そのプロバイダー、Codex にルーティングされるモデルのライブ状態です。", "dash.status": "状態", @@ -51,7 +127,7 @@ export const ja: Record = { "dash.stopConfirm": "プロキシを停止してネイティブの Codex に戻しますか?", "dash.stopping": "停止中…", "dash.codexAutoStart": "Codex と一緒に opencodex を起動", - "dash.codexAutoStartHint": "オンにすると、Codex シムは毎回の Codex CLI/App 起動前に ocx ensure を実行します。オフにすると Codex はそのまま変更されません。", + "dash.codexAutoStartHint": "インストール済み launcher shim に ocx ensure の実行を許可します。この設定だけでは再起動保護はインストールされません。起動安全性で実際の状態を確認してください。", "dash.searchModel": "検索サイドカーモデル", "dash.searchModelHint": "非 OpenAI ルーティングモデルで web_search に使うモデル。ChatGPT ログインが必要です。", "dash.searchReasoning": "検索の推論負荷", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index acbaf9750a4..2bb309e39ef 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3,6 +3,7 @@ import type { TKey } from "./en"; export const ko: Record = { // sidebar / nav / common "nav.dashboard": "대시보드", + "nav.startup": "시작 안전성", "nav.providers": "프로바이더", "nav.models": "모델", "nav.combos": "콤보", @@ -22,6 +23,81 @@ export const ko: Record = { "theme.system": "시스템", "lang.label": "언어", + // startup health + "startup.title": "시작 안전성", + "startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다.", + "startup.refresh": "새로고침", + "startup.loading": "시작 보호 상태 확인 중…", + "startup.error": "시작 보호 상태를 읽지 못했습니다.", + "startup.staleData": "최신 시작 상태 확인에 실패했습니다. 아래 값은 이전 결과이며 보호 증거로 사용하면 안 됩니다.", + "startup.status.native": "네이티브 라우팅", + "startup.status.protected": "재부팅 보호됨", + "startup.status.atRisk": "조치 필요", + "startup.summary.native": "Codex가 로컬 프록시에 의존하지 않습니다", + "startup.summary.protected": "재부팅 후에도 opencodex가 자동으로 준비됩니다", + "startup.summary.atRisk": "재부팅 후 Codex 모델 연결이 끊길 수 있습니다", + "startup.riskDetail": "Codex는 로컬 프록시를 바라보지만 이를 다시 시작할 영구 서비스나 정상 launcher shim이 없습니다.", + "startup.riskDetailCustomLocal": "Codex가 사용자 지정 로컬 게이트웨이를 바라봅니다. opencodex는 해당 게이트웨이의 재시작 수명주기를 관리하거나 검증할 수 없습니다.", + "startup.riskDetailWindowsShim": "Launcher shim은 지원되는 CLI 스크립트만 보호하며 Windows의 Codex Desktop과 직접 codex.exe 실행은 이를 우회할 수 있습니다.", + "startup.safeDetail": "현재 라우팅과 시작 방식이 일치합니다. 재부팅 후 ocx start를 수동으로 실행할 필요가 없습니다.", + "startup.routing": "Codex 라우팅", + "startup.routing.proxy": "로컬 프록시", + "startup.routing.native": "OpenAI 네이티브", + "startup.routing.customLocal": "사용자 지정 로컬 게이트웨이", + "startup.routing.customRemote": "사용자 지정 원격 게이트웨이", + "startup.routing.unknown": "알 수 없거나 잘못된 라우팅", + "startup.restartProtection": "재부팅 보호", + "startup.preference": "필요 시 자동 시작", + "startup.enabled": "켜짐", + "startup.disabled": "꺼짐", + "startup.protection.service": "백그라운드 서비스", + "startup.protection.shim": "Launcher shim", + "startup.protection.none": "설치되지 않음", + "startup.details": "보호 상태 상세", + "startup.service": "백그라운드 서비스", + "startup.serviceHint": "로그인할 때 시작하고 프록시가 중단되면 다시 실행합니다.", + "startup.installed": "설치됨", + "startup.notInstalled": "설치되지 않음", + "startup.unsupported": "지원되지 않음", + "startup.shim": "Codex launcher shim", + "startup.shimHint": "지원되는 Codex 스크립트 런처가 시작될 때 ocx ensure를 실행합니다.", + "startup.healthy": "정상", + "startup.cliOnly": "CLI 전용", + "startup.stale": "업데이트 필요", + "startup.viable": "사용 가능", + "startup.unhealthy": "설치됐지만 비정상", + "startup.conflict": "서비스 충돌", + "startup.installedDisabled": "설치됐지만 꺼짐", + "startup.install": "설치하기", + "startup.installing": "설치 중…", + "startup.serviceInstalled": "백그라운드 서비스를 설치했습니다.", + "startup.shimInstalled": "Codex launcher shim을 설치했습니다.", + "startup.installFailed": "설치하지 못했습니다:", + "startup.tray.title": "Windows 시스템 트레이", + "startup.tray.hint": "로그인할 때 트레이 아이콘을 띄우고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다.", + "startup.tray.login": "Windows 로그인 시 트레이 시작", + "startup.tray.notProtection": "트레이는 제어 화면이며 재부팅 보호 서비스가 아닙니다. 무인 복구에는 정상 백그라운드 서비스가 별도로 필요합니다.", + "startup.tray.running": "실행 중", + "startup.tray.stopped": "설치됨, 숨김", + "startup.tray.stale": "복구 필요", + "startup.tray.notInstalled": "설치되지 않음", + "startup.tray.loading": "확인 중…", + "startup.tray.unavailable": "상태 확인 불가", + "startup.tray.install": "트레이 설치 및 표시", + "startup.tray.start": "트레이 아이콘 표시", + "startup.tray.stop": "트레이 아이콘 종료", + "startup.tray.uninstall": "로그인 트레이 제거", + "startup.tray.error": "Windows 트레이 작업에 실패했습니다. ocx tray status에서 상세 내용을 확인하세요.", + "startup.recovery": "복구 방법", + "startup.recoveryHint": "위의 원클릭 설치를 사용하거나 수동 복구 명령을 복사할 수 있습니다. Codex Desktop과 Windows 실행 파일에는 백그라운드 서비스를 권장합니다.", + "startup.command.service": "권장: 영구 백그라운드 서비스", + "startup.command.shim": "대안: CLI launcher shim", + "startup.command.native": "안전 전환: Codex 네이티브 라우팅 복구", + "startup.copy": "복사", + "startup.copied": "복사됨", + "startup.recommended": "권장 복구 명령: {cmd}", + "startup.navRisk": "시작 보호 상태에 조치가 필요합니다", + // dashboard "dash.subtitle": "로컬 opencodex 프록시와 프로바이더, 그리고 Codex로 라우팅되는 모델의 실시간 상태입니다.", "dash.status": "상태", @@ -46,7 +122,7 @@ export const ko: Record = { "dash.stopConfirm": "프록시를 중지하고 Codex 원본 설정을 복원할까요?", "dash.stopping": "중지 중…", "dash.codexAutoStart": "Codex 실행 시 opencodex 시작", - "dash.codexAutoStartHint": "켜져 있으면 Codex CLI/App 실행 전 shim이 ocx ensure를 실행합니다. 끄면 Codex를 건드리지 않습니다.", + "dash.codexAutoStartHint": "설치된 launcher shim이 ocx ensure를 실행하도록 허용합니다. 이 설정은 재부팅 보호를 설치하지 않으므로 시작 안전성에서 실제 상태를 확인하세요.", "dash.searchModel": "서치 사이드카 모델", "dash.searchModelHint": "비-OpenAI 라우팅 모델의 web_search에 사용되는 모델입니다. ChatGPT 로그인 필요.", "dash.searchReasoning": "서치 추론 강도", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a4872452021..8d3a09af922 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3,6 +3,7 @@ import type { TKey } from "./en"; export const ru: Record = { // sidebar / nav / common "nav.dashboard": "Дашборд", + "nav.startup": "Безопасность запуска", "nav.providers": "Провайдеры", "nav.models": "Модели", "nav.combos": "Комбо", @@ -27,6 +28,81 @@ export const ru: Record = { "theme.system": "Системная", "lang.label": "Язык", + // startup health + "startup.title": "Безопасность запуска", + "startup.subtitle": "Проверьте, сможет ли Codex подключиться к opencodex после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.", + "startup.refresh": "Обновить", + "startup.loading": "Проверка защиты запуска…", + "startup.error": "Не удалось прочитать состояние защиты запуска.", + "startup.staleData": "Последняя проверка не удалась. Значения ниже устарели и не подтверждают защиту.", + "startup.status.native": "Нативная маршрутизация", + "startup.status.protected": "Перезапуск защищён", + "startup.status.atRisk": "Требуется действие", + "startup.summary.native": "Codex не зависит от локального прокси", + "startup.summary.protected": "opencodex будет доступен после перезагрузки", + "startup.summary.atRisk": "После перезагрузки Codex может потерять доступ к моделям", + "startup.riskDetail": "Codex направлен на локальный прокси, но постоянная служба или исправный launcher shim не запустят его снова.", + "startup.riskDetailCustomLocal": "Codex направлен на пользовательский локальный шлюз. opencodex не может управлять или проверять его перезапуск.", + "startup.riskDetailWindowsShim": "Launcher shim защищает поддерживаемые CLI-скрипты, но Codex Desktop и прямой запуск codex.exe в Windows могут обходить его.", + "startup.safeDetail": "Маршрутизация и механизм запуска согласованы. После перезагрузки ручной запуск ocx start не требуется.", + "startup.routing": "Маршрутизация Codex", + "startup.routing.proxy": "Локальный прокси", + "startup.routing.native": "Нативный OpenAI", + "startup.routing.customLocal": "Пользовательский локальный шлюз", + "startup.routing.customRemote": "Пользовательский удалённый шлюз", + "startup.routing.unknown": "Неизвестная или недопустимая маршрутизация", + "startup.restartProtection": "Защита перезапуска", + "startup.preference": "Запуск по требованию", + "startup.enabled": "Включён", + "startup.disabled": "Выключен", + "startup.protection.service": "Фоновая служба", + "startup.protection.shim": "Launcher shim", + "startup.protection.none": "Не установлен", + "startup.details": "Сведения о защите", + "startup.service": "Фоновая служба", + "startup.serviceHint": "Запускается при входе и перезапускает прокси после сбоя.", + "startup.installed": "Установлена", + "startup.notInstalled": "Не установлена", + "startup.unsupported": "Не поддерживается", + "startup.shim": "Codex launcher shim", + "startup.shimHint": "Запускает ocx ensure при запуске поддерживаемого скриптового лаунчера Codex.", + "startup.healthy": "Исправен", + "startup.cliOnly": "Только CLI", + "startup.stale": "Устарел", + "startup.viable": "Готов", + "startup.unhealthy": "Установлен, но неисправен", + "startup.conflict": "Конфликт служб", + "startup.installedDisabled": "Установлен, но отключён", + "startup.install": "Установить", + "startup.installing": "Установка…", + "startup.serviceInstalled": "Фоновая служба успешно установлена.", + "startup.shimInstalled": "Launcher shim Codex успешно установлен.", + "startup.installFailed": "Не удалось установить:", + "startup.tray.title": "Системный трей Windows", + "startup.tray.hint": "Запускает значок при входе для управления запуском, остановкой, перезапуском, панелью и состоянием прокси.", + "startup.tray.login": "Запускать трей при входе в Windows", + "startup.tray.notProtection": "Трей — это контроллер, а не защита перезапуска. Для автоматического восстановления по-прежнему нужна исправная фоновая служба.", + "startup.tray.running": "Работает", + "startup.tray.stopped": "Установлен, скрыт", + "startup.tray.stale": "Требуется ремонт", + "startup.tray.notInstalled": "Не установлен", + "startup.tray.loading": "Проверка…", + "startup.tray.unavailable": "Статус недоступен", + "startup.tray.install": "Установить и показать трей", + "startup.tray.start": "Показать значок", + "startup.tray.stop": "Закрыть значок", + "startup.tray.uninstall": "Удалить трей входа", + "startup.tray.error": "Действие Windows tray завершилось ошибкой. Подробности: ocx tray status.", + "startup.recovery": "Варианты исправления", + "startup.recoveryHint": "Используйте установку в один клик выше или скопируйте команду для ручного восстановления. Для Codex Desktop и Windows рекомендуется фоновая служба.", + "startup.command.service": "Рекомендуется: постоянная фоновая служба", + "startup.command.shim": "Альтернатива: CLI launcher shim", + "startup.command.native": "Безопасный режим: восстановить нативную маршрутизацию Codex", + "startup.copy": "Копировать", + "startup.copied": "Скопировано", + "startup.recommended": "Рекомендуемое исправление: {cmd}", + "startup.navRisk": "Защита запуска требует внимания", + // dashboard "dash.subtitle": "Актуальное состояние локального прокси opencodex, его провайдеров и моделей, маршрутизируемых в Codex.", "dash.status": "Статус", @@ -51,7 +127,7 @@ export const ru: Record = { "dash.stopConfirm": "Остановить прокси и восстановить нативный Codex?", "dash.stopping": "Остановка…", "dash.codexAutoStart": "Запускать opencodex вместе с Codex", - "dash.codexAutoStartHint": "Когда параметр включён, обёртка (shim) Codex выполняет ocx ensure перед каждым запуском Codex CLI/App. Отключите его, чтобы оставить Codex нетронутым.", + "dash.codexAutoStartHint": "Разрешает установленному launcher shim выполнять ocx ensure. Эта настройка не устанавливает защиту перезапуска; проверьте фактическое состояние в разделе безопасности запуска.", "dash.searchModel": "Модель сайдкара поиска", "dash.searchModelHint": "Модель, используемая для web_search на маршрутизируемых моделях, отличных от OpenAI. Требуется вход в аккаунт ChatGPT.", "dash.searchReasoning": "Уровень рассуждений для поиска", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d3ebea74206..dac142b5260 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3,6 +3,7 @@ import type { TKey } from "./en"; export const zh: Record = { // sidebar / nav / common "nav.dashboard": "仪表盘", + "nav.startup": "启动安全", "nav.providers": "提供方", "nav.models": "模型", "nav.combos": "组合", @@ -22,6 +23,81 @@ export const zh: Record = { "theme.system": "跟随系统", "lang.label": "语言", + // startup health + "startup.title": "启动安全", + "startup.subtitle": "检查重启后 Codex 是否仍能连接 opencodex,避免本地代理路由陷入重复重连。", + "startup.refresh": "刷新", + "startup.loading": "正在检查启动保护…", + "startup.error": "无法读取启动保护状态。", + "startup.staleData": "最新启动检查失败。以下数据已过期,不能视为已受保护的证明。", + "startup.status.native": "原生路由", + "startup.status.protected": "已保护重启", + "startup.status.atRisk": "需要处理", + "startup.summary.native": "Codex 不依赖本地代理", + "startup.summary.protected": "重启后 opencodex 会自动可用", + "startup.summary.atRisk": "重启后 Codex 可能无法访问模型", + "startup.riskDetail": "Codex 已指向本地代理,但没有持久服务或正常的 launcher shim 将其重新启动。", + "startup.riskDetailCustomLocal": "Codex 指向自定义本地网关。opencodex 无法管理或验证该网关的重启生命周期。", + "startup.riskDetailWindowsShim": "Launcher shim 仅保护受支持的 CLI 脚本;Windows 上的 Codex Desktop 和直接 codex.exe 启动可以绕过它。", + "startup.safeDetail": "当前路由与启动机制一致。重启后无需手动运行 ocx start。", + "startup.routing": "Codex 路由", + "startup.routing.proxy": "本地代理", + "startup.routing.native": "OpenAI 原生", + "startup.routing.customLocal": "自定义本地网关", + "startup.routing.customRemote": "自定义远程网关", + "startup.routing.unknown": "未知或无效的路由", + "startup.restartProtection": "重启保护", + "startup.preference": "按需启动", + "startup.enabled": "已启用", + "startup.disabled": "已禁用", + "startup.protection.service": "后台服务", + "startup.protection.shim": "Launcher shim", + "startup.protection.none": "未安装", + "startup.details": "保护详情", + "startup.service": "后台服务", + "startup.serviceHint": "登录时启动,并在代理崩溃后重新启动。", + "startup.installed": "已安装", + "startup.notInstalled": "未安装", + "startup.unsupported": "不支持", + "startup.shim": "Codex launcher shim", + "startup.shimHint": "支持的 Codex 脚本启动器运行时执行 ocx ensure。", + "startup.healthy": "正常", + "startup.cliOnly": "仅 CLI", + "startup.stale": "已失效", + "startup.viable": "可用", + "startup.unhealthy": "已安装但异常", + "startup.conflict": "服务冲突", + "startup.installedDisabled": "已安装但禁用", + "startup.install": "安装", + "startup.installing": "正在安装…", + "startup.serviceInstalled": "后台服务安装成功。", + "startup.shimInstalled": "Codex 启动器 shim 安装成功。", + "startup.installFailed": "安装失败:", + "startup.tray.title": "Windows 系统托盘", + "startup.tray.hint": "登录时启动托盘图标,一键控制代理启动、停止、重启、面板和状态。", + "startup.tray.login": "Windows 登录时启动托盘", + "startup.tray.notProtection": "托盘只是控制器,并非重启保护。无人值守恢复仍需要正常的后台服务。", + "startup.tray.running": "运行中", + "startup.tray.stopped": "已安装,未显示", + "startup.tray.stale": "需要修复", + "startup.tray.notInstalled": "未安装", + "startup.tray.loading": "正在检查…", + "startup.tray.unavailable": "状态不可用", + "startup.tray.install": "安装并显示托盘", + "startup.tray.start": "显示托盘图标", + "startup.tray.stop": "退出托盘图标", + "startup.tray.uninstall": "移除登录托盘", + "startup.tray.error": "Windows 托盘操作失败。请运行 ocx tray status 查看详情。", + "startup.recovery": "修复选项", + "startup.recoveryHint": "使用上方的一键安装,或复制命令进行手动修复。Codex Desktop 和 Windows 可执行文件建议使用后台服务。", + "startup.command.service": "推荐:持久后台服务", + "startup.command.shim": "备选:CLI launcher shim", + "startup.command.native": "安全恢复:还原 Codex 原生路由", + "startup.copy": "复制", + "startup.copied": "已复制", + "startup.recommended": "推荐修复:{cmd}", + "startup.navRisk": "启动保护需要处理", + // dashboard "dash.subtitle": "本地 opencodex 代理、其提供方以及路由到 Codex 的模型的实时状态。", "dash.status": "状态", @@ -46,7 +122,7 @@ export const zh: Record = { "dash.stopConfirm": "停止代理并恢复原生 Codex 配置?", "dash.stopping": "正在停止…", "dash.codexAutoStart": "随 Codex 启动 opencodex", - "dash.codexAutoStartHint": "启用后,Codex shim 会在每次 Codex CLI/App 启动前运行 ocx ensure。关闭后不会改动 Codex。", + "dash.codexAutoStartHint": "允许已安装的 launcher shim 运行 ocx ensure。此设置不会安装重启保护;请在启动安全中检查实际状态。", "dash.searchModel": "搜索附属模型", "dash.searchModelHint": "用于非 OpenAI 路由模型的 web_search 的模型。需要 ChatGPT 登录。", "dash.searchReasoning": "搜索推理强度", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 2d2bc5d1462..3f4af66ee71 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -3,13 +3,25 @@ import { formatUptime } from "../formatUptime"; import { IconAlert, IconExternal, IconInfo, IconRefresh, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { useI18n, type TKey } from "../i18n/shared"; +import { settingsPollMayCommit, startupRiskDetailKey } from "../startup-health-ui"; import { formatTokens } from "../format-tokens"; import { EmptyState, Select } from "../ui"; interface HealthData { status: string; version: string; uptime: number } interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } interface ModelInfo { id: string; provider: string; owned_by?: string } -interface SettingsData { codexAutoStart: boolean; port: number; hostname: string } +interface SettingsData { + codexAutoStart: boolean; + port: number; + hostname: string; + startupHealth?: { + status: "native" | "protected" | "at-risk"; + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + autostartEnabled: boolean; + shimCoverage: "full" | "cli-only" | "none"; + diagnosticStale: boolean; + }; +} type SidecarBackend = "openai" | "anthropic"; interface SidecarSetting { backend?: SidecarBackend; model: string } interface SidecarData { webSearch: SidecarSetting; vision: SidecarSetting } @@ -210,6 +222,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const updateRetryRef = useRef(0); const updateRetryTimerRef = useRef(null); const updateRequestEpochRef = useRef(0); + const settingsRequestEpochRef = useRef(0); + const settingsMutationEpochRef = useRef(0); + const settingsMutationInFlightRef = useRef(false); const [updateCheck, setUpdateCheck] = useState(null); const [updateError, setUpdateError] = useState(null); const [updateJob, setUpdateJob] = useState(null); @@ -232,6 +247,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { useEffect(() => { const fetchData = async () => { + const settingsRequestEpoch = ++settingsRequestEpochRef.current; + const settingsMutationEpoch = settingsMutationEpochRef.current; try { const [hRes, pRes, sRes, scRes, shRes, uRes] = await Promise.all([ fetch(`${apiBase}/healthz`), @@ -243,7 +260,15 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { ]); setHealth(await hRes.json()); setProviders(await pRes.json()); - setSettings(await sRes.json()); + const nextSettings = await sRes.json() as SettingsData; + if (settingsPollMayCommit( + { request: settingsRequestEpoch, mutation: settingsMutationEpoch }, + { + request: settingsRequestEpochRef.current, + mutation: settingsMutationEpochRef.current, + mutationInFlight: settingsMutationInFlightRef.current, + }, + )) setSettings(nextSettings); setSidecar(await scRes.json()); // Old servers fall through to the SPA HTML for this route; don't let a parse // failure here take down the whole dashboard. @@ -284,7 +309,10 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { }; fetchData(); const interval = setInterval(fetchData, 5000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + settingsRequestEpochRef.current += 1; + }; }, [apiBase]); useEffect(() => { @@ -438,6 +466,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { if (!settings || settingsSaving) return; const next = !settings.codexAutoStart; setSettingsSaving(true); + settingsMutationInFlightRef.current = true; setSettings({ ...settings, codexAutoStart: next }); try { const res = await fetch(`${apiBase}/api/settings`, { @@ -446,12 +475,14 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { body: JSON.stringify({ codexAutoStart: next }), }); if (!res.ok) throw new Error("save failed"); - const data = await res.json(); - setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart } : prev); + const data = await res.json() as { codexAutoStart: boolean; startupHealth?: SettingsData["startupHealth"] }; + settingsMutationEpochRef.current += 1; + setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); } catch { setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); setError(true); } finally { + settingsMutationInFlightRef.current = false; setSettingsSaving(false); } }; @@ -617,6 +648,18 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
+ {(settings?.startupHealth?.status === "at-risk" || settings?.startupHealth?.diagnosticStale) && ( +
+ + + {settings.startupHealth.diagnosticStale + ? t("startup.staleData") + : t(startupRiskDetailKey(settings.startupHealth))}{" "} + {t("startup.title")} + +
+ )} + {projectConfigWarnings.length > 0 && (
diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx new file mode 100644 index 00000000000..ef3ba2091b2 --- /dev/null +++ b/gui/src/pages/Startup.tsx @@ -0,0 +1,397 @@ +import { useCallback, useEffect, useState } from "react"; +import { IconAlert, IconCheck, IconPower, IconRefresh, IconTerminal } from "../icons"; +import { useI18n, type TKey } from "../i18n/shared"; +import { startupRiskDetailKey } from "../startup-health-ui"; +import { EmptyState } from "../ui"; + +type StartupStatus = "native" | "protected" | "at-risk"; +type StartupProtection = "service" | "shim" | "none"; +type StartupInstallAction = "install-service" | "install-shim"; + +interface StartupHealthData { + status: StartupStatus; + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + routingInjected: boolean; + localRoutingDependency: boolean; + autostartEnabled: boolean; + rebootSafe: boolean; + protection: StartupProtection; + serviceInstalled: boolean; + serviceViable: boolean; + serviceEnabled: boolean; + serviceRunning: boolean; + serviceStale: boolean; + serviceConflict: boolean; + serviceSupported: boolean; + shimInstalled: boolean; + shimHealthy: boolean; + shimCoverage: "full" | "cli-only" | "none"; + platform: string; + recommendedCommand: string | null; + diagnosticStale: boolean; + commands: { + installService: string; + installShim: string; + restoreNative: string; + }; +} + +interface TrayStatusData { + supported: boolean; + installed: boolean; + running: boolean; + stale: boolean; + summary: string; +} + +function isTrayStatusData(value: unknown): value is TrayStatusData { + if (!value || typeof value !== "object") return false; + const row = value as Record; + return typeof row.supported === "boolean" + && typeof row.installed === "boolean" + && typeof row.running === "boolean" + && typeof row.stale === "boolean" + && typeof row.summary === "string"; +} + +const STATUS_KEYS: Record = { + native: "startup.status.native", + protected: "startup.status.protected", + "at-risk": "startup.status.atRisk", +}; + +const SUMMARY_KEYS: Record = { + native: "startup.summary.native", + protected: "startup.summary.protected", + "at-risk": "startup.summary.atRisk", +}; + +const PROTECTION_KEYS: Record = { + service: "startup.protection.service", + shim: "startup.protection.shim", + none: "startup.protection.none", +}; + +function StateBadge({ ok, yes, no }: { ok: boolean; yes: string; no: string }) { + return {ok ? yes : no}; +} + +export default function Startup({ apiBase }: { apiBase: string }) { + const { t } = useI18n(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [copied, setCopied] = useState(null); + const [tray, setTray] = useState(null); + const [trayLoading, setTrayLoading] = useState(true); + const [trayBusy, setTrayBusy] = useState(false); + const [trayError, setTrayError] = useState(false); + const [installBusy, setInstallBusy] = useState(null); + const [installResult, setInstallResult] = useState<{ kind: "success" | "error"; action: StartupInstallAction; detail?: string } | null>(null); + + const refresh = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + setTrayLoading(true); + try { + const res = await fetch(`${apiBase}/api/startup-health`, { signal }); + if (!res.ok) throw new Error("fetch failed"); + const next = await res.json() as StartupHealthData; + if (signal?.aborted) return; + setData(next); + setFailed(next.diagnosticStale); + if (next.platform === "win32") { + setTrayError(false); + try { + const trayRes = await fetch(`${apiBase}/api/windows-tray`, { signal }); + if (!trayRes.ok) throw new Error("tray status failed"); + const trayNext = await trayRes.json() as unknown; + if (!isTrayStatusData(trayNext)) throw new Error("invalid tray status"); + if (!signal?.aborted) { + setTray(trayNext); + setTrayError(false); + } + } catch { + if (!signal?.aborted) { + setTray(null); + setTrayError(true); + } + } finally { + if (!signal?.aborted) setTrayLoading(false); + } + } else { + setTrayLoading(false); + } + } catch { + if (signal?.aborted) return; + setFailed(true); + setTray(null); + setTrayError(true); + setTrayLoading(false); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, [apiBase]); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { void refresh(controller.signal); }, 0); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [refresh]); + + useEffect(() => { + if (!data?.diagnosticStale) return; + const timer = window.setTimeout(() => { void refresh(); }, 2000); + return () => window.clearTimeout(timer); + }, [data, refresh]); + + const copyCommand = async (command: string) => { + try { + await navigator.clipboard.writeText(command); + setCopied(command); + window.setTimeout(() => setCopied(current => current === command ? null : current), 1600); + } catch { + setCopied(null); + } + }; + + const runTrayAction = async (action: "install" | "start" | "stop" | "uninstall") => { + setTrayBusy(true); + setTrayError(false); + try { + const res = await fetch(`${apiBase}/api/windows-tray`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (!res.ok) throw new Error("tray action failed"); + const body = await res.json() as { status: TrayStatusData }; + if (!isTrayStatusData(body.status)) throw new Error("invalid tray action status"); + setTray(body.status); + setTrayError(false); + } catch { + setTray(null); + setTrayError(true); + } finally { + setTrayBusy(false); + } + }; + + const runInstallAction = async (action: StartupInstallAction) => { + setInstallBusy(action); + setInstallResult(null); + try { + const res = await fetch(`${apiBase}/api/startup-action`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + const body = await res.json().catch(() => null) as { error?: unknown } | null; + if (!res.ok) throw new Error(typeof body?.error === "string" ? body.error : "installation failed"); + setInstallResult({ kind: "success", action }); + await refresh(); + } catch (error) { + setInstallResult({ kind: "error", action, detail: error instanceof Error ? error.message : String(error) }); + } finally { + setInstallBusy(null); + } + }; + + const routingKey: TKey = data?.routingKind === "opencodex-local" ? "startup.routing.proxy" + : data?.routingKind === "custom-local" ? "startup.routing.customLocal" + : data?.routingKind === "custom-remote" ? "startup.routing.customRemote" + : data?.routingKind === "unknown" ? "startup.routing.unknown" + : "startup.routing.native"; + + const statusClass = failed + ? "startup-hero--risk" + : data?.status === "protected" + ? "startup-hero--safe" + : data?.status === "at-risk" + ? "startup-hero--risk" + : "startup-hero--native"; + const StatusIcon = failed || data?.status === "at-risk" ? IconAlert : IconCheck; + + return ( + <> +
+
+

{t("startup.title")}

+

{t("startup.subtitle")}

+
+ +
+ + {loading && !data ? ( + + ) : failed && !data ? ( + + ) : data ? ( + <> + {failed &&
{t("startup.staleData")}
} +
+
+
+ + {t(failed ? "startup.status.atRisk" : STATUS_KEYS[data.status])} + +

{t(failed ? "startup.error" : SUMMARY_KEYS[data.status])}

+

{failed + ? t("startup.staleData") + : data.status === "at-risk" + ? t(startupRiskDetailKey(data)) + : t("startup.safeDetail")}

+
+
+ +
+
+
{t("startup.routing")}
+
{t(routingKey)}
+
+
+
{t("startup.restartProtection")}
+
{t(PROTECTION_KEYS[data.protection])}
+
+
+
{t("startup.preference")}
+
{t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")}
+
+
+ +
+
+

{t("startup.details")}

+ {data.platform} +
+
+
{t("startup.service")}{t("startup.serviceHint")}
+
+ + {data.serviceSupported && !data.serviceInstalled && ( + + )} +
+
+
+
{t("startup.shim")}{t("startup.shimHint")}
+
+ + {!data.shimInstalled && ( + + )} +
+
+ {installResult && ( +
+ {installResult.kind === "success" + ? t(installResult.action === "install-service" ? "startup.serviceInstalled" : "startup.shimInstalled") + : `${t("startup.installFailed")} ${installResult.detail ?? ""}`} +
+ )} +
+ + {data.platform === "win32" && ( +
+
+

{t("startup.tray.title")}

+ +
+

{t("startup.tray.hint")}

+
+
+ {t("startup.tray.login")} + {t("startup.tray.notProtection")} +
+ {trayLoading || trayError || !tray + ? {t(trayLoading ? "startup.tray.loading" : "startup.tray.unavailable")} + : } +
+
+ {!trayLoading && !trayError && tray && !tray.installed && !tray.stale && ( + + )} + {!trayLoading && !trayError && tray?.installed && !tray.stale && !tray.running && ( + + )} + {!trayLoading && !trayError && tray?.running && !tray.stale && ( + + )} + {!trayLoading && !trayError && tray && (tray.installed || tray.stale) && ( + + )} +
+ {(trayError || tray?.stale) &&
{t("startup.tray.error")}
} +
+ )} + +
+
+

{t("startup.recovery")}

+ +
+

{t("startup.recoveryHint")}

+
+ {data.serviceSupported && ( +
+
+ {t("startup.command.service")} + {data.commands.installService} +
+ +
+ )} +
+
+ {t("startup.command.shim")} + {data.commands.installShim} +
+ +
+
+
+ {t("startup.command.native")} + {data.commands.restoreNative} +
+ +
+
+ {data.status === "at-risk" && ( +
+ {t("startup.recommended", { cmd: data.recommendedCommand ?? data.commands.installService })} +
+ )} +
+ + ) : null} + + ); +} diff --git a/gui/src/startup-health-ui.ts b/gui/src/startup-health-ui.ts new file mode 100644 index 00000000000..7cd13478ce0 --- /dev/null +++ b/gui/src/startup-health-ui.ts @@ -0,0 +1,26 @@ +import type { TKey } from "./i18n/shared"; + +export interface StartupRiskDetail { + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + shimCoverage: "full" | "cli-only" | "none"; +} + +export function startupRiskDetailKey(health: StartupRiskDetail): TKey { + if (health.routingKind === "custom-local") return "startup.riskDetailCustomLocal"; + if (health.shimCoverage === "cli-only") return "startup.riskDetailWindowsShim"; + return "startup.riskDetail"; +} + +export interface SettingsPollEpoch { + request: number; + mutation: number; +} + +export function settingsPollMayCommit( + started: SettingsPollEpoch, + current: SettingsPollEpoch & { mutationInFlight: boolean }, +): boolean { + return !current.mutationInFlight + && started.request === current.request + && started.mutation === current.mutation; +} diff --git a/gui/src/styles.css b/gui/src/styles.css index ded0fbfe1be..f03f2886466 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -708,6 +708,49 @@ dialog.modal-overlay::backdrop { .toggle.on .toggle-knob { left: 17px; background: var(--toggle-dot-color); } .notice-warn { font-size: var(--text-label); line-height: var(--leading-body); padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } .notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; } + +/* Startup lifecycle health — compact infrastructure status, not a setup wizard. */ +.nav-health-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-round); + background: var(--amber); + box-shadow: 0 0 0 3px var(--amber-soft); + margin-left: auto; + flex: 0 0 auto; +} +.startup-page-sub { margin-bottom: 0; } +.startup-hero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; } +.startup-hero--safe { border-color: color-mix(in srgb, var(--green) 34%, var(--border)); background: color-mix(in srgb, var(--green-soft) 64%, var(--surface)); } +.startup-hero--risk { border-color: color-mix(in srgb, var(--amber) 40%, var(--border)); background: color-mix(in srgb, var(--amber-soft) 72%, var(--surface)); } +.startup-hero--native { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); } +.startup-hero-icon { width: 42px; height: 42px; border-radius: var(--radius); display: grid; place-items: center; background: var(--raised); flex: 0 0 auto; } +.startup-hero-icon svg { width: 21px; height: 21px; } +.startup-hero-copy h3 { margin: 10px 0 4px; font-size: var(--text-title); } +.startup-hero-copy p { margin: 0; color: var(--muted); line-height: var(--leading-body); } +.startup-state-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; } +.startup-state-grid .value { font-size: var(--text-subtitle); } +.startup-details, .startup-actions { margin-bottom: 16px; } +.startup-actions .panel-head > svg { width: 18px; height: 18px; flex: 0 0 auto; } +.startup-detail-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-soft); } +.startup-detail-row > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.startup-detail-row > .startup-detail-actions { flex-direction: row; align-items: center; justify-content: flex-end; flex: 0 0 auto; gap: 8px; } +.startup-detail-row span:not(.badge) { color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); } +.startup-actions > .muted { margin: -4px 0 14px; font-size: var(--text-control); } +.startup-tray-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; } +.startup-command-list { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; } +.startup-command-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px; } +.startup-command-row + .startup-command-row { border-top: 1px solid var(--border-soft); } +.startup-command-row > div { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.startup-command-row strong { font-size: var(--text-control); } +.startup-command-row code { color: var(--muted); font-size: var(--text-label); overflow-wrap: anywhere; } +.startup-action-notice { margin: 14px 0 0; } +@media (max-width: 700px) { + .startup-state-grid { grid-template-columns: 1fr; } + .startup-command-row { align-items: flex-start; } + .startup-detail-row { align-items: flex-start; } + .startup-detail-row > .startup-detail-actions { flex-direction: column; align-items: flex-end; } +} .modal-desc { font-size: var(--text-control); line-height: var(--leading-body); color: var(--muted); margin-bottom: 14px; } .modal-actions { display: flex; gap: 8px; margin-top: 16px; } .modal-actions .btn { flex: 1; } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2eebfd6de79..7c988ffcea2 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -16,6 +16,7 @@ import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWi import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings"; +import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health"; export { resolveCodexHomeDir } from "../codex/home"; const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; @@ -331,6 +332,11 @@ export async function runDoctor(): Promise { console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); } + const startup = collectStartupHealth(readConfigDiagnostics().config); + console.log("\nCodex restart safety"); + console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`); + console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`); + const currentProxyEnv = collectProxyEnv(); const configuredProxy = collectConfiguredProxy(); const runningProxyEnv = collectRunningProxyEnv(); @@ -403,6 +409,10 @@ export async function runDoctor(): Promise { const hints: string[] = []; const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive); const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present; + if (!startup.rebootSafe) { + const command = startup.recommendedCommand ?? startup.commands.restoreNative; + hints.push(`Codex is pinned to the local proxy without persistent startup protection. After restart, requests can reconnect indefinitely. Run '${command}'.`); + } if (anyDrvfs) { hints.push("State dir is on a Windows-mounted (/mnt) drive. Prefer the Linux home (~) under WSL for token/lock reliability."); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 19027802202..edc578575d0 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -49,6 +49,15 @@ const helpEntries: Record = { summary: "Auto-start the proxy when `codex` launches.", details: ["Use `remove` as an alias for `uninstall`."], }, + tray: { + usage: "ocx tray [--json] [--no-start]", + summary: "Install and control the Windows status tray icon.", + details: [ + "The tray starts at Windows login and provides one-click proxy controls.", + "Tray start/stop controls the icon only; use its menu to start or stop the proxy.", + "--no-start (install only) installs the tray without launching it immediately.", + ], + }, ensure: { usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." }, sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." }, "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." }, @@ -164,6 +173,7 @@ Usage: ocx uninstall Remove service/shim/config and restore native Codex (alias: remove) ocx service [sub] Run as a background service (default: install/update/start) ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) + ocx tray Windows status tray (install|start|stop|status|uninstall) ocx ensure Ensure the proxy is running and Codex config/cache are current ocx sync Fetch models from providers and inject into Codex config ocx sync-cache Refresh Codex's model cache from the active catalog diff --git a/src/cli/index.ts b/src/cli/index.ts index bf1b7615865..ca1ddc47c9a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,13 +20,16 @@ import { writeRuntimePort, } from "../config"; import { collectStatus } from "./status"; +import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch"; +import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy"; import { installCrashGuards } from "../lib/crash-guard"; import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help"; import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { stopProxy } from "../lib/process-control"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; -import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; +import { diagnoseService, serviceCommand, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; +import { startupHealthSummary } from "../codex/autostart-health"; import { drainAndShutdown, startServer } from "../server"; import { injectSystemEnv, revertSystemEnv } from "../server/system-env"; import { buildDesktop3pRegistry } from "../claude/desktop-3p"; @@ -302,6 +305,47 @@ async function handleEnsure() { console.log(`✅ Proxy running on port ${port}`); } +/** Fixed tray action: start the proxy without depending on codexAutoStart. */ +async function handleTrayProxyStart(): Promise { + const ok = await runTrayProxyStart({ + findLive: findLiveProxy, + diagnoseService: () => { + const service = diagnoseService(); + return { installed: service.installed, startable: serviceStartableFromTray(service), summary: service.summary }; + }, + startService: () => serviceCommand("start"), + startDirect: () => { + const config = loadConfig(); + const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100; + const child = spawn(process.execPath, startArgv(port), { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env, OCX_SERVICE: "1" }, + }); + child.unref(); + }, + waitForProxy, + info: message => console.log(message), + error: message => console.error(message), + }); + if (!ok) process.exitCode = 1; +} + +async function handleTrayProxyRestart(): Promise { + const ok = await runTrayProxyRestart({ + stop: async () => { + await handleStop(); + return !process.exitCode || process.exitCode === 0; + }, + start: async () => { + await handleTrayProxyStart(); + return !process.exitCode || process.exitCode === 0; + }, + }); + if (!ok) process.exitCode = 1; +} + async function handleStop() { const stoppedService = stopServiceIfInstalled(); if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); @@ -382,6 +426,15 @@ async function handleUninstall() { await runStep("service removed", () => uninstallServiceIfInstalled()); + if (process.platform === "win32") { + await runStep("Windows tray removed", async () => { + const { getWindowsTrayStatus, uninstallWindowsTray } = await import("../tray/windows"); + const tray = getWindowsTrayStatus(); + if (!tray.installed && !tray.stale && !tray.running) return false; + uninstallWindowsTray(); + }); + } + await runStep("native Codex restored", () => { const r = restoreNativeCodex(); if (!r.success) throw new Error(r.message); @@ -448,6 +501,7 @@ async function handleStatus() { console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`); console.log(` Default provider: ${status.json.defaultProvider}`); console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`); + console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`); console.log(` Service: ${status.json.service.summary}`); console.log(` ${status.json.codexShim.summary}`); if (status.json.codexPlugins.applicable) { @@ -594,6 +648,11 @@ switch (command) { case "service": await serviceCommand(...args.slice(1)); break; + case "tray": { + const { windowsTrayCommand } = await import("../tray/windows"); + await windowsTrayCommand(args.slice(1)); + break; + } case "codex-shim": { const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("../codex/shim"); switch (args[1]) { @@ -636,6 +695,23 @@ switch (command) { await refreshVersionCache(channel); break; } + case "__tray-start": + case "__tray-restart": + case "__startup-health": + await dispatchInternalCliCommand(command as InternalCliCommand, { + trayStart: handleTrayProxyStart, + trayRestart: handleTrayProxyRestart, + startupHealth: async () => { + const { collectStartupHealth } = await import("../codex/autostart-health"); + console.log(JSON.stringify(collectStartupHealth(loadConfig()))); + }, + }); + break; + case "__tray-host": { + const { runWindowsTrayHost } = await import("../tray/windows"); + await runWindowsTrayHost(); + break; + } case "__gui-update-worker": { const jobId = args[1]; if (!jobId) process.exit(1); diff --git a/src/cli/internal-dispatch.ts b/src/cli/internal-dispatch.ts new file mode 100644 index 00000000000..37f9a5484e9 --- /dev/null +++ b/src/cli/internal-dispatch.ts @@ -0,0 +1,20 @@ +export type InternalCliCommand = "__tray-start" | "__tray-restart" | "__startup-health"; + +export interface InternalCliHandlers { + trayStart: () => void | Promise; + trayRestart: () => void | Promise; + startupHealth: () => void | Promise; +} + +/** Dispatch fixed internal commands without accepting caller-selected process arguments. */ +export async function dispatchInternalCliCommand( + command: InternalCliCommand, + handlers: InternalCliHandlers, +): Promise { + switch (command) { + case "__tray-start": return void await handlers.trayStart(); + case "__tray-restart": return void await handlers.trayRestart(); + case "__startup-health": return void await handlers.startupHealth(); + default: throw new Error(`Unsupported internal CLI command: ${String(command)}`); + } +} diff --git a/src/cli/status.ts b/src/cli/status.ts index 04a5bddcd60..d434c0edd2a 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -3,7 +3,10 @@ import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -import { serviceStatusSummary } from "../service"; +import { diagnoseService } from "../service"; +import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; +import { getCodexRoutingKind } from "../codex/inject"; +import { diagnoseCodexShim } from "../codex/shim"; type HealthCheck = { ok: boolean; @@ -39,6 +42,7 @@ export type CliStatusJson = { overrideEnv?: string; }; codexAutostart: boolean; + startup: StartupHealth; defaultProvider: string | null; config: { source: "default" | "file" | "fallback"; @@ -115,9 +119,15 @@ export async function collectStatus(): Promise { const listen = selectListenTarget(config, pid, pid ? readRuntimePort(pid) : null); const health = await checkProxyHealth(listen); const bunRuntime = durableBunRuntime(); - const serviceSummary = serviceStatusSummary(); - const { codexShimStatus } = await import("../codex/shim"); - const codexShimSummary = codexShimStatus(); + const service = diagnoseService(); + const serviceSummary = service.summary; + const codexShim = diagnoseCodexShim(); + const codexShimSummary = codexShim.summary; + const startup = collectStartupHealth(config, { + service, + shim: codexShim, + routingKind: getCodexRoutingKind(), + }); const codexPlugins = diagnoseCodexBundledPlugins(); const proxyLabel = pid && health.ok ? `running (PID ${pid})` @@ -157,6 +167,7 @@ export async function collectStatus(): Promise { ...(bunRuntime.source === "override" ? { overrideEnv: bunRuntime.overrideEnv } : {}), }, codexAutostart: codexAutoStartEnabled(config), + startup, defaultProvider: typeof config.defaultProvider === "string" ? config.defaultProvider : null, config: { source: configDiagnostics.source, diff --git a/src/cli/tray-proxy.ts b/src/cli/tray-proxy.ts new file mode 100644 index 00000000000..00559fb451d --- /dev/null +++ b/src/cli/tray-proxy.ts @@ -0,0 +1,52 @@ +export interface TrayProxyLive { port: number } + +export interface TrayProxyServiceState { + installed: boolean; + startable: boolean; + summary: string; +} + +export interface TrayProxyStartIo { + findLive: () => Promise; + diagnoseService: () => TrayProxyServiceState; + startService: () => void | Promise; + startDirect: () => void | Promise; + waitForProxy: () => Promise; + info: (message: string) => void; + error: (message: string) => void; +} + +/** Side-effect coordinator for the tray's fixed proxy-start action. */ +export async function runTrayProxyStart(io: TrayProxyStartIo): Promise { + const live = await io.findLive(); + if (live) { + io.info(`Proxy already running on port ${live.port}.`); + return true; + } + + const service = io.diagnoseService(); + if (service.installed && !service.startable) { + io.error(`Cannot start from the tray because the installed service is not viable: ${service.summary}`); + io.error("Repair or remove the service before starting a direct proxy."); + return false; + } + + if (service.startable) await io.startService(); + else await io.startDirect(); + + const started = await io.waitForProxy(); + if (!started) { + io.error("Proxy did not become healthy after the tray start action."); + return false; + } + io.info(`Proxy running on port ${started.port}.`); + return true; +} + +export async function runTrayProxyRestart(io: { + stop: () => boolean | Promise; + start: () => boolean | Promise; +}): Promise { + if (!await io.stop()) return false; + return io.start(); +} diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts new file mode 100644 index 00000000000..5f25447aa7e --- /dev/null +++ b/src/codex/autostart-health.ts @@ -0,0 +1,149 @@ +import { codexAutoStartEnabled } from "../config"; +import { diagnoseService, type ServiceDiagnostic } from "../service"; +import type { OcxConfig } from "../types"; +import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; +import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim"; + +export type StartupProtection = "service" | "shim" | "none"; +export type StartupHealthStatus = "native" | "protected" | "at-risk"; +export type ShimCoverage = "full" | "cli-only" | "none"; + +export interface StartupHealthInputs { + routingKind: CodexRoutingKind; + autostartEnabled: boolean; + serviceInstalled: boolean; + serviceViable: boolean; + serviceEnabled: boolean; + serviceRunning: boolean; + serviceStale: boolean; + serviceConflict: boolean; + serviceSupported: boolean; + shimInstalled: boolean; + shimHealthy: boolean; + platform: NodeJS.Platform; + diagnosticStale?: boolean; +} + +export interface StartupHealth { + status: StartupHealthStatus; + routingKind: CodexRoutingKind; + routingInjected: boolean; + localRoutingDependency: boolean; + autostartEnabled: boolean; + rebootSafe: boolean; + protection: StartupProtection; + serviceInstalled: boolean; + serviceViable: boolean; + serviceEnabled: boolean; + serviceRunning: boolean; + serviceStale: boolean; + serviceConflict: boolean; + shimInstalled: boolean; + shimHealthy: boolean; + shimCoverage: ShimCoverage; + serviceSupported: boolean; + platform: NodeJS.Platform; + diagnosticStale: boolean; + recommendedCommand: string | null; + commands: { + installService: string; + installShim: string; + restoreNative: string; + }; +} + +const COMMANDS = { + installService: "ocx service install", + installShim: "ocx codex-shim install", + restoreNative: "ocx restore", +} as const; + +export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth { + const shimEffective = inputs.autostartEnabled && inputs.shimHealthy; + const routingInjected = inputs.routingKind === "opencodex-local"; + const localRoutingDependency = inputs.routingKind === "opencodex-local" + || inputs.routingKind === "custom-local" + || inputs.routingKind === "unknown"; + // Script launchers never cover Codex Desktop/app-server surfaces. This is + // intentionally conservative on every OS and for WSL-shared Codex homes. + const shimCoverage: ShimCoverage = !shimEffective + ? "none" + : "cli-only"; + // We can only credit an opencodex service/shim for routing that opencodex owns. + // An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair. + const ownsLocalRouting = inputs.routingKind === "opencodex-local"; + const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable + ? "service" + : ownsLocalRouting && shimEffective + ? "shim" + : "none"; + const rebootSafe = !localRoutingDependency || (ownsLocalRouting && inputs.serviceViable); + const status: StartupHealthStatus = !localRoutingDependency + ? "native" + : rebootSafe + ? "protected" + : "at-risk"; + const recommendedCommand = status !== "at-risk" + ? null + : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown" + ? COMMANDS.restoreNative + : inputs.serviceSupported + ? COMMANDS.installService + : COMMANDS.restoreNative; + return { + ...inputs, + diagnosticStale: inputs.diagnosticStale ?? false, + routingInjected, + localRoutingDependency, + status, + rebootSafe, + protection, + shimCoverage, + recommendedCommand, + commands: { ...COMMANDS }, + }; +} + +export interface StartupHealthDiagnostics { + routingKind?: CodexRoutingKind; + service?: ServiceDiagnostic; + shim?: CodexShimDiagnostic; +} + +/** Collect current machine state without mutating config, services, or shims. */ +export function collectStartupHealth( + config: Pick, + diagnostics: StartupHealthDiagnostics = {}, +): StartupHealth { + const shim = diagnostics.shim ?? diagnoseCodexShim(); + const service = diagnostics.service ?? diagnoseService(); + return deriveStartupHealth({ + routingKind: diagnostics.routingKind ?? getCodexRoutingKind(), + autostartEnabled: codexAutoStartEnabled(config), + serviceInstalled: service.installed, + serviceViable: service.viable, + serviceEnabled: service.enabled, + serviceRunning: service.running, + serviceStale: service.stale, + serviceConflict: service.conflict, + serviceSupported: service.supported, + shimInstalled: shim.installed, + shimHealthy: shim.healthy, + platform: process.platform, + }); +} + +export function startupHealthSummary(health: StartupHealth): string { + if (health.status === "native") return health.routingKind === "custom-remote" + ? "custom remote Codex routing (no local restart dependency)" + : "native Codex routing (no opencodex restart dependency)"; + if (health.protection === "service") return "protected by background service"; + const command = health.recommendedCommand ?? health.commands.restoreNative; + if (health.routingKind === "unknown") return `AT RISK after restart (Codex routing could not be verified; run '${command}')`; + if (health.routingKind === "custom-local") return `AT RISK after restart (custom local gateway lifecycle is not managed by opencodex; run '${command}')`; + if (health.shimCoverage === "cli-only") return `AT RISK for Codex Desktop after restart (launcher shim covers CLI scripts only; run '${command}')`; + if (health.serviceConflict) return `AT RISK after restart (background service managers conflict; run '${command}')`; + if (health.serviceStale) return `AT RISK after restart (background service files are stale; run '${command}')`; + if (health.serviceInstalled && !health.serviceViable) return `AT RISK after restart (installed service is disabled, stopped, or unhealthy; run '${command}')`; + return `AT RISK after restart (no viable background service; run '${command}')`; +} diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 07166a38636..a9c738cf71d 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -151,7 +151,7 @@ export function stripInjectedOpenaiBaseUrl(content: string): string { return lines.filter((_, i) => !drop.has(i)).join("\n"); } -function hasInjectedOpenaiBaseUrl(content: string): boolean { +export function hasInjectedOpenaiBaseUrl(content: string): boolean { const lines = content.split("\n"); const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; @@ -161,6 +161,137 @@ function hasInjectedOpenaiBaseUrl(content: string): boolean { return false; } +export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + +function tomlStringPattern(key: string): RegExp { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const keyToken = `(?:${escaped}|\"${escaped}\"|'${escaped}')`; + return new RegExp(`^\\s*${keyToken}\\s*=\\s*[\"']([^\"']+)[\"']\\s*(?:#.*)?$`); +} + +function rootTomlString(content: string, key: string): string | null { + const lines = content.split("\n"); + const firstTable = lines.findIndex(line => /^\s*\[/.test(line)); + const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable); + const pattern = tomlStringPattern(key); + for (const line of rootLines) { + const match = pattern.exec(line); + if (match?.[1]) return match[1].trim(); + } + return null; +} + +function providerTableStart(lines: string[], provider: string): number { + const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const providerToken = `(?:${escapedProvider}|\"${escapedProvider}\"|'${escapedProvider}')`; + const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|\"model_providers\"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`); + return lines.findIndex(line => header.test(line)); +} + +function providerTableString(content: string, provider: string, key: string): string | null { + const lines = content.split("\n"); + const start = providerTableStart(lines, provider); + if (start === -1) return null; + const pattern = tomlStringPattern(key); + for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) { + const match = pattern.exec(lines[index]); + if (match?.[1]) return match[1].trim(); + } + return null; +} + +type RoutingEndpointKind = "local" | "remote" | "unknown"; + +function ipv4Octets(hostname: string): number[] | null { + const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); + if (dotted) { + const octets = dotted.slice(1).map(Number); + return octets.some(octet => octet > 255) ? null : octets; + } + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname); + if (!mapped) return null; + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; +} + +function classifyRoutingEndpoint(value: string): RoutingEndpointKind { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown"; + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); + if (!hostname) return "unknown"; + if (hostname === "localhost" || hostname.endsWith(".localhost")) return "local"; + if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") return "local"; + const octets = ipv4Octets(hostname); + if (octets) { + if (octets.every(octet => octet === 0)) return "local"; + if (octets[0] === 127) return "local"; + return "remote"; + } + if (/^::ffff:/i.test(hostname)) return "unknown"; + return "remote"; + } catch { + return "unknown"; + } +} + +/** Classify actual routing dependency separately from opencodex ownership. */ +export function classifyCodexRouting(content: string): CodexRoutingKind { + const rootBaseUrl = rootTomlString(content, "openai_base_url"); + if (rootBaseUrl) { + const endpoint = classifyRoutingEndpoint(rootBaseUrl); + if (endpoint === "unknown") return "unknown"; + if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local"; + return endpoint === "local" ? "custom-local" : "custom-remote"; + } + const rootProvider = rootTomlString(content, "model_provider"); + if (rootProvider) { + const providerTableExists = providerTableStart(content.split("\n"), rootProvider) !== -1; + const providerBaseUrl = providerTableString(content, rootProvider, "base_url"); + if (providerBaseUrl) { + const endpoint = classifyRoutingEndpoint(providerBaseUrl); + if (endpoint === "unknown") return "unknown"; + if (rootProvider === "opencodex") return "opencodex-local"; + return endpoint === "local" ? "custom-local" : "custom-remote"; + } + if (rootProvider === "opencodex" || providerTableExists || rootProvider !== "openai") return "unknown"; + } + return "native"; +} + +/** + * True when the active Codex config is owned by opencodex routing. Covers the + * loopback Design B root override and the legacy/non-loopback provider table. + * A user-owned `openai_base_url` is intentionally not classified as injected. + */ +export function hasInjectedCodexRouting(content: string): boolean { + if (hasInjectedOpenaiBaseUrl(content)) return true; + return rootTomlString(content, "model_provider") === "opencodex" + && providerTableString(content, "opencodex", "base_url") !== null; +} + +/** Read-only probe used by status, doctor, and the dashboard. */ +export function isCodexRoutingInjected(): boolean { + const path = CODEX_CONFIG_PATH; + if (!existsSync(path)) return false; + try { + return hasInjectedCodexRouting(readFileSync(path, "utf8")); + } catch { + return false; + } +} + +export function getCodexRoutingKind(): CodexRoutingKind { + const path = CODEX_CONFIG_PATH; + if (!existsSync(path)) return "native"; + try { + return classifyCodexRouting(readFileSync(path, "utf8")); + } catch { + return "unknown"; + } +} + /** * Strip every existing `model_provider` line that we must not duplicate: any line set to * "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any diff --git a/src/codex/shim.ts b/src/codex/shim.ts index de5756c4631..c3216d71150 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -88,6 +88,17 @@ function isShim(path: string): boolean { } } +function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { + try { + const content = readFileSync(path, "utf8"); + if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false; + if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false; + return true; + } catch { + return false; + } +} + /** * A PATH entry that reaches Windows through WSL drive interop * (`//...`; root defaults to /mnt, configurable via @@ -345,7 +356,25 @@ exit $LASTEXITCODE function readState(): ShimState | null { try { - return JSON.parse(readFileSync(statePath(), "utf8")) as ShimState; + const value = JSON.parse(readFileSync(statePath(), "utf8")) as unknown; + if (!value || typeof value !== "object") return null; + const state = value as Record; + if (typeof state.platform !== "string") return null; + const validFile = (item: unknown): item is ShimFileState => { + if (!item || typeof item !== "object") return false; + const file = item as Record; + return typeof file.wrapperPath === "string" + && typeof file.originalPath === "string" + && typeof file.backupPath === "string" + && (file.realPath === undefined || typeof file.realPath === "string") + && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean"); + }; + if (state.wrappers !== undefined) { + if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return null; + } else if (!validFile(state)) { + return null; + } + return state as unknown as ShimState; } catch { return null; } @@ -504,13 +533,39 @@ export function uninstallCodexShim(): { removed: boolean; message: string } { /** True if a Codex autostart shim is currently installed (state file present). */ export function isCodexShimInstalled(): boolean { - return readState() !== null; + return diagnoseCodexShim().installed; } -export function codexShimStatus(): string { +export interface CodexShimDiagnostic { + installed: boolean; + healthy: boolean; + summary: string; +} + +/** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */ +export function diagnoseCodexShim(): CodexShimDiagnostic { const state = readState(); - if (!state) return "Codex autostart shim is not installed."; - return stateFiles(state).map(file => { + if (!state) { + if (existsSync(statePath())) { + return { + installed: true, + healthy: false, + summary: `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`, + }; + } + return { + installed: false, + healthy: false, + summary: "Codex autostart shim is not installed.", + }; + } + const files = stateFiles(state); + const healthy = files.length > 0 && files.every(file => file.preserveOnly + ? existsSync(file.backupPath) && !existsSync(file.originalPath) + : existsSync(file.wrapperPath) + && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false)) + && isHealthyShim(file.wrapperPath, state.platform)); + const summary = files.map(file => { const wrapper = existsSync(file.wrapperPath) ? isShim(file.wrapperPath) ? "shim present" @@ -519,4 +574,9 @@ export function codexShimStatus(): string { 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 }; +} + +export function codexShimStatus(): string { + return diagnoseCodexShim().summary; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 678d0cf5f0c..e40cb30ff60 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -55,6 +55,9 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; +import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; +import { runWindowsTrayAction } from "../windows-tray-control"; +import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -75,9 +78,54 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; @@ -6,6 +7,7 @@ export interface ManagementApiDeps { clearThreadAccountMap?: () => void; clearProviderQuotaCache?: () => void; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise | void; + runStartupInstallAction?: (action: StartupInstallAction) => Promise<{ message: string }>; } diff --git a/src/server/startup-action-control.ts b/src/server/startup-action-control.ts new file mode 100644 index 00000000000..5cb06532a56 --- /dev/null +++ b/src/server/startup-action-control.ts @@ -0,0 +1,41 @@ +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { durableBunPath } from "../lib/bun-runtime"; + +export type StartupInstallAction = "install-service" | "install-shim"; +let activeInstall: StartupInstallAction | null = null; + +export function startupInstallArgv(action: StartupInstallAction): string[] { + return action === "install-service" + ? ["service", "install"] + : ["codex-shim", "install"]; +} + +/** Execute the existing fixed CLI installer outside the proxy event loop. */ +export function runStartupInstallAction(action: StartupInstallAction): Promise<{ message: string }> { + if (activeInstall) return Promise.reject(new Error(`Another startup installation is already running: ${activeInstall}`)); + activeInstall = action; + const bun = durableBunPath(); + const cli = join(import.meta.dir, "..", "cli", "index.ts"); + const operation = new Promise<{ message: string }>((resolve, reject) => { + execFile(bun, [cli, ...startupInstallArgv(action)], { + encoding: "utf8", + env: process.env, + timeout: 60_000, + windowsHide: true, + maxBuffer: 256 * 1024, + }, (error, stdout, stderr) => { + if (error) { + const detail = stderr.trim() || stdout.trim() || error.message; + reject(new Error(detail.slice(0, 2_000))); + return; + } + resolve({ + message: action === "install-service" + ? "Background service installed." + : "Codex launcher shim installed.", + }); + }); + }); + return operation.finally(() => { activeInstall = null; }); +} diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts new file mode 100644 index 00000000000..e4c7fa83ebd --- /dev/null +++ b/src/server/startup-health-cache.ts @@ -0,0 +1,100 @@ +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { codexAutoStartEnabled } from "../config"; +import { deriveStartupHealth, type StartupHealth } from "../codex/autostart-health"; +import { getCodexRoutingKind } from "../codex/inject"; +import { diagnoseCodexShim } from "../codex/shim"; +import { durableBunPath } from "../lib/bun-runtime"; +import type { OcxConfig } from "../types"; + +const CACHE_TTL_MS = 30_000; +const PROBE_TIMEOUT_MS = 5_000; +let cached: { timestamp: number; value: StartupHealth } | null = null; +let inflight: Promise | null = null; +let generation = 0; + +export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth { + if (!value.localRoutingDependency) return { ...value, diagnosticStale: true }; + return { + ...value, + status: "at-risk", + rebootSafe: false, + protection: "none", + diagnosticStale: true, + recommendedCommand: value.routingKind === "custom-local" || value.routingKind === "unknown" + ? value.commands.restoreNative + : value.commands.installService, + }; +} + +function conservativeFallback(config: Pick): StartupHealth { + const shim = diagnoseCodexShim(); + return deriveStartupHealth({ + routingKind: getCodexRoutingKind(), + autostartEnabled: codexAutoStartEnabled(config), + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: process.platform === "win32" || process.platform === "darwin" || process.platform === "linux", + shimInstalled: shim.installed, + shimHealthy: shim.healthy, + platform: process.platform, + diagnosticStale: true, + }); +} + +function runProbe(config: Pick): Promise { + const bun = durableBunPath(); + const cli = join(import.meta.dir, "..", "cli", "index.ts"); + return new Promise(resolve => { + execFile(bun, [cli, "__startup-health"], { + encoding: "utf8", + env: process.env, + timeout: PROBE_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 256 * 1024, + }, (error, stdout) => { + if (!error) { + const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const parsed = JSON.parse(lines[index]) as StartupHealth; + if (["native", "protected", "at-risk"].includes(parsed.status) && typeof parsed.rebootSafe === "boolean") { + resolve({ ...parsed, diagnosticStale: false }); + return; + } + } catch { /* scan earlier output; config repair messages may precede JSON */ } + } + } + resolve(cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config)); + }); + }); +} + +function refreshInBackground(config: Pick): void { + if (inflight) return; + const startedGeneration = generation; + const probe = runProbe(config).then(value => { + if (startedGeneration === generation) cached = { timestamp: Date.now(), value }; + return value; + }); + inflight = probe.finally(() => { + if (inflight === probe || startedGeneration === generation) inflight = null; + }); +} + +/** Stale-while-revalidate: service-manager probes never hold open a model/UI request. */ +export async function getCachedStartupHealth(config: Pick): Promise { + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.value; + refreshInBackground(config); + return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config); +} + +export function invalidateStartupHealthCache(): void { + generation += 1; + cached = null; + inflight = null; +} diff --git a/src/server/windows-tray-control.ts b/src/server/windows-tray-control.ts new file mode 100644 index 00000000000..bc59ef894c3 --- /dev/null +++ b/src/server/windows-tray-control.ts @@ -0,0 +1,41 @@ +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { durableBunPath } from "../lib/bun-runtime"; +import type { WindowsTrayStatus } from "../tray/windows"; + +export type WindowsTrayAction = "install" | "start" | "stop" | "status" | "uninstall"; + +/** Run blocking registry/PowerShell waits outside the proxy event loop. */ +export function runWindowsTrayAction(action: WindowsTrayAction): Promise { + if (action === "status") { + return import("../tray/windows").then(({ getWindowsTrayStatusAsync }) => getWindowsTrayStatusAsync()); + } + const bun = durableBunPath(); + const cli = join(import.meta.dir, "..", "cli", "index.ts"); + return new Promise((resolve, reject) => { + execFile(bun, [cli, "tray", action, "--json"], { + encoding: "utf8", + env: process.env, + timeout: 30_000, + windowsHide: true, + maxBuffer: 256 * 1024, + }, (error, stdout, stderr) => { + const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const parsed = JSON.parse(lines[index]) as WindowsTrayStatus | { error?: unknown }; + if ("supported" in parsed && typeof parsed.supported === "boolean") { + resolve(parsed); + return; + } + const failure = parsed as { error?: unknown }; + if (typeof failure.error === "string") { + reject(new Error(failure.error)); + return; + } + } catch { /* scan earlier lines */ } + } + reject(new Error(error?.message || stderr.trim() || "Windows tray action returned no status")); + }); + }); +} diff --git a/src/service.ts b/src/service.ts index c2006c0c090..26c7b9844c4 100644 --- a/src/service.ts +++ b/src/service.ts @@ -88,7 +88,7 @@ function normalizePathForCompare(path: string): string { return process.platform === "win32" ? resolved.toLowerCase() : resolved; } -interface ServiceInstallState { +export interface ServiceInstallState { version: 1 | 2; codexHome: string; opencodexHome: string; @@ -101,6 +101,23 @@ interface ServiceInstallState { winswSha256?: string; } +export function parseServiceInstallState(value: unknown): ServiceInstallState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const state = value as Record; + if (state.version !== 1 && state.version !== 2) return null; + if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null; + if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null; + for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) { + if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null; + } + if (state.version === 1) { + if (state.backend !== undefined) return null; + } else if (state.backend !== "scheduler" && state.backend !== "native") { + return null; + } + return state as unknown as ServiceInstallState; +} + function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void { const { bun, cli } = cliEntry(); const state: ServiceInstallState = { @@ -124,8 +141,8 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void { function readServiceInstallState(): ServiceInstallState | null { for (const path of serviceStatePaths()) { try { - const parsed = JSON.parse(readFileSync(path, "utf8")) as ServiceInstallState; - if (parsed.version === 1 || parsed.version === 2) return parsed; + const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8"))); + if (parsed) return parsed; } catch { /* try the next known state path */ } @@ -451,6 +468,30 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launche `; } +function taskXmlSection(xml: string, tag: string): string { + return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? ""; +} + +/** Validate the security/lifecycle-critical fields of the registered scheduler task. */ +export function windowsTaskRegistrationHealthy( + xml: string, + wscript = windowsWscript(), + launcher = windowsLauncherVbsPath(), +): boolean { + const trigger = taskXmlSection(xml, "LogonTrigger"); + const principal = taskXmlSection(xml, "Principal"); + const settings = taskXmlSection(xml, "Settings"); + const action = taskXmlSection(xml, "Exec"); + return /\s*true\s*<\/Enabled>/i.test(trigger) + && /\s*InteractiveToken\s*<\/LogonType>/i.test(principal) + && /\s*LeastPrivilege\s*<\/RunLevel>/i.test(principal) + && /\s*true\s*<\/Enabled>/i.test(settings) + && /\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings) + && /\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings) + && action.includes(`${taskXmlString(wscript)}`) + && action.includes(`${taskXmlString(`/b /nologo "${launcher}"`)}`); +} + // ── macOS (launchd) ── function installLaunchd(): void { const dir = join(homedir(), "Library", "LaunchAgents"); @@ -561,6 +602,7 @@ async function installWindowsNative(): Promise { function startWindows(): void { schtasks(["/run", "/tn", TASK]); } function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } } function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } } +function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } } function uninstallWindows(): void { try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ } if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath()); @@ -814,31 +856,141 @@ export function uninstallServiceIfInstalled(): boolean { /** True if a background service (launchd/systemd/Task Scheduler) is installed. */ export function isServiceInstalled(): boolean { - return serviceStatusSummary().startsWith("installed"); + return diagnoseService().installed; +} + +export interface ServiceDiagnostic { + supported: boolean; + installed: boolean; + enabled: boolean; + running: boolean; + viable: boolean; + startable: boolean; + stale: boolean; + conflict: boolean; + backend: ServiceBackend | "launchd" | "systemd" | null; + summary: string; +} + +/** Windows tray may restart a healthy-but-stopped native service; stale/conflicting installs remain blocked. */ +export function serviceStartableFromTray(service: ServiceDiagnostic): boolean { + return service.startable && !service.stale && !service.conflict; +} + +export interface WindowsServiceDiagnosticInputs { + schedulerInstalled: boolean; + schedulerEnabled: boolean; + schedulerAssetsHealthy: boolean; + nativeStatus: "started" | "stopped" | "nonexistent" | "unknown"; + recordedBackend: ServiceBackend | null; + staleBakedPaths: boolean; + nativeRepairAssetsOnly: boolean; + diagnostics: string; +} + +export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic { + const nativeInstalled = inputs.nativeStatus !== "nonexistent"; + const conflict = inputs.schedulerInstalled && nativeInstalled; + const backendStateMismatch = inputs.schedulerInstalled + ? inputs.recordedBackend !== "scheduler" + : nativeInstalled && inputs.recordedBackend !== "native"; + const stale = inputs.staleBakedPaths + || (inputs.schedulerInstalled && !inputs.schedulerAssetsHealthy) + || backendStateMismatch + || (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly); + const backend = inputs.schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null; + const enabled = inputs.schedulerInstalled ? inputs.schedulerEnabled : inputs.nativeStatus === "started"; + const running = nativeInstalled ? inputs.nativeStatus === "started" : inputs.schedulerInstalled && inputs.schedulerEnabled; + const viable = !conflict && !stale + && (inputs.schedulerInstalled ? inputs.schedulerEnabled && inputs.schedulerAssetsHealthy : inputs.nativeStatus === "started"); + const startable = !conflict && !stale + && (inputs.schedulerInstalled + ? inputs.schedulerEnabled && inputs.schedulerAssetsHealthy + : inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped"); + const detail = conflict + ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one" + : stale + ? "stale or missing service assets — run 'ocx service install' to repair" + : inputs.schedulerInstalled + ? inputs.schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled" + : nativeInstalled + ? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}` + : "not installed"; + const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`; + return { + supported: true, + installed: inputs.schedulerInstalled || nativeInstalled, + enabled, + running, + viable, + startable, + stale, + conflict, + backend, + summary, + }; } -export function serviceStatusSummary(): string { +/** + * Fail-closed restart diagnostic. Presence alone is never enough: conflicting + * managers, stale baked paths, disabled registrations, and unknown/stopped + * native managers cannot claim that Codex will reconnect after a reboot. + */ +export function diagnoseService(): ServiceDiagnostic { const diagnostics = serviceDiagnosticsSummary(); if (process.platform === "darwin") { - if (!existsSync(plistPath())) return `not installed (${diagnostics})`; - const status = statusLaunchd(); - return status ? `installed (launchd; ${diagnostics})` : `installed, not loaded (${diagnostics})`; + const installed = existsSync(plistPath()); + const running = installed && Boolean(statusLaunchd()); + const stale = installed && bakedServicePathsDiagnostic() !== null; + const viable = installed && running && !stale; + const summary = !installed ? `not installed (${diagnostics})` + : stale ? `installed, but stale (launchd; ${diagnostics})` + : running ? `installed and loaded (launchd; ${diagnostics})` + : `installed, not loaded (launchd; ${diagnostics})`; + return { supported: true, installed, enabled: running, running, viable, startable: installed && !stale, stale, conflict: false, backend: "launchd", summary }; } if (process.platform === "win32") { - const scheduler = statusWindows(); - const native = winswStatusSummary(); - if (scheduler && native) return `installed (CONFLICT: Task Scheduler AND native WinSW both present — run 'ocx service uninstall' then reinstall one; ${diagnostics})`; - if (native) return `installed (${native}; ${diagnostics})`; - return scheduler ? `installed (Task Scheduler; ${diagnostics})` : `not installed (${diagnostics})`; + const schedulerXml = statusWindowsXml(); + const schedulerInstalled = schedulerXml.length > 0; + const schedulerSettings = taskXmlSection(schedulerXml, "Settings"); + const schedulerEnabled = schedulerInstalled && /\s*true\s*<\/Enabled>/i.test(schedulerSettings); + const schedulerAssets = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()].every(existsSync) + && windowsTaskRegistrationHealthy(schedulerXml); + const nativeStatus = statusWinswRaw(); + const installState = readServiceInstallState(); + const recordedBackend: ServiceBackend | null = !installState + ? null + : installState.backend === "native" ? "native" : "scheduler"; + return deriveWindowsServiceDiagnostic({ + schedulerInstalled, + schedulerEnabled, + schedulerAssetsHealthy: schedulerAssets, + nativeStatus, + recordedBackend, + staleBakedPaths: bakedServicePathsDiagnostic() !== null, + nativeRepairAssetsOnly: Boolean(winswStatusSummary()), + diagnostics, + }); } if (process.platform === "linux") { - if (existsSync("/.dockerenv")) return "unsupported in Docker"; - if (!isSystemd()) return "unsupported: systemd not found"; - if (!existsSync(unitPath())) return `not installed (${diagnostics})`; - const status = statusSystemd(); - return status ? `installed (systemd user; ${diagnostics})` : `installed, not running (${diagnostics})`; + if (existsSync("/.dockerenv")) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported in Docker" }; + if (!isSystemd()) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported: systemd not found" }; + const installed = existsSync(unitPath()); + const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })(); + const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })(); + const stale = installed && bakedServicePathsDiagnostic() !== null; + const viable = installed && enabled && running && !stale; + const summary = !installed ? `not installed (${diagnostics})` + : stale ? `installed, but stale (systemd user; ${diagnostics})` + : viable ? `installed, enabled and running (systemd user; ${diagnostics})` + : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`; + return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary }; } - return `unsupported on ${process.platform}`; + return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` }; +} + +export function serviceStatusSummary(): string { + return diagnoseService().summary; } export function normalizeServiceSubcommand(sub?: string): string { diff --git a/src/tray/assets/opencodex-tray-offline.ico b/src/tray/assets/opencodex-tray-offline.ico new file mode 100644 index 00000000000..80848ab6fc2 Binary files /dev/null and b/src/tray/assets/opencodex-tray-offline.ico differ diff --git a/src/tray/assets/opencodex-tray-online.ico b/src/tray/assets/opencodex-tray-online.ico new file mode 100644 index 00000000000..16d03c06ec3 Binary files /dev/null and b/src/tray/assets/opencodex-tray-online.ico differ diff --git a/src/tray/assets/opencodex-tray-warning.ico b/src/tray/assets/opencodex-tray-warning.ico new file mode 100644 index 00000000000..0cd8830a492 Binary files /dev/null and b/src/tray/assets/opencodex-tray-warning.ico differ diff --git a/src/tray/assets/opencodex-tray.png b/src/tray/assets/opencodex-tray.png new file mode 100644 index 00000000000..201b9ce7b2e Binary files /dev/null and b/src/tray/assets/opencodex-tray.png differ diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 new file mode 100644 index 00000000000..c7dfa58f7af --- /dev/null +++ b/src/tray/windows-tray.ps1 @@ -0,0 +1,290 @@ +param( + [Parameter(Mandatory = $true)][string]$BunPath, + [Parameter(Mandatory = $true)][string]$CliPath, + [Parameter(Mandatory = $true)][string]$CodexHome, + [Parameter(Mandatory = $true)][string]$OpenCodexHome, + [ValidateSet("Run", "Stop")][string]$Mode = "Run", + [int]$HostPid = 0 +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# Normalize aliases before deriving singleton/event names. Without this, +# C:\path and C:\path\. create separate tray instances for the same home. +function Normalize-HomePath([string]$Value) { + $full = [System.IO.Path]::GetFullPath($Value) + $root = [System.IO.Path]::GetPathRoot($full) + if ($full -eq $root) { return $full } + return $full.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +} +$OpenCodexHome = Normalize-HomePath $OpenCodexHome +$CodexHome = Normalize-HomePath $CodexHome + +$script:ownedIcons = New-Object System.Collections.Generic.List[System.Drawing.Icon] +function Load-TrayIcon([string]$Name, [System.Drawing.Icon]$Fallback) { + $path = Join-Path $OpenCodexHome $Name + if (-not [System.IO.File]::Exists($path)) { return $Fallback } + try { + $icon = New-Object System.Drawing.Icon($path) + [void]$script:ownedIcons.Add($icon) + return $icon + } catch { + return $Fallback + } +} +$onlineIcon = Load-TrayIcon "opencodex-tray-online.ico" ([System.Drawing.SystemIcons]::Information) +$warningIcon = Load-TrayIcon "opencodex-tray-warning.ico" ([System.Drawing.SystemIcons]::Warning) +$offlineIcon = Load-TrayIcon "opencodex-tray-offline.ico" ([System.Drawing.SystemIcons]::Error) + +function Get-StableHash([string]$Value) { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant()) + $hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace("-", "").Substring(0, 20) + return $hash + } finally { + $sha.Dispose() + } +} + +$stableHash = Get-StableHash $OpenCodexHome +$stopEventCreated = $false +$stopEvent = New-Object System.Threading.EventWaitHandle($false, [System.Threading.EventResetMode]::AutoReset, "Local\OpenCodexTrayStop-$stableHash", [ref]$stopEventCreated) +if ($Mode -eq "Stop") { + [void]$stopEvent.Set() + $stopEvent.Dispose() + exit 0 +} + +$createdNew = $false +$mutex = New-Object System.Threading.Mutex($true, "Local\OpenCodexTray-$stableHash", [ref]$createdNew) +if (-not $createdNew) { + $stopEvent.Dispose() + $mutex.Dispose() + exit 0 +} + +$heartbeatPath = Join-Path $OpenCodexHome "tray-heartbeat.json" +$actionLogPath = Join-Path $OpenCodexHome "tray-actions.log" + +function Write-ActionLog([string]$Message) { + $line = "[$([DateTimeOffset]::Now.ToString('o'))] $Message" + [System.IO.File]::AppendAllText($actionLogPath, $line + [Environment]::NewLine, (New-Object System.Text.UTF8Encoding($false))) +} + +function ConvertTo-NativeArgument([string]$Value) { + if ($Value.Contains('"') -or $Value.Contains("`r") -or $Value.Contains("`n")) { + throw "Invalid native command argument" + } + return '"' + $Value + '"' +} + +function Start-OcxCommand([string[]]$CommandArgs) { + try { + $allArgs = @($CliPath) + $CommandArgs + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $BunPath + $psi.Arguments = (($allArgs | ForEach-Object { ConvertTo-NativeArgument $_ }) -join " ") + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden + $psi.EnvironmentVariables["CODEX_HOME"] = $CodexHome + $psi.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome + $process = [System.Diagnostics.Process]::Start($psi) + if ($null -ne $process) { $process.Dispose() } + Write-ActionLog "dispatched $($CommandArgs -join ' ')" + return $true + } catch { + Write-ActionLog "launch failed: $($_.Exception.GetType().Name)" + $notify.ShowBalloonTip(5000, "opencodex action failed", "The action could not start. Open the logs folder or run ocx doctor.", [System.Windows.Forms.ToolTipIcon]::Error) + return $false + } +} + +function Read-ListenTarget { + foreach ($path in @((Join-Path $OpenCodexHome "runtime-port.json"), (Join-Path $OpenCodexHome "config.json"))) { + try { + $value = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $candidate = [int]$value.port + if ($candidate -gt 0 -and $candidate -le 65535) { + $candidateHost = [string]$value.hostname + $ip = $null + $hostName = if ([string]::IsNullOrWhiteSpace($candidateHost) -or $candidateHost -in @("localhost", "0.0.0.0", "::", "[::]")) { + "127.0.0.1" + } elseif ([System.Net.IPAddress]::TryParse($candidateHost.Trim("[", "]"), [ref]$ip)) { + if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { "[$($ip.ToString())]" } else { $ip.ToString() } + } else { + "127.0.0.1" + } + return @{ port = $candidate; host = $hostName; pid = $value.pid } + } + } catch { } + } + return @{ port = 10100; host = "127.0.0.1"; pid = $null } +} + +function Read-JsonUrl([string]$Url) { + $request = [System.Net.HttpWebRequest]::Create($Url) + $request.Method = "GET" + $request.Timeout = 700 + $request.ReadWriteTimeout = 700 + $response = $request.GetResponse() + try { + $reader = New-Object System.IO.StreamReader($response.GetResponseStream()) + try { return ($reader.ReadToEnd() | ConvertFrom-Json) } finally { $reader.Dispose() } + } finally { + $response.Dispose() + } +} + +$notify = New-Object System.Windows.Forms.NotifyIcon +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$statusItem = New-Object System.Windows.Forms.ToolStripMenuItem +$statusItem.Enabled = $false +$safetyItem = New-Object System.Windows.Forms.ToolStripMenuItem +$safetyItem.Enabled = $false +$openItem = $menu.Items.Add("Open Dashboard") +$startItem = $menu.Items.Add("Start Proxy") +$stopItem = $menu.Items.Add("Stop Proxy and Restore Native Routing") +$restartItem = $menu.Items.Add("Restart Proxy") +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +[void]$menu.Items.Add($statusItem) +[void]$menu.Items.Add($safetyItem) +$logsItem = $menu.Items.Add("Open Logs Folder") +[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) +$exitItem = $menu.Items.Add("Exit Tray") + +$script:online = $false +$script:port = 10100 +$script:proxyPid = $null +$script:pendingAction = $null +$script:pendingStarted = 0L +$script:pendingDeadline = 0L +$script:pendingOldProxyPid = $null + +function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) { + $script:pendingAction = $Action + $script:pendingStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $script:pendingDeadline = $script:pendingStarted + ($TimeoutSeconds * 1000) + $script:pendingOldProxyPid = $script:proxyPid +} + +function Complete-PendingAction([bool]$Success) { + if ($null -eq $script:pendingAction) { return } + $action = $script:pendingAction + $script:pendingAction = $null + if ($Success) { + Write-ActionLog "$action completed (port=$($script:port), pid=$($script:proxyPid))" + $notify.ShowBalloonTip(2500, "opencodex", "$action completed.", [System.Windows.Forms.ToolTipIcon]::Info) + } else { + Write-ActionLog "$action failed to reach the expected state" + $notify.ShowBalloonTip(5000, "opencodex action failed", "$action did not reach the expected state. Open the logs folder or run ocx doctor.", [System.Windows.Forms.ToolTipIcon]::Error) + } +} + +function Update-TrayState { + $target = Read-ListenTarget + $script:port = [int]$target.port + $health = $null + $origin = "http://$($target.host):$($script:port)" + try { $health = Read-JsonUrl "$origin/healthz" } catch { } + $pidMatches = $null -eq $target.pid -or [int]$target.pid -eq [int]$health.pid + $script:online = $null -ne $health -and $health.status -eq "ok" -and $health.service -eq "opencodex" -and [int]$health.port -eq $script:port -and $pidMatches + $script:proxyPid = if ($script:online) { [int]$health.pid } else { $null } + if ($script:online) { + $statusItem.Text = "Proxy: Online (port $($script:port))" + $notify.Text = "opencodex: Online" + $startItem.Enabled = $false + $stopItem.Enabled = $true + $restartItem.Enabled = $true + try { + $startup = Read-JsonUrl "$origin/api/startup-health" + $label = if ($startup.status -eq "at-risk") { "At risk" } elseif ($startup.status -eq "protected") { "Protected" } else { "Native routing" } + $safetyItem.Text = "Restart safety: $label" + $notify.Icon = if ($startup.status -eq "at-risk") { $warningIcon } else { $onlineIcon } + } catch { + $safetyItem.Text = "Restart safety: unavailable" + $notify.Icon = $warningIcon + } + } else { + $statusItem.Text = "Proxy: Offline" + $safetyItem.Text = "Restart safety: start the proxy to inspect" + $notify.Text = "opencodex: Offline" + $notify.Icon = $offlineIcon + $startItem.Enabled = $true + $stopItem.Enabled = $false + $restartItem.Enabled = $false + } + $heartbeat = @{ pid = $PID; timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } + if ($HostPid -gt 0) { $heartbeat.hostPid = $HostPid } + $heartbeatJson = $heartbeat | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($heartbeatPath, $heartbeatJson, (New-Object System.Text.UTF8Encoding($false))) + + if ($null -ne $script:pendingAction) { + $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $elapsed = $now - $script:pendingStarted + $reached = ($script:pendingAction -eq "Start Proxy" -and $script:online) -or + ($script:pendingAction -eq "Stop Proxy" -and -not $script:online) -or + ($script:pendingAction -eq "Restart Proxy" -and $elapsed -gt 3000 -and $script:online -and $script:proxyPid -ne $script:pendingOldProxyPid) + if ($reached) { Complete-PendingAction $true } + elseif ($now -gt $script:pendingDeadline) { Complete-PendingAction $false } + } +} + +$openItem.add_Click({ Start-OcxCommand @("gui") }) +$startItem.add_Click({ + $statusItem.Text = "Proxy: Starting..." + Set-PendingAction "Start Proxy" 15 + if (-not (Start-OcxCommand @("__tray-start"))) { $script:pendingAction = $null } +}) +$stopItem.add_Click({ + $statusItem.Text = "Proxy: Stopping..." + Set-PendingAction "Stop Proxy" 15 + if (-not (Start-OcxCommand @("stop"))) { $script:pendingAction = $null } +}) +$restartItem.add_Click({ + $statusItem.Text = "Proxy: Restarting..." + Set-PendingAction "Restart Proxy" 20 + if (-not (Start-OcxCommand @("__tray-restart"))) { $script:pendingAction = $null } +}) +$logsItem.add_Click({ + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $OpenCodexHome + $psi.UseShellExecute = $true + [void][System.Diagnostics.Process]::Start($psi) +}) +$exitItem.add_Click({ [System.Windows.Forms.Application]::Exit() }) +$notify.add_DoubleClick({ Start-OcxCommand @("gui") }) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 3000 +$timer.add_Tick({ + if ($stopEvent.WaitOne(0)) { + [System.Windows.Forms.Application]::Exit() + return + } + Update-TrayState +}) +$notify.ContextMenuStrip = $menu +$notify.Icon = $offlineIcon +$notify.Visible = $true +$notify.Text = "opencodex: Checking..." + +try { + Update-TrayState + $timer.Start() + [System.Windows.Forms.Application]::Run() +} finally { + $timer.Stop() + $timer.Dispose() + $notify.Visible = $false + $notify.Dispose() + foreach ($icon in $script:ownedIcons) { $icon.Dispose() } + $menu.Dispose() + try { Remove-Item -LiteralPath $heartbeatPath -Force -ErrorAction SilentlyContinue } catch { } + try { $mutex.ReleaseMutex() } catch { } + $mutex.Dispose() + $stopEvent.Dispose() +} diff --git a/src/tray/windows.ts b/src/tray/windows.ts new file mode 100644 index 00000000000..914c0cd0a2e --- /dev/null +++ b/src/tray/windows.ts @@ -0,0 +1,628 @@ +import { execFile, execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { expandUserPath, getConfigDir } from "../config"; +import { durableBunPath } from "../lib/bun-runtime"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion"; +const TRAY_STATE_VERSION = 1; +const FOREIGN_RUN_VALUE = ""; +const TRAY_ICON_FILES = [ + "opencodex-tray-online.ico", + "opencodex-tray-warning.ico", + "opencodex-tray-offline.ico", +] as const; + +export interface WindowsTrayEntry { + bun: string; + cli: string; + script: string; + codexHome: string; + opencodexHome: string; +} + +interface WindowsTrayState extends WindowsTrayEntry { + version: 1; + launcherPath?: string; + runValue: string; + runCommand: string; +} + +export interface WindowsTrayStatus { + supported: boolean; + installed: boolean; + running: boolean; + stale: boolean; + summary: string; +} + +function trayStatePath(): string { + return join(getConfigDir(), "tray-state.json"); +} + +function trayHeartbeatPath(): string { + return join(getConfigDir(), "tray-heartbeat.json"); +} + +function installedTrayScriptPath(): string { + return join(getConfigDir(), "opencodex-tray.ps1"); +} + +function installedTrayIconPaths(): string[] { + return TRAY_ICON_FILES.map(name => join(getConfigDir(), name)); +} + +export function windowsTrayStatePathsOwned( + state: Pick & { launcherPath?: string }, + configDir = getConfigDir(), +): boolean { + if (resolve(state.opencodexHome) !== resolve(configDir)) return false; + if (resolve(state.script) !== resolve(join(configDir, "opencodex-tray.ps1"))) return false; + return state.launcherPath === undefined + || resolve(state.launcherPath) === resolve(join(configDir, "opencodex-tray.vbs")); +} + +function sourceTrayScriptPath(): string { + return join(import.meta.dir, "windows-tray.ps1"); +} + +function sourceTrayIconPaths(): string[] { + return TRAY_ICON_FILES.map(name => join(import.meta.dir, "assets", name)); +} + +function currentCodexHome(): string { + const raw = process.env.CODEX_HOME?.trim(); + return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex"); +} + +function currentEntry(): WindowsTrayEntry { + return { + bun: durableBunPath(), + cli: join(import.meta.dir, "..", "cli", "index.ts"), + script: installedTrayScriptPath(), + codexHome: currentCodexHome(), + opencodexHome: getConfigDir(), + }; +} + +export function windowsTrayRunValue(opencodexHome: string): string { + const normalized = resolve(opencodexHome).replace(/[\\/]+$/, "").toLowerCase(); + return `OpenCodexTray-${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`; +} + +export function windowsPowerShellPath(systemRoot = process.env.SystemRoot): string { + const candidate = join( + systemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + return existsSync(candidate) ? candidate : "powershell.exe"; +} + +function registryExe(): string { + const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "reg.exe"); + return existsSync(candidate) ? candidate : "reg.exe"; +} + +function runRegistry(args: string[]): string { + return execFileSync(registryExe(), args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }).trim(); +} + +function safePath(value: string): string { + if (/[\u0000-\u001f\"]/.test(value)) { + throw new Error("Windows tray paths cannot contain quotes or control characters."); + } + return value; +} + +export function windowsTrayProcessArgs(entry: WindowsTrayEntry, mode: "Run" | "Stop" = "Run", hostPid?: number): string[] { + const args = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-STA", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-File", safePath(entry.script), + "-BunPath", safePath(entry.bun), + "-CliPath", safePath(entry.cli), + "-CodexHome", safePath(entry.codexHome), + "-OpenCodexHome", safePath(entry.opencodexHome), + "-Mode", mode, + ]; + if (Number.isSafeInteger(hostPid) && (hostPid ?? 0) > 0) args.push("-HostPid", String(hostPid)); + return args; +} + +function quoteRunValue(value: string): string { + safePath(value); + return `\"${value}\"`; +} + +/** Command persisted under HKCU Run. Every value is an owned absolute package/home path. */ +export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string { + return [ + quoteRunValue(powershell), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-STA", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-File", quoteRunValue(entry.script), + "-BunPath", quoteRunValue(entry.bun), + "-CliPath", quoteRunValue(entry.cli), + "-CodexHome", quoteRunValue(entry.codexHome), + "-OpenCodexHome", quoteRunValue(entry.opencodexHome), + "-Mode", "Run", + ].join(" "); +} + +function readState(): WindowsTrayState | null { + try { + const state = JSON.parse(readFileSync(trayStatePath(), "utf8")) as Partial; + if (state.version !== TRAY_STATE_VERSION) return null; + for (const key of ["bun", "cli", "script", "codexHome", "opencodexHome", "runValue", "runCommand"] as const) { + if (typeof state[key] !== "string" || state[key].length === 0) return null; + } + if (state.launcherPath !== undefined && typeof state.launcherPath !== "string") return null; + const valid = state as WindowsTrayState; + for (const value of [valid.bun, valid.cli, valid.script, valid.codexHome, valid.opencodexHome]) safePath(value); + // State is advisory, not an authority for executable or deletion paths. In + // particular, never let a forged state file redirect PowerShell -File. + if (!windowsTrayStatePathsOwned(valid)) return null; + if (valid.runValue !== windowsTrayRunValue(valid.opencodexHome)) return null; + return valid; + } catch { + return null; + } +} + +function replaceOwnedFile(path: string, contents: string | Buffer): void { + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, contents, { mode: 0o600 }); + try { + try { chmodSync(temporary, 0o600); } catch { /* best-effort */ } + if (process.platform === "win32") { + const hardened = hardenSecretPath(temporary, { required: true }); + if (!hardened.ok) throw new Error("Windows tray ACL hardening did not complete; refusing to persist executable state."); + } + renameSync(temporary, path); + } finally { + try { if (existsSync(temporary)) unlinkSync(temporary); } catch { /* best-effort */ } + } +} + +function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void { + const path = trayStatePath(); + replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n"); +} + +export function parseWindowsTrayRunValue(output: string, runValue: string): string | null { + const escaped = runValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^\\s*${escaped}\\s+(\\S+)\\s*(.*)$`, "mi").exec(output); + if (!match || match[1] !== "REG_SZ" || !match[2]?.trim()) return FOREIGN_RUN_VALUE; + return match[2].trim(); +} + +export function windowsRegistryParentShowsRunKey(output: string): boolean { + const expected = RUN_KEY.toLowerCase(); + return output.split(/\r?\n/).some(line => line.trim().toLowerCase() + .replace(/^hkey_current_user\\/, "hkcu\\") === expected); +} + +export type WindowsRegistryRunner = (args: string[]) => string; +export type WindowsRegistryAsyncRunner = (args: string[]) => Promise; + +function registryExitCode(error: unknown): number | null { + const value = error as { status?: unknown; code?: unknown }; + const code = Number(value.status ?? value.code); + return Number.isFinite(code) ? code : null; +} + +function syncRegistryAbsenceIsProven(run: WindowsRegistryRunner): boolean { + try { + run(["query", RUN_KEY, "/reg:64"]); + return true; + } catch (runError) { + if (registryExitCode(runError) !== 1) return false; + try { + const parent = run(["query", RUN_PARENT_KEY, "/reg:64"]); + return !windowsRegistryParentShowsRunKey(parent); + } catch { + return false; + } + } +} + +export function readWindowsTrayRunValueWithRunner(runValue: string, run: WindowsRegistryRunner): string | null { + try { + const output = run(["query", RUN_KEY, "/v", runValue, "/reg:64"]); + return parseWindowsTrayRunValue(output, runValue); + } catch (error) { + if (registryExitCode(error) === 1) { + // reg.exe also uses exit 1 for access/query failures. Only treat the + // value as absent after proving Run is readable or does not exist under + // a readable CurrentVersion parent. + if (syncRegistryAbsenceIsProven(run)) return null; + } + throw new Error("Unable to verify the owned Windows tray registry value; refusing to change persistence."); + } +} + +function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): string | null { + return readWindowsTrayRunValueWithRunner(runValue, runRegistry); +} + +function runRegistryAsync(args: string[]): Promise { + return new Promise((resolvePromise, rejectPromise) => { + execFile(registryExe(), args, { + encoding: "utf8", + timeout: 2_000, + windowsHide: true, + maxBuffer: 64 * 1024, + }, (error, stdout) => { + if (error) rejectPromise(error); + else resolvePromise(stdout.trim()); + }); + }); +} + +async function asyncRegistryAbsenceIsProven(run: WindowsRegistryAsyncRunner): Promise { + try { + await run(["query", RUN_KEY, "/reg:64"]); + return true; + } catch (runError) { + if (registryExitCode(runError) !== 1) return false; + try { + const parent = await run(["query", RUN_PARENT_KEY, "/reg:64"]); + return !windowsRegistryParentShowsRunKey(parent); + } catch { + return false; + } + } +} + +export async function readWindowsTrayRunValueWithAsyncRunner( + runValue: string, + run: WindowsRegistryAsyncRunner, +): Promise { + try { + const output = await run(["query", RUN_KEY, "/v", runValue, "/reg:64"]); + return parseWindowsTrayRunValue(output, runValue); + } catch (error) { + if (registryExitCode(error) === 1 && await asyncRegistryAbsenceIsProven(run)) { + return null; + } + throw new Error("Unable to verify Windows tray registry status."); + } +} + +async function readOwnedRunValueAsync(runValue = windowsTrayRunValue(getConfigDir())): Promise { + return readWindowsTrayRunValueWithAsyncRunner(runValue, runRegistryAsync); +} + +function readHeartbeat(): { pid: number; hostPid?: number; timestamp: number } | null { + try { + const heartbeat = JSON.parse(readFileSync(trayHeartbeatPath(), "utf8").replace(/^\uFEFF/, "")) as { pid?: unknown; hostPid?: unknown; timestamp?: unknown }; + if (!Number.isSafeInteger(heartbeat.pid) || (heartbeat.pid as number) <= 0 || typeof heartbeat.timestamp !== "number") return null; + const hostPid = Number.isSafeInteger(heartbeat.hostPid) && (heartbeat.hostPid as number) > 0 ? heartbeat.hostPid as number : undefined; + return { pid: heartbeat.pid as number, hostPid, timestamp: heartbeat.timestamp }; + } catch { + return null; + } +} + +function processAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function heartbeatProcessAlive(heartbeat = readHeartbeat()): boolean { + return Boolean(heartbeat && processAlive(heartbeat.pid)); +} + +function heartbeatRunning(): boolean { + const heartbeat = readHeartbeat(); + return Boolean(heartbeat && Date.now() - heartbeat.timestamp <= 15_000 && heartbeatProcessAlive(heartbeat)); +} + +function waitForHeartbeat(expected: boolean, timeoutMs = 8_000): boolean { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (heartbeatRunning() === expected) return true; + Bun.sleepSync(100); + } + return heartbeatRunning() === expected; +} + +function waitForTrayExit(previous: ReturnType, timeoutMs = 15_000): boolean { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const powershellExited = !previous || !processAlive(previous.pid); + const hostExited = !previous?.hostPid || !processAlive(previous.hostPid); + if (!heartbeatProcessAlive() && powershellExited && hostExited) return true; + Bun.sleepSync(100); + } + const powershellExited = !previous || !processAlive(previous.pid); + const hostExited = !previous?.hostPid || !processAlive(previous.hostPid); + return !heartbeatProcessAlive() && powershellExited && hostExited; +} + +export function windowsTrayRegistrationIsStale(inputs: { + registered: boolean; + registrationOwned: boolean; + running: boolean; + heartbeatFresh: boolean; +}): boolean { + if (!inputs.registered && inputs.running) return true; + if (inputs.registered && !inputs.registrationOwned) return true; + return inputs.running && !inputs.heartbeatFresh; +} + +function trayStatusFrom(registered: string | null): WindowsTrayStatus { + const state = readState(); + const heartbeat = readHeartbeat(); + const running = heartbeatProcessAlive(heartbeat); + const registrationOwned = state !== null + && registered === state.runCommand + && [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path)); + const stale = windowsTrayRegistrationIsStale({ + registered: registered !== null, + registrationOwned, + running, + heartbeatFresh: Boolean(heartbeat && Date.now() - heartbeat.timestamp <= 15_000), + }); + const installed = registered !== null && state !== null && registered === state.runCommand && !stale; + const summary = registered === null + ? running ? "unregistered tray process is still running" : "not installed" + : stale + ? "startup registration is foreign, stale, or points to missing package files" + : running + ? "installed and running" + : "installed, not currently running"; + return { supported: true, installed, running, stale, summary }; +} + +export function getWindowsTrayStatus(): WindowsTrayStatus { + if (process.platform !== "win32") { + return { supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` }; + } + return trayStatusFrom(readOwnedRunValue()); +} + +export async function getWindowsTrayStatusAsync(): Promise { + if (process.platform !== "win32") { + return { supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` }; + } + const runValue = windowsTrayRunValue(getConfigDir()); + const registered = await readOwnedRunValueAsync(runValue); + return trayStatusFrom(registered); +} + +function assertWindows(): void { + if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`); +} + +function spawnTray(state: WindowsTrayEntry): void { + const child = spawn(state.bun, [state.cli, "__tray-host"], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"), + }, + }); + child.unref(); +} + +function parseTrayHostEntry(): WindowsTrayEntry { + const encoded = process.env.OCX_TRAY_ENTRY_B64; + if (!encoded) throw new Error("Missing tray host entry."); + const value = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Partial; + for (const key of ["bun", "cli", "script", "codexHome", "opencodexHome"] as const) { + if (typeof value[key] !== "string") throw new Error(`Invalid tray host field: ${key}`); + safePath(value[key]); + } + return value as WindowsTrayEntry; +} + +/** Detached Bun host keeps the attached WinForms PowerShell process alive. */ +export async function runWindowsTrayHost(): Promise { + assertWindows(); + const entry = parseTrayHostEntry(); + delete process.env.OCX_TRAY_ENTRY_B64; + const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), { + stdio: "ignore", + windowsHide: true, + env: process.env, + }); + await new Promise((resolvePromise, rejectPromise) => { + child.once("error", rejectPromise); + child.once("exit", code => { + if (code && code !== 0) rejectPromise(new Error(`Windows tray host exited with code ${code}.`)); + else resolvePromise(); + }); + }); +} + +function signalTrayStop(): ReturnType { + const previous = readHeartbeat(); + // The stop event name depends only on the current home. Never execute paths + // recovered from tray-state.json while attempting cleanup or repair. + execFileSync(windowsPowerShellPath(), windowsTrayProcessArgs(currentEntry(), "Stop"), { + stdio: "ignore", + windowsHide: true, + timeout: 15_000, + }); + return previous; +} + +export function installWindowsTray(startNow = true): WindowsTrayStatus { + assertWindows(); + const entry = currentEntry(); + const sourceScript = sourceTrayScriptPath(); + const iconPairs = sourceTrayIconPaths().map((source, index) => ({ source, installed: installedTrayIconPaths()[index] })); + for (const path of [entry.bun, entry.cli, sourceScript, ...iconPairs.map(pair => pair.source)]) { + if (!existsSync(path)) throw new Error(`Cannot install the tray because a required file is missing: ${path}`); + } + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + const runCommand = buildWindowsTrayRunCommand(entry); + const runValue = windowsTrayRunValue(entry.opencodexHome); + const existing = readOwnedRunValue(runValue); + const state = readState(); + if (existing && (!state || existing !== state.runCommand)) { + throw new Error(`Refusing to replace a foreign or unowned HKCU Run value named ${runValue}.`); + } + if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) { + throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`); + } + if (!state && iconPairs.some(pair => existsSync(pair.installed))) { + throw new Error("Refusing to overwrite unowned Windows tray icon assets."); + } + const wasRunning = heartbeatProcessAlive(); + if (wasRunning && !state) { + throw new Error("Refusing to replace an unowned running tray process. Exit it before installing."); + } + if (wasRunning && state) { + const previous = signalTrayStop(); + if (!waitForTrayExit(previous)) throw new Error("The old tray did not exit; refusing to replace its persistent script."); + } + + const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null; + const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null; + const previousIconBytes = new Map(iconPairs.map(pair => [ + pair.installed, + existsSync(pair.installed) ? readFileSync(pair.installed) : null, + ])); + const restorePreviousInstall = () => { + try { + if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes); + else if (existsSync(entry.script)) unlinkSync(entry.script); + } catch { /* rollback best-effort */ } + for (const [path, contents] of previousIconBytes) { + try { + if (contents) replaceOwnedFile(path, contents); + else if (existsSync(path)) unlinkSync(path); + } catch { /* rollback best-effort */ } + } + try { + if (previousStateBytes) replaceOwnedFile(trayStatePath(), previousStateBytes); + else if (existsSync(trayStatePath())) unlinkSync(trayStatePath()); + } catch { /* rollback best-effort */ } + try { + if (existing !== null) runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", existing, "/f", "/reg:64"]); + else runRegistry(["delete", RUN_KEY, "/v", runValue, "/f", "/reg:64"]); + } catch { /* rollback best-effort */ } + if (wasRunning && state && !heartbeatRunning()) { + try { + spawnTray(currentEntry()); + waitForHeartbeat(true); + } catch { /* retain the primary installation failure */ } + } + }; + + try { + const hardenedDir = hardenSecretDir(getConfigDir(), { required: true }); + if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence."); + replaceOwnedFile(entry.script, readFileSync(sourceScript)); + for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source)); + runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]); + writeState(entry, runValue, runCommand); + } catch (error) { + restorePreviousInstall(); + throw error; + } + if (startNow && !heartbeatRunning()) spawnTray(entry); + if (startNow && !waitForHeartbeat(true)) { + restorePreviousInstall(); + throw new Error("The tray startup registration was installed, but the tray process did not become healthy."); + } + if (state?.launcherPath && existsSync(state.launcherPath)) { + try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ } + } + return getWindowsTrayStatus(); +} + +export function startWindowsTray(): WindowsTrayStatus { + assertWindows(); + const state = readState(); + if (!state || readOwnedRunValue(state.runValue) !== state.runCommand) throw new Error("The tray is not installed. Install it first."); + // Persisted state proves registration ownership but never selects an + // executable. Resolve every launch path from the running installation. + if (!heartbeatRunning()) spawnTray(currentEntry()); + if (!waitForHeartbeat(true)) throw new Error("The tray process did not become healthy after launch."); + return getWindowsTrayStatus(); +} + +export function stopWindowsTray(): WindowsTrayStatus { + assertWindows(); + let previous = readHeartbeat(); + if (previous) { + previous = signalTrayStop(); + } + if (!waitForTrayExit(previous)) throw new Error("The tray did not exit after the stop signal. Its login registration was preserved."); + return getWindowsTrayStatus(); +} + +export function uninstallWindowsTray(): WindowsTrayStatus { + assertWindows(); + const state = readState(); + const existing = state ? readOwnedRunValue(state.runValue) : readOwnedRunValue(); + if (existing && (!state || existing !== state.runCommand)) { + throw new Error(`Refusing to remove a foreign or unowned HKCU Run value named ${state?.runValue ?? windowsTrayRunValue(getConfigDir())}.`); + } + let previous = readHeartbeat(); + if (previous) { + previous = signalTrayStop(); + } + if (!waitForTrayExit(previous)) throw new Error("The tray did not exit; refusing to remove its owned registration or state."); + if (existing) runRegistry(["delete", RUN_KEY, "/v", state?.runValue ?? windowsTrayRunValue(getConfigDir()), "/f", "/reg:64"]); + const ownedPaths = [trayStatePath(), trayHeartbeatPath(), ...(state?.launcherPath ? [state.launcherPath] : [])]; + if (state?.script && resolve(state.script) === resolve(installedTrayScriptPath())) ownedPaths.push(state.script); + if (state) ownedPaths.push(...installedTrayIconPaths()); + for (const path of ownedPaths) { + try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ } + } + return getWindowsTrayStatus(); +} + +/** Update hook: refresh trusted paths and relaunch only when the tray was already installed. */ +export function repairWindowsTrayIfInstalled(startNow = true): WindowsTrayStatus | null { + if (process.platform !== "win32" || !readState()) return null; + return installWindowsTray(startNow); +} + +export async function windowsTrayCommand(args: string[]): Promise { + const wantsJson = args.includes("--json"); + const startNow = !args.includes("--no-start"); + const values = args.filter(value => value !== "--json" && value !== "--no-start"); + const sub = values[0] ?? "status"; + if (args.includes("--no-start") && sub !== "install" || values.length > 1 || !["install", "start", "stop", "status", "uninstall", "remove"].includes(sub)) { + console.error("Usage: ocx tray [--json] [--no-start]"); + process.exitCode = 1; + return; + } + try { + const status = sub === "install" ? installWindowsTray(startNow) + : sub === "start" ? startWindowsTray() + : sub === "stop" ? stopWindowsTray() + : sub === "uninstall" || sub === "remove" ? uninstallWindowsTray() + : getWindowsTrayStatus(); + console.log(wantsJson ? JSON.stringify(status) : `Windows tray: ${status.summary}`); + } catch (error) { + if (wantsJson) console.log(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })); + else console.error(`Windows tray error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/src/update/index.ts b/src/update/index.ts index cee5cdfc7c5..7b108529f16 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; /** * A `codex-history-backup-*.json` surviving a stop means the native-history restore was @@ -170,6 +171,26 @@ export async function runUpdate(): Promise { const { isServiceInstalled } = await import("../service"); serviceWasInstalled = isServiceInstalled(); } catch { /* best-effort */ } + let trayWasInstalled = false; + let trayWasRunning = false; + if (process.platform === "win32") { + try { + const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); + const tray = getWindowsTrayStatus(); + const trayPlan = handoffWindowsTrayForUpdate(tray, { + stop: () => { + const stopped = stopWindowsTray(); + return { exitStatus: 0, running: stopped.running }; + }, + start: () => startWindowsTray(), + }); + trayWasInstalled = trayPlan.refreshAfterReplacement; + trayWasRunning = trayPlan.restoreOnFailure; + } catch (error) { + console.error(`⚠️ Could not stop the Windows tray; aborting before package replacement: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } // Capture listen target before stop clears runtime state (same contract as GUI update worker). // Prefer a live runtime record; a stale crashed leftover must not override config.port. @@ -200,6 +221,12 @@ export async function runUpdate(): Promise { }); if (stopStdio === "pipe") logSpawnOutput("", stop); if (stop.status !== 0 || readPid() || readRuntimePort()) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* preserve the proxy stop failure */ } + } console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); process.exit(1); } @@ -238,6 +265,16 @@ export async function runUpdate(): Promise { } catch (e) { console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`); } + if (trayWasInstalled) { + const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs]; + const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true }); + if (tray.status === 0) { + console.log("🔧 Refreshed Windows tray startup paths."); + } else { + console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'."); + if (trayWasRunning) spawnSync(process.execPath, [process.argv[1], "tray", "start"], { stdio: "ignore", windowsHide: true }); + } + } // The stop above unloaded any managed service; reinstall it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. @@ -286,6 +323,12 @@ export async function runUpdate(): Promise { console.log("Restart the proxy: ocx start"); } } else { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* keep the primary update failure */ } + } console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`); process.exit(1); } diff --git a/src/update/job.ts b/src/update/job.ts index 6c6789e4605..f81c52a907b 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -20,6 +20,7 @@ import { updateCommandStr, } from "./index"; import { isNewer } from "./notify"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; @@ -474,6 +475,8 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar port: runtimeTrusted ? rt.port : configPort, hostname: (runtimeTrusted ? rt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1", }; + let trayWasInstalled = false; + let trayWasRunning = false; if (!job) { job = { id: jobId, @@ -517,6 +520,28 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar command: cmd.display, }, integrityLine); + if (process.platform === "win32") { + try { + const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); + const tray = getWindowsTrayStatus(); + const trayPlan = handoffWindowsTrayForUpdate(tray, { + stop: () => { + const stopped = stopWindowsTray(); + return { exitStatus: 0, running: stopped.running }; + }, + start: () => startWindowsTray(), + }); + trayWasInstalled = trayPlan.refreshAfterReplacement; + trayWasRunning = trayPlan.restoreOnFailure; + } catch (error) { + updateJob(job, { + status: "failed", + error: `Could not stop the Windows tray; aborting before package replacement: ${error instanceof Error ? error.message : String(error)}`, + }); + return; + } + } + /* [Decision Log] - 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다. - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. @@ -524,6 +549,12 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar */ const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); if (result.status !== 0) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* retain the primary update failure */ } + } updateJob(job, { status: "failed", exitCode: result.status, @@ -533,6 +564,15 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar return; } + if (trayWasInstalled) { + const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs]; + const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000); + if (tray.status !== 0) { + updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'."); + if (trayWasRunning) runLoggedCommand(job, process.execPath, [process.argv[1], "tray", "start"], 15_000); + } + } + if (restart) { job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); await restartAfterUpdate(job, captured); @@ -543,6 +583,12 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version."); } catch (err) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* retain the primary worker failure */ } + } updateJob(job, { status: "failed", error: err instanceof Error ? err.message : String(err), diff --git a/src/update/tray-update-plan.d.mts b/src/update/tray-update-plan.d.mts new file mode 100644 index 00000000000..9e9d45d2560 --- /dev/null +++ b/src/update/tray-update-plan.d.mts @@ -0,0 +1,18 @@ +export interface WindowsTrayUpdatePlan { + installed: boolean; + running: boolean; + stopBeforeReplacement: boolean; + restoreOnFailure: boolean; + refreshAfterReplacement: boolean; + installArgs: string[]; +} + +export declare function planWindowsTrayUpdate(status: { installed?: boolean; running?: boolean }): WindowsTrayUpdatePlan; +export declare function windowsTrayStopConfirmed(exitStatus: number | null, stillRunning: boolean): boolean; +export declare function handoffWindowsTrayForUpdate( + status: { installed?: boolean; running?: boolean }, + io: { + stop: () => { exitStatus: number | null; running: boolean }; + start: () => unknown; + }, +): WindowsTrayUpdatePlan; diff --git a/src/update/tray-update-plan.mjs b/src/update/tray-update-plan.mjs new file mode 100644 index 00000000000..695a3467aa7 --- /dev/null +++ b/src/update/tray-update-plan.mjs @@ -0,0 +1,38 @@ +/** + * Shared, side-effect-free contract for preserving the Windows tray across all + * updater entry points (npm launcher, CLI updater, and GUI worker). + */ +export function planWindowsTrayUpdate(status) { + const installed = status?.installed === true; + const running = status?.running === true; + return { + installed, + running, + stopBeforeReplacement: running, + restoreOnFailure: running, + refreshAfterReplacement: installed, + installArgs: ["tray", "install", ...(!running ? ["--no-start"] : [])], + }; +} + +export function windowsTrayStopConfirmed(exitStatus, stillRunning) { + return exitStatus === 0 && !stillRunning; +} + +/** Stop before replacement and restore best-effort when the handoff cannot be confirmed. */ +export function handoffWindowsTrayForUpdate(status, io) { + const plan = planWindowsTrayUpdate(status); + if (!plan.stopBeforeReplacement) return plan; + try { + const stopped = io.stop(); + if (!windowsTrayStopConfirmed(stopped.exitStatus, stopped.running)) { + throw new Error("the tray still reports running after shutdown"); + } + return plan; + } catch (error) { + if (plan.restoreOnFailure) { + try { io.start(); } catch { /* preserve the handoff failure */ } + } + throw error; + } +} diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index d856b5667a5..de9d0eed9cc 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -13,6 +13,8 @@ starts the proxy when needed and opens `http://localhost:`. | Endpoint area | Responsibility | | --- | --- | | Config/settings | Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. | +| Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. | +| Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. 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. | | 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. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | @@ -44,6 +46,17 @@ The dashboard sidebar includes a stop button that calls `POST /api/stop`. The bu confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The endpoint restores native Codex config, stops any installed service to prevent respawn, and exits. +## Startup safety + +The dashboard sidebar exposes a **Startup safety** page. Its warning state is derived from active +Codex routing plus the actual service and launcher-shim installation state; the +`codexAutoStart` preference alone is never presented as proof of restart protection. The page shows +copyable repair commands (`ocx service install`, `ocx codex-shim install`, and `ocx restore`). On +Windows it can also install an owned, per-user system tray. The resident tray owns only its icon, +home-scoped singleton, and HKCU Run registration; fixed proxy actions delegate to the CLI so drain, +service conflict handling, native restore, and PID identity remain centralized. Tray presence never +makes `startup.status` protected. + ## UX boundary The dashboard is a local control surface, not a separate service. It should reflect the same config diff --git a/tests/autostart-health.test.ts b/tests/autostart-health.test.ts new file mode 100644 index 00000000000..f26ed10e99f --- /dev/null +++ b/tests/autostart-health.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; +import { deriveStartupHealth, startupHealthSummary } from "../src/codex/autostart-health"; +import { classifyCodexRouting, hasInjectedCodexRouting } from "../src/codex/inject"; +import { handleManagementAPI } from "../src/server/management-api"; +import { invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../src/server/startup-health-cache"; +import type { OcxConfig } from "../src/types"; + +const base = { + routingKind: "opencodex-local" as const, + autostartEnabled: true, + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + platform: "win32" as const, +}; + +describe("Codex startup health", () => { + test("flags injected routing without a persistent starter as restart-unsafe", () => { + const health = deriveStartupHealth(base); + expect(health).toMatchObject({ + status: "at-risk", + rebootSafe: false, + protection: "none", + recommendedCommand: "ocx service install", + }); + expect(startupHealthSummary(health)).toContain("AT RISK"); + expect(startupHealthSummary(health)).toContain("ocx service install"); + }); + + test("treats a background service as restart protection", () => { + const health = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(health).toMatchObject({ + status: "protected", + rebootSafe: true, + protection: "service", + recommendedCommand: null, + }); + }); + + test("never preserves a green local-routing claim when diagnostics are stale", () => { + const protectedHealth = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + expect(markStartupHealthDiagnosticStale(protectedHealth)).toMatchObject({ + status: "at-risk", + rebootSafe: false, + protection: "none", + diagnosticStale: true, + }); + }); + + test("classifies a healthy Windows shim as CLI-only rather than Desktop-safe", () => { + const windowsShim = deriveStartupHealth({ ...base, shimInstalled: true, shimHealthy: true }); + expect(windowsShim).toMatchObject({ protection: "shim", shimCoverage: "cli-only", status: "at-risk" }); + const unixShim = deriveStartupHealth({ ...base, platform: "linux", shimInstalled: true, shimHealthy: true }); + expect(unixShim).toMatchObject({ protection: "shim", shimCoverage: "cli-only", status: "at-risk" }); + expect(deriveStartupHealth({ ...base, shimInstalled: true, shimHealthy: false }).status).toBe("at-risk"); + expect(deriveStartupHealth({ ...base, autostartEnabled: false, shimInstalled: true, shimHealthy: true }).status).toBe("at-risk"); + }); + + test("native routing has no opencodex restart dependency", () => { + const health = deriveStartupHealth({ ...base, routingKind: "native" }); + expect(health).toMatchObject({ status: "native", rebootSafe: true, protection: "none" }); + }); + + test("recognizes marker-owned and legacy routing without claiming user overrides", () => { + expect(hasInjectedCodexRouting([ + '# Auto-injected by opencodex', + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "[features]", + ].join("\n"))).toBe(true); + expect(hasInjectedCodexRouting([ + 'model_provider = "opencodex"', + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + ].join("\n"))).toBe(true); + expect(hasInjectedCodexRouting('openai_base_url = "http://127.0.0.1:10100/v1"')).toBe(false); + expect(classifyCodexRouting('openai_base_url = "http://127.0.0.1:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('"openai_base_url" = "http://127.0.0.2:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('openai_base_url = "http://0.0.0.0:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('openai_base_url = "http://[::]:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('openai_base_url = "http://[::ffff:127.0.0.1]:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('openai_base_url = "http://[::ffff:127.1.2.3]:10100/v1"')).toBe("custom-local"); + expect(classifyCodexRouting('openai_base_url = "not-a-url"')).toBe("unknown"); + expect(classifyCodexRouting('openai_base_url = "https://gateway.example/v1"')).toBe("custom-remote"); + expect(classifyCodexRouting([ + '"model_provider" = "gateway"', + '[model_providers."gateway"]', + '"base_url" = "http://127.0.0.2:10100/v1"', + ].join("\n"))).toBe("custom-local"); + expect(classifyCodexRouting([ + 'model_provider = "gateway"', + '[model_providers.gateway]', + ].join("\n"))).toBe("unknown"); + expect(classifyCodexRouting('model_provider = "missing-custom"')).toBe("unknown"); + expect(classifyCodexRouting('model_provider = "openai"')).toBe("native"); + expect(classifyCodexRouting([ + "[features]", + 'model_provider = "opencodex"', + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + ].join("\n"))).toBe("native"); + expect(classifyCodexRouting([ + 'model_provider = "opencodex"', + "[model_providers.opencodex]", + 'base_url = "https://gateway.example/v1"', + ].join("\n"))).toBe("opencodex-local"); + expect(classifyCodexRouting([ + "# Auto-injected by opencodex", + 'openai_base_url = "http://192.168.1.10:10100/v1"', + ].join("\n"))).toBe("opencodex-local"); + }); + + test("fails closed for installed-but-broken services and custom local gateways", () => { + expect(deriveStartupHealth({ ...base, serviceInstalled: true, serviceStale: true })).toMatchObject({ + status: "at-risk", + rebootSafe: false, + serviceViable: false, + }); + expect(deriveStartupHealth({ ...base, routingKind: "custom-local" })).toMatchObject({ + status: "at-risk", + routingInjected: false, + localRoutingDependency: true, + protection: "none", + recommendedCommand: "ocx restore", + }); + expect(deriveStartupHealth({ ...base, routingKind: "custom-local", serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true })).toMatchObject({ + status: "at-risk", + rebootSafe: false, + protection: "none", + recommendedCommand: "ocx restore", + }); + expect(deriveStartupHealth({ ...base, routingKind: "custom-remote" })).toMatchObject({ + status: "native", + localRoutingDependency: false, + }); + expect(deriveStartupHealth({ ...base, routingKind: "unknown", serviceInstalled: true, serviceViable: true })).toMatchObject({ + status: "at-risk", + rebootSafe: false, + protection: "none", + recommendedCommand: "ocx restore", + }); + const custom = deriveStartupHealth({ ...base, routingKind: "custom-local" }); + expect(startupHealthSummary(custom)).toContain("run 'ocx restore'"); + expect(startupHealthSummary(custom)).not.toContain("ocx service install"); + }); + + test("exposes a secret-free startup health DTO to the dashboard", async () => { + invalidateStartupHealthCache(); + const url = new URL("http://localhost/api/startup-health"); + let timerFired = false; + const timer = setTimeout(() => { timerFired = true; }, 25); + const responsePromise = handleManagementAPI( + new Request(url), + url, + { port: 10100, providers: {}, defaultProvider: "openai", codexAutoStart: true } as OcxConfig, + ); + await Bun.sleep(75); + expect(timerFired).toBe(true); // service-manager probes run in a child, not the proxy event loop + clearTimeout(timer); + const response = await responsePromise; + expect(response?.status).toBe(200); + + const body = await response!.json() as Record; + expect(["native", "protected", "at-risk"]).toContain(body.status); + expect(typeof body.rebootSafe).toBe("boolean"); + expect(typeof body.routingInjected).toBe("boolean"); + expect(body.commands).toEqual({ + installService: "ocx service install", + installShim: "ocx codex-shim install", + restoreNative: "ocx restore", + }); + + const serialized = JSON.stringify(body).toLowerCase(); + for (const secretName of ["api_key", "apikey", "authorization", "access_token", "refresh_token"]) { + expect(serialized).not.toContain(secretName); + } + }); +}); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index dcdbfe22e45..4a4119a891a 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -45,6 +45,13 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); + test("tray help documents the install-only no-start flag", () => { + const result = runCli(["help", "tray"]); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("--no-start"); + }); + test("unknown command with help flag remains an error", () => { const result = runCli(["foobar", "--help"]); expect(result.status).toBe(1); diff --git a/tests/cli-status-json.test.ts b/tests/cli-status-json.test.ts index 42f60752334..9c8e7db52dd 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -53,6 +53,17 @@ describe("CLI status JSON", () => { paths?: { config?: unknown; pid?: unknown; runtime?: unknown }; runtime?: { source?: unknown }; codexAutostart?: unknown; + startup?: { + status?: unknown; + rebootSafe?: unknown; + routingInjected?: unknown; + serviceInstalled?: unknown; + shimInstalled?: unknown; + shimHealthy?: unknown; + shimCoverage?: unknown; + serviceSupported?: unknown; + commands?: unknown; + }; defaultProvider?: unknown; config?: { source?: unknown; error?: unknown }; service?: { summary?: unknown }; @@ -73,6 +84,15 @@ describe("CLI status JSON", () => { expect(typeof parsed.paths?.runtime).toBe("string"); expect(typeof parsed.runtime?.source).toBe("string"); expect(parsed.codexAutostart).toBe(false); + expect(["native", "protected", "at-risk"]).toContain(parsed.startup?.status); + expect(typeof parsed.startup?.rebootSafe).toBe("boolean"); + expect(typeof parsed.startup?.routingInjected).toBe("boolean"); + expect(typeof parsed.startup?.serviceInstalled).toBe("boolean"); + expect(typeof parsed.startup?.shimInstalled).toBe("boolean"); + expect(typeof parsed.startup?.shimHealthy).toBe("boolean"); + expect(["full", "cli-only", "none"]).toContain(parsed.startup?.shimCoverage); + expect(typeof parsed.startup?.serviceSupported).toBe("boolean"); + expect(typeof parsed.startup?.commands).toBe("object"); expect(parsed.defaultProvider).toBe("openai"); expect(parsed.config?.source).toBe("file"); expect(parsed.config?.error).toBeNull(); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index c711b16b37f..d7176f5a027 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; +import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; @@ -301,10 +301,12 @@ describe("Codex autostart shim", () => { const state = JSON.parse(readFileSync(join(home, "codex-shim.json"), "utf8")); expect(state.wrappers).toHaveLength(3); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: true }); const removed = uninstallCodexShim(); expect(removed.removed).toBe(true); + expect(diagnoseCodexShim()).toMatchObject({ installed: false, healthy: false }); expect(readFileSync(cmd, "utf8")).toBe(cmdOriginal); expect(readFileSync(ps1, "utf8")).toBe(ps1Original); expect(readFileSync(bare, "utf8")).toBe(bareOriginal); diff --git a/tests/internal-cli-dispatch.test.ts b/tests/internal-cli-dispatch.test.ts new file mode 100644 index 00000000000..5c64299ff87 --- /dev/null +++ b/tests/internal-cli-dispatch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { dispatchInternalCliCommand, type InternalCliCommand } from "../src/cli/internal-dispatch"; + +describe("internal CLI dispatcher", () => { + test.each([ + ["__tray-start", "trayStart"], + ["__tray-restart", "trayRestart"], + ["__startup-health", "startupHealth"], + ] as const)("routes %s to only %s", async (command, expected) => { + const calls: string[] = []; + await dispatchInternalCliCommand(command as InternalCliCommand, { + trayStart: async () => { calls.push("trayStart"); }, + trayRestart: async () => { calls.push("trayRestart"); }, + startupHealth: async () => { calls.push("startupHealth"); }, + }); + expect(calls).toEqual([expected]); + }); + + test("propagates an action failure to the CLI boundary", async () => { + await expect(dispatchInternalCliCommand("__tray-start", { + trayStart: async () => { throw new Error("start failed"); }, + trayRestart: () => {}, + startupHealth: () => {}, + })).rejects.toThrow("start failed"); + }); + + test("rejects an unknown runtime value without invoking a handler", async () => { + const calls: string[] = []; + await expect(dispatchInternalCliCommand("__invalid" as InternalCliCommand, { + trayStart: () => { calls.push("trayStart"); }, + trayRestart: () => { calls.push("trayRestart"); }, + startupHealth: () => { calls.push("startupHealth"); }, + })).rejects.toThrow("Unsupported internal CLI command"); + expect(calls).toEqual([]); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 50f06ea96b4..afc9f4e1eba 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, normalizeServiceSubcommand, resolveServiceListenPort, serviceLogPath, serviceStatusSummary } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, normalizeServiceSubcommand, parseServiceInstallState, resolveServiceListenPort, serviceLogPath, serviceStartableFromTray, serviceStatusSummary, windowsTaskRegistrationHealthy } from "../src/service"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; import type { OcxConfig } from "../src/types"; @@ -237,6 +237,21 @@ describe("Windows service task", () => { expect(xml).not.toContain("C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd"); }); + test("validates the registered scheduler action, trigger, principal, and settings", () => { + const wscript = "C:\\Windows\\System32\\wscript.exe"; + const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; + const xml = buildWindowsTaskXml("ignored.cmd", launcher).replace(/.*?<\/Command>/, `${wscript}`); + expect(windowsTaskRegistrationHealthy(xml, wscript, launcher)).toBe(true); + for (const mutated of [ + xml.replace("", ""), + xml.replace("InteractiveToken", "Password"), + xml.replace("LeastPrivilege", "HighestAvailable"), + xml.replace("IgnoreNew", "Parallel"), + xml.replace(wscript, "C:\\Windows\\System32\\cmd.exe"), + xml.replace(launcher, "C:\\Temp\\foreign.vbs"), + ]) expect(windowsTaskRegistrationHealthy(mutated, wscript, launcher)).toBe(false); + }); + test("hidden launcher VBS stays resident and escapes quotes in the wrapper path", () => { const vbs = buildWindowsLauncherVbs('C:\\Users\\quo"te\\.opencodex\\opencodex-service.cmd'); @@ -476,6 +491,57 @@ describe("service lifecycle cleanup ordering", () => { }); describe("service diagnostics", () => { + const base = { + schedulerInstalled: false, + schedulerEnabled: false, + schedulerAssetsHealthy: true, + nativeStatus: "nonexistent" as const, + recordedBackend: null, + staleBakedPaths: false, + nativeRepairAssetsOnly: false, + diagnostics: "logs: test", + }; + + test("fails closed for disabled, stale, conflicting, stopped, and ghost Windows services", () => { + expect(deriveWindowsServiceDiagnostic({ ...base, schedulerInstalled: true, schedulerEnabled: true, recordedBackend: "scheduler" })).toMatchObject({ viable: true, backend: "scheduler" }); + expect(deriveWindowsServiceDiagnostic({ ...base, schedulerInstalled: true })).toMatchObject({ viable: false, enabled: false }); + expect(deriveWindowsServiceDiagnostic({ ...base, schedulerInstalled: true, schedulerEnabled: true, staleBakedPaths: true })).toMatchObject({ viable: false, stale: true }); + expect(deriveWindowsServiceDiagnostic({ ...base, schedulerInstalled: true, schedulerEnabled: true, nativeStatus: "started" })).toMatchObject({ viable: false, conflict: true }); + expect(deriveWindowsServiceDiagnostic({ ...base, nativeStatus: "stopped" })).toMatchObject({ installed: true, viable: false, startable: false, stale: true, running: false }); + expect(deriveWindowsServiceDiagnostic({ ...base, nativeRepairAssetsOnly: true })).toMatchObject({ installed: false, viable: false, stale: true }); + }); + + test("a stopped healthy WinSW service remains startable from the tray", () => { + const stoppedNative = deriveWindowsServiceDiagnostic({ ...base, nativeStatus: "stopped", recordedBackend: "native" }); + expect(serviceStartableFromTray(stoppedNative)).toBe(true); + expect(serviceStartableFromTray({ ...stoppedNative, stale: true })).toBe(false); + expect(serviceStartableFromTray({ ...stoppedNative, conflict: true })).toBe(false); + expect(serviceStartableFromTray(deriveWindowsServiceDiagnostic({ ...base, nativeStatus: "unknown" }))).toBe(false); + const disabledScheduler = deriveWindowsServiceDiagnostic({ ...base, schedulerInstalled: true, schedulerEnabled: false }); + expect(serviceStartableFromTray(disabledScheduler)).toBe(false); + const mismatchedScheduler = deriveWindowsServiceDiagnostic({ + ...base, + schedulerInstalled: true, + schedulerEnabled: true, + recordedBackend: "native", + }); + expect(mismatchedScheduler).toMatchObject({ backend: "scheduler", stale: true, viable: false, startable: false }); + }); + + test("rejects malformed service backend state instead of defaulting it to scheduler", () => { + const valid = { + version: 2, + codexHome: "C:\\codex", + opencodexHome: "C:\\opencodex", + backend: "scheduler", + }; + expect(parseServiceInstallState(valid)?.backend).toBe("scheduler"); + expect(parseServiceInstallState({ ...valid, backend: "garbage" })).toBeNull(); + expect(parseServiceInstallState({ ...valid, backend: undefined })).toBeNull(); + expect(parseServiceInstallState({ ...valid, version: 1, backend: "scheduler" })).toBeNull(); + expect(parseServiceInstallState({ ...valid, version: 1, backend: undefined })?.version).toBe(1); + }); + test("status summary exposes the service log path", () => { const summary = serviceStatusSummary(); diff --git a/tests/startup-action-control.test.ts b/tests/startup-action-control.test.ts new file mode 100644 index 00000000000..5c36fd33190 --- /dev/null +++ b/tests/startup-action-control.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { startupInstallArgv, type StartupInstallAction } from "../src/server/startup-action-control"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; + +const config = { port: 10100, providers: {}, defaultProvider: "openai", codexAutoStart: true } as OcxConfig; + +describe("startup install actions", () => { + test("maps the allowlisted actions to fixed CLI argv", () => { + expect(startupInstallArgv("install-service")).toEqual(["service", "install"]); + expect(startupInstallArgv("install-shim")).toEqual(["codex-shim", "install"]); + }); + + test("management API dispatches an allowlisted install action", async () => { + const calls: StartupInstallAction[] = []; + const url = new URL("http://localhost/api/startup-action"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "install-service" }), + }), url, config, { + runStartupInstallAction: async action => { + calls.push(action); + return { message: "installed" }; + }, + }); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true, action: "install-service", message: "installed" }); + expect(calls).toEqual(["install-service"]); + }); + + test("rejects unknown actions before invoking the installer", async () => { + let called = false; + const url = new URL("http://localhost/api/startup-action"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "run-anything" }), + }), url, config, { + runStartupInstallAction: async () => { + called = true; + return { message: "unexpected" }; + }, + }); + expect(response?.status).toBe(400); + expect(called).toBe(false); + }); +}); diff --git a/tests/startup-health-ui.test.ts b/tests/startup-health-ui.test.ts new file mode 100644 index 00000000000..7d6d9398787 --- /dev/null +++ b/tests/startup-health-ui.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { settingsPollMayCommit, startupRiskDetailKey } from "../gui/src/startup-health-ui"; + +describe("startup health UI decisions", () => { + test("selects the shared risk-detail message", () => { + expect(startupRiskDetailKey({ routingKind: "custom-local", shimCoverage: "none" })) + .toBe("startup.riskDetailCustomLocal"); + expect(startupRiskDetailKey({ routingKind: "opencodex-local", shimCoverage: "cli-only" })) + .toBe("startup.riskDetailWindowsShim"); + expect(startupRiskDetailKey({ routingKind: "unknown", shimCoverage: "none" })) + .toBe("startup.riskDetail"); + }); + + test("rejects stale or mutation-racing settings polls", () => { + const started = { request: 4, mutation: 2 }; + expect(settingsPollMayCommit(started, { request: 4, mutation: 2, mutationInFlight: false })).toBe(true); + expect(settingsPollMayCommit(started, { request: 5, mutation: 2, mutationInFlight: false })).toBe(false); + expect(settingsPollMayCommit(started, { request: 4, mutation: 3, mutationInFlight: false })).toBe(false); + expect(settingsPollMayCommit(started, { request: 4, mutation: 2, mutationInFlight: true })).toBe(false); + }); +}); diff --git a/tests/tray-proxy.test.ts b/tests/tray-proxy.test.ts new file mode 100644 index 00000000000..1ecf0130d77 --- /dev/null +++ b/tests/tray-proxy.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { runTrayProxyRestart, runTrayProxyStart, type TrayProxyStartIo } from "../src/cli/tray-proxy"; + +function startIo(overrides: Partial = {}) { + const calls: string[] = []; + const io: TrayProxyStartIo = { + findLive: async () => null, + diagnoseService: () => ({ installed: false, startable: false, summary: "not installed" }), + startService: async () => { calls.push("service"); }, + startDirect: () => { calls.push("direct"); }, + waitForProxy: async () => ({ port: 10100 }), + info: message => { calls.push(`info:${message}`); }, + error: message => { calls.push(`error:${message}`); }, + ...overrides, + }; + return { io, calls }; +} + +describe("tray proxy coordinator", () => { + test("returns immediately when a proxy is already live", async () => { + const { io, calls } = startIo({ findLive: async () => ({ port: 20200 }) }); + expect(await runTrayProxyStart(io)).toBe(true); + expect(calls).toEqual(["info:Proxy already running on port 20200."]); + }); + + test("refuses an installed but unviable service instead of bypassing it", async () => { + const { io, calls } = startIo({ + diagnoseService: () => ({ installed: true, startable: false, summary: "stale" }), + }); + expect(await runTrayProxyStart(io)).toBe(false); + expect(calls.some(call => call.startsWith("error:Cannot start"))).toBe(true); + expect(calls).not.toContain("direct"); + expect(calls).not.toContain("service"); + }); + + test("uses a viable service and otherwise falls back to a direct start", async () => { + const service = startIo({ + diagnoseService: () => ({ installed: true, startable: true, summary: "healthy" }), + }); + expect(await runTrayProxyStart(service.io)).toBe(true); + expect(service.calls).toContain("service"); + expect(service.calls).not.toContain("direct"); + + const direct = startIo(); + expect(await runTrayProxyStart(direct.io)).toBe(true); + expect(direct.calls).toContain("direct"); + expect(direct.calls).not.toContain("service"); + }); + + test("fails when the selected start path never becomes healthy", async () => { + const { io, calls } = startIo({ waitForProxy: async () => null }); + expect(await runTrayProxyStart(io)).toBe(false); + expect(calls).toContain("direct"); + expect(calls.some(call => call.includes("did not become healthy"))).toBe(true); + }); + + test("propagates the selected start failure without trying an alternate path", async () => { + const service = startIo({ + diagnoseService: () => ({ installed: true, startable: true, summary: "healthy" }), + startService: async () => { service.calls.push("service"); throw new Error("service failed"); }, + }); + await expect(runTrayProxyStart(service.io)).rejects.toThrow("service failed"); + expect(service.calls).toContain("service"); + expect(service.calls).not.toContain("direct"); + + const direct = startIo({ + startDirect: () => { direct.calls.push("direct"); throw new Error("spawn failed"); }, + }); + await expect(runTrayProxyStart(direct.io)).rejects.toThrow("spawn failed"); + expect(direct.calls).toContain("direct"); + expect(direct.calls).not.toContain("service"); + }); + + test("restart never starts after a failed stop", async () => { + const calls: string[] = []; + expect(await runTrayProxyRestart({ + stop: async () => { calls.push("stop"); return false; }, + start: async () => { calls.push("start"); return true; }, + })).toBe(false); + expect(calls).toEqual(["stop"]); + + expect(await runTrayProxyRestart({ + stop: async () => true, + start: async () => { calls.push("start-after-stop"); return true; }, + })).toBe(true); + expect(calls).toContain("start-after-stop"); + }); +}); diff --git a/tests/update-tray-handoff.test.ts b/tests/update-tray-handoff.test.ts new file mode 100644 index 00000000000..7945ccd95a0 --- /dev/null +++ b/tests/update-tray-handoff.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate, windowsTrayStopConfirmed } from "../src/update/tray-update-plan.mjs"; + +describe("Windows tray update handoff contract", () => { + test("preserves an installed, running tray across replacement", () => { + expect(planWindowsTrayUpdate({ installed: true, running: true })).toEqual({ + installed: true, + running: true, + stopBeforeReplacement: true, + restoreOnFailure: true, + refreshAfterReplacement: true, + installArgs: ["tray", "install"], + }); + }); + + test("refreshes an installed, idle tray without starting it", () => { + expect(planWindowsTrayUpdate({ installed: true, running: false })).toMatchObject({ + stopBeforeReplacement: false, + restoreOnFailure: false, + refreshAfterReplacement: true, + installArgs: ["tray", "install", "--no-start"], + }); + }); + + test("does nothing for an absent tray and fails closed on incomplete stop", () => { + expect(planWindowsTrayUpdate({ installed: false, running: false })).toMatchObject({ + stopBeforeReplacement: false, + restoreOnFailure: false, + refreshAfterReplacement: false, + }); + expect(windowsTrayStopConfirmed(0, false)).toBe(true); + expect(windowsTrayStopConfirmed(1, false)).toBe(false); + expect(windowsTrayStopConfirmed(0, true)).toBe(false); + }); + + test("restores a previously running tray when stop throws or remains live", () => { + const calls: string[] = []; + expect(() => handoffWindowsTrayForUpdate({ installed: true, running: true }, { + stop: () => { calls.push("stop-throw"); throw new Error("registry status failed"); }, + start: () => { calls.push("restore-after-throw"); }, + })).toThrow("registry status failed"); + expect(calls).toEqual(["stop-throw", "restore-after-throw"]); + + expect(() => handoffWindowsTrayForUpdate({ installed: true, running: true }, { + stop: () => ({ exitStatus: 0, running: true }), + start: () => { calls.push("restore-still-live"); }, + })).toThrow("still reports running"); + expect(calls).toContain("restore-still-live"); + }); + + test("all three updater lanes consume the same tested plan", () => { + const root = join(import.meta.dir, ".."); + for (const path of ["src/update/index.ts", "src/update/job.ts", "bin/ocx.mjs"]) { + const source = readFileSync(join(root, path), "utf8"); + expect(source).toContain("planWindowsTrayUpdate"); + expect(source).toContain("handoffWindowsTrayForUpdate"); + expect(source).toContain("installArgs"); + } + }); +}); diff --git a/tests/windows-tray.test.ts b/tests/windows-tray.test.ts new file mode 100644 index 00000000000..29e24576135 --- /dev/null +++ b/tests/windows-tray.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + buildWindowsTrayRunCommand, + parseWindowsTrayRunValue, + readWindowsTrayRunValueWithAsyncRunner, + readWindowsTrayRunValueWithRunner, + windowsTrayProcessArgs, + windowsTrayRunValue, + windowsTrayStatePathsOwned, + windowsTrayRegistrationIsStale, + windowsRegistryParentShowsRunKey, + type WindowsTrayEntry, +} from "../src/tray/windows"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; + +const entry: WindowsTrayEntry = { + bun: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\bun.exe", + cli: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\src\\cli\\index.ts", + script: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\src\\tray\\windows-tray.ps1", + codexHome: "C:\\사용자 공간\\.codex", + opencodexHome: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\.opencodex", +}; + +describe("Windows tray packaging and command safety", () => { + test("uses fixed argv for the hidden PowerShell host", () => { + const args = windowsTrayProcessArgs(entry); + expect(args).toContain("-NoProfile"); + expect(args).toContain("-NonInteractive"); + expect(args).toContain("-STA"); + expect(args).toContain(entry.script); + expect(args).toContain(entry.bun); + expect(args).toContain(entry.cli); + expect(args).not.toContain("-Command"); + expect(windowsTrayProcessArgs(entry, "Run", 4242)).toContain("4242"); + }); + + test("quotes metacharacter and Unicode paths without shell interpolation", () => { + const powershellCommand = buildWindowsTrayRunCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(powershellCommand).toContain(`-File "${entry.script}"`); + expect(powershellCommand).toContain(`-OpenCodexHome "${entry.opencodexHome}"`); + expect(powershellCommand).not.toContain("cmd /c"); + expect(powershellCommand).not.toContain("-Command"); + }); + + test("rejects quote and control-character path injection", () => { + expect(() => windowsTrayProcessArgs({ ...entry, opencodexHome: 'C:\\bad" -Command whoami' })).toThrow(); + expect(() => windowsTrayProcessArgs({ ...entry, cli: "C:\\bad\r\nwhoami" })).toThrow(); + }); + + test("never trusts state-selected executable or deletion paths", () => { + const home = "C:\\Users\\Test\\.opencodex"; + expect(windowsTrayStatePathsOwned({ + opencodexHome: home, + script: join(home, "opencodex-tray.ps1"), + launcherPath: join(home, "opencodex-tray.vbs"), + }, home)).toBe(true); + expect(windowsTrayStatePathsOwned({ + opencodexHome: home, + script: "C:\\attacker\\payload.ps1", + }, home)).toBe(false); + expect(windowsTrayStatePathsOwned({ + opencodexHome: home, + script: join(home, "opencodex-tray.ps1"), + launcherPath: "C:\\victim\\document.txt", + }, home)).toBe(false); + }); + + test("treats a live unregistered tray as stale so uninstall cannot skip it", () => { + expect(windowsTrayRegistrationIsStale({ + registered: false, + registrationOwned: false, + running: true, + heartbeatFresh: true, + })).toBe(true); + expect(windowsTrayRegistrationIsStale({ + registered: false, + registrationOwned: false, + running: false, + heartbeatFresh: false, + })).toBe(false); + }); + + test("normalizes equivalent homes to one owned Run value", () => { + expect(windowsTrayRunValue("C:\\Users\\Test\\.opencodex")) + .toBe(windowsTrayRunValue("C:\\Users\\Test\\.opencodex\\.")); + }); + + test("treats an unexpected registry type or unreadable value as foreign", () => { + const value = "OpenCodexTray-test"; + const command = '"C:\\Windows\\powershell.exe" -File "C:\\tray.ps1"'; + expect(parseWindowsTrayRunValue(` ${value} REG_SZ ${command}`, value)).toBe(command); + expect(parseWindowsTrayRunValue(` ${value} REG_EXPAND_SZ ${command}`, value)).not.toBe(command); + expect(parseWindowsTrayRunValue("unexpected output", value)).not.toBeNull(); + }); + + test("distinguishes a missing Run key from an unreadable existing key", () => { + const parent = [ + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion", + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer", + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", + ].join("\r\n"); + expect(windowsRegistryParentShowsRunKey(parent)).toBe(true); + expect(windowsRegistryParentShowsRunKey(parent.replace(/\\Run\r?\n?$/, ""))).toBe(false); + }); + + test("fails closed when registry absence cannot be proven", async () => { + const value = "OpenCodexTray-test"; + const statusError = (status: number) => Object.assign(new Error(`reg exit ${status}`), { status }); + const codeError = (code: number) => Object.assign(new Error(`reg exit ${code}`), { code }); + + expect(readWindowsTrayRunValueWithRunner(value, args => { + if (args.includes("/v")) throw statusError(1); + if (args[1]?.endsWith("\\Run")) return "readable"; + throw new Error("unexpected query"); + })).toBeNull(); + expect(() => readWindowsTrayRunValueWithRunner(value, () => { throw statusError(5); })) + .toThrow("refusing to change persistence"); + expect(() => readWindowsTrayRunValueWithRunner(value, args => { + if (args.includes("/v")) throw statusError(1); + throw statusError(5); + })).toThrow("refusing to change persistence"); + + await expect(readWindowsTrayRunValueWithAsyncRunner(value, async args => { + if (args.includes("/v")) throw codeError(1); + if (args[1]?.endsWith("\\Run")) return "readable"; + throw new Error("unexpected query"); + })).resolves.toBeNull(); + await expect(readWindowsTrayRunValueWithAsyncRunner(value, async () => { throw codeError(5); })) + .rejects.toThrow("Unable to verify Windows tray registry status"); + await expect(readWindowsTrayRunValueWithAsyncRunner(value, async args => { + if (args.includes("/v")) throw codeError(1); + throw codeError(5); + })).rejects.toThrow("Unable to verify Windows tray registry status"); + }); + + test("proves a missing Run key only through the readable parent path", async () => { + const value = "OpenCodexTray-test"; + const syncCalls: string[][] = []; + const syncResult = readWindowsTrayRunValueWithRunner(value, args => { + syncCalls.push(args); + if (args.includes("/v") || args[1]?.endsWith("\\Run")) { + throw Object.assign(new Error("missing"), { status: 1 }); + } + return "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer"; + }); + expect(syncResult).toBeNull(); + expect(syncCalls).toEqual([ + ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", value, "/reg:64"], + ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/reg:64"], + ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion", "/reg:64"], + ]); + + const asyncCalls: string[][] = []; + const asyncResult = await readWindowsTrayRunValueWithAsyncRunner(value, async args => { + asyncCalls.push(args); + if (args.includes("/v") || args[1]?.endsWith("\\Run")) { + throw Object.assign(new Error("missing"), { code: 1 }); + } + return "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer"; + }); + expect(asyncResult).toBeNull(); + expect(asyncCalls).toEqual(syncCalls); + + expect(() => readWindowsTrayRunValueWithRunner(value, args => { + if (args.includes("/v") || args[1]?.endsWith("\\Run")) { + throw Object.assign(new Error("unreadable"), { status: 1 }); + } + return "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + })).toThrow("refusing to change persistence"); + + await expect(readWindowsTrayRunValueWithAsyncRunner(value, async args => { + if (args.includes("/v") || args[1]?.endsWith("\\Run")) { + throw Object.assign(new Error("unreadable"), { code: 1 }); + } + return "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; + })).rejects.toThrow("Unable to verify Windows tray registry status"); + + const parentFailureSync = (args: string[]): string => { + if (args.includes("/v") || args[1]?.endsWith("\\Run")) throw Object.assign(new Error("missing"), { status: 1 }); + throw Object.assign(new Error("parent unreadable"), { status: 5 }); + }; + expect(() => readWindowsTrayRunValueWithRunner(value, parentFailureSync)) + .toThrow("refusing to change persistence"); + await expect(readWindowsTrayRunValueWithAsyncRunner(value, async args => { + if (args.includes("/v") || args[1]?.endsWith("\\Run")) throw Object.assign(new Error("missing"), { code: 1 }); + throw Object.assign(new Error("parent unreadable"), { code: 5 }); + })).rejects.toThrow("Unable to verify Windows tray registry status"); + }); + + test("PowerShell controller uses mutex/event shutdown and bans command evaluation", () => { + const typescript = readFileSync(join(import.meta.dir, "..", "src", "tray", "windows.ts"), "utf8"); + const source = readFileSync(join(import.meta.dir, "..", "src", "tray", "windows-tray.ps1"), "utf8"); + expect(typescript).not.toContain("\u0000"); + expect(typescript).toContain("OCX_TRAY_ENTRY_B64"); + expect(typescript).toContain('detached: true'); + expect(source).toContain("System.Threading.Mutex"); + expect(source).toContain("System.Threading.EventWaitHandle"); + expect(source).toContain("GetFullPath"); + expect(source).toContain("GetPathRoot"); + expect(source).toContain("$heartbeat.hostPid = $HostPid"); + expect(source).toContain('Start-OcxCommand @("__tray-restart")'); + expect(source).toContain('Load-TrayIcon "opencodex-tray-online.ico"'); + expect(source).toContain('Load-TrayIcon "opencodex-tray-warning.ico"'); + expect(source).toContain('Load-TrayIcon "opencodex-tray-offline.ico"'); + expect(source).toContain("$notify.Icon = $offlineIcon"); + expect(source).not.toContain("$menu.add_Opening({ Update-TrayState })"); + expect(source).not.toContain("Invoke-Expression"); + expect(source).not.toContain("taskkill"); + expect(source).not.toContain("Stop-Process"); + }); + + test("ships branded multi-size Windows tray icons", () => { + const assets = join(import.meta.dir, "..", "src", "tray", "assets"); + for (const name of ["online", "warning", "offline"]) { + const path = join(assets, `opencodex-tray-${name}.ico`); + expect(existsSync(path)).toBe(true); + const bytes = readFileSync(path); + expect(bytes.readUInt16LE(0)).toBe(0); + expect(bytes.readUInt16LE(2)).toBe(1); + expect(bytes.readUInt16LE(4)).toBeGreaterThanOrEqual(7); + } + }); + + test("serves tray status without blocking the proxy event loop", async () => { + if (process.platform !== "win32") return; + const url = new URL("http://localhost/api/windows-tray"); + let timerFired = false; + const timer = setTimeout(() => { timerFired = true; }, 0); + const responsePromise = handleManagementAPI( + new Request(url), + url, + { port: 10100, providers: {}, defaultProvider: "openai" } as OcxConfig, + ); + await Bun.sleep(50); + expect(timerFired).toBe(true); + clearTimeout(timer); + const response = await responsePromise; + expect(response?.status).toBe(200); + const body = await response!.json() as Record; + expect(body.supported).toBe(true); + expect(typeof body.installed).toBe("boolean"); + expect(typeof body.running).toBe("boolean"); + }); + + test("copies the tray script into the hardened home and gates all update lanes", () => { + const root = join(import.meta.dir, ".."); + const tray = readFileSync(join(root, "src", "tray", "windows.ts"), "utf8"); + expect(tray).toContain('join(getConfigDir(), "opencodex-tray.ps1")'); + expect(tray).toContain('join(import.meta.dir, "assets", name)'); + expect(tray).toContain("installedTrayIconPaths()"); + expect(tray).toContain("const hardened = hardenSecretPath(temporary, { required: true })"); + expect(tray).toContain("if (!hardened.ok)"); + expect(tray).toContain("if (!hardenedDir.ok)"); + expect(tray).toContain("refusing to replace its persistent script"); + expect(tray).toContain("restorePreviousInstall"); + expect(tray).toContain("previousStateBytes"); + expect(tray).toContain("previousScriptBytes"); + expect(tray).toContain('windowsTrayProcessArgs(currentEntry(), "Stop")'); + expect(tray).not.toContain("spawnTray(state)"); + expect(tray).toContain("return readWindowsTrayRunValueWithRunner(runValue, runRegistry)"); + expect(tray).toContain("return readWindowsTrayRunValueWithAsyncRunner(runValue, runRegistryAsync)"); + + const updateSources = [ + join(root, "src", "update", "index.ts"), + join(root, "src", "update", "job.ts"), + join(root, "bin", "ocx.mjs"), + ].map(path => readFileSync(path, "utf8")); + for (const source of updateSources) { + expect(source).toContain("tray"); + expect(source).toContain("stop"); + expect(source).toContain("aborting before package replacement"); + } + }); +});