diff --git a/devlog/_plan/260905_main_quota_guard/000_plan.md b/devlog/_plan/260905_main_quota_guard/000_plan.md index d6553ef87c..0b86401b4d 100644 --- a/devlog/_plan/260905_main_quota_guard/000_plan.md +++ b/devlog/_plan/260905_main_quota_guard/000_plan.md @@ -35,7 +35,8 @@ Reuse existing config mutation/rollback, quota parsing, account identity reconci 1. wp0: source-grounded docs-only roadmap and independent audit; lock before production edits. 2. wp1 / `010_policy.md`: main quota protection contracts, admission and management, with regression coverage. Bottom PR targets dev and works without the UI layer. 3. wp2 / `020_settings.md`: switch, confirmation, main-card state and supported Reserve compatibility documentation; depends on the policy contract. Upper PR targets the bottom branch. -4. wp3 / `030_delivery.md`: exact-head review/CI and bottom-up authorized admin merge, followed by fetched ancestry and closure evidence. +4. wp-reserve / `030_reserve_compatibility.md`: source-grounded explicit Reserve metadata/availability and independent quota handling; depends on the preceding identity and settings contracts. +5. wp3 / `040_delivery.md`: exact-head review/CI and bottom-up authorized admin merge, followed by fetched ancestry and closure evidence. Pending macOS and other platform gates remain mandatory. The Reserve client gate is a separate feasibility decision, not permission to misrepresent server state. If source establishes a safe OCX-only compatibility patch, concretize it as a P amendment before writing. If it requires modifying the installed Desktop client or publishing to an unspecified upstream repository, record the boundary and ask for that specific decision after completing in-scope work; do not claim same-picker coexistence. diff --git a/devlog/_plan/260905_main_quota_guard/021_settings_dispatch.md b/devlog/_plan/260905_main_quota_guard/021_settings_dispatch.md new file mode 100644 index 0000000000..4090e05b1a --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/021_settings_dispatch.md @@ -0,0 +1,25 @@ +# Settings implementation stale check and ownership + +Consumes runtime headfe2e10e15 (PR3552). Linux full suite and behavioral criteria passed; remaining macOS checks stay in final delivery. No local test suites. Design Read/dials in020 unchanged: existing monochrome developer console, D8/V2/M1, no concept imagery. + +## UI worker scope + +NEW MainAccountHardLockSetting.tsx, MODIFY codex-set-multiauth.tsx, useCodexAccountPool.ts DTO, codex-account-pool-main-card.tsx, scoped styles-codex-set.css; NEW gui/tests/main-account-hard-lock-setting.test.tsx. No locale/doc/backend edits by the worker. + +The actual tab URL is #codex-set, not #codex-set/multiauth. The parent creates exactly one useCodexAccountPool(apiBase) controller and injects it into CodexAccountPool, whose fallback becomes inert. New setting receives onSaved:()=>Promise calling controller.load(false). Invoke after acknowledged PUT for both enable/disable, not dialog open/cancel. A failed status reload does not relabel the successful PUT as failed; show a separate retryable saved-but-status-unconfirmed notice. + +Persisted field codexMainAccountHardLock:boolean, status mainAccountHardLock:{enabled:boolean,state:off|unknown|ready|blocked,resetAt?:milliseconds}. Main-card DTO has this optional status. Never derive status from rounded bars; ready means monitoring, not a promise that every other account restriction is absent. + +No optimistic protection claim before acknowledgment. GET generations are invalidated on writes; cancel/Escape makes no request. Native dialog traps/restores focus; pending submission rejects duplicates and cannot be dismissed into an ambiguous success. Load error offers retry. Save failure describes inability to confirm, keeps recoverable state, and reloads authoritative state when appropriate. + +Show main status when advanced settings are collapsed, and suppress the use-main button when policy blocks. Offer a link to the actual Codex settings route when the card appears elsewhere. Do not create a new event bus, duplicate account store, or force an upstream quota refresh on every settings save. + +## Translation contract (main owns all locales) + +Use only these new keys under codexAuth: mainHardLockTitle, mainHardLockDesc, mainHardLockConfirmTitle, mainHardLockConfirmBody, mainHardLockConfirm, mainHardLockEnabled, mainHardLockDisabled, mainHardLockLoadFailed, mainHardLockSaveFailed, mainHardLockRefreshFailed, mainHardLockBlocked, mainHardLockUnknown, mainHardLockMonitoring, mainHardLockManage. Reuse common.retry/common.close/codexAuth.cancel for common controls. Main can add a key only after synchronizing the worker and all locales. + +Copy must state5h first, weekly otherwise, monthly-only fallback; fresh0 unlocks automatically while enabled. While blocked, Reserve is unavailable too; staying below ordinary exhaustion may prevent Reserve activation. In-flight/direct/unmatched-keyring use is outside the guarantee. No claim that the Reserve picker feature has shipped in this UI layer. + +## Main scope and verification + +All locale keys, public English/Korean usage docs, isolated fixture preview and browser QA, records and PR body. Browser at390/768/1280px, Korean/English, enable/cancel/Escape/saving/savefail/loadfail/disable and current-block status. Native browser tool first; no Playwright install. Build/i18n/lint are allowed; no local suites. CI executes component regressions. Screenshots must contain fixtures only and be embedded in the upper PR. diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg new file mode 100644 index 0000000000..71878517aa Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg new file mode 100644 index 0000000000..38529b096d Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg new file mode 100644 index 0000000000..311b57410b Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg new file mode 100644 index 0000000000..1c75078b25 Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg new file mode 100644 index 0000000000..ea40cb4c0a Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg new file mode 100644 index 0000000000..c7eac0a940 Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg new file mode 100644 index 0000000000..34368d7031 Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg new file mode 100644 index 0000000000..921f539335 Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg new file mode 100644 index 0000000000..d312a70d9d Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg new file mode 100644 index 0000000000..fe276457df Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg new file mode 100644 index 0000000000..8028059006 Binary files /dev/null and b/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg differ diff --git a/devlog/_plan/260905_main_quota_guard/023_ui_review.md b/devlog/_plan/260905_main_quota_guard/023_ui_review.md new file mode 100644 index 0000000000..7cf0515bf7 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/023_ui_review.md @@ -0,0 +1,13 @@ +# UI review and rendered feedback + +Rendered correction: main-card policy text initially touched the card edge. Align it with the existing16px card inset and place the manage action in a wrapping row with an adequate target. No global tokens changed. + +Independent reviewer Descartes found one accepted lifecycle blocker: a delayed PUT for proxyA can invoke its captured onSaved after apiBase changes toB, allowing old load(A) to advance the reused controller generation and replace B's displayed accounts. +RCA: keying only the setting protects its local state, not the parent-owned callback/controller. Bind the parent controller and completion callback to its proxy lifetime, while still allowing refresh after Advanced collapses within the same proxy. Add a deferred-PUT A->B regression that verifies B's account/status remains authoritative and no late old-proxy reload wins. Do not suppress the existing same-proxy collapsed-save refresh. + +Existing source review accepted acknowledgment, cancellation, error distinctions, main-card recovery, copy in9locales and state priority. Browser already observed Escape cancellation with0PUT, one confirmed save with1PUT, policy block hiding main activation, and a fresh0 usage update returning monitoring while the switch stays enabled. These are fixture-backed real-component observations, not live account changes. + +The lifecycle blocker is closed by keyed parent ownership plus retiring callbacks on unmount; same-proxy Advanced collapse retains its soft reload. Reviewer Descartes returned PASS on the repair and both regressions. +Rendered mobile feedback: keep Korean words together and use44px confirmation targets. The embedded browser could move keyboard focus out of the native dialog at the two-button boundary; explicitly wrap Tab/Shift-Tab between Cancel and Confirm. No background page control becomes a focus target. Browser capture initially inherited80% zoom; device emulation was used to verify exact390/768/1280 CSS widths, and temporary emulation will be cleared at teardown. + +UI CI React Doctor atfba57fcc1 flagged anchor-target-exists for the fallback href=#codex-set. This is a real application hash route, not an in-document anchor; use the existing navigateHash button convention to express that intent without a scanner suppression. Preserve the same-page callback. Artifact integrity also found native JPEG screenshot bytes with .png suffixes; rename to .jpg without altering/re-encoding any image content. diff --git a/devlog/_plan/260905_main_quota_guard/024_ui_verification.md b/devlog/_plan/260905_main_quota_guard/024_ui_verification.md new file mode 100644 index 0000000000..4acc9ecb55 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/024_ui_verification.md @@ -0,0 +1,46 @@ +# UI verification checkpoint + +Actual React CodexSet/CodexAccountPool components were served by the existing Vite stack against an isolated fixture API on loopback15141/15142. Settings GET/PUT used the real management handler with in-memory persistence and fixture-only quota provenance. No live account, credit, installed app or port10100 was changed. + +## Observed interaction matrix + +| Scenario | Observed result | +| --- | --- | +| Enable attempt, Escape | Dialog closed; focus returned to toggle; fixture PUT count remained0. | +| Confirm enable | One PUT; acknowledged enabled state; owner reloaded status. | +| Short98 / weekly100 | Monitoring, not a99% block. | +| Short99 | Main card showed blocked status; use-main action disappeared; status remained outside collapsed Advanced. | +| Manage protection on same page | Advanced opened and setting section received focus/scroll. | +| Short0 / weekly100 | Monitoring returned; configuration remained enabled. | +| Failed disable PUT | Existing enabled value retained after authoritative reload; actionable save-confirmation error. | +| Retry disable after failure | Confirmed off with other limits explicitly unchanged. | +| Settings GET failure | New toggle disabled; scoped Retry restored interaction after a valid response. | +| Successful PUT, failed account reload | Enabled stayed true; separate saved-but-status-unconfirmed notice. | +| Retry account reload | Main status updated with no additional PUT (count remained4). | +| Unknown selected usage | Main card showed usage-confirmation-needed, not a fabricated0 or a claim of availability. | +| Keyboard bounds | Cancel Shift-Tab -> Confirm; Confirm Tab -> Cancel; Escape restored toggle. | +| Responsive copy | Korean390/768/1280 CSS widths and English390/1280 inspected. Dialog remained within viewport; no horizontal overflow. | + +Screenshots in022_ui_evidence contain only fixture identities. Initial80% embedded-browser zoom caused capture cropping; final captures use matched viewport/device metrics atDPR1. Temporary emulation/viewport overrides were reset and the agent-created tab was closed. Ordinary pointer interactions were checked before emulation; keyboard navigation was used for exact emulated viewports. + +Teardown: verified the fixture Bun process and workspace Vite process before stopping them. No listener remains on15141 or15142. The running proxy on10100 was never targeted. + +## Source and build checks + +- Independent Descartes review PASS after the old-proxy completion fix; follow-up two-button focus wrap and scoped CSS also PASS. +- GUI `bun run build`, `bun run lint:i18n`, `bun run lint`: exit0 after final source/CSS changes. Existing large-bundle advisory remains. +- Docs `bun install --frozen-lockfile && bun run build`: exit0,425 pages. Existing bundle/404-entry advisory unchanged. +- No local test suite executed. New component regressions are authored for exact-head CI; all CI checks remain mandatory before merge. +- React Doctor0.9.11, run from the actual gui project against the parent branch, found no issues after expressing fallback navigation with navigateHash. No scanner rule was suppressed. Native screenshot suffixes were corrected to.jpg; the bytes are unchanged JPEGs. +- Runtime parent PR3552 atfe2e10e15: complete Cross-platform CI33930796875 succeeded, including macOS. Parent marked ready for review; no merge yet. + +## Changed surfaces + +MainAccountHardLockSetting: strict read/acknowledgment, bounded requests, confirmation/focus/error/retry states. +CodexSetMultiauth: one proxy-scoped controller and completion lifetime. +CodexAccountPool: local Manage action opens Advanced. +Main-card component and account hook: safe server-owned status contract and activation suppression while blocked. +styles-codex-set.css: scoped card alignment, wrapping and touch targets. +All9locale modules: matching14keys with window/zero/Reserve consequences. +English/Korean account reference: opt-in behavior, scope and recovery guidance. +New component test: state/lifecycle/cancel/focus and real-parent identity-race regression; CI execution pending. diff --git a/devlog/_plan/260905_main_quota_guard/025_focus_review.md b/devlog/_plan/260905_main_quota_guard/025_focus_review.md new file mode 100644 index 0000000000..fd412e796f --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/025_focus_review.md @@ -0,0 +1,11 @@ +# UI review follow-up + +Parent runtime rebased onto f42d86fca: fresh-only lower usage recovery, existing minute sweep, malformed-negative rejection, cold hydration and quarantine-preserving background refresh. Public English/Korean guide now describes that contract. Docs build passed425pages. + +External review3938946546 identified missing disable-path focus restoration. The switch now arms restoration for either action, focuses the section while authoritative reload leaves it disabled, and restores the enabled toggle after successful GET. Independent Descartes review found an additional delayed-recovery focus steal after deliberate departure; the section now cancels intent on a non-null focus target outside itself. Null-target disabled-control blur and internal navigation retain intent. ACK/save-versus-refresh semantics are unchanged. + +Descartes final source review PASS, blocking_issues0. Existing setting test397lines plus new focused82line departure/null-target regression authored for CI, not executed locally. GUI build/typecheck, lint and GUI-scoped React Doctor0.9.11 passed after the final fix. Existing responsive screenshots remain visually representative because this amendment changes focus behavior only. + +Isolated browser check used fixture ports15141/15142, not live10100. Successful disable restored focus to the toggle; failed PUT plus failed GET focused the setting section and kept the switch disabled. No real account, upstream inference or reset credits were used. + +Departure scenario also passed in the real browser: after failed PUT/GET, focused the separate usage-threshold input, restored the fixture GET endpoint, and let the actual30s settings poll complete. The protected switch became enabled while focus remained on the threshold input. diff --git a/devlog/_plan/260905_main_quota_guard/026_ui_dev_integration.md b/devlog/_plan/260905_main_quota_guard/026_ui_dev_integration.md new file mode 100644 index 0000000000..7d9c624603 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/026_ui_dev_integration.md @@ -0,0 +1,7 @@ +# UI integration with current quota controls + +UI rebased onto runtime03ee2f119/latest dev593978db0. Union conflict resolution preserves the quotaAutoRefresh DTO/control props and the independent hard-lock status/Manage/navigation wiring. Aristotle read-only union review found no missing wiring; its fixture update supplies the new required quotaAutoRefresh fields/props without changing assertions. + +GUI build/typecheck passed. No local test suite ran. Isolated real-component browser inspection at1280x900 and390x844 verified no horizontal overflow, coexistence of the new automatic-window controls and hard-lock status, confirmation/save with toggle focus restoration, and Manage focus/scroll to the peer-level setting beneath Ultra Fast. Captured three new JPEGs in022_ui_evidence with integrated in the filenames; prior screenshots remain historical evidence. + +The preview used fake quota/account data, separate homes and ports15141/15142, and an explicit outbound-fetch refusal. Automatic-window switches remained off; no model warmup, real account change or live10100 mutation occurred. Viewport/CDP overrides, tab and preview processes were cleaned up. Fresh exact-head CI and maintainer review remain required after publication. diff --git a/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md b/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md new file mode 100644 index 0000000000..0921802523 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md @@ -0,0 +1,37 @@ +# Explicit Reserve alongside independently routed models + +Loop archetype: spec satisfaction. Trigger: original owner request2; grounded by installed Desktop/source findings in001. Consume runtime provenance and settings contracts. No installed application mutation, synthetic exhaustion or forged ordinary_usage_allowed. Existing authless config is the explicit user-selected client mode; never enable it automatically. + +## Contract + +NEW `src/codex/reserve-availability.ts`: memory-only, identity-generation-bound Reserve observation. Public status is available/unavailable/unknown, without identity/credential data. Record only a completed owned main WHAM response associated with captured MainQuotaWriter. Require fresh matching identity, ordinary rate_limit.allowed=false, rate_limit_upsell.banner_type=luna_reserve, additional_rate_limits entry limit_name=gpt-reserve with allowed=true. Reject contradictory explicit account/user identifiers. Missing/stale/failed observations never grant access. Use a named bounded freshness TTL and existing owned refresh/single-flight route; do not probe unrelated pool accounts. No persisted entitlement grant. + +MODIFY `src/codex/quota.ts` WHAM types to retain the optional allowed/banner/identity fields and additional Reserve window shape without folding it into ordinary percentages. Field chain: authenticated WHAM input -> reserve parser/recorder with MainQuotaWriter -> memory observation -> fresh availability getter -> catalog and explicit-main request gate -> safe DTO. Ordinary quota parser stays ordinary; no new use of Reserve percentages in99% policy. +MODIFY `src/codex/auth-api.ts`: successful identity-validated main WHAM path records Reserve availability with the already captured writer; unsuccessful/contradictory replies do not extend validity. Expose only safe availability in main account DTO if needed for actionable status. Keep usage refresh available while main is protected. Main identity changes invalidate observations through the existing generation API. + +## Independent quota semantics + +MODIFY `src/codex/routing.ts`: +```diff +-type CodexQuotaScope = 'shared' | 'spark'; ++type CodexQuotaScope = 'shared' | 'spark' | 'reserve'; + const NATIVE_MODEL_QUOTA_SCOPES = { + 'gpt-5.3-codex-spark': 'spark', ++ 'gpt-reserve': 'reserve', + }; +``` +Creation: exact native wire model mapping. Serialization: existing scoped health/affinity structures; inspect every scope field/enum/string consumer at the next P. Deserialization: existing scope validators must accept reserve explicitly, not silently default it to shared. Consumers: global-first cooldown lookup, scoped health writes, affinity/pool cursor, probe claim/settlement and status/error formatting. Existing independent-scope predicates already cover non-shared; every spark-only exception must be classified rather than blindly duplicated. +Global Retry-After/default throttles remain account-wide and win over scope-specific evidence. A shared reset-derived cooldown does not imply Reserve exhaustion. Generic recovery claim and auth-api settlement currently exclude only spark; exclude reserve too so an ordinary success cannot clear Reserve health. Do not add an automatic Reserve recovery worker in this first slice. + +## Catalog and exact request gate + +MODIFY `src/codex/catalog/metadata.ts` observed account-native filter to preserve an actual full-shape gpt-reserve row despite supported_in_api=false, without relaxing other unknown models. Preserve raw upstream metadata; availability is a separate fact. Project only the configured main selector through `src/codex/catalog/account-models.ts` and existing catalog assembly owner, and only when fresh Reserve permission and explicit authless mode are active. Do not expose Reserve on added-account selectors or OpenAI API-key rows. If current source provides Reserve as a generated preset rather than a roster row, use only that source-defined preset after documenting its exact fields at the stale check; do not silently invent capability metadata. +MODIFY `src/codex/auth-context.ts`: exact main/gpt-reserve request must revalidate fresh owned Reserve availability before dispatch, keep main hard-lock/pause/reauth/global cooldown checks, and use reserve quota scope. Refuse missing grant with an actionable safe error. No fallback to normal Luna, another account or an external provider. Bare/automatic client handling stays outside the explicit compatibility contract unless the next P proves a necessary source-defined route. +Existing `src/router.ts` already accepts main/gpt-* namespaces; do not broaden all unknown bare routes. Existing `codexDesktopAuthless` injection disables the Desktop native Reserve-only picker gate, but also its automatic Reserve behavior. Document manual Reserve selection and restart requirement truthfully. + +## Tests and public documentation + +NEW focused Reserve tests in tests/codex-integration (register both layout manifests): positive/absent/stale/contradictory/identity-change grants, exact-main route, no native fallback, external model availability, main99 lock still wins, global throttle wins, shared reset-derived scope independence, ordinary recovery/success does not clear Reserve cooldown, catalog only observed/source-defined metadata and eligible main selector. +MODIFY docs-site English/Korean guides/codex-integration.md and structure/08_openai-provider-tiers.md: opt-in command, its native-account UI tradeoff, manual Reserve selection and upstream eligibility requirement. No new top-level toggle unless existing setting truly lacks a supported entrypoint. + +Verification: source/built catalog and recorded-grant scenarios, static checks, exact-head CI, isolated mixed-picker projection. Live upstream Reserve success is not currently testable because no Reserve-active account was observed. Do not exhaust an account to manufacture it; label that live test unavailable, and never present it as passed. Stop/ask if implementation needs a materially different installed-app patch or unknown upstream permission. Scope/freshness/API details are revalidated in its own P before any code in this decade. diff --git a/devlog/_plan/260905_main_quota_guard/030_delivery.md b/devlog/_plan/260905_main_quota_guard/040_delivery.md similarity index 86% rename from devlog/_plan/260905_main_quota_guard/030_delivery.md rename to devlog/_plan/260905_main_quota_guard/040_delivery.md index 40c5312f8d..a138ef75d9 100644 --- a/devlog/_plan/260905_main_quota_guard/030_delivery.md +++ b/devlog/_plan/260905_main_quota_guard/040_delivery.md @@ -1,6 +1,6 @@ # Reviewable stack delivery -Depends on wp1 and wp2. No production code in this cycle unless a verified defect requires a new scoped repair plan. +Depends on the completed runtime, settings and Reserve compatibility outputs. No production code in this cycle unless a verified defect requires a new scoped repair plan. All remaining platform checks, including macOS, are mandatory before each merge; runtime checkpoints are not merge approval. ## Branch and PR operations diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 240ec19edc..35224f82be 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -71,6 +71,30 @@ ocx login anthropic ## 계정과 키 풀 +### 메인 계정 99% 보호 + +**Codex 설정 → 다중 인증 → 고급 설정**에서 Ultra Fast 옆의 **메인 계정 99% 차단**을 +켤 수 있습니다. 켜기 전에 영향 안내가 나오며, 취소하면 설정은 바뀌지 않습니다. +고급 설정을 접어도 메인 계정 카드에 보호 상태와 사용량 확인 필요 여부, 현재 차단 여부가 표시됩니다. + +**5h 창이 있으면 5h 사용률**, 없으면 주간 사용률을 봅니다. 월간 전용 계정은 월간을 +기준으로 합니다. 여러 창에서 가장 높은 값을 고르는 방식은 아닙니다. 새 사용률이 **0%**로 +리셋되면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 99%가 되면 차단합니다. +값이 빠진 응답을 0%로 보지 않으며, 이미 확인한 차단 수치를 누락된 응답만으로 지우지도 않습니다. +예정된 리셋 시간이 지났다는 이유만으로 풀지는 않습니다. 차단 중에는 기존 1분 주기 점검에서 +실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다. +일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다. + +저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock": true`이며, +기본값은 꺼짐입니다. 식별된 메인 계정의 새 요청을 막는 기능이지 마지막 1%를 예약하는 기능은 +아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. +추가 계정과 다른 공급자는 계속 사용할 수 있습니다. + +차단 중에는 해당 메인 계정의 Luna Reserve도 쓸 수 없습니다. 일반 사용량이 소진되지 않으면 +Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 처리 방식으로 돌아가지만 서버가 +허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며, +리셋 크레딧을 자동으로 소비하지는 않습니다. + ### `ocx account ` 실행 중인 프록시를 통해 제공자 계정과 API 키 풀을 나열하고 전환합니다. 제공되는 도움말 표면은 다음과 같습니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 6396a68c41..89a679a77e 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -93,6 +93,31 @@ Remove the stored OAuth credential for a provider. ## Accounts and key pools +### Main-account 99% protection + +In **Codex settings → Multi-auth → Advanced settings**, **Block main account at 99%** +is an independent opt-in beside Ultra Fast. Enabling it first shows the consequences; cancelling +does not change the setting. The main-account card shows monitoring, unknown usage, or a current +policy block even when Advanced settings is closed. + +The policy uses the **5h window when present**, otherwise the weekly window. Monthly-only +accounts use their monthly window. It does not take the highest percentage across windows. +A fresh **0%** observation automatically releases the block while the switch stays on; the next +99% observation blocks again. Unknown usage does not fabricate a zero, and a missing reading does +not erase an already measured blocking tuple. A predicted reset time alone does not unlock it. +While blocked, the existing once-per-minute background cycle checks fresh owned usage; failed or +invalid readings retain the block. Other pause, reauthentication, and upstream limits remain independent. + +The persisted option is `"codexMainAccountHardLock": true` in OpenCodex's `config.json`; it is off +by default. This protects new requests using the identified main account, not the last 1% itself: +already-running requests, unmatched caller-owned keyring credentials, and traffic outside the +proxy can still spend quota. Added accounts and other providers remain available. + +While this policy blocks main, Luna Reserve on that account is blocked too. Staying below ordinary +quota exhaustion may prevent Reserve activation. Disabling the switch restores normal local +handling, not additional upstream entitlement. Use the account quota refresh action to obtain a +fresh observation; no reset credit is consumed automatically. + ### `ocx account ` List and switch provider accounts and API-key pools through the running proxy. The shipped help diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 13f4f7a26f..37df538ef2 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -36,7 +36,7 @@ type QuotaAutoRefreshSettings = Record void; /** Whole boxes rendered inside Advanced settings. Never fold these internally. */ advancedExtras?: ReactNode; + /** This surface supplies the protection setting in advancedExtras; manage opens it locally. */ + hasMainHardLockSetting?: boolean; /** * WP3: when Providers owns the controller, every surface shares one instance so a * mutation on Overview is immediately visible on the Accounts tab. The standalone @@ -69,6 +71,22 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const hardLockFocusPending = useRef(false); + const focusHardLockSetting = useCallback(() => { + const target = document.getElementById("codex-main-hard-lock-setting"); + target?.focus(); + target?.scrollIntoView({ block: "nearest" }); + }, []); + useEffect(() => { + if (advancedOpen && hardLockFocusPending.current) { + hardLockFocusPending.current = false; + focusHardLockSetting(); + } + }, [advancedOpen, focusHardLockSetting]); + const manageMainHardLock = () => { + if (advancedOpen) focusHardLockSetting(); + else { hardLockFocusPending.current = true; setAdvancedOpen(true); } + }; const [reauthId, setReauthId] = useState(null); const [actionFeedback, setActionFeedback] = useState(null); const [actionFeedbackTone, setActionFeedbackTone] = useState(null); @@ -451,6 +469,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined} quotaAutoRefreshBusy={quotaAutoRefreshBusy} onToggleQuotaAutoRefresh={(entry, window) => { void toggleQuotaAutoRefresh(entry, window); }} + onManageMainHardLock={hasMainHardLockSetting ? manageMainHardLock : undefined} />
diff --git a/gui/src/components/MainAccountHardLockSetting.tsx b/gui/src/components/MainAccountHardLockSetting.tsx new file mode 100644 index 0000000000..7f5e895331 --- /dev/null +++ b/gui/src/components/MainAccountHardLockSetting.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createBoundedFetch } from "../bounded-fetch"; +import { startVisibilityPoll } from "../visibility-poll"; +import { useT } from "../i18n/shared"; +import type { MainAccountHardLockStatus } from "../hooks/useCodexAccountPool"; + +type Props = { apiBase: string; onSaved: () => Promise }; +type Snapshot = { codexMainAccountHardLock: boolean; mainAccountHardLock: MainAccountHardLockStatus }; + +function readSnapshot(value: unknown): Snapshot { + if (!value || typeof value !== "object") throw new Error("settings shape"); + const payload = value as Partial; + const policy = payload.mainAccountHardLock; + if (typeof payload.codexMainAccountHardLock !== "boolean" || !policy + || policy.enabled !== payload.codexMainAccountHardLock + || !(policy.enabled ? ["unknown", "ready", "blocked"] : ["off"]).includes(policy.state) + || (policy.resetAt !== undefined && (typeof policy.resetAt !== "number" || !Number.isFinite(policy.resetAt)))) { + throw new Error("settings shape"); + } + return payload as Snapshot; +} + +function HardLockConfirmation({ pending, onCancel, onConfirm }: { + pending: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + const t = useT(); + const id = useId(); + const dialogRef = useRef(null); + const cancelRef = useRef(null); + const confirmRef = useRef(null); + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + cancelRef.current?.focus(); + return () => { if (dialog?.open) dialog.close(); }; + }, []); + return ( + { event.preventDefault(); onCancel(); }} + onKeyDown={event => { + if (event.key !== "Tab" || pending) return; + const from = event.shiftKey ? cancelRef.current : confirmRef.current; + const to = event.shiftKey ? confirmRef.current : cancelRef.current; + if (document.activeElement === from) { event.preventDefault(); to?.focus(); } + }}> + + +
+ + + ); +} + +/** A changed proxy identity must not inherit another proxy's acknowledged setting. */ +export default function MainAccountHardLockSetting(props: Props) { + return ; +} + +function HardLockSetting({ apiBase, onSaved }: Props) { + const t = useT(); + const id = useId(); + const [snapshot, setSnapshot] = useState(null); + const [loadError, setLoadError] = useState(false); + const [saveError, setSaveError] = useState(false); + const [refreshError, setRefreshError] = useState(false); + const [saved, setSaved] = useState(null); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const busyRef = useRef(false); + const mountedRef = useRef(false); + const generationRef = useRef(0); + const readAbortRef = useRef(null); + const toggleRef = useRef(null); + const sectionRef = useRef(null); + const restoreFocusRef = useRef(false); + + const load = useCallback(async () => { + if (busyRef.current) return; + const generation = ++generationRef.current; + readAbortRef.current?.abort(); + const bounded = createBoundedFetch(15_000); + readAbortRef.current = bounded.controller; + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: bounded.signal }); + if (!response.ok) throw new Error("load"); + const next = readSnapshot(await response.json()); + if (!mountedRef.current || generation !== generationRef.current) return; + setSnapshot(next); + setLoadError(false); + setSaved(null); + } catch { + if (mountedRef.current && generation === generationRef.current) setLoadError(true); + } finally { + bounded.clear(); + } + }, [apiBase]); + + useEffect(() => { + mountedRef.current = true; + const timeout = window.setTimeout(() => { void load(); }, 0); + const stop = startVisibilityPoll(() => { void load(); }, 30_000); + return () => { + mountedRef.current = false; + generationRef.current += 1; + readAbortRef.current?.abort(); + window.clearTimeout(timeout); + stop(); + }; + }, [load]); + + useEffect(() => { + if (confirming || saving || !restoreFocusRef.current) return; + if (toggleRef.current && !toggleRef.current.disabled) { + toggleRef.current.focus(); + restoreFocusRef.current = false; + } else { + // Keep the intent through failed/pending authoritative reads: the section is + // focusable while the switch is disabled, and a successful GET completes restoration. + sectionRef.current?.focus(); + } + }, [confirming, saving, loadError, snapshot]); + + const refreshMain = async () => { + let confirmed = false; + try { confirmed = await onSaved(); } catch { /* Saved config is not a failed PUT. */ } + if (mountedRef.current) setRefreshError(!confirmed); + }; + + const save = async (requested: boolean) => { + // The toggle requires a successful read before opening confirmation. A later poll + // failure must not silently turn an already-open confirmation into a no-op. + if (busyRef.current || !snapshot) return; + busyRef.current = true; + generationRef.current += 1; + readAbortRef.current?.abort(); + setSaving(true); + setSaveError(false); + setRefreshError(false); + setSaved(null); + const bounded = createBoundedFetch(15_000); + let acknowledged = false; + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ codexMainAccountHardLock: requested }), signal: bounded.signal, + }); + if (!response.ok) throw new Error("save"); + const payload: unknown = await response.json(); + if (!payload || typeof payload !== "object" || !("ok" in payload) || payload.ok !== true) { + throw new Error("unconfirmed"); + } + const next = readSnapshot(payload); + acknowledged = true; + if (mountedRef.current) { + setSnapshot(next); + setLoadError(false); + setSaved(next.codexMainAccountHardLock); + } + } catch { + if (mountedRef.current) { + setSaveError(true); + setLoadError(true); + } + } finally { + bounded.clear(); + } + // Also refresh the owner if Advanced was collapsed while a disable PUT was pending. + if (acknowledged) await refreshMain(); + busyRef.current = false; + if (!mountedRef.current) return; + setSaving(false); + setConfirming(false); + if (!acknowledged) void load(); // A timeout may still have committed: re-read, never guess. + }; + + const cancel = () => { + if (!busyRef.current) setConfirming(false); + }; + const retryRefresh = async () => { + if (busyRef.current) return; + busyRef.current = true; + generationRef.current += 1; + setSaving(true); + await refreshMain(); + busyRef.current = false; + if (mountedRef.current) setSaving(false); + }; + const enabled = snapshot?.codexMainAccountHardLock; + return ( +
{ + // A deliberate departure cancels restoration; disabled controls can blur to null. + if (event.relatedTarget !== null && !event.currentTarget.contains(event.relatedTarget)) { + restoreFocusRef.current = false; + } + }}> +
+ {t("codexAuth.mainHardLockTitle")} +
{t("codexAuth.mainHardLockDesc")}
+
+ +
+ {(saveError || loadError) &&

{t(saveError ? "codexAuth.mainHardLockSaveFailed" : "codexAuth.mainHardLockLoadFailed")}{" "} + +

} + {saved !== null && !refreshError &&

{t(saved ? "codexAuth.mainHardLockEnabled" : "codexAuth.mainHardLockDisabled")}

} + {refreshError &&

{t("codexAuth.mainHardLockRefreshFailed")}{" "} + +

} +
+ {confirming && { void save(true); }} />} +
+ ); +} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index ebd2c39ea7..86f83179d9 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -8,6 +8,7 @@ import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { NoticeTone } from "../ui"; import { CodexQuotaAutoRefreshControls } from "./codex-account-pool-cards"; +import { navigateHash } from "../hash-routing"; import { doctorCopyButtonLabel, formatOAuthHealthLabel, @@ -38,6 +39,7 @@ export function CodexAccountPoolMainCard({ doctorCopyOutcomeFor, quotaAutoRefreshBusy, onToggleQuotaAutoRefresh, + onManageMainHardLock, }: { t: TFn; main: CodexAccountEntry | undefined; @@ -64,6 +66,7 @@ export function CodexAccountPoolMainCard({ doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; quotaAutoRefreshBusy: string | null; onToggleQuotaAutoRefresh: (account: CodexAccountEntry, window: "fiveHour" | "weekly") => void; + onManageMainHardLock?: () => void; }) { const mainFallbackLabel = t("codexAuth.codexApp"); const mainId = main?.id ?? "__main__"; @@ -85,15 +88,17 @@ export function CodexAccountPoolMainCard({ }; const showReauth = Boolean(main?.needsReauth) || oauthHealthShowsReauth(main?.health?.status); const inCooldown = oauthHealthIsCooldown(main?.health?.status); + const policy = main?.mainAccountHardLock; + const hardLocked = policy?.enabled === true && policy.state === "blocked"; const healthLabel = formatOAuthHealthLabel(t, main?.health); const healthSummary = main ? formatOAuthHealthSummary(t, "codex", mainId, main.health) : null; return ( -
+
- + {t("codexAuth.mainAccount")} {main?.plan && {main.plan}} @@ -109,7 +114,7 @@ export function CodexAccountPoolMainCard({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {!main?.paused && ( + {!main?.paused && !hardLocked && ( {isMainActive ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") @@ -117,7 +122,7 @@ export function CodexAccountPoolMainCard({ )} - {!main?.paused && (!isMainActive || pinnedId !== "__main__") && !showReauth && !inCooldown && ( + {!main?.paused && !hardLocked && (!isMainActive || pinnedId !== "__main__") && !showReauth && !inCooldown && ( @@ -166,6 +171,15 @@ export function CodexAccountPoolMainCard({ /> )}
+ {policy?.enabled && ( +
+

{t(hardLocked ? "codexAuth.mainHardLockBlocked" + : policy.state === "ready" ? "codexAuth.mainHardLockMonitoring" : "codexAuth.mainHardLockUnknown")}

+ {onManageMainHardLock + ? + : } +
+ )} {healthSummary && (
{healthSummary}
)} diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index d5c01bc304..0cd82d7293 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -24,6 +24,13 @@ import { * Modals, toasts, prompts and popovers stay in the presentation layer. */ +export interface MainAccountHardLockStatus { + enabled: boolean; + state: "off" | "unknown" | "ready" | "blocked"; + /** Server timestamp in milliseconds; not a client-side unlock instruction. */ + resetAt?: number; +} + export interface CodexAccountEntry { id: string; email: string; @@ -45,6 +52,7 @@ export interface CodexAccountEntry { fiveHourEnabled: boolean; weeklyEnabled: boolean; }; + mainAccountHardLock?: MainAccountHardLockStatus; needsReauth?: boolean; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2f34948385..1b91e05e1d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1202,6 +1202,20 @@ export const de: Record = { "codexAuth.sparkQuotaFailed": "Codex-Spark-Kontingent konnte nicht geändert werden", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", + "codexAuth.mainHardLockTitle": "Hauptkonto bei 99 % sperren", + "codexAuth.mainHardLockDesc": "Verwendet das 5-Stunden-Fenster, falls vorhanden, sonst das Wochenfenster (bei rein monatlichen Konten das Monatsfenster). Ein neuer Wert von 0 % hebt die Sperre automatisch auf; der Schutz bleibt aktiv.", + "codexAuth.mainHardLockConfirmTitle": "99-%-Schutz für das Hauptkonto aktivieren?", + "codexAuth.mainHardLockConfirmBody": "Während der Sperre ist auch Luna Reserve für das Hauptkonto nicht verfügbar. Ohne vollständigen Verbrauch des normalen Kontingents wird Reserve möglicherweise nicht aktiviert. Zusätzliche Konten und andere Anbieter bleiben nutzbar. Laufende Anfragen, nicht zugeordnete Schlüsselbund-Zugangsdaten und Anfragen außerhalb dieses Proxys sind nicht geschützt.", + "codexAuth.mainHardLockConfirm": "Schutz aktivieren", + "codexAuth.mainHardLockEnabled": "99-%-Schutz ist aktiviert.", + "codexAuth.mainHardLockDisabled": "99-%-Schutz ist deaktiviert. Andere Kontolimits gelten weiterhin.", + "codexAuth.mainHardLockLoadFailed": "Einstellung konnte nicht geladen werden. Erneut versuchen, um den aktuellen Zustand zu prüfen.", + "codexAuth.mainHardLockSaveFailed": "Speicherung konnte nicht bestätigt werden. Einstellung vor einem neuen Versuch erneut laden.", + "codexAuth.mainHardLockRefreshFailed": "Einstellung gespeichert, aber Kontostatus konnte nicht aktualisiert werden. Bitte erneut versuchen.", + "codexAuth.mainHardLockBlocked": "Durch 99-%-Schutz gesperrt", + "codexAuth.mainHardLockUnknown": "Schutz aktiv · Nutzung unbekannt", + "codexAuth.mainHardLockMonitoring": "Schutz aktiv · Überwachung", + "codexAuth.mainHardLockManage": "Schutzeinstellung anzeigen", "codexAuth.ultraFastDesc": "Verhindert, dass ein selbst konfigurierter ultrafast-Diensttarif beim Neuaufbau des Katalogs entfernt wird, und benennt ihn in den Anfrageprotokollen. Ultra Fast wird nicht in die Modellauswahl aufgenommen: Upstream kündigt nur Fast an, ein Eintrag würde also eine Geschwindigkeit anbieten, die die Leitung nicht liefern kann.", "codexAuth.ultraFastLoadFailed": "Die Ultra-Fast-Einstellung konnte nicht gelesen werden.", "codexAuth.ultraFastEnabled": "Ultra-Fast-Tarif aktiviert", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7a3a21b11a..aec7477655 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1744,6 +1744,20 @@ export const en = { "codexAuth.sparkQuotaFailed": "Could not change the Codex Spark quota setting", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.ultraFastTitle": "Ultra Fast service tier", + "codexAuth.mainHardLockTitle": "Block main account at 99%", + "codexAuth.mainHardLockDesc": "Uses 5h usage when available, otherwise weekly (monthly for monthly-only accounts). A fresh 0% reading unlocks automatically; protection stays on.", + "codexAuth.mainHardLockConfirmTitle": "Enable the main account 99% lock?", + "codexAuth.mainHardLockConfirmBody": "While blocked, the main account cannot use Luna Reserve. Keeping normal usage below exhaustion may prevent Reserve activation. Added accounts and other providers remain available. Running requests, unmatched keyring credentials, and traffic outside this proxy are not protected.", + "codexAuth.mainHardLockConfirm": "Enable protection", + "codexAuth.mainHardLockEnabled": "99% protection is on.", + "codexAuth.mainHardLockDisabled": "99% protection is off. Other account limits still apply.", + "codexAuth.mainHardLockLoadFailed": "Could not load this setting. Retry to check its current state.", + "codexAuth.mainHardLockSaveFailed": "Could not confirm the save. Reload the setting before trying again.", + "codexAuth.mainHardLockRefreshFailed": "Setting saved, but account status could not be refreshed. Please retry.", + "codexAuth.mainHardLockBlocked": "Blocked by 99% protection", + "codexAuth.mainHardLockUnknown": "Protection on · usage unknown", + "codexAuth.mainHardLockMonitoring": "Protection on · monitoring", + "codexAuth.mainHardLockManage": "View protection setting", "codexAuth.ultraFastDesc": "Keeps an ultrafast service tier you configured yourself from being stripped when the catalog is regenerated, and names it in the request logs. It does not add Ultra Fast to the model picker: upstream advertises only Fast, so a picker row would offer a speed the wire cannot deliver.", "codexAuth.ultraFastLoadFailed": "Could not read the Ultra Fast setting.", "codexAuth.ultraFastEnabled": "Ultra Fast tier enabled", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e953217ede..b0f5c3958a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1676,6 +1676,20 @@ export const fr: Record = { "codexAuth.sparkQuotaFailed": "Impossible de modifier le réglage du quota Codex Spark", "codexAuth.refreshQuota": "Actualiser les quotas", "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", + "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 99 %", + "codexAuth.mainHardLockDesc": "Utilise la fenêtre de 5 h si elle existe, sinon la semaine (le mois pour les comptes mensuels uniquement). Une nouvelle mesure à 0 % lève le blocage automatiquement ; la protection reste active.", + "codexAuth.mainHardLockConfirmTitle": "Activer la protection à 99 % du compte principal ?", + "codexAuth.mainHardLockConfirmBody": "Pendant le blocage, Luna Reserve est également indisponible sur le compte principal. Ne pas épuiser le quota normal peut empêcher l’activation de Reserve. Les comptes ajoutés et les autres fournisseurs restent utilisables. Les requêtes en cours, les identifiants du trousseau non reconnus et le trafic hors de ce proxy ne sont pas protégés.", + "codexAuth.mainHardLockConfirm": "Activer la protection", + "codexAuth.mainHardLockEnabled": "La protection à 99 % est active.", + "codexAuth.mainHardLockDisabled": "La protection à 99 % est désactivée. Les autres limites du compte restent applicables.", + "codexAuth.mainHardLockLoadFailed": "Impossible de charger ce réglage. Réessayez pour vérifier son état.", + "codexAuth.mainHardLockSaveFailed": "Impossible de confirmer l’enregistrement. Rechargez le réglage avant de réessayer.", + "codexAuth.mainHardLockRefreshFailed": "Réglage enregistré, mais l’état du compte n’a pas pu être actualisé. Réessayez.", + "codexAuth.mainHardLockBlocked": "Bloqué par la protection à 99 %", + "codexAuth.mainHardLockUnknown": "Protection active · utilisation inconnue", + "codexAuth.mainHardLockMonitoring": "Protection active · surveillance", + "codexAuth.mainHardLockManage": "Voir le réglage de protection", "codexAuth.ultraFastDesc": "Empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même lors de la régénération du catalogue, et le nomme dans les journaux de requêtes. Ultra Fast n’est pas ajouté au sélecteur de modèles : l’amont n’annonce que Fast, une entrée proposerait donc une vitesse que le transport ne peut pas fournir.", "codexAuth.ultraFastLoadFailed": "Impossible de lire le réglage Ultra Fast.", "codexAuth.ultraFastEnabled": "Niveau Ultra Fast activé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e0f8e317c3..d881c4aafb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1636,6 +1636,20 @@ export const ja: Record = { "codexAuth.sparkQuotaFailed": "Codex Spark 使用量の設定を変更できませんでした", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", + "codexAuth.mainHardLockTitle": "メインアカウントを99%で停止", + "codexAuth.mainHardLockDesc": "5時間枠があればその使用率、なければ週間使用率を使います(月間のみのアカウントは月間)。0%にリセットされると自動解除し、設定は有効のままです。", + "codexAuth.mainHardLockConfirmTitle": "メインアカウントの99%保護を有効にしますか?", + "codexAuth.mainHardLockConfirmBody": "停止中はメインアカウントのLuna Reserveも使えません。通常枠を使い切らない場合、Reserveが有効にならないことがあります。追加アカウントや他のプロバイダーは引き続き使えます。実行中のリクエスト、照合できないキーチェーン認証情報、このプロキシ外の通信は対象外です。", + "codexAuth.mainHardLockConfirm": "保護を有効にする", + "codexAuth.mainHardLockEnabled": "99%保護を有効にしました。", + "codexAuth.mainHardLockDisabled": "99%保護を無効にしました。他のアカウント制限は引き続き適用されます。", + "codexAuth.mainHardLockLoadFailed": "設定を読み込めませんでした。再試行して現在の状態を確認してください。", + "codexAuth.mainHardLockSaveFailed": "保存を確認できませんでした。設定を再読み込みしてから再試行してください。", + "codexAuth.mainHardLockRefreshFailed": "設定は保存されましたが、アカウント状態を更新できませんでした。再試行してください。", + "codexAuth.mainHardLockBlocked": "99%保護により停止中", + "codexAuth.mainHardLockUnknown": "保護有効・使用率不明", + "codexAuth.mainHardLockMonitoring": "保護有効・監視中", + "codexAuth.mainHardLockManage": "保護設定を表示", "codexAuth.ultraFastDesc": "自分で設定した ultrafast サービスティアがカタログ再生成時に削除されないようにし、リクエストログにそのティア名を記録します。モデルピッカーに Ultra Fast は追加しません。アップストリームは Fast しか公開しておらず、ピッカーに項目を出すと実際には出せない速度を選ばせることになるためです。", "codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。", "codexAuth.ultraFastEnabled": "Ultra Fast ティアを有効にしました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ab644d34d4..c0ff19b3f2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1226,6 +1226,20 @@ export const ko: Record = { "codexAuth.sparkQuotaFailed": "Codex Spark 할당량 설정을 바꾸지 못했습니다", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", + "codexAuth.mainHardLockTitle": "메인 계정 99% 차단", + "codexAuth.mainHardLockDesc": "5h 창이 있으면 5h, 없으면 주간 사용률을 기준으로 합니다. 월간 전용 계정은 월간을 봅니다. 0%로 리셋되면 자동으로 풀리고 설정은 유지됩니다.", + "codexAuth.mainHardLockConfirmTitle": "메인 계정 99% 차단을 켤까요?", + "codexAuth.mainHardLockConfirmBody": "차단 중에는 메인 계정의 Luna Reserve도 사용할 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청에는 적용되지 않습니다.", + "codexAuth.mainHardLockConfirm": "확인하고 켜기", + "codexAuth.mainHardLockEnabled": "99% 보호 설정을 켰습니다.", + "codexAuth.mainHardLockDisabled": "99% 보호 설정을 껐습니다. 다른 계정 제한은 그대로 적용됩니다.", + "codexAuth.mainHardLockLoadFailed": "설정을 불러오지 못했습니다. 다시 시도해 현재 상태를 확인하세요.", + "codexAuth.mainHardLockSaveFailed": "저장 여부를 확인하지 못했습니다. 설정을 다시 불러온 뒤 시도하세요.", + "codexAuth.mainHardLockRefreshFailed": "설정은 저장됐지만 계정 상태를 다시 확인하지 못했습니다. 다시 시도하세요.", + "codexAuth.mainHardLockBlocked": "99% 보호로 차단 중", + "codexAuth.mainHardLockUnknown": "보호 켜짐 · 사용량 확인 필요", + "codexAuth.mainHardLockMonitoring": "99% 보호 켜짐", + "codexAuth.mainHardLockManage": "차단 설정 보기", "codexAuth.ultraFastDesc": "직접 설정한 ultrafast 서비스 티어가 카탈로그를 다시 만들 때 지워지지 않게 하고, 요청 로그에 그 티어 이름을 남깁니다. 모델 피커에 Ultra Fast를 추가하지는 않습니다. 업스트림은 Fast만 알리기 때문에, 피커에 칸을 만들면 실제로 낼 수 없는 속도를 고르게 하는 셈입니다.", "codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.", "codexAuth.ultraFastEnabled": "Ultra Fast 티어를 켰습니다", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d1e721ebe6..9d38b2485b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1687,6 +1687,20 @@ export const ru: Record = { "codexAuth.sparkQuotaFailed": "Не удалось изменить настройку квоты Codex Spark", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", + "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 99%", + "codexAuth.mainHardLockDesc": "Используется окно 5 ч, если оно есть, иначе недельное (месячное для аккаунтов только с месячным лимитом). Новое значение 0% автоматически снимает блокировку; защита остаётся включённой.", + "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта при 99%?", + "codexAuth.mainHardLockConfirmBody": "Во время блокировки Luna Reserve основного аккаунта тоже недоступна. Если обычная квота не исчерпана, Reserve может не активироваться. Дополнительные аккаунты и другие провайдеры остаются доступны. Текущие запросы, несопоставленные данные связки ключей и запросы вне этого прокси не защищены.", + "codexAuth.mainHardLockConfirm": "Включить защиту", + "codexAuth.mainHardLockEnabled": "Защита при 99% включена.", + "codexAuth.mainHardLockDisabled": "Защита при 99% выключена. Остальные лимиты аккаунта сохраняются.", + "codexAuth.mainHardLockLoadFailed": "Не удалось загрузить настройку. Повторите попытку, чтобы проверить её состояние.", + "codexAuth.mainHardLockSaveFailed": "Не удалось подтвердить сохранение. Перезагрузите настройку перед повторной попыткой.", + "codexAuth.mainHardLockRefreshFailed": "Настройка сохранена, но состояние аккаунта не обновилось. Повторите попытку.", + "codexAuth.mainHardLockBlocked": "Заблокирован защитой при 99%", + "codexAuth.mainHardLockUnknown": "Защита включена · расход неизвестен", + "codexAuth.mainHardLockMonitoring": "Защита включена · наблюдение", + "codexAuth.mainHardLockManage": "Открыть настройку защиты", "codexAuth.ultraFastDesc": "Не даёт удалить настроенный вами уровень ultrafast при перегенерации каталога и записывает его имя в журналы запросов. Ultra Fast не добавляется в выбор моделей: вышестоящий сервис объявляет только Fast, поэтому пункт в списке предлагал бы скорость, которую канал не может обеспечить.", "codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.", "codexAuth.ultraFastEnabled": "Уровень Ultra Fast включён", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5a39f0c3c3..5b9ad0e3fb 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1705,6 +1705,20 @@ export const tr: Record = { "codexAuth.sparkQuotaFailed": "Codex Spark kotası ayarı değiştirilemedi", "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", + "codexAuth.mainHardLockTitle": "Ana hesabı %99’da durdur", + "codexAuth.mainHardLockDesc": "Varsa 5 saatlik, yoksa haftalık kullanım esas alınır (yalnızca aylık hesaplarda aylık kullanım). Yeni %0 ölçümü engeli otomatik kaldırır; koruma açık kalır.", + "codexAuth.mainHardLockConfirmTitle": "Ana hesap için %99 koruması açılsın mı?", + "codexAuth.mainHardLockConfirmBody": "Engel sürerken ana hesabın Luna Reserve erişimi de kullanılamaz. Normal kotanın tükenmemesi Reserve’in etkinleşmesini önleyebilir. Ek hesaplar ve diğer sağlayıcılar kullanılmaya devam eder. Çalışan istekler, eşleştirilemeyen anahtarlık kimlik bilgileri ve bu proxy dışındaki trafik korunmaz.", + "codexAuth.mainHardLockConfirm": "Korumayı aç", + "codexAuth.mainHardLockEnabled": "%99 koruması açık.", + "codexAuth.mainHardLockDisabled": "%99 koruması kapalı. Diğer hesap sınırları geçerliliğini korur.", + "codexAuth.mainHardLockLoadFailed": "Ayar yüklenemedi. Güncel durumu kontrol etmek için yeniden deneyin.", + "codexAuth.mainHardLockSaveFailed": "Kayıt doğrulanamadı. Yeniden denemeden önce ayarı tekrar yükleyin.", + "codexAuth.mainHardLockRefreshFailed": "Ayar kaydedildi ancak hesap durumu yenilenemedi. Yeniden deneyin.", + "codexAuth.mainHardLockBlocked": "%99 koruması nedeniyle engellendi", + "codexAuth.mainHardLockUnknown": "Koruma açık · kullanım bilinmiyor", + "codexAuth.mainHardLockMonitoring": "Koruma açık · izleniyor", + "codexAuth.mainHardLockManage": "Koruma ayarını göster", "codexAuth.ultraFastDesc": "Kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler ve istek günlüklerinde bu katmanın adını yazar. Ultra Fast’i model seçicisine eklemez: üst kaynak yalnızca Fast duyurur, bu yüzden bir satır eklemek hattın veremeyeceği bir hızı seçtirmek olurdu.", "codexAuth.ultraFastLoadFailed": "Ultra Fast ayarı okunamadı.", "codexAuth.ultraFastEnabled": "Ultra Fast katmanı etkinleştirildi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 94101c189e..915bacc6f8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1303,6 +1303,20 @@ export const zhTW: Record = { "codexAuth.sparkQuotaFailed": "無法變更 Codex Spark 配額設定", "codexAuth.refreshQuota": "重新整理額度", "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", + "codexAuth.mainHardLockTitle": "主帳戶用量達 99% 時阻擋請求", + "codexAuth.mainHardLockDesc": "有 5 小時額度時以該額度為準,否則使用週額度(僅有月額度的帳戶使用月額度)。新用量重設為 0% 後會自動解除阻擋,保護設定仍保持開啟。", + "codexAuth.mainHardLockConfirmTitle": "開啟主帳戶 99% 保護?", + "codexAuth.mainHardLockConfirmBody": "阻擋期間,主帳戶也無法使用 Luna Reserve。一般額度未用盡時,Reserve 可能不會啟用。新增帳戶與其他供應商仍可使用。進行中的請求、無法比對的鑰匙圈憑證,以及此代理之外的流量不受此保護。", + "codexAuth.mainHardLockConfirm": "開啟保護", + "codexAuth.mainHardLockEnabled": "99% 保護已開啟。", + "codexAuth.mainHardLockDisabled": "99% 保護已關閉,其他帳戶限制仍然適用。", + "codexAuth.mainHardLockLoadFailed": "無法載入此設定。請重試以確認目前狀態。", + "codexAuth.mainHardLockSaveFailed": "無法確認是否已儲存。請重新載入設定後再試。", + "codexAuth.mainHardLockRefreshFailed": "設定已儲存,但無法更新帳戶狀態。請重試。", + "codexAuth.mainHardLockBlocked": "已被 99% 保護阻擋", + "codexAuth.mainHardLockUnknown": "保護已開啟 · 用量未知", + "codexAuth.mainHardLockMonitoring": "保護已開啟 · 監測中", + "codexAuth.mainHardLockManage": "查看保護設定", "codexAuth.ultraFastDesc": "讓你自行設定的 ultrafast 服務層級在重新產生目錄時不被移除,並在請求記錄中寫下該層級名稱。它不會把 Ultra Fast 加入模型選擇器:上游只公布 Fast,選擇器出現該項等於讓使用者挑一個實際無法提供的速度。", "codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。", "codexAuth.ultraFastEnabled": "已啟用 Ultra Fast 層級", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5626b46abd..0761f05e51 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1219,6 +1219,20 @@ export const zh: Record = { "codexAuth.sparkQuotaFailed": "无法更改 Codex Spark 配额设置", "codexAuth.refreshQuota": "刷新额度", "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", + "codexAuth.mainHardLockTitle": "主账户用量达 99% 时阻止请求", + "codexAuth.mainHardLockDesc": "有 5 小时额度时以该额度为准,否则使用周额度(仅有月额度的账户使用月额度)。新用量重置为 0% 后会自动解除阻止,保护设置仍保持开启。", + "codexAuth.mainHardLockConfirmTitle": "开启主账户 99% 保护?", + "codexAuth.mainHardLockConfirmBody": "阻止期间,主账户也无法使用 Luna Reserve。普通额度未耗尽时,Reserve 可能不会激活。附加账户和其他提供商仍可使用。正在进行的请求、无法匹配的钥匙串凭据以及此代理之外的流量不受此保护。", + "codexAuth.mainHardLockConfirm": "开启保护", + "codexAuth.mainHardLockEnabled": "99% 保护已开启。", + "codexAuth.mainHardLockDisabled": "99% 保护已关闭,其他账户限制仍然适用。", + "codexAuth.mainHardLockLoadFailed": "无法加载此设置。请重试以确认当前状态。", + "codexAuth.mainHardLockSaveFailed": "无法确认是否已保存。请重新加载设置后再试。", + "codexAuth.mainHardLockRefreshFailed": "设置已保存,但无法刷新账户状态。请重试。", + "codexAuth.mainHardLockBlocked": "已被 99% 保护阻止", + "codexAuth.mainHardLockUnknown": "保护已开启 · 用量未知", + "codexAuth.mainHardLockMonitoring": "保护已开启 · 监测中", + "codexAuth.mainHardLockManage": "查看保护设置", "codexAuth.ultraFastDesc": "让你自己配置的 ultrafast 服务层级在重新生成目录时不被删除,并在请求日志中记录该层级名称。它不会把 Ultra Fast 加入模型选择器:上游只公布 Fast,选择器中出现该项等于让用户选择一个实际无法提供的速度。", "codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。", "codexAuth.ultraFastEnabled": "已启用 Ultra Fast 层级", diff --git a/gui/src/pages/codex-set-multiauth.tsx b/gui/src/pages/codex-set-multiauth.tsx index 8f76f91af5..4efa1c9b64 100644 --- a/gui/src/pages/codex-set-multiauth.tsx +++ b/gui/src/pages/codex-set-multiauth.tsx @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import CodexAccountPool from "../components/CodexAccountPool"; import DefaultModeRequestUserInputSetting from "../components/DefaultModeRequestUserInputSetting"; import UltraFastTierSetting from "../components/UltraFastTierSetting"; +import MainAccountHardLockSetting from "../components/MainAccountHardLockSetting"; +import { useCodexAccountPool } from "../hooks/useCodexAccountPool"; import CodexAccountPickerSetting from "../components/CodexAccountPickerSetting"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; import { navigateHash } from "../hash-routing"; @@ -105,7 +107,23 @@ type CachedMode = { * move either (devlog 004 §C: a dozen test files bind to it). */ export default function CodexSetMultiauth({ apiBase }: { apiBase: string }) { + // The controller, not just the setting, owns proxy-specific state and callbacks. + return ; +} + +function CodexSetMultiauthForProxy({ apiBase }: { apiBase: string }) { const t = useT(); + const poolController = useCodexAccountPool(apiBase); + const { load: loadAccounts } = poolController; + const ownerMountedRef = useRef(false); + useLayoutEffect(() => { + ownerMountedRef.current = true; + // Retire captured callbacks at commit, before a replacement proxy can be displayed. + return () => { ownerMountedRef.current = false; }; + }, []); + const onHardLockSaved = useCallback(() => ownerMountedRef.current + ? loadAccounts(false) + : Promise.resolve(false), [loadAccounts]); const configCacheKey = `ocx.codex-auth.config.v1:${apiBase}`; const cached = readSessionListCache(configCacheKey); const [bannerState, setBannerState] = useState(() => cached?.bannerState ?? null); @@ -192,12 +210,15 @@ export default function CodexSetMultiauth({ apiBase }: { apiBase: string }) { <> + } /> diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index f3cac1b253..25daedcd68 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -402,3 +402,42 @@ .codex-set-base-dialog__dot.active { background: var(--green); } + +/* Main quota protection shares existing card/toggle/dialog tokens. */ +.codex-main-hard-lock-setting.card-row { + margin-top: 16px; + flex-wrap: wrap; + gap: 12px; +} +.codex-main-hard-lock-copy { flex: 1 1 240px; min-width: 0; } +.codex-main-hard-lock-setting > .toggle { flex: 0 0 auto; margin-left: auto; } +.codex-main-hard-lock-feedback { flex: 1 0 100%; min-width: 0; } +.codex-main-hard-lock-feedback:empty { display: none; } +.codex-main-hard-lock-feedback p, +.codex-main-hard-lock-status p { margin: 0; } +.codex-main-hard-lock-feedback [role="alert"] { color: var(--red); } +.codex-main-hard-lock-status { + margin-top: 12px; + padding: 0 16px 8px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + font-size: var(--text-label); + line-height: var(--leading-body); + color: var(--muted); +} +.codex-main-hard-lock-status.is-blocked { color: var(--amber); } +.codex-main-hard-lock-status .link-btn { min-height: 32px; display: inline-flex; align-items: center; } +@media (max-width: 640px) { + .codex-main-hard-lock-status .link-btn { min-height: 44px; } + .codex-main-hard-lock-dialog .modal-actions .btn { min-height: 44px; } +} +.codex-main-hard-lock-dialog .modal-actions { flex-wrap: wrap; } +.codex-main-hard-lock-copy .card-sub, +.codex-main-hard-lock-dialog .modal-desc { overflow-wrap: anywhere; } +.codex-main-hard-lock-copy .card-sub { text-wrap: balance; } +.codex-main-hard-lock-dialog .modal-desc { text-wrap: pretty; } +:lang(ko) .codex-main-hard-lock-copy .card-sub, +:lang(ko) .codex-main-hard-lock-dialog .modal-desc { word-break: keep-all; } diff --git a/gui/tests/main-account-hard-lock-focus.test.tsx b/gui/tests/main-account-hard-lock-focus.test.tsx new file mode 100644 index 0000000000..4e5f0fa159 --- /dev/null +++ b/gui/tests/main-account-hard-lock-focus.test.tsx @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import MainAccountHardLockSetting from "../src/components/MainAccountHardLockSetting"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +function response(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +test.each(["outside input", "null target"])("late recovery respects focus departure to %s", async departure => { + const previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + const testWindow = new Window({ url: "http://localhost/#codex-set" }); + let root: Root | null = null; + let poll: (() => void) | undefined; + let finishRead!: (value: Response) => void; + const laterRead = new Promise(resolve => { finishRead = resolve; }); + let reads = 0; + const known = { codexMainAccountHardLock: true, mainAccountHardLock: { enabled: true, state: "ready" } }; + const flush = async () => { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }; + try { + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + for (const key of ["document", "window", "navigator", "localStorage"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? testWindow : testWindow[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + const original = testWindow.setInterval.bind(testWindow); + testWindow.setInterval = ((callback: TimerHandler, ms?: number, ...args: unknown[]) => { + if (typeof callback === "function") poll = callback as () => void; + return original(callback, ms, ...args); + }) as typeof testWindow.setInterval; + globalThis.fetch = (async (_input, init) => { + if (init?.method === "PUT") return response({}, 500); + reads++; + if (reads === 1) return response(known); + if (reads === 2) return response({}, 503); + return laterRead; + }) as typeof fetch; + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + true} /> + + ); + }); + await act(async () => { await flush(); }); + const toggle = host.querySelector("button.toggle")!; + const section = host.querySelector("#codex-main-hard-lock-setting")!; + const outside = host.querySelector("input")!; + toggle.focus(); + await act(async () => { toggle.click(); await flush(); }); + expect(reads).toBe(2); + expect(toggle.disabled).toBe(true); + expect(testWindow.document.activeElement).toBe(section); + await act(async () => { + poll?.(); + await flush(); + if (departure === "outside input") outside.focus(); + else section.dispatchEvent(new testWindow.FocusEvent("focusout", { bubbles: true, relatedTarget: null })); + }); + expect(reads).toBe(3); + expect(toggle.disabled).toBe(true); + await act(async () => { finishRead(response(known)); await flush(); }); + expect(toggle.disabled).toBe(false); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(testWindow.document.activeElement).toBe(departure === "outside input" ? outside : toggle); + } finally { + await act(async () => { root?.unmount(); }); + await testWindow.happyDOM.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } +}); diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx new file mode 100644 index 0000000000..92243d983c --- /dev/null +++ b/gui/tests/main-account-hard-lock-setting.test.tsx @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import MainAccountHardLockSetting from "../src/components/MainAccountHardLockSetting"; +import { CodexAccountPoolMainCard } from "../src/components/codex-account-pool-main-card"; +import CodexSetMultiauth from "../src/pages/codex-set-multiauth"; +import type { CodexAccountEntry, MainAccountHardLockStatus } from "../src/hooks/useCodexAccountPool"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useT } from "../src/i18n/shared"; + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let root: Root | null; +let poll: (() => void) | undefined; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function response(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} +function settings(enabled: boolean, state: MainAccountHardLockStatus["state"] = enabled ? "ready" : "off") { + return { codexMainAccountHardLock: enabled, mainAccountHardLock: { enabled, state } }; +} +async function flush() { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} +function button(host: ParentNode, selector: string): HTMLButtonElement { + const element = host.querySelector(selector); + if (!element) throw new Error(`Missing button: ${selector}`); + return element; +} +const toggle = (host: ParentNode) => button(host, "#codex-main-hard-lock-setting > .toggle"); +const confirm = (host: ParentNode) => button(host, "dialog .btn-primary"); +async function click(target: HTMLButtonElement) { + await act(async () => { target.click(); await flush(); }); +} +async function mount(fetchMock: typeof fetch, content: ReactNode = true} />) { + globalThis.fetch = fetchMock; + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render({content}); + await flush(); + }); + await act(async () => { await flush(); }); + return host; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + testWindow = new Window({ url: "http://localhost/#codex-set" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + for (const key of ["document", "window", "navigator", "localStorage"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? testWindow : testWindow[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + const original = testWindow.setInterval.bind(testWindow); + testWindow.setInterval = ((callback: TimerHandler, ms?: number, ...args: unknown[]) => { + if (typeof callback === "function") poll = callback as () => void; + return original(callback, ms, ...args); + }) as typeof testWindow.setInterval; + root = null; + poll = undefined; +}); +afterEach(async () => { + await act(async () => { root?.unmount(); }); + root = null; + await testWindow.happyDOM.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); +}); + +describe("main account protection setting", () => { + test("does not guess off while loading; failed reads stay disabled and retryable", async () => { + const initial = deferred(); + let reads = 0; + const host = await mount((async () => ++reads === 1 ? initial.promise : response(settings(true))) as typeof fetch); + expect(toggle(host).disabled).toBe(true); + expect(toggle(host).hasAttribute("aria-pressed")).toBe(false); + await act(async () => { initial.resolve(response({}, 503)); await flush(); }); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + await click(button(host, '.codex-main-hard-lock-feedback button')); + expect(toggle(host).disabled).toBe(false); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + }); + + test.each(["cancel", "escape", "backdrop"])("%s dismisses confirmation without a write and restores focus", async kind => { + let puts = 0; + let reloads = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") puts++; + return response(settings(false)); + }) as typeof fetch, { reloads++; return true; }} />); + toggle(host).focus(); + await click(toggle(host)); + expect(host.querySelector("dialog")?.open).toBe(true); + expect(host.querySelector("dialog")?.textContent).toContain("Luna Reserve"); + if (kind === "escape") { + await act(async () => { + host.querySelector("dialog")!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); + }); + } else await click(button(host, kind === "cancel" ? "dialog .btn-ghost" : ".modal-backdrop-dismiss")); + expect(host.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(toggle(host)); + expect(puts).toBe(0); + expect(reloads).toBe(0); + }); + + test("Tab and Shift-Tab wrap between confirmation actions without reaching background controls", async () => { + const host = await mount((async () => response(settings(false))) as typeof fetch); + await click(toggle(host)); + const cancel = button(host, "dialog .btn-ghost"); + expect(testWindow.document.activeElement).toBe(cancel); + await act(async () => { + cancel.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true })); + }); + expect(testWindow.document.activeElement).toBe(confirm(host)); + await act(async () => { + confirm(host).dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + }); + expect(testWindow.document.activeElement).toBe(cancel); + }); + + test("pending enable cannot be dismissed or duplicated, and is not optimistic", async () => { + const put = deferred(); + const bodies: unknown[] = []; + let reloads = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") { bodies.push(JSON.parse(String(init.body))); return put.promise; } + return response(settings(false)); + }) as typeof fetch, { reloads++; return true; }} />); + await click(toggle(host)); + act(() => { confirm(host).click(); confirm(host).click(); }); + await act(async () => { host.querySelector("dialog")!.dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); }); + expect(host.querySelector("dialog")?.open).toBe(true); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(confirm(host).disabled).toBe(true); + expect(bodies).toEqual([{ codexMainAccountHardLock: true }]); + expect(reloads).toBe(0); + await act(async () => { put.resolve(response({ ok: true, ...settings(true, "blocked") })); await flush(); }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(toggle(host)); + expect(reloads).toBe(1); + }); + + test.each([ + { ok: true }, + { ...settings(false) }, + { ok: false, ...settings(false) }, + { ok: true, codexMainAccountHardLock: "false" }, + ])("rejects incomplete acknowledgment %j and re-reads without assuming rollback", async payload => { + let reads = 0; + let reloads = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") return response(payload); + reads++; + return response(settings(reads === 1)); + }) as typeof fetch, { reloads++; return true; }} />); + await click(toggle(host)); + expect(host.querySelector("dialog")).toBeNull(); + expect(reads).toBe(2); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).toContain("Could not confirm the save"); + expect(reloads).toBe(0); + }); + + test("a failed PUT never exposes private server detail and preserves a retry path", async () => { + const reload = deferred(); + let reads = 0; + let retry = false; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") return response({ error: "private account detail" }, 500); + return ++reads === 1 || retry ? response(settings(true)) : reload.promise; + }) as typeof fetch); + toggle(host).focus(); + await click(toggle(host)); + expect(toggle(host).disabled).toBe(true); + expect(testWindow.document.activeElement?.id).toBe("codex-main-hard-lock-setting"); + await act(async () => { reload.resolve(response({}, 503)); await flush(); }); + expect(toggle(host).disabled).toBe(true); + expect(testWindow.document.activeElement?.id).toBe("codex-main-hard-lock-setting"); + retry = true; + await click(button(host, '.codex-main-hard-lock-feedback button')); + expect(testWindow.document.activeElement).toBe(toggle(host)); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(toggle(host).disabled).toBe(false); + expect(host.textContent).toContain("Could not confirm the save"); + expect(host.textContent).not.toContain("private account detail"); + expect(button(host, '.codex-main-hard-lock-feedback button').disabled).toBe(false); + }); + + test("a poll failure while confirmation is open does not silently discard confirmation", async () => { + let reads = 0; + let puts = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") { puts++; return response({ ok: true, ...settings(true) }); } + return ++reads === 1 ? response(settings(false)) : response({}, 503); + }) as typeof fetch); + await click(toggle(host)); + await act(async () => { poll?.(); await flush(); }); + await click(confirm(host)); + expect(puts).toBe(1); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector("dialog")).toBeNull(); + }); + + test("a fresh zero usage status unlocks without disabling the policy", async () => { + let reads = 0; + const host = await mount((async () => response(settings(true, ++reads === 1 ? "blocked" : "ready"))) as typeof fetch); + await act(async () => { poll?.(); await flush(); }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(toggle(host).disabled).toBe(false); + }); + + test("successful disable refresh failure is retryable without another PUT", async () => { + let puts = 0; + let reloads = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") { puts++; return response({ ok: true, ...settings(false) }); } + return response(settings(true)); + }) as typeof fetch, ++reloads > 1} />); + await click(toggle(host)); + expect(host.querySelector("dialog")).toBeNull(); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).toContain("Setting saved, but account status"); + expect(host.textContent).not.toContain("Could not confirm the save"); + await click(button(host, '.codex-main-hard-lock-feedback button')); + expect(puts).toBe(1); + expect(reloads).toBe(2); + expect(host.textContent).not.toContain("could not be refreshed"); + }); + + test.each(["during", "after"])("stale GET arriving %s PUT cannot restore the old state", async timing => { + const stale = deferred(); + const put = deferred(); + let reads = 0; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") return put.promise; + return ++reads === 1 ? response(settings(true)) : stale.promise; + }) as typeof fetch); + await act(async () => { poll?.(); await flush(); }); + toggle(host).focus(); + await click(toggle(host)); + // Disabled native controls can lose focus; require restoration, not accidental retention. + host.querySelector("#codex-main-hard-lock-setting")!.focus(); + if (timing === "during") await act(async () => { stale.resolve(response(settings(true))); await flush(); }); + await act(async () => { put.resolve(response({ ok: true, ...settings(false) })); await flush(); }); + if (timing === "after") await act(async () => { stale.resolve(response(settings(true))); await flush(); }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(testWindow.document.activeElement).toBe(toggle(host)); + }); +}); + +function mainAccount(state: MainAccountHardLockStatus["state"]): CodexAccountEntry { + return { id: "__main__", email: "fixture@example.test", isMain: true, paused: false, + priority: 0, hasCredential: true, plan: "plus", + quota: { weeklyPercent: 100, shortPercent: 0, updatedAt: Date.now() }, + quotaAutoRefresh: { fiveHourAvailable: false, weeklyAvailable: false, fiveHourEnabled: false, weeklyEnabled: false }, + mainAccountHardLock: { enabled: state !== "off", state } }; +} +function MainCard({ state }: { state: MainAccountHardLockStatus["state"] }) { + return {}} + onTogglePause={() => {}} pauseUpdatingId={null} pauseBusy={false} onPriorityChange={() => {}} + quotaAutoRefreshBusy={null} onToggleQuotaAutoRefresh={() => {}} + priorityUpdatingId={null} switchingId={null} onOpenReset={() => {}} />; +} +test.each([ + ["blocked", "Blocked by 99% protection", false], + ["unknown", "Protection on · usage unknown", true], + ["ready", "Protection on · monitoring", true], +] as const)("main card uses server %s state, not rounded weekly usage", async (state, label, canSwitch) => { + const host = await mount((async () => response({})) as typeof fetch, ); + expect(host.querySelector(".codex-main-hard-lock-status")?.textContent).toContain(label); + expect(Boolean(host.querySelector(".codex-account-switch"))).toBe(canSwitch); + testWindow.location.hash = "#providers"; + await click(button(host, ".codex-main-hard-lock-status button")); + expect(testWindow.location.hash).toBe("#codex-set"); +}); + +test("same-page manage opens Advanced; save refreshes the one injected account controller", async () => { + let enabled = true; + let accountReads = 0; + let forcedReads = 0; + const host = await mount((async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/api/settings") { + if (init?.method === "PUT") enabled = JSON.parse(String(init.body)).codexMainAccountHardLock; + return response({ ok: true, ...settings(enabled, enabled ? "blocked" : "off"), showCodexSparkQuota: false, codexAccountPickerEnabled: false }); + } + if (url.pathname === "/api/codex-auth/accounts") { + accountReads++; + if (url.searchParams.has("refresh")) forcedReads++; + return response({ accounts: [mainAccount(enabled ? "blocked" : "off")] }); + } + if (url.pathname === "/api/codex-auth/active") return response({ activeCodexAccountId: "__main__", autoSwitchThreshold: 80, accountPoolStrategy: "quota", accountPoolStickyLimit: 1 }); + if (url.pathname === "/api/config") return response({ providers: {} }); + return response({}); + }) as typeof fetch, ); + expect(accountReads).toBe(1); + expect(host.querySelector("#codex-main-hard-lock-setting")).toBeNull(); + await click(button(host, ".codex-main-hard-lock-status button")); + expect(button(host, ".codex-auth-advanced__toggle").getAttribute("aria-expanded")).toBe("true"); + expect(testWindow.document.activeElement?.id).toBe("codex-main-hard-lock-setting"); + await click(toggle(host)); + expect(accountReads).toBe(2); + expect(forcedReads).toBe(0); + expect(host.querySelector(".codex-main-hard-lock-status")).toBeNull(); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); +}); + +test("late proxy A PUT cannot reload A or replace proxy B's parent-owned account status", async () => { + const pendingPut = deferred(); + const proxyA = "http://hard-lock-lifetime-a"; + const proxyB = "http://hard-lock-lifetime-b"; + const requests: string[] = []; + let aEnabled = true; + const host = await mount((async (input, init) => { + const url = new URL(String(input)); + requests.push(`${init?.method ?? "GET"} ${url.origin}${url.pathname}`); + const isA = url.origin === proxyA; + const enabled = isA ? aEnabled : true; + const state = isA ? (aEnabled ? "blocked" : "off") : "unknown"; + if (url.pathname === "/api/settings") { + if (init?.method === "PUT") { + expect(url.origin).toBe(proxyA); + expect(JSON.parse(String(init.body))).toEqual({ codexMainAccountHardLock: false }); + return pendingPut.promise; + } + return response({ ...settings(enabled, state), showCodexSparkQuota: false, codexAccountPickerEnabled: false }); + } + if (url.pathname === "/api/codex-auth/accounts") return response({ + accounts: [{ ...mainAccount(state), email: isA ? "proxy-a@example.test" : "proxy-b@example.test" }], + }); + if (url.pathname === "/api/codex-auth/active") return response({ activeCodexAccountId: "__main__", autoSwitchThreshold: 80, accountPoolStrategy: "quota", accountPoolStickyLimit: 1 }); + if (url.pathname === "/api/config") return response({ providers: {} }); + return response({}); + }) as typeof fetch, ); + await click(button(host, ".codex-main-hard-lock-status button")); + await click(toggle(host)); + expect(toggle(host).disabled).toBe(true); + expect(requests.filter(request => request.startsWith("PUT "))).toHaveLength(1); + + await act(async () => { + root!.render(); + await flush(); + }); + await act(async () => { await flush(); }); + expect(host.textContent).toContain("proxy-b@example.test"); + expect(host.textContent).not.toContain("proxy-a@example.test"); + expect(host.querySelector(".codex-main-hard-lock-status")?.textContent).toContain("Protection on · usage unknown"); + const requestsBeforeAck = [...requests]; + aEnabled = false; + await act(async () => { pendingPut.resolve(response({ ok: true, ...settings(false) })); await flush(); }); + expect(requests).toEqual(requestsBeforeAck); + expect(host.textContent).toContain("proxy-b@example.test"); + expect(host.textContent).not.toContain("proxy-a@example.test"); + expect(host.querySelector(".codex-main-hard-lock-status")?.textContent).toContain("Protection on · usage unknown"); +}); + +test("collapsing Advanced within the same proxy still refreshes the owner after a delayed save", async () => { + const pendingPut = deferred(); + let enabled = true; + let accountReads = 0; + const host = await mount((async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/api/settings") { + if (init?.method === "PUT") return pendingPut.promise; + return response({ ...settings(enabled, enabled ? "blocked" : "off"), showCodexSparkQuota: false, codexAccountPickerEnabled: false }); + } + if (url.pathname === "/api/codex-auth/accounts") { + accountReads++; + expect(url.searchParams.has("refresh")).toBe(false); + return response({ accounts: [mainAccount(enabled ? "blocked" : "off")] }); + } + if (url.pathname === "/api/codex-auth/active") return response({ activeCodexAccountId: "__main__", autoSwitchThreshold: 80, accountPoolStrategy: "quota", accountPoolStickyLimit: 1 }); + if (url.pathname === "/api/config") return response({ providers: {} }); + return response({}); + }) as typeof fetch, ); + await click(button(host, ".codex-main-hard-lock-status button")); + await click(toggle(host)); + expect(toggle(host).disabled).toBe(true); + await click(button(host, ".codex-auth-advanced__toggle")); + expect(host.querySelector("#codex-main-hard-lock-setting")).toBeNull(); + expect(accountReads).toBe(1); + enabled = false; + await act(async () => { pendingPut.resolve(response({ ok: true, ...settings(false) })); await flush(); }); + expect(accountReads).toBe(2); + expect(host.querySelector(".codex-main-hard-lock-status")).toBeNull(); +});