diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx index 57e315abe..271447e86 100644 --- a/gui/src/components/AccountPoolStrategyControls.tsx +++ b/gui/src/components/AccountPoolStrategyControls.tsx @@ -3,6 +3,7 @@ import { ACCOUNT_POOL_STRATEGIES, type AccountPoolStrategy, } from "../account-pool-strategy"; +import { NumberStepper } from "./NumberStepper"; const STRATEGY_LABEL_KEYS = { quota: "accountPool.strategyQuota", @@ -16,11 +17,19 @@ export interface AccountPoolStrategyControlsProps { disabled?: boolean; strategySelectId?: string; stickyInputId?: string; + /** When true, omit the outer strategy label (parent card already titled). */ + hideStrategyLabel?: boolean; onStrategyChange(strategy: AccountPoolStrategy): void; onStickyDraftChange(value: string): void; onStickyCommit(): void; } +function clampStickyDraft(raw: string, delta: number): string { + const parsed = Number.parseInt(raw, 10); + const base = Number.isFinite(parsed) ? parsed : 1; + return String(Math.min(100, Math.max(1, base + delta))); +} + /** * Shared strategy select + round-robin sticky limit for Codex / Anthropic account pools. */ @@ -30,15 +39,16 @@ export default function AccountPoolStrategyControls({ disabled = false, strategySelectId = "account-pool-strategy", stickyInputId = "account-pool-sticky-limit", + hideStrategyLabel = false, onStrategyChange, onStickyDraftChange, onStickyCommit, }: AccountPoolStrategyControlsProps) { const t = useT(); return ( -
- )} diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index a540b3ebc..df9842440 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -101,9 +101,9 @@ export default function CodexPoolStrategySetting({ apiBase }: { apiBase: string const loading = !ready && !loadError; return ( -
+
{t("accountPool.strategy")} -
+
{loadError ? t("accountPool.strategyLoadFailed") : loading @@ -120,6 +120,7 @@ export default function CodexPoolStrategySetting({ apiBase }: { apiBase: string strategy={strategy} stickyDraft={stickyDraft} disabled={saving} + hideStrategyLabel strategySelectId="codex-pool-strategy" stickyInputId="codex-pool-sticky-limit" onStrategyChange={(next) => { diff --git a/gui/src/components/NumberStepper.tsx b/gui/src/components/NumberStepper.tsx new file mode 100644 index 000000000..1b9f85528 --- /dev/null +++ b/gui/src/components/NumberStepper.tsx @@ -0,0 +1,43 @@ +import { IconChevron } from "../icons"; + +export interface NumberStepperProps { + disabled?: boolean; + /** Increase the bound value (parent owns parsing / clamping). */ + onIncrement(): void; + /** Decrease the bound value. */ + onDecrement(): void; + incrementLabel: string; + decrementLabel: string; +} + +/** Compact up/down chevron pair matching dashboard control chrome. */ +export function NumberStepper({ + disabled = false, + onIncrement, + onDecrement, + incrementLabel, + decrementLabel, +}: NumberStepperProps) { + return ( +
+ + +
+ ); +} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da..19fdf1142 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -102,20 +102,27 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + refreshFeedback, onRefresh, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + refreshFeedback?: string | null; onRefresh: () => void; }) { if (embedded) return null; return ( -
+

{t("nav.codexAuth")}

- +
+ + {refreshFeedback ?? ""} + + +
); } @@ -131,17 +138,25 @@ export function CodexAccountPoolLoadStates({ accountsCount: number; onRetry: () => void; }): ReactNode { - return ( - <> - {loadState === "loading" && accountsCount === 0 && ( -
{t("pws.accountsLoading")}
- )} - {loadState === "error" && ( -
- {t("codexAuth.loadFailed")} - -
- )} - - ); + // Initial load with no accounts yet: keep a reserved skeleton strip (no top-of-page + // banner that pushes the rest of the page down). Refresh-with-data stays silent here — + // the cards keep showing last-good rows while the header button shows busy state. + if (loadState === "loading" && accountsCount === 0) { + return ( +
+
+
+ {t("pws.accountsLoading")} +
+ ); + } + if (loadState === "error") { + return ( +
+ {t("codexAuth.loadFailed")} + +
+ ); + } + return null; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index e71ce13f3..aba6af1dd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -30,6 +30,7 @@ export const de: Record = { "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.backToDashboard": "Zurück zum Dashboard", "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.", @@ -73,8 +74,12 @@ export const de: Record = { "startup.installedDisabled": "Installiert, aber deaktiviert", "startup.install": "Installieren", "startup.installing": "Wird installiert…", + "startup.repair": "Reparieren", + "startup.repairing": "Wird repariert…", "startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.", + "startup.serviceRepaired": "Hintergrunddienst wurde erfolgreich repariert.", "startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.", + "startup.shimRepaired": "Codex-Launcher-Shim wurde erfolgreich repariert.", "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.", @@ -671,6 +676,8 @@ export const de: Record = { "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", "codexAuth.autoSwitchThreshold": "Wechselschwelle", "codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent", + "codexAuth.autoSwitchThresholdInc": "Wechsel-Schwelle erhöhen", + "codexAuth.autoSwitchThresholdDec": "Wechsel-Schwelle verringern", "codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.", "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert", @@ -697,6 +704,8 @@ export const de: Record = { "accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.", "accountPool.stickyLimit": "Sticky-Erfolge vor Rotation", "accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation", + "accountPool.stickyLimitInc": "Sticky-Limit erhöhen", + "accountPool.stickyLimitDec": "Sticky-Limit verringern", "accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.", "accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.", @@ -808,6 +817,7 @@ export const de: Record = { "api.generate": "Generieren", "api.generating": "Erstelle…", "api.activeKeys": "Aktive Schlüssel ({count})", + "api.activeKeysLoading": "Aktive Schlüssel", "api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.", "api.colName": "Name", "api.colKey": "Schlüssel", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e896d89cf..364dc115d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -37,6 +37,7 @@ export const en = { "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.backToDashboard": "Back to Dashboard", "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.", @@ -80,8 +81,12 @@ export const en = { "startup.installedDisabled": "Installed but disabled", "startup.install": "Install", "startup.installing": "Installing…", + "startup.repair": "Repair", + "startup.repairing": "Repairing…", "startup.serviceInstalled": "Background service installed successfully.", + "startup.serviceRepaired": "Background service repaired successfully.", "startup.shimInstalled": "Codex launcher shim installed successfully.", + "startup.shimRepaired": "Codex launcher shim repaired 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.", @@ -1085,6 +1090,8 @@ export const en = { "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", "codexAuth.autoSwitchThreshold": "Switch threshold", "codexAuth.autoSwitchThresholdAria": "Switch threshold, percent", + "codexAuth.autoSwitchThresholdInc": "Increase switch threshold", + "codexAuth.autoSwitchThresholdDec": "Decrease switch threshold", "codexAuth.autoSwitchLoadFailed": "Automatic switching setting could not be loaded.", "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", "codexAuth.autoSwitchUpdated": "Automatic account switching updated", @@ -1112,6 +1119,8 @@ export const en = { "accountPool.strategyHint": "Applies to new sessions only; existing threads keep account affinity.", "accountPool.stickyLimit": "Sticky successes before rotate", "accountPool.stickyLimitAria": "Sticky successes before rotate", + "accountPool.stickyLimitInc": "Increase sticky limit", + "accountPool.stickyLimitDec": "Decrease sticky limit", "accountPool.stickyLimitHelp": "Keep the selected account for this many successful new-session binds before advancing.", "accountPool.stickyLimitInvalid": "Enter a whole number from 1 to 100", "accountPool.strategyLoadFailed": "Rotation strategy could not be loaded.", @@ -1204,6 +1213,7 @@ export const en = { "api.generate": "Generate", "api.generating": "Creating…", "api.activeKeys": "Active keys ({count})", + "api.activeKeysLoading": "Active keys", "api.noKeys": "No API keys yet. Generate one above.", "api.colName": "Name", "api.colKey": "Key", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1d6b12d17..c23d84c17 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -37,6 +37,7 @@ export const ja: Record = { "startup.title": "起動安全性", "startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が opencodex へ到達できるか確認します。", "startup.refresh": "更新", + "startup.backToDashboard": "ダッシュボードに戻る", "startup.loading": "起動保護を確認中…", "startup.error": "起動保護を読み取れませんでした。", "startup.staleData": "最新の確認に失敗しました。以下は古い値であり、保護の証明にはなりません。", @@ -80,8 +81,12 @@ export const ja: Record = { "startup.installedDisabled": "インストール済み・無効", "startup.install": "インストール", "startup.installing": "インストール中…", + "startup.repair": "修復", + "startup.repairing": "修復中…", "startup.serviceInstalled": "バックグラウンドサービスをインストールしました。", + "startup.serviceRepaired": "バックグラウンドサービスを修復しました。", "startup.shimInstalled": "Codex ランチャー shim をインストールしました。", + "startup.shimRepaired": "Codex ランチャー shim を修復しました。", "startup.installFailed": "インストールに失敗しました:", "startup.tray.title": "Windows システムトレイ", "startup.tray.hint": "ログイン時にトレイを起動し、プロキシの開始・停止・再起動・ダッシュボード・状態をクリックで操作します。", @@ -1042,6 +1047,8 @@ export const ja: Record = { "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", "codexAuth.autoSwitchThreshold": "切り替えしきい値", "codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)", + "codexAuth.autoSwitchThresholdInc": "切替しきい値を上げる", + "codexAuth.autoSwitchThresholdDec": "切替しきい値を下げる", "codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。", "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", @@ -1068,6 +1075,8 @@ export const ja: Record = { "accountPool.strategyHint": "新規セッションにのみ適用されます。既存スレッドはアカウント親和性を維持します。", "accountPool.stickyLimit": "ローテーション前の固定成功数", "accountPool.stickyLimitAria": "ローテーション前の固定成功数", + "accountPool.stickyLimitInc": "スティッキー上限を上げる", + "accountPool.stickyLimitDec": "スティッキー上限を下げる", "accountPool.stickyLimitHelp": "次へ進む前に、選んだアカウントをこの回数の成功した新規セッション紐付け分保持します。", "accountPool.stickyLimitInvalid": "1 から 100 までの整数を入力してください", "accountPool.strategyLoadFailed": "ローテーション戦略を読み込めませんでした。", @@ -1184,6 +1193,7 @@ export const ja: Record = { "api.generate": "生成", "api.generating": "作成中…", "api.activeKeys": "アクティブなキー ({count})", + "api.activeKeysLoading": "有効なキー", "api.noKeys": "まだ API キーがありません。上で生成してください。", "api.colName": "名前", "api.colKey": "キー", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7b1841a66..ce94d7dea 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -32,6 +32,7 @@ export const ko: Record = { "startup.title": "시작 안전성", "startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다.", "startup.refresh": "새로고침", + "startup.backToDashboard": "대시보드로 돌아가기", "startup.loading": "시작 보호 상태 확인 중…", "startup.error": "시작 보호 상태를 읽지 못했습니다.", "startup.staleData": "최신 시작 상태 확인에 실패했습니다. 아래 값은 이전 결과이며 보호 증거로 사용하면 안 됩니다.", @@ -75,8 +76,12 @@ export const ko: Record = { "startup.installedDisabled": "설치됐지만 꺼짐", "startup.install": "설치하기", "startup.installing": "설치 중…", + "startup.repair": "복구", + "startup.repairing": "복구 중…", "startup.serviceInstalled": "백그라운드 서비스를 설치했습니다.", + "startup.serviceRepaired": "백그라운드 서비스를 복구했습니다.", "startup.shimInstalled": "Codex launcher shim을 설치했습니다.", + "startup.shimRepaired": "Codex launcher shim을 복구했습니다.", "startup.installFailed": "설치하지 못했습니다:", "startup.tray.title": "Windows 시스템 트레이", "startup.tray.hint": "로그인할 때 트레이 아이콘을 띄우고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다.", @@ -688,6 +693,8 @@ export const ko: Record = { "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", "codexAuth.autoSwitchThreshold": "전환 임계값", "codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)", + "codexAuth.autoSwitchThresholdInc": "전환 임계값 증가", + "codexAuth.autoSwitchThresholdDec": "전환 임계값 감소", "codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.", "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", @@ -714,6 +721,8 @@ export const ko: Record = { "accountPool.strategyHint": "새 세션에만 적용됩니다. 기존 스레드는 계정 어피니티를 유지합니다.", "accountPool.stickyLimit": "회전 전 sticky 성공 횟수", "accountPool.stickyLimitAria": "회전 전 sticky 성공 횟수", + "accountPool.stickyLimitInc": "스티키 한도 증가", + "accountPool.stickyLimitDec": "스티키 한도 감소", "accountPool.stickyLimitHelp": "다음으로 진행하기 전에 선택된 계정을 이 횟수의 성공한 새 세션 바인딩 동안 유지합니다.", "accountPool.stickyLimitInvalid": "1에서 100 사이의 정수를 입력하세요", "accountPool.strategyLoadFailed": "로테이션 전략을 불러오지 못했습니다.", @@ -828,6 +837,7 @@ export const ko: Record = { "api.generate": "생성", "api.generating": "생성 중…", "api.activeKeys": "활성 키 ({count})", + "api.activeKeysLoading": "활성 키", "api.noKeys": "아직 API 키가 없습니다. 위에서 하나 생성하세요.", "api.colName": "이름", "api.colKey": "키", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 19af35380..626f9af20 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -37,6 +37,7 @@ export const ru: Record = { "startup.title": "Безопасность запуска", "startup.subtitle": "Проверьте, сможет ли Codex подключиться к opencodex после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.", "startup.refresh": "Обновить", + "startup.backToDashboard": "Назад к панели", "startup.loading": "Проверка защиты запуска…", "startup.error": "Не удалось прочитать состояние защиты запуска.", "startup.staleData": "Последняя проверка не удалась. Значения ниже устарели и не подтверждают защиту.", @@ -80,8 +81,12 @@ export const ru: Record = { "startup.installedDisabled": "Установлен, но отключён", "startup.install": "Установить", "startup.installing": "Установка…", + "startup.repair": "Исправить", + "startup.repairing": "Исправление…", "startup.serviceInstalled": "Фоновая служба успешно установлена.", + "startup.serviceRepaired": "Фоновая служба успешно исправлена.", "startup.shimInstalled": "Launcher shim Codex успешно установлен.", + "startup.shimRepaired": "Launcher shim Codex успешно исправлен.", "startup.installFailed": "Не удалось установить:", "startup.tray.title": "Системный трей Windows", "startup.tray.hint": "Запускает значок при входе для управления запуском, остановкой, перезапуском, панелью и состоянием прокси.", @@ -1084,6 +1089,8 @@ export const ru: Record = { "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", "codexAuth.autoSwitchThreshold": "Порог переключения", "codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах", + "codexAuth.autoSwitchThresholdInc": "Увеличить порог переключения", + "codexAuth.autoSwitchThresholdDec": "Уменьшить порог переключения", "codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.", "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", @@ -1110,6 +1117,8 @@ export const ru: Record = { "accountPool.strategyHint": "Применяется только к новым сессиям; существующие треды сохраняют привязку к аккаунту.", "accountPool.stickyLimit": "Успешных запросов до ротации", "accountPool.stickyLimitAria": "Успешных запросов до ротации", + "accountPool.stickyLimitInc": "Увеличить sticky-лимит", + "accountPool.stickyLimitDec": "Уменьшить sticky-лимит", "accountPool.stickyLimitHelp": "Удерживать выбранный аккаунт на указанное число успешных привязок новых сессий, прежде чем перейти дальше.", "accountPool.stickyLimitInvalid": "Введите целое число от 1 до 100", "accountPool.strategyLoadFailed": "Не удалось загрузить стратегию ротации.", @@ -1226,6 +1235,7 @@ export const ru: Record = { "api.generate": "Сгенерировать", "api.generating": "Создание…", "api.activeKeys": "Активные ключи ({count})", + "api.activeKeysLoading": "Активные ключи", "api.noKeys": "API-ключей пока нет. Сгенерируйте ключ выше.", "api.colName": "Имя", "api.colKey": "Ключ", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a341bd735..92649bdcf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -32,6 +32,7 @@ export const zh: Record = { "startup.title": "启动安全", "startup.subtitle": "检查重启后 Codex 是否仍能连接 opencodex,避免本地代理路由陷入重复重连。", "startup.refresh": "刷新", + "startup.backToDashboard": "返回仪表盘", "startup.loading": "正在检查启动保护…", "startup.error": "无法读取启动保护状态。", "startup.staleData": "最新启动检查失败。以下数据已过期,不能视为已受保护的证明。", @@ -75,8 +76,12 @@ export const zh: Record = { "startup.installedDisabled": "已安装但禁用", "startup.install": "安装", "startup.installing": "正在安装…", + "startup.repair": "修复", + "startup.repairing": "正在修复…", "startup.serviceInstalled": "后台服务安装成功。", + "startup.serviceRepaired": "后台服务修复成功。", "startup.shimInstalled": "Codex 启动器 shim 安装成功。", + "startup.shimRepaired": "Codex 启动器 shim 修复成功。", "startup.installFailed": "安装失败:", "startup.tray.title": "Windows 系统托盘", "startup.tray.hint": "登录时启动托盘图标,一键控制代理启动、停止、重启、面板和状态。", @@ -688,6 +693,8 @@ export const zh: Record = { "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", "codexAuth.autoSwitchThreshold": "切换阈值", "codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)", + "codexAuth.autoSwitchThresholdInc": "提高切换阈值", + "codexAuth.autoSwitchThresholdDec": "降低切换阈值", "codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。", "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", @@ -714,6 +721,8 @@ export const zh: Record = { "accountPool.strategyHint": "仅适用于新会话;现有线程保持账号亲和性。", "accountPool.stickyLimit": "轮换前的粘性成功次数", "accountPool.stickyLimitAria": "轮换前的粘性成功次数", + "accountPool.stickyLimitInc": "提高粘性上限", + "accountPool.stickyLimitDec": "降低粘性上限", "accountPool.stickyLimitHelp": "在推进到下一个账号之前,将所选账号保留这么多次成功的新会话绑定。", "accountPool.stickyLimitInvalid": "请输入 1 到 100 之间的整数", "accountPool.strategyLoadFailed": "无法加载轮换策略。", @@ -828,6 +837,7 @@ export const zh: Record = { "api.generate": "生成", "api.generating": "创建中…", "api.activeKeys": "活跃密钥({count})", + "api.activeKeysLoading": "有效密钥", "api.noKeys": "还没有 API 密钥。请在上方生成一个。", "api.colName": "名称", "api.colKey": "密钥", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 87f0704d7..baae66241 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -43,6 +43,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [keys, setKeys] = useState([]); const [endpoints, setEndpoints] = useState(DEFAULT_ENDPOINTS); const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(true); + const [keysLoading, setKeysLoading] = useState(true); const [keysLoadFailed, setKeysLoadFailed] = useState(false); const [actionError, setActionError] = useState(null); const [models, setModels] = useState([]); @@ -80,6 +81,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { setKeysLoadFailed(false); } catch { setKeysLoadFailed(true); + } finally { + setKeysLoading(false); } }, [apiBase]); @@ -275,6 +278,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { { + const next = nextCollapsed ? new Set(GROUPS.map((group) => group.id)) : new Set(); + GROUP_COLLAPSE.write(next); + setCollapsed(next); + }; + const toggleModel = (id: string, currentlyExcluded: boolean) => { setExcluded(current => { const next = new Set(current); @@ -219,6 +225,14 @@ export default function Grok({ apiBase }: { apiBase: string }) { {status && status.candidates.length > 0 && (
+
+ + +
{GROUPS.map(group => { const view = grokGroupView(status.candidates, aliasById, excluded, group.id); if (view.total === 0) return null; diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index ccd3f7922..a8915901c 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconRefresh } from "../icons"; -import { useI18n } from "../i18n/shared"; +import { type TFn, useI18n } from "../i18n/shared"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { EmptyState } from "../ui"; import { StartupDetailsSection, @@ -15,110 +16,156 @@ import { type TrayStatusData, } from "./startup-shared"; +type CodexRuntimeSettings = { + version?: string | null; + newerAvailable?: { path?: string; version?: string | null } | null; + catalogClamp?: { active?: boolean; removedEfforts?: string[]; runtimeVersion?: string | null }; +}; + +type StartupPageCache = { + data: StartupHealthData; + warning: string | null; + fix: string | null; + tray: TrayStatusData | null; +}; + +const STARTUP_PAGE_CACHE_PREFIX = "ocx.startup.page.v1:"; + +function shellChain(commands: string[], platform: string | undefined): string { + // Windows PowerShell 5.x rejects bash `&&`; `;` works in PowerShell and cmd. + const sep = platform === "win32" ? "; " : " && "; + return commands.join(sep); +} + +function deriveCodexRuntimeNotice( + runtime: CodexRuntimeSettings | undefined, + t: TFn, + platform?: string, +): { warning: string | null; fix: string | null } { + if (!runtime) return { warning: null, fix: null }; + const clampActive = Boolean(runtime.catalogClamp?.active); + const newer = Boolean(runtime.newerAvailable); + const version = (clampActive + ? runtime.catalogClamp?.runtimeVersion + : runtime.version) ?? runtime.version ?? "unknown"; + const efforts = (runtime.catalogClamp?.removedEfforts ?? []).join(", "); + const doctorSync = shellChain(["ocx doctor --fix-codex-runtime", "ocx sync"], platform); + if (clampActive) { + return { + warning: efforts + ? t("startup.codexRuntime.clampHiddenWithEfforts", { version, efforts }) + : t("startup.codexRuntime.clampHidden", { version }), + fix: newer ? doctorSync : "ocx sync", + }; + } + if (newer) { + return { + warning: t("startup.codexRuntime.olderBinary", { version }), + fix: doctorSync, + }; + } + return { warning: null, fix: null }; +} + 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 cacheKey = `${STARTUP_PAGE_CACHE_PREFIX}${apiBase}`; + const cached = useMemo(() => readSessionListCache(cacheKey), [cacheKey]); + + const [data, setData] = useState(() => cached?.data ?? null); + const [loading, setLoading] = useState(() => !cached?.data); + const [failed, setFailed] = useState(() => Boolean(cached?.data?.diagnosticStale)); const [copied, setCopied] = useState(null); - const [tray, setTray] = useState(null); - const [trayLoading, setTrayLoading] = useState(true); + const [tray, setTray] = useState(() => cached?.tray ?? null); + const [trayLoading, setTrayLoading] = useState(() => !cached?.data); 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 [codexRuntimeWarning, setCodexRuntimeWarning] = useState(null); - const [codexRuntimeFix, setCodexRuntimeFix] = useState(null); + const [installResult, setInstallResult] = useState<{ kind: "success" | "error"; action: StartupInstallAction; repair?: boolean; detail?: string } | null>(null); + const [codexRuntimeWarning, setCodexRuntimeWarning] = useState(() => cached?.warning ?? null); + const [codexRuntimeFix, setCodexRuntimeFix] = useState(() => cached?.fix ?? null); + /** True while settings (runtime notice) are still in flight — reserves notice slot height. */ + const [runtimeNoticePending, setRuntimeNoticePending] = useState(() => !cached?.data); const loadGenerationRef = useRef(0); + const paintedRef = useRef(Boolean(cached?.data)); const refresh = useCallback(async (signal?: AbortSignal) => { const generation = ++loadGenerationRef.current; + const keepSecondary = paintedRef.current; setLoading(true); - setTrayLoading(true); + // Keep prior notice/tray visible on revalidation; only reserve empty slots on first paint. + if (!keepSecondary) { + setTrayLoading(true); + setRuntimeNoticePending(true); + } try { + // Kick settings off immediately so it overlaps the health round-trip. + const settingsPromise = fetch(`${apiBase}/api/settings`, { signal }) + .then(async (settingsRes) => { + if (!settingsRes.ok) return null; + return await settingsRes.json() as { codexRuntime?: CodexRuntimeSettings }; + }) + .catch(() => null); + 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 || generation !== loadGenerationRef.current) return; + + // Paint hero/details/recovery as soon as health arrives. setData(next); + paintedRef.current = true; setFailed(next.diagnosticStale); - try { - const settingsRes = await fetch(`${apiBase}/api/settings`, { signal }); - if (settingsRes.ok) { - const settings = await settingsRes.json() as { - codexRuntime?: { - version?: string | null; - newerAvailable?: { path?: string; version?: string | null } | null; - catalogClamp?: { active?: boolean; removedEfforts?: string[]; runtimeVersion?: string | null }; - }; - }; - if (!signal?.aborted && generation === loadGenerationRef.current) { - const runtime = settings.codexRuntime; - const clampActive = Boolean(runtime?.catalogClamp?.active); - const newer = Boolean(runtime?.newerAvailable); - const version = (clampActive - ? runtime?.catalogClamp?.runtimeVersion - : runtime?.version) ?? runtime?.version ?? "unknown"; - const efforts = (runtime?.catalogClamp?.removedEfforts ?? []).join(", "); - if (clampActive) { - setCodexRuntimeWarning( - efforts - ? t("startup.codexRuntime.clampHiddenWithEfforts", { version, efforts }) - : t("startup.codexRuntime.clampHidden", { version }), - ); - } else if (newer) { - setCodexRuntimeWarning(t("startup.codexRuntime.olderBinary", { version })); - } else { - setCodexRuntimeWarning(null); - } - setCodexRuntimeFix( - newer - ? "ocx doctor --fix-codex-runtime && ocx sync" - : clampActive - ? "ocx sync" - : null, - ); - } - } else if (!signal?.aborted && generation === loadGenerationRef.current) { - setCodexRuntimeWarning(null); - setCodexRuntimeFix(null); - } - } catch { - if (!signal?.aborted && generation === loadGenerationRef.current) { - setCodexRuntimeWarning(null); - setCodexRuntimeFix(null); - } - } + setLoading(false); + + const trayPromise = next.platform === "win32" + ? fetch(`${apiBase}/api/windows-tray`, { signal }) + .then(async (trayRes) => { + 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"); + return { tray: trayNext, error: false as const }; + }) + .catch(() => ({ tray: null, error: true as const })) + : Promise.resolve({ tray: null, error: false as const }); + + const [settings, trayResult] = await Promise.all([settingsPromise, trayPromise]); + if (signal?.aborted || generation !== loadGenerationRef.current) return; + + const notice = deriveCodexRuntimeNotice(settings?.codexRuntime, t, next.platform); + setCodexRuntimeWarning(notice.warning); + setCodexRuntimeFix(notice.fix); + setRuntimeNoticePending(false); + const nextTray = next.platform === "win32" ? trayResult.tray : null; if (next.platform === "win32") { + setTray(nextTray); + setTrayError(trayResult.error); + } else { + setTray(null); 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 && generation === loadGenerationRef.current) { - setTray(trayNext); - setTrayError(false); - } - } catch { - if (!signal?.aborted && generation === loadGenerationRef.current) { - setTray(null); - setTrayError(true); - } - } } + setTrayLoading(false); + + writeSessionListCache(cacheKey, { + data: next, + warning: notice.warning, + fix: notice.fix, + tray: nextTray, + } satisfies StartupPageCache); } catch { if (signal?.aborted || generation !== loadGenerationRef.current) return; setFailed(true); - setTray(null); - setTrayError(true); - } finally { - if (generation === loadGenerationRef.current) { - setTrayLoading(false); - setLoading(false); + if (!keepSecondary) { + setTray(null); + setTrayError(true); + setCodexRuntimeWarning(null); + setCodexRuntimeFix(null); } + setRuntimeNoticePending(false); + setTrayLoading(false); + setLoading(false); } - }, [apiBase, t]); + }, [apiBase, cacheKey, t]); useEffect(() => { const controller = new AbortController(); @@ -170,23 +217,23 @@ export default function Startup({ apiBase }: { apiBase: string }) { } }; - const runInstallAction = async (action: StartupInstallAction) => { + const runInstallAction = async (action: StartupInstallAction, opts?: { repair?: boolean }) => { setInstallBusy(action); setInstallResult(null); try { const res = await fetch(`${apiBase}/api/startup-action`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), + body: JSON.stringify({ action, repair: opts?.repair === true }), }); if (!res.ok) { const body = await res.json().catch(() => null) as { error?: unknown } | null; throw new Error(typeof body?.error === "string" ? body.error : "installation failed"); } - setInstallResult({ kind: "success", action }); + setInstallResult({ kind: "success", action, repair: opts?.repair === true }); await refresh(); } catch (error) { - setInstallResult({ kind: "error", action, detail: error instanceof Error ? error.message : String(error) }); + setInstallResult({ kind: "error", action, repair: opts?.repair === true, detail: error instanceof Error ? error.message : String(error) }); } finally { setInstallBusy(null); } @@ -199,9 +246,12 @@ export default function Startup({ apiBase }: { apiBase: string }) {

{t("startup.title")}

{t("startup.subtitle")}

- +
+ {t("startup.backToDashboard")} + +
{loading && !data ? ( @@ -211,16 +261,23 @@ export default function Startup({ apiBase }: { apiBase: string }) { ) : data ? ( <> {failed &&
{t("startup.staleData")}
} - {codexRuntimeWarning && ( -
-

{codexRuntimeWarning}

- {codexRuntimeFix && ( -

- - {codexRuntimeFix} -

+ {(runtimeNoticePending || codexRuntimeWarning) && ( +
+ {codexRuntimeWarning && ( +
+

{codexRuntimeWarning}

+ {codexRuntimeFix && ( +
+ + {codexRuntimeFix} +
+ )} +
)}
)} @@ -230,7 +287,7 @@ export default function Startup({ apiBase }: { apiBase: string }) { failed={failed} installBusy={installBusy} installResult={installResult} - onInstall={(action) => { void runInstallAction(action); }} + onInstall={(action, opts) => { void runInstallAction(action, opts); }} /> {data.platform === "win32" && (
-
-

{t("api.activeKeys", { count: keys.length })}

- {keys.length > 0 ? ( +
+

+ {keysLoading ? t("api.activeKeysLoading") : t("api.activeKeys", { count: keys.length })} +

+ {keysLoading ? ( +
+ ) : keys.length > 0 ? (
diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index 98659f889..9b8970abb 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -84,10 +84,12 @@ export function StartupDetailsSection({ data: StartupHealthData; failed: boolean; installBusy: StartupInstallAction | null; - installResult: { kind: "success" | "error"; action: StartupInstallAction; detail?: string } | null; - onInstall: (action: StartupInstallAction) => void; + installResult: { kind: "success" | "error"; action: StartupInstallAction; repair?: boolean; detail?: string } | null; + onInstall: (action: StartupInstallAction, opts?: { repair?: boolean }) => void; }) { const { t } = useI18n(); + const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && !data.serviceViable; + const shimNeedsRepair = data.shimInstalled && !data.shimHealthy; return (
@@ -108,6 +110,11 @@ export function StartupDetailsSection({ {t(installBusy === "install-service" ? "startup.installing" : "startup.install")} )} + {serviceNeedsRepair && ( + + )}
@@ -125,12 +132,19 @@ export function StartupDetailsSection({ {t(installBusy === "install-shim" ? "startup.installing" : "startup.install")} )} + {shimNeedsRepair && ( + + )}
{installResult && (
{installResult.kind === "success" - ? t(installResult.action === "install-service" ? "startup.serviceInstalled" : "startup.shimInstalled") + ? installResult.action === "install-service" + ? t(installResult.repair ? "startup.serviceRepaired" : "startup.serviceInstalled") + : t(installResult.repair ? "startup.shimRepaired" : "startup.shimInstalled") : `${t("startup.installFailed")} ${installResult.detail ?? ""}`}
)} @@ -189,7 +203,9 @@ export function StartupTraySection({ }}>{t("startup.tray.uninstall")} )} - {(trayError || tray?.stale) &&
{t("startup.tray.error")}
} + {(trayError || tray?.stale) && ( +
{t("startup.tray.error")}
+ )}
); } diff --git a/gui/src/session-list-cache.ts b/gui/src/session-list-cache.ts new file mode 100644 index 000000000..e970f8334 --- /dev/null +++ b/gui/src/session-list-cache.ts @@ -0,0 +1,30 @@ +/** + * SessionStorage helpers for non-secret GUI list/summary shapes (SWR seeds). + * Never store API keys, tokens, or credentials here — XSS can read sessionStorage. + */ + +export function readSessionListCache(key: string): T | null { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function writeSessionListCache(key: string, value: unknown): void { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + /* private mode / quota */ + } +} + +export function clearSessionListCache(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + /* ignore */ + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 29a04a56d..d2f8bcd6a 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -366,10 +366,9 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .page-head h2 { font-size: var(--text-title); } .page-sub { color: var(--muted); font-size: var(--text-body); margin: 4px 0 22px; max-width: 70ch; } -/* Page-level underline tabs (Logs & Debug). Distinct from pill .segmented filters. */ -/* Shared by Logs and Dashboard. Tabs never wrap: the strip scrolls horizontally so a - narrow viewport cannot push them onto a second row or squash their labels (Q7). */ -.page-tabs { display: flex; flex-wrap: nowrap; gap: 2px; border-bottom: 1px solid var(--border); margin: 2px 0 14px; overflow-x: auto; scrollbar-width: thin; } +/* Page-level underline tabs (Logs & Debug / Dashboard). Distinct from pill .segmented filters. */ +/* Let the row take the height it needs — no horizontal scrollbar on a short tab strip. */ +.page-tabs { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 1px solid var(--border); margin: 2px 0 14px; overflow: visible; } .page-tab { flex: 0 0 auto; white-space: nowrap; appearance: none; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 8px 12px; color: var(--muted); cursor: pointer; font: inherit; font-size: var(--text-control); } .page-tab:hover { color: var(--text); } .page-tab--active { color: var(--text); border-bottom-color: var(--accent); font-weight: var(--weight-semibold); } @@ -400,6 +399,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } font: inherit; font-size: var(--text-control); font-weight: var(--weight-medium); line-height: var(--leading-ui); cursor: pointer; border: 1px solid transparent; transition: background var(--motion-fast), border-color var(--motion-fast), opacity var(--motion-fast); white-space: nowrap; } +a.btn, a.btn:hover { text-decoration: none; } .btn svg { width: 15px; height: 15px; } .btn:disabled { opacity: 0.55; cursor: default; } .btn-primary { background: var(--accent); color: var(--accent-ink); } @@ -840,16 +840,120 @@ dialog.modal-overlay::backdrop { .codex-auto-switch-controls > .toggle { margin-left: auto; } .codex-auto-switch-threshold { display: flex; flex-direction: column; align-items: flex-start; margin: 0; } .codex-auto-switch-threshold .field-label { margin-bottom: 4px; white-space: nowrap; } -.codex-auto-switch-input-wrap { display: flex; align-items: center; gap: 6px; } +.codex-auto-switch-input-wrap { display: flex; align-items: center; gap: 10px; } .codex-auto-switch-input { width: 84px; text-align: right; font-variant-numeric: tabular-nums; } .codex-auto-switch-input[readonly] { cursor: progress; opacity: 0.7; } +/* Hide native number spinners — we use .ocx-stepper chevrons instead. */ +.codex-auto-switch-input::-webkit-outer-spin-button, +.codex-auto-switch-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.codex-auto-switch-input[type="number"] { appearance: textfield; -moz-appearance: textfield; } .codex-auto-switch-unit { color: var(--muted); font-size: var(--text-control); } .codex-auto-switch-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } .codex-auto-switch-feedback.is-error { color: var(--red); } + +.ocx-stepper { + display: inline-flex; + flex-direction: column; + gap: 2px; + flex: 0 0 auto; +} +.ocx-stepper__btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-2xs); + background: var(--raised); + color: var(--muted); + cursor: pointer; +} +.ocx-stepper__btn:hover:not(:disabled) { color: var(--text); border-color: var(--faint); } +.ocx-stepper__btn:disabled { opacity: 0.45; cursor: not-allowed; } +.ocx-stepper__btn:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 1px; } +.ocx-stepper__btn svg { display: block; } + +.codex-auth-page-head { align-items: flex-start; } +.codex-auth-page-head__actions { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.codex-auth-page-head__feedback { + min-width: 8rem; + max-width: 18rem; + text-align: right; + font-size: var(--text-label); + color: var(--muted); + line-height: var(--leading-body); +} +.codex-auth-load-skeleton { + display: grid; + gap: 12px; + margin: 8px 0 16px; +} +.codex-auth-load-skeleton__card { + min-height: 88px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(90deg, var(--raised) 0%, var(--surface) 50%, var(--raised) 100%); + background-size: 200% 100%; + animation: codex-auth-skeleton-shimmer 1.2s ease-in-out infinite; +} +@keyframes codex-auth-skeleton-shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + +.account-pool-strategy-card { margin-top: 16px; } +.account-pool-strategy-card > strong { display: block; } +.account-pool-strategy-card .card-sub { margin-top: 4px; } +.account-pool-strategy-controls { margin-top: 12px; display: grid; gap: 12px; } +.account-pool-strategy-controls .field { display: grid; gap: 6px; margin: 0; } +.api-active-keys-skeleton { + min-height: 96px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(90deg, var(--raised) 0%, var(--surface) 50%, var(--raised) 100%); + background-size: 200% 100%; + animation: codex-auth-skeleton-shimmer 1.2s ease-in-out infinite; +} .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-page-sub { margin-bottom: 0; } +.startup-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.startup-runtime-notice-slot { + margin-bottom: 12px; + min-height: 52px; +} +.startup-runtime-notice-slot--pending { + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--amber-soft) 45%, transparent); +} +.startup-runtime-notice-slot .startup-runtime-notice { margin-bottom: 0; } +.startup-runtime-notice { + flex-direction: column; + align-items: stretch; + gap: 8px; +} +.startup-runtime-notice__text { margin: 0; } +.startup-runtime-notice__fix { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.startup-runtime-notice__fix code { + margin: 0; + overflow-wrap: anywhere; + word-break: break-word; +} .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)); } @@ -867,7 +971,8 @@ dialog.modal-overlay::backdrop { .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-tray-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; min-height: 36px; } +.startup-tray-error { margin-top: 12px; } .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); } diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 1dd495b79..902f43210 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -669,7 +669,7 @@ font-size: 0.85rem; color: inherit; cursor: pointer; - min-height: 48px; + min-height: 62px; transition: border-color 0.15s; } @@ -687,7 +687,7 @@ color: inherit; background: var(--bg); resize: vertical; - min-height: 48px; + min-height: 62px; } .pws-notes-textarea:focus { diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index c7a119771..a7cef399e 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -140,16 +140,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; clearProviderQuotaCache?: () => void; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise | void; - runStartupInstallAction?: (action: StartupInstallAction) => Promise<{ message: string }>; + runStartupInstallAction?: ( + action: StartupInstallAction, + options?: { repair?: boolean }, + ) => Promise<{ message: string }>; } diff --git a/src/server/startup-action-control.ts b/src/server/startup-action-control.ts index 18508ee60..08b81f71a 100644 --- a/src/server/startup-action-control.ts +++ b/src/server/startup-action-control.ts @@ -106,10 +106,14 @@ export function resetStartupInstallStateForTests(): void { installState = { status: "idle" }; } -export function startupInstallArgv(action: StartupInstallAction): string[] { - return action === "install-service" - ? ["service", "install"] - : ["codex-shim", "install"]; +export function startupInstallArgv( + action: StartupInstallAction, + options?: { repair?: boolean }, +): string[] { + if (action === "install-service") { + return options?.repair ? ["service", "repair"] : ["service", "install"]; + } + return ["codex-shim", "install"]; } export interface CliInstallFailure { @@ -152,10 +156,13 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro return classifyCliInstallFailure(stdout, stderr, error).detail; } -function runCliInstall(action: StartupInstallAction): Promise<{ stdout: string; stderr: string }> { +function runCliInstall( + action: StartupInstallAction, + options?: { repair?: boolean }, +): Promise<{ stdout: string; stderr: string }> { const bun = durableBunPath(); const cli = join(import.meta.dir, "..", "cli", "index.ts"); - const argv = [cli, ...startupInstallArgv(action)]; + const argv = [cli, ...startupInstallArgv(action, options)]; return new Promise((resolve, reject) => { execFile(bun, argv, { encoding: "utf8", @@ -219,29 +226,37 @@ function applyReconciliationOutcome( /** * Execute the existing fixed CLI installer outside the proxy event loop. * + * Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart + * without Task Scheduler re-registration, so it must not enter the UAC elevation path. + * * After an elevation request timeout the lock becomes `indeterminate` until the * original elevated transaction completes and is reconciled. A process restart * clears this in-memory lock — callers must then inspect Task Scheduler reality * (see evaluateSchedulerInstallRestartReconciliation) before installing again. */ -export function runStartupInstallAction(action: StartupInstallAction): Promise<{ message: string }> { +export function runStartupInstallAction( + action: StartupInstallAction, + options?: { repair?: boolean }, +): Promise<{ message: string }> { const busy = rejectIfBusy(action); if (busy) return Promise.reject(busy); + const repair = options?.repair === true; const attemptId = randomUUID(); const startedAt = Date.now(); installState = { status: "running", action, attemptId, startedAt }; const operation = (async () => { try { - await runCliInstall(action); + await runCliInstall(action, { repair }); } catch (error) { const code = installFailureCode(error); const detail = error instanceof Error ? error.message : String(error); - // Elevate only for a structured Task Scheduler /create access denial — never for - // WinSW removal, asset writes, or generic permission errors. + // Elevate only for fresh install + structured Task Scheduler /create access denial — + // never for repair, WinSW removal, asset writes, or generic permission errors. if ( - action === "install-service" + !repair + && action === "install-service" && process.platform === "win32" && (code === WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER || isWindowsSchtasksCreateAccessDenied(detail)) @@ -276,10 +291,11 @@ export function runStartupInstallAction(action: StartupInstallAction): Promise<{ throw error; } } + if (action === "install-service") { + return { message: repair ? "Background service repaired." : "Background service installed." }; + } return { - message: action === "install-service" - ? "Background service installed." - : "Codex launcher shim installed.", + message: repair ? "Codex launcher shim repaired." : "Codex launcher shim installed.", }; })(); diff --git a/src/service.ts b/src/service.ts index a68300827..0cf6ac581 100644 --- a/src/service.ts +++ b/src/service.ts @@ -351,8 +351,41 @@ function sh(cmd: string): string { return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); } +/** + * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the + * registered task document is UTF-16; reading that as UTF-8 makes every health check + * fail ("registration present but unhealthy") and rolls back a successful elevated create. + */ +export function decodeSchtasksOutput(buffer: Buffer): string { + if (buffer.length === 0) return ""; + const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; + const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; + const looksUtf16Le = buffer.length >= 4 + && buffer[1] === 0x00 + && buffer[3] === 0x00 + && buffer[0] !== 0x00; + if (bomUtf16Le || looksUtf16Le) { + return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim(); + } + if (bomUtf16Be) { + // Swap pairs then decode as utf16le. + const swapped = Buffer.alloc(buffer.length - 2); + for (let i = 2; i + 1 < buffer.length; i += 2) { + swapped[i - 2] = buffer[i + 1]!; + swapped[i - 1] = buffer[i]!; + } + return swapped.toString("utf16le").trim(); + } + return buffer.toString("utf8").replace(/^\uFEFF/, "").trim(); +} + function runFile(file: string, args: string[]): string { - return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim(); + const buffer = execFileSync(file, args, { + encoding: "buffer", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) as Buffer; + return decodeSchtasksOutput(buffer); } function windowsSchtasks(): string { @@ -483,7 +516,9 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: { : !assetsHealthy ? "Required scheduler service assets are missing." : !registrationHealthy - ? "Task Scheduler registration is present but unhealthy." + ? (inputs.xml.trim() + ? "Task Scheduler registration is present but unhealthy." + : "Task Scheduler task is present but its XML could not be read.") : nativeStatusUnknown ? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent." : "ok"; @@ -502,9 +537,18 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: { /** Conflict-free postcondition check for an elevated scheduler install. */ export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification { const taskInstalled = windowsSchedulerTaskInstalled(taskName); - const xml = taskInstalled ? (() => { - try { return querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { return ""; } - })() : ""; + let xml = ""; + if (taskInstalled) { + try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; } + } + // After elevated create, non-elevated `/query /xml` can fail or return empty while the + // task is still listed. Fall back to the on-disk document we registered. + if (taskInstalled && !xml.trim()) { + const diskPath = windowsTaskXmlPath(); + if (existsSync(diskPath)) { + try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ } + } + } return evaluateWindowsSchedulerInstallVerification({ taskInstalled, xml, @@ -892,6 +936,38 @@ function taskXmlString(value: string): string { .replace(/'/g, "'"); } +/** Case-insensitive Exec/Command match (Windows may rewrite SystemRoot casing). */ +function taskXmlCommandMatches(action: string, command: string): boolean { + const value = /\s*([^<]*?)\s*<\/Command>/i.exec(action)?.[1]?.trim(); + return value != null && value.length > 0 && value.toLowerCase() === command.toLowerCase(); +} + +/** + * Exec/Arguments match. Task Scheduler may export quotes as `"` or literal `"`. + * Compare launcher paths case-insensitively. + */ +function taskXmlArgumentsMatch(action: string, launcher: string): boolean { + const raw = /\s*([^<]*?)\s*<\/Arguments>/i.exec(action)?.[1]?.trim(); + if (!raw) return false; + const normalized = raw.replace(/"/gi, '"'); + const want = `/b /nologo "${launcher}"`; + return normalized.toLowerCase() === want.toLowerCase(); +} + +/** + * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated + * `schtasks /create` often rewrites the registered task to HighestAvailable even when + * the source XML asked for LeastPrivilege — still InteractiveToken / same user. + */ +function taskXmlRunLevelAcceptable(principal: string): boolean { + if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false; + const count = taskXmlElementCount(principal, "RunLevel"); + if (count === 0) return true; + if (count > 1) return false; + const value = new RegExp(`]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase(); + return value === "leastprivilege" || value === "highestavailable"; +} + export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { const { bun, cli } = entry; const bunRuntime = durableBunRuntime(); @@ -1072,12 +1148,12 @@ export function windowsTaskRegistrationHealthy( return taskXmlElementCount(triggers, "LogonTrigger") > 0 && taskXmlOptionalValueEquals(trigger, "Enabled", "true") && /\s*InteractiveToken\s*<\/LogonType>/i.test(principal) - && taskXmlOptionalValueEquals(principal, "RunLevel", "LeastPrivilege") + && taskXmlRunLevelAcceptable(principal) && taskXmlOptionalValueEquals(settings, "Enabled", "true") && /\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings) && /\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings) - && action.includes(`${taskXmlString(wscript)}`) - && action.includes(`${taskXmlString(`/b /nologo "${launcher}"`)}`); + && taskXmlCommandMatches(action, wscript) + && taskXmlArgumentsMatch(action, launcher); } export interface WindowsSchedulerXmlState { @@ -1147,9 +1223,22 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut } } -function installWindows(): void { +/** + * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task. + * Used by fresh install (before schtasks /create) and by repair (no elevation). + */ +function writeWindowsSchedulerAssets(): void { if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); + const script = windowsServiceScriptPath(); + writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8"); + // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile + // paths on some WSH/codepage combinations — same contract as the task XML below. + writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le"); + writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le"); +} + +function installWindows(): void { // Transactional backend switch: installing the scheduler backend removes a native // service first — two live managers would both respawn the proxy (conflict). if (statusWinswRaw() !== "nonexistent") { @@ -1166,17 +1255,73 @@ function installWindows(): void { // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the // script mid-rewrite runs a torn batch file, and its open handle can fail the write. try { stopWindows(); } catch { /* not running */ } - const script = windowsServiceScriptPath(); - writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8"); - // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile - // paths on some WSH/codepage combinations — same contract as the task XML below. - writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le"); - writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le"); - schtasks(buildWindowsSchtasksCreateArgs(script)); + writeWindowsSchedulerAssets(); + schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath())); schtasks(["/run", "/tn", TASK]); writeServiceInstallState("scheduler"); } +export interface RepairServiceDeps { + diagnose?: () => ServiceDiagnostic; + assertEnv?: () => void; + assertAuth?: () => void; + writeSchedulerAssets?: () => void; + stopScheduler?: () => void; + startScheduler?: () => void; + writeSchedulerState?: () => void; + repairNative?: () => void | Promise; + repairLaunchd?: () => void; + repairSystemd?: () => void; +} + +/** + * Repair an already-installed background service without Task Scheduler re-registration. + * + * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC. + * Windows native: WinSW asset rewrite + restart (skips `install /p` when present). + * macOS/Linux: re-run the user-level install/reload path. + */ +export async function repairService(deps: RepairServiceDeps = {}): Promise { + const diagnose = deps.diagnose ?? diagnoseService; + const diag = diagnose(); + if (!diag.supported) { + throw new Error(`Background service is unsupported (${diag.summary}).`); + } + if (diag.conflict) { + throw new Error( + "Cannot repair while Task Scheduler and native WinSW are both present. " + + "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.", + ); + } + if (!diag.installed) { + throw new Error("Background service is not installed. Run 'ocx service install' first."); + } + + (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)(); + (deps.assertAuth ?? assertServiceAuthEnvironment)(); + + if (process.platform === "win32") { + if (diag.backend === "native") { + await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))(); + return; + } + try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } + (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(); + (deps.startScheduler ?? startWindows)(); + (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))(); + return; + } + if (process.platform === "darwin") { + (deps.repairLaunchd ?? installLaunchd)(); + return; + } + if (process.platform === "linux") { + (deps.repairSystemd ?? installSystemd)(); + return; + } + throw new Error(`Background service repair is unsupported on ${process.platform}.`); +} + /** * Opt-in native backend (`ocx service install --native`). Transactional: removes the * scheduler backend first; on failure the machine is left with NO service (explicitly @@ -1664,6 +1809,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { for (const mutated of [ xml.replace("", ""), xml.replace("InteractiveToken", "Password"), - xml.replace("LeastPrivilege", "HighestAvailable"), + xml.replace("LeastPrivilege", "InvalidLevel"), xml.replace("IgnoreNew", "Parallel"), xml.replace(wscript, "C:\\Windows\\System32\\cmd.exe"), xml.replace(launcher, "C:\\Temp\\foreign.vbs"), @@ -276,6 +276,19 @@ describe("Windows service task", () => { }); }); + test("accepts elevated-create rewrites (HighestAvailable, path casing, raw quotes)", () => { + const wscript = "C:\\Windows\\System32\\wscript.exe"; + const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; + const xml = buildWindowsTaskXml("ignored.cmd", launcher) + .replace(/.*?<\/Command>/, `C:\\WINDOWS\\System32\\wscript.exe`) + .replace("LeastPrivilege", "HighestAvailable") + .replace( + `/b /nologo "${launcher}"`, + `/b /nologo "${launcher}"`, + ); + expect(windowsTaskRegistrationHealthy(xml, wscript, launcher)).toBe(true); + }); + test("rejects explicit unsafe values even though defaults may be omitted", () => { const wscript = "C:\\Windows\\System32\\wscript.exe"; const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; @@ -546,17 +559,23 @@ describe("service lifecycle cleanup ordering", () => { test("Windows service install ends the running task before rewriting its assets, with write retry", async () => { const service = await readText("src/service.ts"); - const installWindows = service.slice(service.indexOf("function installWindows()"), service.indexOf("function startWindows()")); + const assetsHelper = service.slice( + service.indexOf("function writeWindowsSchedulerAssets()"), + service.indexOf("function installWindows()"), + ); + const installWindows = service.slice(service.indexOf("function installWindows()"), service.indexOf("async function installWindowsNative()")); const stopAt = installWindows.indexOf("stopWindows();"); - const scriptWriteAt = installWindows.indexOf("writeServiceAssetWithRetry(script"); - const xmlWriteAt = installWindows.indexOf("writeServiceAssetWithRetry(windowsTaskXmlPath()"); + const assetsAt = installWindows.indexOf("writeWindowsSchedulerAssets();"); + const createAt = installWindows.indexOf("buildWindowsSchtasksCreateArgs"); expect(stopAt).toBeGreaterThan(-1); - expect(scriptWriteAt).toBeGreaterThan(-1); - expect(xmlWriteAt).toBeGreaterThan(-1); - expect(stopAt).toBeLessThan(scriptWriteAt); - expect(scriptWriteAt).toBeLessThan(xmlWriteAt); + expect(assetsAt).toBeGreaterThan(-1); + expect(createAt).toBeGreaterThan(-1); + expect(stopAt).toBeLessThan(assetsAt); + expect(assetsAt).toBeLessThan(createAt); expect(installWindows).not.toContain("writeFileSync(script"); + expect(assetsHelper).toContain("writeServiceAssetWithRetry(script"); + expect(assetsHelper).toContain("writeServiceAssetWithRetry(windowsTaskXmlPath()"); // Retry helper tolerates transient Windows file locks from the just-ended task. expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"'); }); @@ -709,3 +728,61 @@ describe("service diagnostics", () => { expect(statusCase).toContain("serviceDiagnosticsSummary()"); }); }); + +describe("service repair", () => { + const baseDiag = { + supported: true, + installed: true, + enabled: true, + running: true, + viable: false, + startable: true, + stale: true, + conflict: false, + backend: "scheduler" as const, + summary: "stale", + }; + + test("scheduler repair rewrites assets and restarts without schtasks create", async () => { + const calls: string[] = []; + await repairService({ + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + repairNative: async () => { calls.push("native"); }, + }); + expect(calls).toEqual(["env", "auth", "stop", "assets", "start", "state"]); + }); + + test("repair rejects when nothing is installed", async () => { + await expect(repairService({ + diagnose: () => ({ ...baseDiag, installed: false, backend: null, summary: "not installed" }), + writeSchedulerAssets: () => { throw new Error("should not write"); }, + })).rejects.toThrow(/not installed/i); + }); + + test("repair rejects conflict without touching assets", async () => { + let wrote = false; + await expect(repairService({ + diagnose: () => ({ ...baseDiag, conflict: true, summary: "CONFLICT" }), + writeSchedulerAssets: () => { wrote = true; }, + })).rejects.toThrow(/both present/i); + expect(wrote).toBe(false); + }); + + test("native repair uses the WinSW repair path", async () => { + const calls: string[] = []; + await repairService({ + diagnose: () => ({ ...baseDiag, backend: "native" }), + assertEnv: () => {}, + assertAuth: () => {}, + repairNative: async () => { calls.push("native"); }, + writeSchedulerAssets: () => { calls.push("scheduler"); }, + }); + expect(calls).toEqual(["native"]); + }); +}); diff --git a/tests/startup-action-control-elevation.test.ts b/tests/startup-action-control-elevation.test.ts index 668d155b7..f89d4dfed 100644 --- a/tests/startup-action-control-elevation.test.ts +++ b/tests/startup-action-control-elevation.test.ts @@ -149,6 +149,17 @@ describe("startup install elevation retry", () => { expect(finalizeMock).not.toHaveBeenCalled(); }); + test("does not elevate service repair even with create-access-denied marker", async () => { + failCli(`Windows access denied while running Task Scheduler.\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`); + await expect(runStartupInstallAction("install-service", { repair: true })).rejects.toThrow( + WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER, + ); + expect(finalizeMock).not.toHaveBeenCalled(); + expect(execFileMock).toHaveBeenCalled(); + const argv = execFileMock.mock.calls[0]![1] as string[]; + expect(argv.slice(-2)).toEqual(["service", "repair"]); + }); + test("does not elevate on non-Windows platforms", async () => { Object.defineProperty(process, "platform", { value: "linux" }); failCli(`Windows access denied while running Task Scheduler.\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`); diff --git a/tests/startup-action-control.test.ts b/tests/startup-action-control.test.ts index 5c36fd331..b5ef9cb92 100644 --- a/tests/startup-action-control.test.ts +++ b/tests/startup-action-control.test.ts @@ -8,25 +8,45 @@ const config = { port: 10100, providers: {}, defaultProvider: "openai", codexAut describe("startup install actions", () => { test("maps the allowlisted actions to fixed CLI argv", () => { expect(startupInstallArgv("install-service")).toEqual(["service", "install"]); + expect(startupInstallArgv("install-service", { repair: true })).toEqual(["service", "repair"]); expect(startupInstallArgv("install-shim")).toEqual(["codex-shim", "install"]); + expect(startupInstallArgv("install-shim", { repair: true })).toEqual(["codex-shim", "install"]); }); test("management API dispatches an allowlisted install action", async () => { - const calls: StartupInstallAction[] = []; + const calls: Array<{ action: StartupInstallAction; repair?: boolean }> = []; 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); + runStartupInstallAction: async (action, options) => { + calls.push({ action, repair: options?.repair }); 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"]); + expect(await response!.json()).toEqual({ ok: true, action: "install-service", repair: false, message: "installed" }); + expect(calls).toEqual([{ action: "install-service", repair: false }]); + }); + + test("management API forwards repair mode for service actions", async () => { + const calls: Array<{ action: StartupInstallAction; repair?: boolean }> = []; + 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", repair: true }), + }), url, config, { + runStartupInstallAction: async (action, options) => { + calls.push({ action, repair: options?.repair }); + return { message: "repaired" }; + }, + }); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true, action: "install-service", repair: true, message: "repaired" }); + expect(calls).toEqual([{ action: "install-service", repair: true }]); }); test("rejects unknown actions before invoking the installer", async () => { diff --git a/tests/windows-scheduler-install-verification.test.ts b/tests/windows-scheduler-install-verification.test.ts index 6343cbac3..a41e19d18 100644 --- a/tests/windows-scheduler-install-verification.test.ts +++ b/tests/windows-scheduler-install-verification.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { buildWindowsTaskXml, + decodeSchtasksOutput, evaluateWindowsSchedulerInstallVerification, probeWindowsSchedulerTask, setQuerySchtasksForTests, @@ -13,6 +14,30 @@ afterEach(() => { setQuerySchtasksForTests(null); }); +describe("decodeSchtasksOutput", () => { + test("decodes UTF-16LE BOM XML that would fail as UTF-8", () => { + const xml = buildWindowsTaskXml( + "C:\\Users\\x\\.opencodex\\opencodex-service.cmd", + "C:\\Users\\x\\.opencodex\\opencodex-service-launcher.vbs", + ); + const utf16 = Buffer.from(`\uFEFF${xml}`, "utf16le"); + const decoded = decodeSchtasksOutput(utf16); + expect(decoded.startsWith(" { + const text = "Folder: \\\nTaskName: opencodex-proxy"; + expect(decodeSchtasksOutput(Buffer.from(text, "utf8"))).toBe(text); + }); +}); + describe("windowsSchedulerCsvIncludesTask", () => { test("matches quoted Task Scheduler CSV task names", () => { const csv = [