diff --git a/docs/product/codex-accounts.md b/docs/product/codex-accounts.md index 8bb003625..cd06680bb 100644 --- a/docs/product/codex-accounts.md +++ b/docs/product/codex-accounts.md @@ -24,9 +24,9 @@ ChatGPT 登录的 Codex 订阅额度耗尽时,Desktop 在 Renderer 中用两道账号级布尔门禁用 Composer 提交:账号额度门和 reserve `hardBlocked`。API Key 登录不经过这两道门。它们是界面上的订阅额度预检,不是协议限制;外部 Harness 的 `turn/start` 由 Host 路由,不会发到官方后端。 -因此在单个 Composer 选中外部 Agent、Adapter 就绪且没有 codexhost 自身的提交阻塞时,`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存,Codex 额度横幅保持显示;其他 Composer、Codex 路径和空输入、附件、运行中等其他原生限制不受影响。外部 Harness 的真实额度与错误由其自身处理。切回 Codex、Composer 移除或扩展卸载时恢复实时原生结果。 +因此在单个 Composer 选中外部 Agent、Adapter 就绪且没有 codexhost 自身的提交阻塞时,`renderer-codex-usage-gate.ts` 只把该 Composer 对这两道门的订阅快照投影为 `false`,继续走原生提交链路。不写账号、atom 或额度查询缓存;旁路生效期间只隐藏该 Composer 内的 Codex 额度横幅,恢复原生状态时还原其显示样式。其他 Composer、Codex 路径和空输入、附件、运行中等其他原生限制不受影响。外部 Harness 的真实额度与错误由其自身处理。切回 Codex、Composer 移除或扩展卸载时恢复实时原生结果。 -门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。 +门按其 selector 实际读取的字段识别,不依赖压缩名或 hook 序号。兼容直接 `[store, atom]` 订阅及 Desktop 26.928 的 readonly signal adapter / lazy snapshot wrapper:以成对的订阅 effect 和原生快照一致性验证 wrapper,限量重放 readonly 布尔 selector 的依赖以识别间接 reserve 门;拒绝循环依赖、追踪型 render、混合门和快照不匹配。Desktop 26.1002 的 Account 门可读取 reserve `active` 作为豁免,并同时由 submit owner 和编辑器到 owner 之间的子组件订阅;这些订阅按组件分别投影。每次重试从 React 当前已提交树重新发现作用域,包括 alternate 切换后才挂载的子组件;无法确认当前作用域时恢复原生限制。释放时分别恢复 subscriber 与 React instance 原来的 getter,不把 wrapper 替换成另一个函数。无法唯一识别时保留原生限制,并在 Agent 控件悬停提示中说明。升级后的诊断步骤见 [Desktop 更新兼容性诊断手册](../operations/codex-desktop-upgrade-diagnosis-playbook.md#检查-codex-额度门)。 ## 其他 Harness 的只读账号额度 diff --git a/packages/renderer-extension/src/renderer-codex-usage-banner.ts b/packages/renderer-extension/src/renderer-codex-usage-banner.ts new file mode 100644 index 000000000..8875c5cf3 --- /dev/null +++ b/packages/renderer-extension/src/renderer-codex-usage-banner.ts @@ -0,0 +1,108 @@ +/** + * Hides Desktop's Codex usage-limit banner in one external Harness Composer + * while its usage gate is bypassed. That banner (for example "Codex usage + * limit reached ... Upgrade / Reset usage") describes the Codex path only, so + * it is misleading once the Composer submits to another Harness. + * + * The banner is identified by the props of the component Desktop renders it + * with (`banner`, `rateLimitStatus`, `sendBlocked`), searched only inside the + * submit owner's subtree. Only the inline `display` of its outermost host + * elements is changed and restored; React state, atoms and the banner's own + * dismissal bookkeeping are untouched, so the Codex Harness keeps the banner. + */ +type RecordValue = Record; +type Fiber = { memoizedProps?: unknown; child?: unknown; sibling?: unknown; stateNode?: unknown }; + +const MAX_FIBERS = 20_000; + +function isRecord(value: unknown): value is RecordValue { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUsageBanner(props: unknown): boolean { + return ( + isRecord(props) && + "banner" in props && + "rateLimitStatus" in props && + typeof props.sendBlocked === "boolean" + ); +} + +function isElement(value: unknown): value is HTMLElement { + return ( + isRecord(value) && + typeof (value as { setAttribute?: unknown }).setAttribute === "function" && + isRecord((value as { style?: unknown }).style) + ); +} + +/** Outermost host elements rendered by `fiber`, without descending past them. */ +function hostElements(fiber: Fiber, budget: { remaining: number }): HTMLElement[] { + const result: HTMLElement[] = []; + const visit = (node: unknown): void => { + for (let current = node; isRecord(current); current = (current as Fiber).sibling) { + if (budget.remaining-- <= 0) return; + const { stateNode } = current as Fiber; + if (isElement(stateNode)) result.push(stateNode); + else visit((current as Fiber).child); + } + }; + visit(fiber.child); + return result; +} + +/** Usage banner host elements inside `owner`'s committed subtree. */ +export function findCodexUsageBannerElements(owner: unknown): HTMLElement[] { + if (!isRecord(owner)) return []; + const budget = { remaining: MAX_FIBERS }; + const found: HTMLElement[] = []; + const stack: unknown[] = [(owner as Fiber).child]; + while (stack.length > 0 && budget.remaining > 0) { + const node = stack.pop(); + if (!isRecord(node)) continue; + budget.remaining -= 1; + const fiber = node as Fiber; + stack.push(fiber.sibling); + if (isUsageBanner(fiber.memoizedProps)) found.push(...hostElements(fiber, budget)); + else stack.push(fiber.child); + } + return budget.remaining > 0 ? found : []; +} + +const HIDDEN_ATTRIBUTE = "data-codexhost-usage-banner-hidden"; + +export interface RendererCodexUsageBanner { + /** Hide the banners currently inside `owner`; restore any no longer there. */ + hide(owner: unknown): void; + restore(): void; +} + +export function createRendererCodexUsageBanner(): RendererCodexUsageBanner { + const hidden = new Map(); + const show = (element: HTMLElement): void => { + const previous = hidden.get(element); + hidden.delete(element); + if (!previous || element.getAttribute(HIDDEN_ATTRIBUTE) === null) return; + element.removeAttribute(HIDDEN_ATTRIBUTE); + if (previous.value) element.style.setProperty("display", previous.value, previous.priority); + else element.style.removeProperty("display"); + }; + return { + hide(owner) { + const current = new Set(findCodexUsageBannerElements(owner)); + for (const element of [...hidden.keys()]) if (!current.has(element)) show(element); + for (const element of current) { + if (hidden.has(element)) continue; + hidden.set(element, { + value: element.style.getPropertyValue("display"), + priority: element.style.getPropertyPriority("display"), + }); + element.setAttribute(HIDDEN_ATTRIBUTE, ""); + element.style.setProperty("display", "none", "important"); + } + }, + restore() { + for (const element of [...hidden.keys()]) show(element); + }, + }; +} diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts index 49c242d59..4ecdb79fc 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -9,9 +9,15 @@ * only. It never writes Accounts, atoms or query data, so the Codex path and * other Composers keep the native result. Other native submit blockers are * unaffected. Anything that cannot be verified keeps the native restriction. + * + * Desktop 26.1002 moved the Account gate into a child of the submit owner and + * lets an active reserve exempt it, so gates are collected along the Composer's + * committed ancestors up to the owner. */ import { committedReactAncestors } from "@codexhost/desktop-control/renderer-bindings"; +import { createRendererCodexUsageBanner } from "./renderer-codex-usage-banner.js"; + type RecordValue = Record; type Atom = { read(get: (atom: Atom) => unknown): unknown }; type Store = { @@ -30,6 +36,7 @@ type GateKind = "account" | "reserve"; /** One Desktop `useSyncExternalStore` subscription: memo, instance and effect hooks. */ interface Subscription { + fiber: Fiber; subscriber: Subscriber; store: Store; atom: Atom; @@ -38,6 +45,8 @@ interface Subscription { } interface Projection { + kind: GateKind; + fiber: Fiber; subscriber: Subscriber; instance: Instance; restore(): void; @@ -62,12 +71,22 @@ function hooksOf(fiber: unknown): Hook[] { return hook == null ? result : []; } +/** A submit owner and the committed fibers from the editor up to it, inclusive. */ +interface GateScope { + owner: Fiber; + fibers: Fiber[]; +} + /** * Composer components that combine native submit blockers into `submitDisabled` - * and hold the reserve gate. Desktop wraps the owner in a pass-through component - * with the same props but no gate hooks, so props alone are ambiguous. + * and, within their scope, hold the reserve gate. Desktop wraps the owner in a + * pass-through component with the same props but no gate hooks, so props alone + * are ambiguous. */ -function submitOwners(composer: Element): Fiber[] { +function submitOwners( + composer: Element, + projections?: ReadonlyMap, +): GateScope[] { // The ProseMirror editor is not rendered by React; start at its nearest React host. let element: Element | null = composer.querySelector(EDITOR_SELECTOR) ?? composer; for (let depth = 0; element && depth < MAX_DOM_DEPTH; depth += 1) { @@ -75,30 +94,43 @@ function submitOwners(composer: Element): Fiber[] { name.startsWith("__reactFiber$"), ); if (key) { - const owners = committedReactAncestors( + const ancestors = committedReactAncestors( Object.getOwnPropertyDescriptor(element, key)?.value, - ).filter( - ({ memoizedProps: props }) => + ) as Fiber[]; + const scopes: GateScope[] = []; + ancestors.forEach(({ memoizedProps: props }, index) => { + if ( isRecord(props) && "onLocalSubmitStart" in props && - typeof props.submitDisabled === "boolean", - ) as Fiber[]; - return owners.filter((owner) => { - const gates = gateSubscriptions(owner); + typeof props.submitDisabled === "boolean" + ) { + scopes.push({ owner: ancestors[index] as Fiber, fibers: ancestors.slice(0, index + 1) }); + } + }); + // An outer pass-through wrapper's scope contains the inner owner's, so + // only the innermost owner whose scope holds the reserve gate counts. + const valid = scopes.filter((scope) => { + const gates = gateSubscriptions(scope, projections); return !gates.invalid && gates.reserve.length > 0; }); + return valid.filter( + (scope) => !valid.some((other) => other !== scope && scope.fibers.includes(other.owner)), + ); } element = element.parentElement; } return []; } -function findSubmitOwner(composer: Element): Fiber | null { - const owners = submitOwners(composer); - return owners.length === 1 ? (owners[0] ?? null) : null; +function findSubmitScope( + composer: Element, + projections: ReadonlyMap, +): GateScope | null { + const scopes = submitOwners(composer, projections); + return scopes.length === 1 ? (scopes[0] ?? null) : null; } -function subscriptionAt(hook: Hook): Subscription | null { +function subscriptionAt(hook: Hook, fiber: Fiber): Subscription | null { const memo = hook.memoizedState; if (!Array.isArray(memo) || !Array.isArray(memo[1])) return null; const [subscriber, dependencies] = memo as [unknown, unknown[]]; @@ -135,6 +167,7 @@ function subscriptionAt(hook: Hook): Subscription | null { return null; } return { + fiber, subscriber: subscriber as Subscriber, store: store as Store, atom: atom as Atom, @@ -203,8 +236,10 @@ function gateKind({ store, atom }: Subscription): GateKind | "mixed" | null { } const reserve = read.has("reserve.hardBlocked") && !read.has("reserve.active"); const account = read.has("auth.authMethod") && read.has("limit.allowed"); - // A combined selector cannot be separated into the two verified gates. - if (account && (read.has("reserve.hardBlocked") || read.has("reserve.active"))) return "mixed"; + // Desktop 26.1002 lets an active reserve exempt the Account gate; reading + // `reserve.active` alone only relaxes it. A selector that also reads the + // reserve hard block cannot be separated into the two verified gates. + if (account && read.has("reserve.hardBlocked")) return "mixed"; if (reserve) return "reserve"; if (account) return "account"; return null; @@ -212,8 +247,9 @@ function gateKind({ store, atom }: Subscription): GateKind | "mixed" | null { function snapshotsMatch( { subscriber, instance, store, atom }: Subscription, - projected?: Projection, + projections?: ReadonlyMap, ): boolean { + const projected = projections?.get(instance); // Direct snapshots also cover already-bound gates. A lazy wrapper must agree // with this readonly atom; never silently treat a mismatched Account gate as // the legitimate API-key case where its selector does not read usage. @@ -238,42 +274,50 @@ function snapshotsMatch( } function gateSubscriptions( - owner: Fiber, - projections?: ReadonlyMap, + scope: GateScope, + projections?: ReadonlyMap, ): Record & { invalid: boolean } { const found: Record & { invalid: boolean } = { account: [], reserve: [], invalid: false, }; - for (const hook of hooksOf(owner)) { - const subscription = subscriptionAt(hook); - const kind = subscription && gateKind(subscription); - if (subscription && kind) { - if (kind !== "mixed" && snapshotsMatch(subscription, projections?.get(kind))) { - found[kind].push(subscription); - } else found.invalid = true; + for (const fiber of scope.fibers) { + for (const hook of hooksOf(fiber)) { + const subscription = subscriptionAt(hook, fiber); + const kind = subscription && gateKind(subscription); + if (subscription && kind) { + if (kind !== "mixed" && snapshotsMatch(subscription, projections)) { + found[kind].push(subscription); + } else found.invalid = true; + } } } return found; } /** - * Gate subscriptions by kind; `null` when a kind is ambiguous. The account gate - * returns early without reading usage when it cannot block (for example API-key - * sign-in), so it may legitimately be absent until it reads `rate_limit`. + * Gate subscriptions with their kind; `null` when a kind is ambiguous within one + * component. Desktop 26.1002 subscribes the Account gate in more than one + * component of the scope, and each subscription blocks its own component. The + * account gate returns early without reading usage when it cannot block (for + * example API-key sign-in), so it may legitimately be absent until it reads + * `rate_limit`. */ function discoverGates( - owner: Fiber, - projections: ReadonlyMap, -): Map | null { - const gates = gateSubscriptions(owner, projections); + scope: GateScope, + projections: ReadonlyMap, +): Array<[GateKind, Subscription]> | null { + const gates = gateSubscriptions(scope, projections); if (gates.invalid) return null; - const found = new Map(); + const found: Array<[GateKind, Subscription]> = []; for (const kind of ["account", "reserve"] as const) { - const subscriptions = gates[kind]; - if (subscriptions.length > 1) return null; - if (subscriptions[0]) found.set(kind, subscriptions[0]); + const fibers = new Set(); + for (const subscription of gates[kind]) { + if (fibers.has(subscription.fiber)) return null; + fibers.add(subscription.fiber); + found.push([kind, subscription]); + } } return found; } @@ -301,7 +345,10 @@ export function inspectComposerCodexUsageGate(composer: Element): RendererCodexU * subscription effect once with `store.sub` captured synchronously; the shared * store keeps no extra subscription. */ -function project({ subscriber, store, atom, instance, subscribeEffect }: Subscription): Projection { +function project( + kind: GateKind, + { fiber, subscriber, store, atom, instance, subscribeEffect }: Subscription, +): Projection { for (const [target, key] of [ [store, "sub"], [subscriber, "getSnapshot"], @@ -332,6 +379,8 @@ function project({ subscriber, store, atom, instance, subscribeEffect }: Subscri instance.getSnapshot = allowed; if (instance.value !== false) onChange(); return { + kind, + fiber, subscriber, instance, restore() { @@ -342,13 +391,11 @@ function project({ subscriber, store, atom, instance, subscribeEffect }: Subscri }; } -function isBound(owner: Fiber, projections: Iterable): boolean { - const alternate = isRecord(owner.alternate) ? owner.alternate : null; - const bound = [...projections]; - return [owner, alternate].some((fiber) => { - const hooks = hooksOf(fiber); - return bound.every(({ subscriber, instance }) => - hooks.some( +function isBound(projections: Iterable): boolean { + return [...projections].every(({ fiber, subscriber, instance }) => { + const alternate = isRecord(fiber.alternate) ? fiber.alternate : null; + return [fiber, alternate].some((candidate) => + hooksOf(candidate).some( (hook) => Array.isArray(hook.memoizedState) && hook.memoizedState[0] === subscriber && @@ -369,11 +416,13 @@ export interface RendererCodexUsageGate { } export function createRendererCodexUsageGate(composer: Element): RendererCodexUsageGate { - let owner: Fiber | null = null; - const projections = new Map(); + let owner: GateScope | null = null; + const projections = new Map(); let retryAt = 0; let allowed = false; + const banner = createRendererCodexUsageBanner(); const release = (): void => { + banner.restore(); const previous = [...projections.values()]; owner = null; projections.clear(); @@ -386,27 +435,36 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs } }; const bind = (): RendererCodexUsageGateStatus => { - const current = owner ?? findSubmitOwner(composer); + // A commit can publish an alternate owner with additional gate subscriptions. + const current = findSubmitScope(composer, projections); const gates = current && discoverGates(current, projections); // The reserve gate always reads its field, so its absence means an unknown contract. - if (!current || !gates?.has("reserve")) throw new Error("Codex usage gate is unavailable"); + if (!current || !gates?.some(([kind]) => kind === "reserve")) { + throw new Error("Codex usage gate is unavailable"); + } owner = current; for (const [kind, gate] of gates) { - if (!projections.has(kind)) projections.set(kind, project(gate)); + if (!projections.has(gate.instance)) projections.set(gate.instance, project(kind, gate)); } return "bypassed"; }; + // The usage banner only describes the Codex path; hide it while bypassed. const apply = (): RendererCodexUsageGateStatus => { + const status = applyGate(); + if (status === "bypassed" && owner) banner.hide(owner.owner); + else banner.restore(); + return status; + }; + const applyGate = (): RendererCodexUsageGateStatus => { if (!allowed || !composer.isConnected) { release(); retryAt = 0; return "native"; } - if (owner && !isBound(owner, projections.values())) { + if (owner && !isBound(projections.values())) { release(); retryAt = 0; } - if (owner && projections.size === 2) return "bypassed"; if (Date.now() < retryAt) return owner ? "bypassed" : "unsupported"; retryAt = Date.now() + RETRY_DELAY_MS; try { diff --git a/packages/renderer-extension/test/renderer-codex-usage-banner.test.ts b/packages/renderer-extension/test/renderer-codex-usage-banner.test.ts new file mode 100644 index 000000000..7b12d03f5 --- /dev/null +++ b/packages/renderer-extension/test/renderer-codex-usage-banner.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + createRendererCodexUsageBanner, + findCodexUsageBannerElements, +} from "../src/renderer-codex-usage-banner.js"; + +function element(display = "") { + const attributes = new Map(); + const props = new Map(); + if (display) props.set("display", [display, ""]); + return { + setAttribute: (name: string, value: string) => attributes.set(name, value), + getAttribute: (name: string) => attributes.get(name) ?? null, + removeAttribute: (name: string) => attributes.delete(name), + style: { + getPropertyValue: (name: string) => props.get(name)?.[0] ?? "", + getPropertyPriority: (name: string) => props.get(name)?.[1] ?? "", + setProperty: (name: string, value: string, priority = "") => + props.set(name, [value, priority]), + removeProperty: (name: string) => props.delete(name), + }, + display: () => props.get("display")?.[0] ?? "", + }; +} + +type Node = { + memoizedProps?: unknown; + child?: Node | null; + sibling?: Node | null; + stateNode?: unknown; +}; + +/** owner -> other -> banner(component) -> host aside (-> nested host). */ +function tree( + bannerProps: Record = { banner: {}, rateLimitStatus: {}, sendBlocked: true }, +) { + const aside = element(); + const nested = element(); + const other = element(); + const bannerHost: Node = { stateNode: aside, child: { stateNode: nested } }; + const banner: Node = { memoizedProps: bannerProps, child: { child: bannerHost } }; + const unrelated: Node = { memoizedProps: { banner: {} }, child: { stateNode: other } }; + const owner: Node = { child: { child: unrelated, sibling: banner } }; + return { owner, banner, aside, nested, other }; +} + +describe("Codex usage banner in external Harness Composers", () => { + it("finds only the outermost host elements of the usage banner", () => { + const t = tree(); + expect(findCodexUsageBannerElements(t.owner)).toEqual([t.aside]); + expect(findCodexUsageBannerElements(tree({ banner: {}, sendBlocked: true }).owner)).toEqual([]); + expect(findCodexUsageBannerElements(null)).toEqual([]); + }); + + it("hides and restores the banner's inline display without touching others", () => { + const t = tree(); + t.aside.style.setProperty("display", "flex"); + const banner = createRendererCodexUsageBanner(); + banner.hide(t.owner); + expect(t.aside.display()).toBe("none"); + expect(t.aside.style.getPropertyPriority("display")).toBe("important"); + expect(t.other.display()).toBe(""); + banner.hide(t.owner); + banner.restore(); + expect(t.aside.display()).toBe("flex"); + expect(t.aside.getAttribute("data-codexhost-usage-banner-hidden")).toBeNull(); + }); + + it("restores a banner that leaves the owner's subtree", () => { + const t = tree(); + const banner = createRendererCodexUsageBanner(); + banner.hide(t.owner); + expect(t.aside.display()).toBe("none"); + t.banner.memoizedProps = {}; + banner.hide(t.owner); + expect(t.aside.display()).toBe(""); + }); +}); diff --git a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts index aa6c371fe..c56003963 100644 --- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts +++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts @@ -34,6 +34,10 @@ function desktopStore() { read: (get) => (get(reserve) as { hardBlocked: boolean }).hardBlocked, }; const reserveActive: Atom = { read: (get) => (get(reserve) as { active: boolean }).active }; + // Desktop 26.1002: an active reserve exempts the Account gate. + const exemptAccountGate: Atom = { + read: (get) => accountGate.read(get) === true && get(reserveActive) !== true, + }; const listeners = new Map void>>(); const emit = () => { // Model Jotai's propagation to derived signal atoms; React's listener @@ -59,6 +63,7 @@ function desktopStore() { accountGate, reserveGate, reserveActive, + exemptAccountGate, setAuthMethod(value: string) { authMethod = value; emit(); @@ -83,20 +88,27 @@ function composerFixture( duplicateAccount?: boolean; snapshotWrapper?: boolean; signalReserve?: boolean; + /** Desktop 26.1002: exempting Account gate, also subscribed in a child of the owner. */ + childAccount?: boolean; } = {}, ) { + const accountGate = options.childAccount ? source.exemptAccountGate : source.accountGate; const subscribers: Array<{ getSnapshot(): unknown }> = []; const instances: Array<{ value: unknown; getSnapshot(): unknown }> = []; const hooks: Array> = []; const rerender = vi.fn(); - for (const selector of [ - source.reserveActive, - source.accountGate, - ...(options.omitReserve ? [] : [source.reserveGate]), - ...(options.duplicateAccount ? [source.accountGate] : []), - ]) { + const childHooks: Array> = []; + type SelectorEntry = [Atom, Array>]; + const selectors: SelectorEntry[] = [ + [source.reserveActive, hooks], + [accountGate, hooks], + ...(options.omitReserve ? [] : ([[source.reserveGate, hooks]] satisfies SelectorEntry[])), + ...(options.duplicateAccount ? ([[accountGate, hooks]] satisfies SelectorEntry[]) : []), + ...(options.childAccount ? ([[accountGate, childHooks]] satisfies SelectorEntry[]) : []), + ]; + for (const [selector, target] of selectors) { // Desktop 26.928 wraps parameterized selectors in a readonly signal atom. - const signal = options.signalReserve && selector !== source.accountGate; + const signal = options.signalReserve && selector !== accountGate; const atom: Atom = signal ? { read: (get) => get(selector) } : selector; const value = { atom, @@ -128,7 +140,7 @@ function composerFixture( rerender(); }), }; - hooks.push( + target.push( { memoizedState: [subscriber, signal ? [value, undefined] : [source.store, atom]] }, { queue: instance }, { memoizedState: effect }, @@ -139,9 +151,11 @@ function composerFixture( } // Desktop owners have more than a thousand hooks; do not rely on positions. hooks.unshift(...Array.from({ length: 230 }, () => ({ memoizedState: null }))); - hooks.forEach((hook, i) => { - hook.next = hooks[i + 1] ?? null; - }); + for (const list of [hooks, childHooks]) { + list.forEach((hook, i) => { + hook.next = list[i + 1] ?? null; + }); + } const props = { onLocalSubmitStart() {}, submitDisabled: false }; // Desktop wraps the owner in a pass-through component with the same props. const wrapper = { @@ -154,7 +168,16 @@ function composerFixture( memoizedState: hooks[0] as unknown, return: wrapper, }; - const editor = { parentElement: null, __reactFiber$test: owner }; + // 26.1002 subscribes the Account gate again in a child without owner props. + const child = { + memoizedProps: { submitDisabled: false, onSubmitLocal() {} }, + memoizedState: childHooks[0] ?? null, + return: owner, + }; + const editor = { + parentElement: null, + __reactFiber$test: options.childAccount ? child : owner, + }; const composer = { isConnected: true, querySelector: () => editor, @@ -168,6 +191,8 @@ function composerFixture( subscribers, instances, hooks, + editor, + child, blocked: () => instances.slice(1).some((instance) => instance.getSnapshot() === true), }; } @@ -177,6 +202,40 @@ afterEach(() => { }); describe("Codex usage gate for external Harness Composers", () => { + it("binds 26.1002 exempting Account gates in the owner and its child", () => { + const source = desktopStore(); + const external = composerFixture(source, { childAccount: true }); + const codex = composerFixture(source, { childAccount: true }); + expect(inspectComposerCodexUsageGate(external.composer)).toEqual({ + ownerCount: 1, + reserveGateCount: 1, + accountGateCount: 2, + }); + const ownerAccount = required(external.instances[1]); + const childAccount = required(external.instances[3]); + const blocked = () => [ownerAccount, childAccount].some((i) => i.getSnapshot() === true); + expect(blocked()).toBe(true); + expect(external.gate.update(true)).toBe("bypassed"); + expect(blocked()).toBe(false); + expect(codex.blocked()).toBe(true); + expect(source.store.get(source.exemptAccountGate)).toBe(true); + expect(source.store.set).not.toHaveBeenCalled(); + expect(external.gate.update(false)).toBe("native"); + expect(blocked()).toBe(true); + }); + + it("keeps a selector reading the reserve hard block with Account usage native", () => { + const source = desktopStore(); + const combined: Atom = { + read: (get) => get(source.reserveGate) === true || source.accountGate.read(get) === true, + }; + source.exemptAccountGate.read = combined.read; + const f = composerFixture(source, { childAccount: true }); + expect(f.gate.update(true)).toBe("unsupported"); + expect(f.blocked()).toBe(true); + expect(source.store.set).not.toHaveBeenCalled(); + }); + it("lifts only this Composer's usage gates without touching the shared store", () => { const f = composerFixture(); const { store } = f.source; @@ -326,6 +385,84 @@ describe("Codex usage gate for external Harness Composers", () => { expect(f.blocked()).toBe(false); }); + it.each([false, true])( + "binds a late child Account gate (snapshotWrapper: %s)", + (snapshotWrapper) => { + vi.useFakeTimers(); + const f = composerFixture(undefined, { childAccount: true, snapshotWrapper }); + const ownerAccount = required(f.instances[1]); + const childAccount = required(f.instances[3]); + // The child has not mounted its Account subscription yet. + const childHooks = f.child.memoizedState; + f.child.memoizedState = null; + expect(f.gate.update(true)).toBe("bypassed"); + expect(ownerAccount.getSnapshot()).toBe(false); + // React refreshes the owner's lazy snapshot wrapper on its next render. + if (snapshotWrapper) { + ownerAccount.getSnapshot = () => required(f.subscribers[1]).getSnapshot(); + } + // React mounts a new child fiber that subscribes the Account gate. + f.editor.__reactFiber$test = { ...f.child, memoizedState: childHooks }; + expect(childAccount.getSnapshot()).toBe(true); + vi.advanceTimersByTime(1000); + expect(f.gate.refresh()).toBe("bypassed"); + expect(childAccount.getSnapshot()).toBe(false); + expect(ownerAccount.getSnapshot()).toBe(false); + expect(f.gate.update(false)).toBe("native"); + expect(childAccount.getSnapshot()).toBe(true); + expect(ownerAccount.getSnapshot()).toBe(true); + }, + ); + + it.each([false, true])( + "binds a late child Account gate in the committed alternate tree (snapshotWrapper: %s)", + (snapshotWrapper) => { + vi.useFakeTimers(); + const f = composerFixture(undefined, { childAccount: true, snapshotWrapper }); + const ownerAccount = required(f.instances[1]); + const childAccount = required(f.instances[3]); + const childHooks = f.child.memoizedState; + f.child.memoizedState = null; + const rootState = { current: null as unknown }; + const previousRoot = { stateNode: rootState, child: f.owner }; + Object.assign(f.owner, { return: previousRoot, child: f.child }); + rootState.current = previousRoot; + expect(f.gate.update(true)).toBe("bypassed"); + expect(ownerAccount.getSnapshot()).toBe(false); + if (snapshotWrapper) { + ownerAccount.getSnapshot = () => required(f.subscribers[1]).getSnapshot(); + } + + const child = { ...f.child, memoizedState: childHooks, alternate: f.child }; + const owner = { ...f.owner, child, alternate: f.owner }; + const currentRoot = { stateNode: rootState, child: owner }; + Object.assign(f.owner, { alternate: owner }); + Object.assign(owner, { return: currentRoot }); + child.return = owner; + Object.assign(f.child, { alternate: child }); + // The DOM retains the old Fiber pointer while React publishes the alternate. + rootState.current = currentRoot; + expect(childAccount.getSnapshot()).toBe(true); + vi.advanceTimersByTime(1000); + expect(f.gate.refresh()).toBe("bypassed"); + expect(childAccount.getSnapshot()).toBe(false); + expect(ownerAccount.getSnapshot()).toBe(false); + expect(f.gate.update(false)).toBe("native"); + expect(childAccount.getSnapshot()).toBe(true); + expect(ownerAccount.getSnapshot()).toBe(true); + }, + ); + + it("restores native gates when the committed submit scope is no longer available", () => { + vi.useFakeTimers(); + const f = composerFixture(); + expect(f.gate.update(true)).toBe("bypassed"); + Object.assign(f.editor, { __reactFiber$test: { return: null } }); + vi.advanceTimersByTime(1000); + expect(f.gate.refresh()).toBe("unsupported"); + expect(f.blocked()).toBe(true); + }); + it("restores the original wrapper after React refreshes the instance getter", () => { const f = composerFixture(undefined, { snapshotWrapper: true, signalReserve: true }); const instance = required(f.instances[1]); diff --git a/tests/e2e/renderer-codex-usage-gate.spec.ts b/tests/e2e/renderer-codex-usage-gate.spec.ts new file mode 100644 index 000000000..b4ecc2e7f --- /dev/null +++ b/tests/e2e/renderer-codex-usage-gate.spec.ts @@ -0,0 +1,162 @@ +import { expect, test } from "@playwright/test"; +import { build } from "esbuild"; +import path from "node:path"; + +const browserExecutable = process.env.CODEXHOST_PLAYWRIGHT_EXECUTABLE_PATH; +if (browserExecutable) test.use({ launchOptions: { executablePath: browserExecutable } }); + +const { outputFiles } = await build({ + stdin: { + contents: ` + import { createRendererCodexUsageGate } from "./packages/renderer-extension/src/renderer-codex-usage-gate.ts"; + + const auth = { read: () => ({ authMethod: "chatgpt" }) }; + const usage = { read: () => ({ data: { rate_limit: { allowed: false } } }) }; + const reserve = { read: () => ({ active: false, hardBlocked: false }) }; + const accountGate = { read: get => get(auth).authMethod === "chatgpt" && + !get(reserve).active && get(usage).data.rate_limit.allowed === false }; + const reserveGate = { read: get => get(reserve).hardBlocked }; + const store = { + get: atom => atom.read(store.get), + sub: () => () => {}, + }; + + function composer(id) { + const form = document.createElement("form"); + form.id = id; + form.innerHTML = '
draft
0'; + document.body.append(form); + const editor = form.querySelector('[role="textbox"]'); + const send = form.querySelector("button"); + const banner = form.querySelector("aside"); + const output = form.querySelector("output"); + let childMounted = false; + let submitted = 0; + const subscriptions = []; + const render = () => { + send.disabled = !editor.textContent.trim() || subscriptions.some((entry, index) => + (index < 2 || childMounted) && entry.instance.getSnapshot()); + }; + const subscribe = atom => { + const subscriber = { + getSnapshot: () => store.get(atom), + subscribe: listener => store.sub(atom, listener), + createRender: () => undefined, + }; + const instance = { + value: subscriber.getSnapshot(), + getSnapshot: () => subscriber.getSnapshot(), + }; + const effect = { + deps: [subscriber.subscribe], + create: () => subscriber.subscribe(render), + }; + const entry = { subscriber, instance, hooks: [ + { memoizedState: [subscriber, [store, atom]] }, + { queue: instance }, + { memoizedState: effect }, + ] }; + subscriptions.push(entry); + return entry; + }; + const link = entries => { + const hooks = entries.flatMap(entry => entry.hooks); + hooks.forEach((hook, index) => hook.next = hooks[index + 1] ?? null); + return hooks[0]; + }; + const ownerHooks = link([subscribe(accountGate), subscribe(reserveGate)]); + const childHooks = link([subscribe(accountGate)]); + const rootState = { current: null }; + const child = { memoizedState: null, return: null }; + const bannerFiber = { + memoizedProps: { banner: {}, rateLimitStatus: {}, sendBlocked: true }, + child: { stateNode: banner }, + }; + child.sibling = bannerFiber; + const owner = { + memoizedProps: { onLocalSubmitStart() {}, submitDisabled: true }, + memoizedState: ownerHooks, child, return: null, + }; + const root = { stateNode: rootState, child: owner }; + owner.return = root; + child.return = owner; + rootState.current = root; + Object.defineProperty(editor, "__reactFiber$usageGate", { value: child }); + editor.addEventListener("input", render); + form.addEventListener("submit", event => { + event.preventDefault(); + output.textContent = String(++submitted); + }); + render(); + return { + gate: createRendererCodexUsageGate(form), + commitLateChild() { + childMounted = true; + const nextChild = { ...child, memoizedState: childHooks, alternate: child }; + const nextOwner = { ...owner, child: nextChild, alternate: owner }; + const nextRoot = { stateNode: rootState, child: nextOwner }; + owner.alternate = nextOwner; + child.alternate = nextChild; + nextOwner.return = nextRoot; + nextChild.return = nextOwner; + rootState.current = nextRoot; + render(); + }, + }; + } + const external = composer("external"); + composer("codex"); + globalThis.usageGateFixture = external; + `, + resolveDir: path.resolve(import.meta.dirname, "../.."), + sourcefile: "renderer-codex-usage-gate-e2e-entry.ts", + loader: "ts", + }, + bundle: true, + format: "iife", + platform: "browser", + target: "es2024", + write: false, +}); +const browserBundle = outputFiles[0]?.text; +if (!browserBundle) throw new Error("Codex usage gate bundle was not generated"); + +test("external submission follows committed gates and restores the Codex banner", async ({ + page, +}) => { + await page.clock.install(); + await page.setContent(""); + await page.addScriptTag({ content: browserBundle }); + const external = page.locator("#external"); + const codex = page.locator("#codex"); + await expect(external.getByRole("button", { name: "Send" })).toBeDisabled(); + await expect(external.locator("aside")).toBeVisible(); + + expect( + await page.evaluate(() => Reflect.get(globalThis, "usageGateFixture").gate.update(true)), + ).toBe("bypassed"); + await expect(external.getByRole("button", { name: "Send" })).toBeEnabled(); + await expect(external.locator("aside")).toBeHidden(); + await expect(codex.getByRole("button", { name: "Send" })).toBeDisabled(); + await expect(codex.locator("aside")).toBeVisible(); + + await page.evaluate(() => Reflect.get(globalThis, "usageGateFixture").commitLateChild()); + await expect(external.getByRole("button", { name: "Send" })).toBeDisabled(); + await page.clock.fastForward(1000); + expect( + await page.evaluate(() => Reflect.get(globalThis, "usageGateFixture").gate.refresh()), + ).toBe("bypassed"); + await external.getByRole("button", { name: "Send" }).click(); + await expect(external.locator("output")).toHaveText("1"); + await expect(codex.locator("output")).toHaveText("0"); + + await external.getByRole("textbox").fill(""); + await expect(external.getByRole("button", { name: "Send" })).toBeDisabled(); + await external.getByRole("textbox").fill("another draft"); + await expect(external.getByRole("button", { name: "Send" })).toBeEnabled(); + expect( + await page.evaluate(() => Reflect.get(globalThis, "usageGateFixture").gate.update(false)), + ).toBe("native"); + await expect(external.getByRole("button", { name: "Send" })).toBeDisabled(); + await expect(external.locator("aside")).toBeVisible(); +});