From 73f1e0534d74b2a0696959806b7b0f9fe6e59a41 Mon Sep 17 00:00:00 2001 From: yuwk <1729065730@qq.com> Date: Fri, 16 Oct 2026 20:51:44 +0800 Subject: [PATCH 1/6] fix: adapt Codex usage gates to Desktop 26.1002 Desktop 26.1002 lets an active reserve exempt the ChatGPT Account gate and subscribes it again in a child of the submit owner. The classifier treated the exempting read as a combined selector and only searched the owner, so external Harness Composers stayed blocked when Codex usage was exhausted. Fixes #534 --- .../src/renderer-codex-usage-gate.ts | 146 +++++++++++------- .../test/renderer-codex-usage-gate.test.ts | 78 ++++++++-- 2 files changed, 160 insertions(+), 64 deletions(-) diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts index 49c242d59..81495a6bb 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -9,6 +9,10 @@ * 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"; @@ -30,6 +34,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 +43,8 @@ interface Subscription { } interface Projection { + kind: GateKind; + fiber: Fiber; subscriber: Subscriber; instance: Instance; restore(): void; @@ -62,12 +69,19 @@ 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): 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 +89,40 @@ 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); 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): GateScope | null { + const scopes = submitOwners(composer); + 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 +159,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 +228,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 +239,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 +266,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 +337,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 +371,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 +383,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,8 +408,9 @@ 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(); + const boundKinds = (): Set => new Set([...projections.values()].map((p) => p.kind)); let retryAt = 0; let allowed = false; const release = (): void => { @@ -386,13 +426,15 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs } }; const bind = (): RendererCodexUsageGateStatus => { - const current = owner ?? findSubmitOwner(composer); + const current = owner ?? findSubmitScope(composer); 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"; }; @@ -402,11 +444,11 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs 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 (owner && boundKinds().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-gate.test.ts b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts index aa6c371fe..db9a46d05 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,26 @@ 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> = []; + const selectors: Array<[Atom, Array>]> = [ + [source.reserveActive, hooks], + [accountGate, hooks], + ...(options.omitReserve ? [] : ([[source.reserveGate, hooks]] as const)), + ...(options.duplicateAccount ? ([[accountGate, hooks]] as const) : []), + ...(options.childAccount ? ([[accountGate, childHooks]] as const) : []), + ]; + 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 +139,7 @@ function composerFixture( rerender(); }), }; - hooks.push( + target.push( { memoizedState: [subscriber, signal ? [value, undefined] : [source.store, atom]] }, { queue: instance }, { memoizedState: effect }, @@ -139,9 +150,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 +167,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, @@ -177,6 +199,38 @@ 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 blocked = () => + [external.instances[1], external.instances[3]].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); + }); + it("lifts only this Composer's usage gates without touching the shared store", () => { const f = composerFixture(); const { store } = f.source; From 78b729bed20cea626f83d81dece5680b04b8570d Mon Sep 17 00:00:00 2001 From: ump45nose Date: Fri, 16 Oct 2026 21:20:37 +0800 Subject: [PATCH 2/6] fix(test): use mutable SelectorEntry tuples in usage-gate fixtures `as const` made the conditional selector entries readonly, which failed `tsc -b` on ubuntu-22.04 (TS4104). Use `satisfies` so the tuples stay assignable to SelectorEntry[]. --- .../test/renderer-codex-usage-gate.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 db9a46d05..35bc47254 100644 --- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts +++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts @@ -98,12 +98,13 @@ function composerFixture( const hooks: Array> = []; const rerender = vi.fn(); const childHooks: Array> = []; - const selectors: Array<[Atom, Array>]> = [ + type SelectorEntry = [Atom, Array>]; + const selectors: SelectorEntry[] = [ [source.reserveActive, hooks], [accountGate, hooks], - ...(options.omitReserve ? [] : ([[source.reserveGate, hooks]] as const)), - ...(options.duplicateAccount ? ([[accountGate, hooks]] as const) : []), - ...(options.childAccount ? ([[accountGate, childHooks]] as const) : []), + ...(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. From c1f6a17ff09bf87c6e7b9e7f4ed2eb3daa8296a7 Mon Sep 17 00:00:00 2001 From: ump45nose Date: Sat, 17 Oct 2026 05:22:10 +0800 Subject: [PATCH 3/6] fix: bind an Account gate a child subscribes after the pair was bound Re-read the submit scope on each throttled retry instead of returning early once both gate kinds are projected, so a later Account subscription (e.g. a child mounted after the first bind) is projected too. Also tighten two usage-gate test assertions from review. --- .../src/renderer-codex-usage-gate.ts | 11 +++++-- .../test/renderer-codex-usage-gate.test.ts | 30 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts index 81495a6bb..21fd36045 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -410,7 +410,6 @@ export interface RendererCodexUsageGate { export function createRendererCodexUsageGate(composer: Element): RendererCodexUsageGate { let owner: GateScope | null = null; const projections = new Map(); - const boundKinds = (): Set => new Set([...projections.values()].map((p) => p.kind)); let retryAt = 0; let allowed = false; const release = (): void => { @@ -425,8 +424,15 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs } } }; + // Re-read the scope while bound, so a component that subscribes another + // Account gate after the first bind (a child mounted later) is found too. + const scope = (): GateScope | null => { + const found = findSubmitScope(composer); + if (!owner) return found; + return found?.owner === owner.owner ? found : owner; + }; const bind = (): RendererCodexUsageGateStatus => { - const current = owner ?? findSubmitScope(composer); + const current = scope(); const gates = current && discoverGates(current, projections); // The reserve gate always reads its field, so its absence means an unknown contract. if (!current || !gates?.some(([kind]) => kind === "reserve")) { @@ -448,7 +454,6 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs release(); retryAt = 0; } - if (owner && boundKinds().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-gate.test.ts b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts index 35bc47254..d2e20d59c 100644 --- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts +++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts @@ -191,6 +191,8 @@ function composerFixture( subscribers, instances, hooks, + editor, + child, blocked: () => instances.slice(1).some((instance) => instance.getSnapshot() === true), }; } @@ -209,8 +211,9 @@ describe("Codex usage gate for external Harness Composers", () => { reserveGateCount: 1, accountGateCount: 2, }); - const blocked = () => - [external.instances[1], external.instances[3]].some((i) => i?.getSnapshot() === true); + 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); @@ -230,6 +233,7 @@ describe("Codex usage gate for external Harness Composers", () => { 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", () => { @@ -381,6 +385,28 @@ describe("Codex usage gate for external Harness Composers", () => { expect(f.blocked()).toBe(false); }); + it("binds an Account gate a child subscribes after the pair was bound", () => { + vi.useFakeTimers(); + const f = composerFixture(undefined, { childAccount: true }); + 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 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("restores the original wrapper after React refreshes the instance getter", () => { const f = composerFixture(undefined, { snapshotWrapper: true, signalReserve: true }); const instance = required(f.instances[1]); From e40b37faf96032c8ae7de1492825d699e8bf0aac Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:57:29 +0800 Subject: [PATCH 4/6] fix: preserve projections when rediscovering submit scopes --- .../src/renderer-codex-usage-gate.ts | 16 ++++-- .../test/renderer-codex-usage-gate.test.ts | 49 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/renderer-extension/src/renderer-codex-usage-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts index 21fd36045..633875852 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -81,7 +81,10 @@ interface GateScope { * pass-through component with the same props but no gate hooks, so props alone * are ambiguous. */ -function submitOwners(composer: Element): GateScope[] { +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) { @@ -105,7 +108,7 @@ function submitOwners(composer: Element): GateScope[] { // 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); + const gates = gateSubscriptions(scope, projections); return !gates.invalid && gates.reserve.length > 0; }); return valid.filter( @@ -117,8 +120,11 @@ function submitOwners(composer: Element): GateScope[] { return []; } -function findSubmitScope(composer: Element): GateScope | null { - const scopes = submitOwners(composer); +function findSubmitScope( + composer: Element, + projections: ReadonlyMap, +): GateScope | null { + const scopes = submitOwners(composer, projections); return scopes.length === 1 ? (scopes[0] ?? null) : null; } @@ -427,7 +433,7 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs // Re-read the scope while bound, so a component that subscribes another // Account gate after the first bind (a child mounted later) is found too. const scope = (): GateScope | null => { - const found = findSubmitScope(composer); + const found = findSubmitScope(composer, projections); if (!owner) return found; return found?.owner === owner.owner ? found : owner; }; 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 d2e20d59c..10fb81895 100644 --- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts +++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts @@ -385,27 +385,34 @@ describe("Codex usage gate for external Harness Composers", () => { expect(f.blocked()).toBe(false); }); - it("binds an Account gate a child subscribes after the pair was bound", () => { - vi.useFakeTimers(); - const f = composerFixture(undefined, { childAccount: true }); - 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 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 (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("restores the original wrapper after React refreshes the instance getter", () => { const f = composerFixture(undefined, { snapshotWrapper: true, signalReserve: true }); From 2d8b2e284930c8efe216c597566a4a2c0784e9ae Mon Sep 17 00:00:00 2001 From: yuwk <1729065730@qq.com> Date: Sat, 17 Oct 2026 11:28:43 +0800 Subject: [PATCH 5/6] fix: hide the Codex usage banner in bypassed external Harness Composers The usage-limit banner (Upgrade / Reset usage) describes the Codex path only. While an external Harness Composer bypasses the usage gate, hide the banner's host elements found by its props inside the submit owner's subtree and restore them when the Composer returns to the native result. --- .../src/renderer-codex-usage-banner.ts | 108 ++++++++++++++++++ .../src/renderer-codex-usage-gate.ts | 11 ++ .../test/renderer-codex-usage-banner.test.ts | 78 +++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 packages/renderer-extension/src/renderer-codex-usage-banner.ts create mode 100644 packages/renderer-extension/test/renderer-codex-usage-banner.test.ts 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 633875852..d242ce708 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -16,6 +16,8 @@ */ 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 = { @@ -418,7 +420,9 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs 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(); @@ -450,7 +454,14 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs } 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; 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(""); + }); +}); From 9a7584387db080a72f9abfc27fb3d36d1fc7ab23 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 17 Oct 2026 22:25:19 +0800 Subject: [PATCH 6/6] fix: follow committed Composer usage gate scopes Rediscover the current committed submit scope on each retry, including alternate owners and newly mounted Account subscriptions. Restore native restrictions when that scope is unavailable. Cover direct and lazy snapshots across alternate commits, plus browser submission, banner restoration, empty input, and Codex Composer isolation. Synchronize the account feature documentation. --- docs/product/codex-accounts.md | 4 +- .../src/renderer-codex-usage-gate.ts | 10 +- .../test/renderer-codex-usage-gate.test.ts | 49 ++++++ tests/e2e/renderer-codex-usage-gate.spec.ts | 162 ++++++++++++++++++ 4 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/renderer-codex-usage-gate.spec.ts 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-gate.ts b/packages/renderer-extension/src/renderer-codex-usage-gate.ts index d242ce708..4ecdb79fc 100644 --- a/packages/renderer-extension/src/renderer-codex-usage-gate.ts +++ b/packages/renderer-extension/src/renderer-codex-usage-gate.ts @@ -434,15 +434,9 @@ export function createRendererCodexUsageGate(composer: Element): RendererCodexUs } } }; - // Re-read the scope while bound, so a component that subscribes another - // Account gate after the first bind (a child mounted later) is found too. - const scope = (): GateScope | null => { - const found = findSubmitScope(composer, projections); - if (!owner) return found; - return found?.owner === owner.owner ? found : owner; - }; const bind = (): RendererCodexUsageGateStatus => { - const current = scope(); + // 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?.some(([kind]) => kind === "reserve")) { 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 10fb81895..c56003963 100644 --- a/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts +++ b/packages/renderer-extension/test/renderer-codex-usage-gate.test.ts @@ -414,6 +414,55 @@ describe("Codex usage gate for external Harness Composers", () => { }, ); + 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(); +});