From 31bdafd87cbab47c271d5f095a2105fa360b4c0e Mon Sep 17 00:00:00 2001 From: ViceEye Date: Thu, 8 Oct 2026 14:20:39 +0100 Subject: [PATCH 1/9] =?UTF-8?q?fix(composer):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=A4=8D=E7=94=A8=E7=94=A8=E9=87=8F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=BF=AE=E5=A4=8D=E9=80=BB=E8=BE=91=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=96=AD=E7=BA=BF=E9=87=8D=E8=BF=9E=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/inject/api-quota-gate.js | 372 +++++++++++++++++- assets/inject/api-quota-gate.test.cjs | 66 +++- assets/inject/native-api-quota-gate.test.cjs | 100 +++++ crates/codex-plus-core/src/assets.rs | 5 + crates/codex-plus-core/src/bridge.rs | 185 +++++++++ crates/codex-plus-core/src/launcher.rs | 21 + .../tests/native_api_quota_gate.rs | 17 + 7 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 assets/inject/native-api-quota-gate.test.cjs create mode 100644 crates/codex-plus-core/tests/native_api_quota_gate.rs diff --git a/assets/inject/api-quota-gate.js b/assets/inject/api-quota-gate.js index cbc65f0a4..d65e73c09 100644 --- a/assets/inject/api-quota-gate.js +++ b/assets/inject/api-quota-gate.js @@ -7,7 +7,375 @@ return !!profile && profile.relayMode === "official" && profile.officialMixApiKey === true; } - const api = { permitsExternalApi }; - if (typeof window !== "undefined") window.__codexPlusApiQuotaGate = api; + function locate(text, url) { + if (typeof text !== "string" || !text || typeof url !== "string") return null; + const id = "[A-Za-z_$][\\w$]*"; + // 绑定身份校验和额度判断的特征,不能仅按压缩后的函数名定位。 + const atomPattern = new RegExp(`(${id})=${id}\\(${id},\\(\\{get:${id}\\}\\)=>\\{[^}]{0,1400}?\\.authMethod!==\`chatgpt\`[^}]{0,1400}?\\.rate_limit\\?\\.allowed!==!1`, "g"); + const atoms = [...text.matchAll(atomPattern)]; + if (atoms.length !== 1) return null; + const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const gatePattern = new RegExp(`(${id})=${id}\\(${escape(atoms[0][1])}\\)&&(${id})===\`local\``, "g"); + const gates = [...text.matchAll(gatePattern)]; + if (gates.length !== 1) return null; + const gate = gates[0]; + const tailStart = gate.index + gate[0].length; + const tail = text.slice(tailStart, tailStart + 18000); + const assignment = new RegExp(`(${id})=(${id}(?:\\|\\|${id})*\\|\\|${escape(gate[1])})(?=[,;])`).exec(tail); + if (!assignment || !tail.includes(`submitDisabled:${assignment[1]}`)) return null; + const offset = tailStart + assignment.index + assignment[1].length + 1; + const before = text.slice(0, offset).split("\n"); + return { + urlRegex: `^${escape(url)}$`, + lineNumber: before.length - 1, + columnNumber: before.at(-1).length, + quotaVariable: gate[1], + hostVariable: gate[2], + }; + } + + function condition(location) { + const identifier = /^[A-Za-z_$][\w$]*$/; + if (!identifier.test(location?.quotaVariable) || !identifier.test(location?.hostVariable)) return null; + const quota = location.quotaVariable; + const host = location.hostVariable; + // 条件始终返回 false,页面不会因这条断点暂停。 + return `(${quota}&&window.__codexPlusExternalApiQuotaAllowed?.(${host})===true&&(${quota}=false),false)`; + } + + let refreshedComposers = new WeakMap(); + function refreshComposers(location, force = false) { + if (!condition(location)) return 0; + if (force) refreshedComposers = new WeakMap(); + const allowed = window.__codexPlusExternalApiQuotaAllowed?.("local") === true; + let refreshed = 0; + for (const root of document.querySelectorAll("[data-codex-composer-root]")) { + let fiber = root[Object.keys(root).find(key => key.startsWith("__reactFiber$"))]; + for (let depth = 0; fiber && depth < 80; depth++, fiber = fiber.return) { + if (typeof fiber.type !== "function") continue; + const source = fiber.type.toString(); + if (!source.includes(`&&${location.hostVariable}===\`local\``) + || !source.includes(`||${location.quotaVariable},`) + || !source.includes("submitDisabled:")) continue; + const hooks = []; + for (let hook = fiber.memoizedState; hook; hook = hook.next) { + if (hook.memoizedState instanceof Set && hook.memoizedState.size === 0 + && typeof hook.queue?.dispatch === "function") hooks.push(hook); + } + // 只重绘唯一可识别的空 Set 状态;不修改草稿或非空停止队列。 + if (hooks.length !== 1) break; + const dispatch = hooks[0].queue.dispatch; + const previous = refreshedComposers.get(dispatch); + if (previous === allowed || (previous === undefined && !allowed && !force)) break; + refreshedComposers.set(dispatch, allowed); + dispatch(new Set()); + refreshed++; + break; + } + } + return refreshed; + } + + // 复用增强模式的 Query 发布点,不依赖压缩变量名或 app 资源文件名。 + function installNative(settings) { + if (window.__codexPlusNativeQuotaRuntime) { + window.__codexPlusNativeQuotaRuntime.update(settings); + return window.__codexPlusNativeQuotaRuntime; + } + let currentSettings = settings; + const officialUsageRuntime = { + rawPayloads: new WeakMap(), pendingPublications: new WeakMap(), rewriteDepth: 0, + }; + function officialUsagePolicy() { + const enabled = permitsExternalApi(currentSettings, "local"); + return { official: true, hideAlerts: false, unlockSend: enabled }; + } + function officialUsagePolicyKey() { + return officialUsagePolicy().unlockSend ? "official-mix" : "off"; + } + function isOfficialUsageStatus(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const rateLimit = value.rate_limit; + if (!rateLimit || typeof rateLimit !== "object" || typeof rateLimit.allowed !== "boolean") return false; + return typeof value.plan_type === "string" + || typeof value.user_id === "string" + || typeof value.account_id === "string"; + } + + function isImageGenerationUpsell(value) { + return String(value?.banner_type || "") === "image_generation_limit_reached"; + } + + function isMainRateLimitQueryKey(queryKey) { + return Array.isArray(queryKey) + && queryKey[0] === "rate-limit-status" + && queryKey[1] !== "image-generation"; + } + + // 低额度提示和发送锁都只看当前是不是官登混入 Key。纯官登不改这份用量。 + // 桌面端发送按钮读 rate_limit.allowed;limit_reached 为 true 也会被当成已用完。 + // 混入时在查询发布前把 allowed 写成 true,并清掉 limit_reached。 + // 百分比、重置时间、账号、积分和消费上限不动。图片额度横幅单独留下。 + function rewriteOfficialUsageStatus(value, policy = officialUsagePolicy()) { + if (!policy.unlockSend || !isOfficialUsageStatus(value)) return null; + if (value.rate_limit.allowed === true && value.rate_limit.limit_reached !== true) return null; + return { ...value, rate_limit: { ...value.rate_limit, allowed: true, limit_reached: false } }; + } + + function rewriteOfficialUsagePayload(value, policy = officialUsagePolicy()) { + if (!value || typeof value !== "object") return value; + if (isOfficialUsageStatus(value)) return rewriteOfficialUsageStatus(value, policy) || value; + if (value.usage && value.usage !== value && isOfficialUsageStatus(value.usage)) { + const usage = rewriteOfficialUsageStatus(value.usage, policy); + return usage ? { ...value, usage } : value; + } + return value; + } + + function rewriteTrackedOfficialUsagePayload(value) { + const raw = officialUsageRuntime.rawPayloads.get(value) || value; + if (officialUsagePolicyKey() === "off") return raw; + const next = rewriteOfficialUsagePayload(value); + if (next !== value) officialUsageRuntime.rawPayloads.set(next, raw); + return next; + } + + function looksLikeQueryClient(value) { + return !!value + && typeof value.getQueryCache === "function" + && typeof value.setQueryData === "function"; + } + + // 图片额度使用同一条 /wham/usage,只能靠查询键 image-generation 排除。 + // 这里只在第一次挂上缓存时找客户端,不进每轮 DOM 扫描。 + function queryClientFromFiber(fiber) { + const seen = new Set(); + const stack = [fiber]; + let visited = 0; + while (stack.length && visited < 8000) { + const node = stack.pop(); + if (!node || typeof node !== "object" || seen.has(node)) continue; + seen.add(node); + visited += 1; + const props = node.memoizedProps || node.pendingProps; + if (looksLikeQueryClient(props?.client)) return props.client; + if (looksLikeQueryClient(props?.value)) return props.value; + if (looksLikeQueryClient(node.stateNode)) return node.stateNode; + const state = node.memoizedState; + if (state && typeof state === "object" && looksLikeQueryClient(state.memoizedState)) return state.memoizedState; + if (node.child) stack.push(node.child); + if (node.sibling) stack.push(node.sibling); + } + return null; + } + + let officialUsageClient = null; + + function findCodexQueryClient() { + const explicit = window.__REACT_QUERY_CLIENT__ || window.__codexQueryClient; + if (looksLikeQueryClient(explicit)) return explicit; + if (looksLikeQueryClient(officialUsageClient)) return officialUsageClient; + const roots = [document.getElementById?.("root"), document.body, document.documentElement].filter(Boolean); + for (const root of roots) { + let key = ""; + try { + key = Object.keys(root).find((name) => name.startsWith("__reactContainer$") || name.startsWith("__reactFiber$")) || ""; + } catch { + key = ""; + } + if (!key) continue; + let fiber = root[key]; + if (fiber?.stateNode?.current) fiber = fiber.stateNode.current; + const client = queryClientFromFiber(fiber); + if (client) { + officialUsageClient = client; + return client; + } + } + return null; + } + + function mainRateLimitQueries(client) { + const cache = client.getQueryCache?.(); + if (cache && typeof cache.findAll === "function") { + return cache.findAll({ queryKey: ["rate-limit-status"] }).filter((query) => isMainRateLimitQueryKey(query?.queryKey)); + } + if (typeof client.getQueriesData === "function") { + return client.getQueriesData({ queryKey: ["rate-limit-status"] }) + .filter(([queryKey]) => isMainRateLimitQueryKey(queryKey)) + .map(([queryKey, data]) => ({ queryKey, state: { data } })); + } + return []; + } + + // Query.setData 是 GET /wham/usage 和 SSE snapshot 共用的发布点。 + // 在通知订阅者之前改写,RK 第一次读到的 allowed 就是结果。 + function patchOfficialUsageQueryPublication(client) { + const cache = client.getQueryCache?.(); + if (!cache) return; + const listed = typeof cache.getAll === "function" + ? cache.getAll() + : (typeof cache.findAll === "function" ? cache.findAll({ queryKey: ["rate-limit-status"] }) : []); + const query = listed.find((item) => typeof Object.getPrototypeOf(item)?.setData === "function"); + if (!query) return; + const proto = Object.getPrototypeOf(query); + if (typeof proto.setData !== "function" || proto.setData.__codexPlusNativeQuotaPublication) return; + const original = proto.setData; + function codexPlusPublishUsageData(data, ...rest) { + if (!isMainRateLimitQueryKey(this?.queryKey)) return original.call(this, data, ...rest); + const raw = officialUsageRuntime.rawPayloads.get(data) || data; + const next = officialUsageRuntime.rewrite(data); + const previousPublication = officialUsageRuntime.pendingPublications.get(this); + const publication = { raw }; + officialUsageRuntime.pendingPublications.set(this, publication); + officialUsageRuntime.rewriteDepth += 1; + try { + const stored = original.call(this, next, ...rest); + // TanStack 结构共享可能返回另一对象;快照绑定实际缓存对象,不绑定输入副本。 + // 若订阅者已嵌套发布更新,沿用它登记的快照,不能用外层旧值覆盖。 + if (publication.raw === raw && stored && typeof stored === "object") { + if (next !== raw) officialUsageRuntime.rawPayloads.set(stored, raw); + else officialUsageRuntime.rawPayloads.delete(stored); + } + if (previousPublication) previousPublication.raw = publication.raw; + return stored; + } finally { + officialUsageRuntime.rewriteDepth -= 1; + if (previousPublication) officialUsageRuntime.pendingPublications.set(this, previousPublication); + else officialUsageRuntime.pendingPublications.delete(this); + } + } + codexPlusPublishUsageData.__codexPlusNativeQuotaPublication = true; + proto.setData = codexPlusPublishUsageData; + } + + function patchOfficialUsageQueryClient(client) { + if (!client || typeof client.setQueryData !== "function") return; + patchOfficialUsageQueryPublication(client); + if (client.__codexPlusNativeQuotaRewrite) return; + const original = client.setQueryData; + client.setQueryData = function codexPlusSetUsageQueryData(queryKey, updater, ...rest) { + if (!isMainRateLimitQueryKey(queryKey)) { + return original.call(this, queryKey, updater, ...rest); + } + const nextUpdater = typeof updater === "function" + ? (previous) => { + // setData 的同步订阅者可能马上写回,此时结构共享对象尚未返回。 + const query = this.getQueryCache?.()?.find?.({ queryKey, exact: true }); + const publishing = query && officialUsageRuntime.pendingPublications.get(query); + const raw = publishing ? publishing.raw : (officialUsageRuntime.rawPayloads.get(previous) || previous); + return officialUsageRuntime.rewrite(updater(raw)); + } + : officialUsageRuntime.rewrite(updater); + officialUsageRuntime.rewriteDepth += 1; + try { + return original.call(this, queryKey, nextUpdater, ...rest); + } finally { + officialUsageRuntime.rewriteDepth -= 1; + } + }; + const cache = client.getQueryCache?.(); + if (cache && typeof cache.subscribe === "function") { + cache.subscribe((event) => { + if (officialUsageRuntime.rewriteDepth > 0) return; + const query = event?.query; + if (!isMainRateLimitQueryKey(query?.queryKey)) return; + const current = query.state?.data; + const next = officialUsageRuntime.rewrite(current); + if (next === current) return; + client.setQueryData(query.queryKey, next); + }); + } + client.__codexPlusNativeQuotaRewrite = true; + } + + function rewriteCachedOfficialUsage(client) { + if (!client || !officialUsagePolicy().official) return; + for (const query of mainRateLimitQueries(client)) { + const current = query.state?.data; + const next = officialUsageRuntime.rewrite(current); + if (next !== current) client.setQueryData(query.queryKey, next); + } + } + + function invalidateMainRateLimitQueries(client) { + if (!client || typeof client.invalidateQueries !== "function") return; + for (const query of mainRateLimitQueries(client)) { + try { + Promise.resolve(client.invalidateQueries({ queryKey: query.queryKey, exact: true })).catch(() => {}); + } catch { + } + } + } + + let officialUsagePolicyApplied = ""; + let officialUsageClientTimer = null; + + function syncOfficialUsagePolicy() { + const key = officialUsagePolicyKey(); + const client = findCodexQueryClient(); + if (client) { + officialUsageClient = client; + patchOfficialUsageQueryClient(client); + if (officialUsageClientTimer) { + clearTimeout(officialUsageClientTimer); + officialUsageClientTimer = null; + } + } else if (!officialUsageClientTimer) { + let attempts = 0; + const retry = () => { + attempts += 1; + officialUsageClientTimer = null; + if (findCodexQueryClient()) { + syncOfficialUsagePolicy(); + return; + } + if (attempts < 20) officialUsageClientTimer = setTimeout(retry, 300); + }; + officialUsageClientTimer = setTimeout(retry, 300); + return; + } else { + return; + } + if (key === officialUsagePolicyApplied) { + if (key !== "off") rewriteCachedOfficialUsage(client); + return; + } + const previous = officialUsagePolicyApplied; + officialUsagePolicyApplied = key; + if (key === "off") { + // 先同步恢复真实用量;断网或刷新悬挂时也不能沿用混入模式的解锁结果。 + for (const query of mainRateLimitQueries(client)) { + const raw = officialUsageRuntime.rawPayloads.get(query.state?.data); + if (raw) client.setQueryData(query.queryKey, raw); + } + if (previous) invalidateMainRateLimitQueries(client); + return; + } + rewriteCachedOfficialUsage(client); + if (previous) invalidateMainRateLimitQueries(client); + } + + + const runtime = { + update(next) { + currentSettings = next; + syncOfficialUsagePolicy(); + }, + refresh: syncOfficialUsagePolicy, + }; + officialUsageRuntime.rewrite = rewriteTrackedOfficialUsagePayload; + window.__codexPlusNativeQuotaRuntime = runtime; + syncOfficialUsagePolicy(); + return runtime; + } + + const api = { permitsExternalApi, locate, condition, refreshComposers, installNative }; if (typeof module === "object" && module.exports) module.exports = api; + if (typeof window !== "undefined") { + window.__codexPlusApiQuotaGate = api; + if (typeof window.__codexPlusExternalApiQuotaAllowed !== "function") { + window.__codexPlusExternalApiQuotaAllowed = (hostId) => permitsExternalApi(window.__codexPlusSettings, hostId); + } + } })(); diff --git a/assets/inject/api-quota-gate.test.cjs b/assets/inject/api-quota-gate.test.cjs index d238103c6..2e76d74dc 100644 --- a/assets/inject/api-quota-gate.test.cjs +++ b/assets/inject/api-quota-gate.test.cjs @@ -1,5 +1,6 @@ const assert = require("node:assert/strict"); -const { permitsExternalApi } = require("./api-quota-gate.js"); +const vm = require("node:vm"); +const { permitsExternalApi, locate, condition } = require("./api-quota-gate.js"); const profile = { id: "api", relayMode: "official", officialMixApiKey: true, upstreamBaseUrl: "https://proxy.example/v1" }; const settings = { relayProfilesEnabled: true, activeRelayId: "api", relayProfiles: [profile], activeRelaySessionProvider: "openai" }; @@ -20,3 +21,66 @@ for (const patch of [ {officialMixApiKey:false}, {relayMode:"pureApi"}, ]) assert.equal(permitsExternalApi({...settings,relayProfiles:[{...profile,...patch}]}, "local"), false); console.log("external relay official-mix policy passed"); + +const fixture = [ + "var Q;function init(){Q=derived(root,({get:g})=>{let a=g(auth),r=g(rate).data;if(a.authMethod!==`chatgpt`||r.rate_limit?.allowed!==!1)return!1;return true})}", + "function Composer(){let quota=read(Q)&&host===`local`;let disabled=busy||quota;render({submitDisabled:disabled})}", +].join("\n"); +const found = locate(fixture, "app://-/assets/app-primary-fixture.js"); +assert.equal(found.quotaVariable, "quota"); +assert.equal(found.hostVariable, "host"); +assert.equal(fixture.split("\n")[found.lineNumber].slice(found.columnNumber,found.columnNumber+4), "busy"); +assert.equal(locate("unrelated source", "app://-/a.js"), null); +assert.equal(locate(fixture + fixture, "app://-/a.js"), null, "ambiguous matches must fail closed"); +assert.equal(locate(fixture.replace("submitDisabled:disabled", "other:disabled"), "app://-/a.js"), null); +assert.equal(locate(fixture + "\nlet other=read(Q)&&host===`local`;", "app://-/a.js"), null); +assert.equal(locate(fixture.replace("busy||quota", "busy||another"), "app://-/a.js"), null); +const unicodeFixture = fixture.replace("let disabled=", 'let label="\u{1f600}";let disabled='); +const unicodeFound = locate(unicodeFixture, "app://-/a.js"); +assert.equal(unicodeFixture.split("\n")[unicodeFound.lineNumber].slice(unicodeFound.columnNumber, unicodeFound.columnNumber + 4), "busy"); +assert.equal(condition({quotaVariable:"x;alert(1)",hostVariable:"host"}), null); + +for (const allowed of [false, true]) { + const context = {quota:true, host:"local", busy:false, window:{__codexPlusExternalApiQuotaAllowed:()=>allowed}}; + assert.equal(vm.runInNewContext(condition(found), context), false, "debugger condition never pauses"); + assert.equal(context.quota, !allowed); + assert.equal(vm.runInNewContext("busy||quota", context), !allowed); + context.busy = true; + assert.equal(vm.runInNewContext("busy||quota", context), true, "other composer blocks must survive"); +} +console.log("API quota gate policy, source matching, fail-closed behavior and other blocks passed"); + +const fs = require("node:fs"); +let allowed = true; +const calls = []; +const dispatch = value => calls.push(value); +const hook = { memoizedState: new Set(), queue: {dispatch}, next: null }; +const type = () => {}; +type.toString = () => "function Adapter(){let quota=read(Q)&&host===`local`,disabled=busy||quota,next;render({submitDisabled:disabled})}"; +const root = {__reactFiber$test: {type:"div", return:{type, memoizedState:hook, return:null}}}; +const context = { + Set, WeakMap, URL, + window: {__codexPlusExternalApiQuotaAllowed:()=>allowed}, + document: {querySelectorAll:()=>[root]}, +}; +vm.runInNewContext(fs.readFileSync(require.resolve("./api-quota-gate.js"), "utf8"), context); +const refresh = context.window.__codexPlusApiQuotaGate.refreshComposers; +assert.equal(refresh(found), 1, "already mounted composer must render after installation"); +assert.equal(calls[0].size, 0); +assert.equal(refresh(found), 0, "polls must not keep rerendering"); +allowed = false; +assert.equal(refresh(found), 1, "switching back must restore the official gate"); +assert.equal(refresh(found), 0); +assert.equal(refresh(found, true), 1, "rearming in official mode must restore original render"); +allowed = true; +assert.equal(refresh(found), 1); +assert.equal(refresh(found, true), 1, "reconnected breakpoint must invalidate stale render"); +hook.memoizedState = new Set(["active-stop"]); +assert.equal(refresh(found, true), 0, "never change active stop state"); +hook.memoizedState = new Set(); +hook.next = {memoizedState:new Set(),queue:{dispatch:()=>{}},next:null}; +assert.equal(refresh(found, true), 0, "ambiguous hooks fail closed"); +hook.next = null; +type.toString = () => "function unrelated(){}"; +assert.equal(refresh(found, true), 0); +console.log("Composer startup refresh, reconnect, policy changes and deduplication passed"); diff --git a/assets/inject/native-api-quota-gate.test.cjs b/assets/inject/native-api-quota-gate.test.cjs new file mode 100644 index 000000000..be5bed0c9 --- /dev/null +++ b/assets/inject/native-api-quota-gate.test.cjs @@ -0,0 +1,100 @@ +function runNativeQuotaTests(createGate) { + const equal = (actual, expected, message) => { + if (!Object.is(actual, expected)) throw new Error(message); + }; + const mixed = { + relayProfilesEnabled: true, activeRelayId: "mix", + relayProfiles: [{ id: "mix", relayMode: "official", officialMixApiKey: true }], + }; + const official = { ...mixed, relayProfiles: [{ ...mixed.relayProfiles[0], officialMixApiKey: false }] }; + const raw = { + plan_type: "plus", rate_limit: { allowed: false, limit_reached: true, used_percent: 100 }, + rate_limit_warning: { text: "keep" }, credits: { balance: 0 }, + }; + const publications = []; + const listeners = []; + class Query { + constructor(queryKey, data) { this.queryKey = queryKey; this.state = { data }; } + setData(data) { + // 模拟 TanStack 结构共享:实际缓存对象不是传入对象。 + this.state.data = { ...data }; + publications.push(this.state.data); + for (const listener of listeners) listener({ query: this }); + return this.state.data; + } + } + const main = new Query(["rate-limit-status"], raw); + const image = new Query(["rate-limit-status", "image-generation"], raw); + const unrelated = new Query(["other"], raw); + const cache = { + getAll: () => [main, image, unrelated], + findAll: () => [main, image], + find: ({ queryKey }) => [main, image, unrelated].find(q => JSON.stringify(q.queryKey) === JSON.stringify(queryKey)), + subscribe: listener => listeners.push(listener), + }; + const client = { + getQueryCache: () => cache, + setQueryData(key, updater) { + const query = cache.find({ queryKey: key }); + return query.setData(typeof updater === "function" ? updater(query.state.data) : updater); + }, + invalidateQueries: () => Promise.resolve(), + }; + const gate = createGate(client); + const runtime = gate.installNative(mixed); + equal(main.state.data.rate_limit.allowed, true, "existing exhausted cache must unlock"); + equal(main.state.data.rate_limit.limit_reached, false, "limit_reached must clear"); + equal(main.state.data.rate_limit.used_percent, 100, "usage percentage must survive"); + equal(main.state.data.rate_limit_warning, raw.rate_limit_warning, "alerts are outside standalone scope"); + equal(main.state.data.credits, raw.credits, "credits must survive"); + equal(image.state.data, raw, "image query must stay untouched"); + equal(unrelated.state.data, raw, "unrelated query must stay untouched"); + const listenerCount = listeners.length; + equal(gate.installNative(mixed), runtime, "reinjection must reuse runtime"); + equal(listeners.length, listenerCount, "reinjection must not add subscriptions"); + + const updated = { ...raw, rate_limit: { ...raw.rate_limit, used_percent: 92 } }; + main.setData(updated); // SSE / query.fetch 都走同一个发布点。 + equal(publications.at(-1).rate_limit.allowed, true, "subscribers must see unlocked data on first publication"); + runtime.update(official); + equal(main.state.data.rate_limit.allowed, false, "switching back must restore official lock"); + equal(main.state.data.rate_limit.used_percent, 92, "restore latest server snapshot, not startup snapshot"); + + runtime.update(mixed); + let updaterAllowed; + client.setQueryData(main.queryKey, previous => { + updaterAllowed = previous.rate_limit.allowed; + return { ...previous, account_id: "new" }; + }); + equal(updaterAllowed, false, "functional updater must receive real server data"); + equal(main.state.data.rate_limit.allowed, true, "functional update must still unlock"); + runtime.update(null); + equal(main.state.data.rate_limit.allowed, false, "settings failure must restore lock"); + equal(main.state.data.account_id, "new", "functional update must survive restoration"); + + runtime.update({ ...mixed, relayProfilesEnabled: false }); + main.setData(raw); + equal(main.state.data.rate_limit.allowed, false, "disabled relay must not unlock new publication"); + runtime.update({ ...mixed, relayProfiles: [{ ...mixed.relayProfiles[0], relayMode: "pureApi" }] }); + equal(main.state.data.rate_limit.allowed, false, "pure API must not use official-mix bypass"); + runtime.update(mixed); + main.setData({ usage: updated, envelope: "preserve" }); + equal(main.state.data.usage.rate_limit.allowed, true, "wrapped usage response must unlock"); + equal(main.state.data.envelope, "preserve", "response envelope must survive"); + runtime.update(official); + equal(main.state.data.usage.rate_limit.allowed, false, "wrapped usage response must restore"); + return "Native quota policy, existing cache, publication, restoration, structural sharing and reinjection passed"; +} +module.exports = runNativeQuotaTests; +if (require.main === module) { + const fs = require("node:fs"); + const vm = require("node:vm"); + console.log(runNativeQuotaTests(client => { + const context = { + module: { exports: {} }, window: { __REACT_QUERY_CLIENT__: client }, + document: {}, setTimeout, clearTimeout, + }; + vm.runInNewContext(fs.readFileSync(require.resolve("./api-quota-gate.js"), "utf8"), context); + return context.module.exports; + })); +} diff --git a/crates/codex-plus-core/src/assets.rs b/crates/codex-plus-core/src/assets.rs index 37ecb3c5e..dd7e618aa 100644 --- a/crates/codex-plus-core/src/assets.rs +++ b/crates/codex-plus-core/src/assets.rs @@ -44,6 +44,7 @@ const DREAM_SKIN_DEFAULT_IMAGE: &[u8] = const DREAM_SKIN_DEFAULT_IMAGE: &[u8] = include_bytes!("../../../assets/inject/upstream/dream-skin/macos/portal-hero.png"); const PET_REAL_MOUSE_SCRIPT: &str = include_str!("../../../assets/inject/pet-real-mouse-inject.js"); +const API_QUOTA_GATE_SCRIPT: &str = include_str!("../../../assets/inject/api-quota-gate.js"); const STEPWISE_SCRIPT: &str = concat!( "(() => {\n", include_str!("../../../assets/inject/floating-panel/runtime/state.js"), @@ -313,6 +314,10 @@ pub fn pet_real_mouse_script() -> &'static str { PET_REAL_MOUSE_SCRIPT } +pub fn api_quota_gate_script() -> &'static str { + API_QUOTA_GATE_SCRIPT +} + const PET_V2_SPRITE_DETECTION_SCRIPT: &str = r#" const isV2Sprite = async (mascot) => { if (!mascot) return false; diff --git a/crates/codex-plus-core/src/bridge.rs b/crates/codex-plus-core/src/bridge.rs index 212ff58b4..4847f6b91 100644 --- a/crates/codex-plus-core/src/bridge.rs +++ b/crates/codex-plus-core/src/bridge.rs @@ -276,6 +276,191 @@ pub async fn add_script_to_new_documents( .await } +/// 独立模式复用增强模式的 Query 发布逻辑;CDP 只响应设置请求,不轮询页面。 +const NATIVE_QUOTA_SETTINGS_BINDING: &str = "codexPlusNativeQuotaSettings"; + +fn native_quota_settings() -> Value { + // 只传额度策略必需字段,不能把 Key、configContents 或 authContents 放进页面。 + let Ok(settings) = crate::settings::SettingsStore::default().load() else { + return Value::Null; + }; + let profile = settings.active_relay_profile(); + json!({ + "relayProfilesEnabled": settings.relay_profiles_enabled, + "activeRelayId": profile.id, + "relayProfiles": [{ + "id": profile.id, + "relayMode": profile.relay_mode, + "officialMixApiKey": profile.official_mix_api_key, + }], + }) +} + +fn native_quota_bootstrap_script() -> String { + format!( + "{}\n{}", + crate::assets::api_quota_gate_script(), + r#" +(() => { + if (window.__codexPlusNativeQuotaBootstrap) { + window.__codexPlusNativeQuotaBootstrap.request(); + return; + } + let sequence = 0; + let timer = null; + const state = { + request() { + const seq = ++sequence; + clearTimeout(timer); + timer = setTimeout(() => { + if (seq === sequence) window.__codexPlusApiQuotaGate.installNative(null); + }, 5000); + try { + window.codexPlusNativeQuotaSettings(JSON.stringify({ sequence: seq })); + } catch { + clearTimeout(timer); + window.__codexPlusApiQuotaGate.installNative(null); + } + }, + accept(seq, settings) { + if (seq !== sequence) return; + clearTimeout(timer); + window.__codexPlusApiQuotaGate.installNative(settings); + }, + }; + window.__codexPlusNativeQuotaBootstrap = state; + window.addEventListener("focus", () => state.request()); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") state.request(); + }); + document.addEventListener("DOMContentLoaded", () => state.request(), { once: true }); + state.request(); +})(); +"#, + ) +} + +pub async fn supervise_isolated_api_quota_gate(debug_port: u16) { + let mut retry_delay = std::time::Duration::from_millis(500); + loop { + match install_isolated_api_quota_gate_session(debug_port).await { + Ok(()) => { + retry_delay = std::time::Duration::from_millis(500); + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "bridge.native_quota_gate_retry", + json!({ "debug_port": debug_port, "error": error.to_string() }), + ); + } + } + tokio::time::sleep(retry_delay).await; + if retry_delay < std::time::Duration::from_secs(5) { + retry_delay = retry_delay.saturating_mul(2); + } + } +} + +async fn install_isolated_api_quota_gate_session(debug_port: u16) -> anyhow::Result<()> { + let targets = crate::cdp::list_targets(debug_port).await?; + let target = crate::cdp::pick_injectable_codex_page_target(&targets)?; + let websocket_url = target + .web_socket_debugger_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL"))?; + install_isolated_api_quota_gate_target_url(websocket_url).await +} + +pub async fn install_isolated_api_quota_gate(websocket_url: &str) -> anyhow::Result<()> { + install_isolated_api_quota_gate_target_url(websocket_url).await +} + +async fn install_isolated_api_quota_gate_target_url(websocket_url: &str) -> anyhow::Result<()> { + let socket = connect_cdp_websocket(websocket_url).await?; + let mut session = CdpSession::new(socket); + session + .send_command(next_message_id(), "Runtime.enable", json!({})) + .await?; + session + .send_command( + next_message_id(), + "Runtime.addBinding", + json!({ "name": NATIVE_QUOTA_SETTINGS_BINDING }), + ) + .await?; + let script = native_quota_bootstrap_script(); + let registration = session + .send_command( + next_message_id(), + "Page.addScriptToEvaluateOnNewDocument", + json!({ "source": script }), + ) + .await?; + let identifier = registration.pointer("/result/identifier") + .and_then(Value::as_str) + .context("native quota new-document script was not registered")? + .to_string(); + let initialized = session + .send_command( + next_message_id(), + "Runtime.evaluate", + runtime_evaluate_params(&script), + ) + .await + .and_then(ensure_runtime_evaluate_succeeded); + if let Err(error) = initialized { + session.remove_registered_scripts(&[identifier]).await; + session.close().await; + return Err(error); + } + + // binding 和新文档脚本归属于此会话,不能初始化后立即断开。 + // 会话结束或断开时清理注册脚本并关闭。 + let result = serve_native_quota_settings(&mut session).await; + session.remove_registered_scripts(&[identifier]).await; + session.close().await; + result +} + +async fn serve_native_quota_settings(session: &mut CdpSession) -> anyhow::Result<()> +where + S: SinkExt + + StreamExt> + + Unpin + + Send, + >::Error: std::error::Error + Send + Sync + 'static, +{ + loop { + // send_command 等待响应时收到的 binding 事件也必须处理。 + while let Some(event) = session.binding_calls.pop_front() { + if event.pointer("/params/name").and_then(Value::as_str) + != Some(NATIVE_QUOTA_SETTINGS_BINDING) + { + continue; + } + let Some(context_id) = event.pointer("/params/executionContextId") + .and_then(Value::as_u64) else { continue }; + let Some(payload) = event.pointer("/params/payload") + .and_then(Value::as_str) else { continue }; + let Ok(payload) = serde_json::from_str::(payload) else { continue }; + let Some(sequence) = payload.get("sequence").and_then(Value::as_u64) else { continue }; + let expression = format!( + "window.__codexPlusNativeQuotaBootstrap?.accept({}, {})", + sequence, native_quota_settings(), + ); + let mut params = runtime_evaluate_params(&expression); + params["contextId"] = json!(context_id); + // 导航可能销毁发起请求的上下文;其下一份文档会重新请求。 + let _ = session + .send_command(next_message_id(), "Runtime.evaluate", params) + .await; + } + if session.next_message().await?.is_none() { + return Ok(()); + } + } +} + /// issue #2177:AppServerRequestClient 在 Codex 26.908+ 被藏进模块闭包且不再导出, /// 渲染层扫描无法触达,直接改写 dispatcher 又会撞上不可写的 RPC stub。 /// 分两段接管:渲染层用纯文本定位算出 sendRequest 的断点坐标并放到 diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index b784f4ef3..d9f9270c4 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -664,6 +664,10 @@ where } } + if !settings.enhancements_enabled { + tokio::spawn(crate::bridge::supervise_isolated_api_quota_gate(debug_port)); + } + if !settings.enhancements_enabled || !injection_degraded { timeline.mark("ready", 100); let status = launch_status( @@ -719,6 +723,13 @@ fn remote_control_provider_proxy_enabled(settings: &BackendSettings) -> bool { profile.relay_mode == crate::settings::RelayMode::Official && profile.official_mix_api_key } +fn active_relay_profile_is_official_mix(settings: &BackendSettings) -> bool { + if !settings.relay_profiles_enabled { + return false; + } + let profile = settings.active_relay_profile(); + profile.relay_mode == crate::settings::RelayMode::Official && profile.official_mix_api_key +} #[cfg(windows)] fn apply_codexplusplus_window_icon_after_launch(process_id: u32) { let icon_resource_path = @@ -2899,6 +2910,16 @@ async fn retry_injection(debug_port: u16, helper_port: u16) -> anyhow::Result<() Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Codex injection failed"))) } +async fn try_inject_isolated_quota_gate(debug_port: u16) -> anyhow::Result<()> { + let targets = crate::cdp::list_targets(debug_port).await?; + let target = crate::cdp::pick_injectable_codex_page_target(&targets)?; + let websocket_url = target + .web_socket_debugger_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL"))?; + crate::bridge::install_isolated_api_quota_gate(websocket_url).await +} + pub async fn check_and_reinject_bridge(debug_port: u16, helper_port: u16) -> bool { // This one-shot entry point preserves its historical immediate-repair behavior. let mut health_failures = BRIDGE_HEALTH_FAILURE_THRESHOLD.saturating_sub(1); diff --git a/crates/codex-plus-core/tests/native_api_quota_gate.rs b/crates/codex-plus-core/tests/native_api_quota_gate.rs new file mode 100644 index 000000000..670d93301 --- /dev/null +++ b/crates/codex-plus-core/tests/native_api_quota_gate.rs @@ -0,0 +1,17 @@ +use std::process::Command; + +#[test] +fn native_api_quota_gate_preserves_publication_and_restores_official_usage() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/inject/native-api-quota-gate.test.cjs"); + let output = Command::new("node") + .arg(path) + .output() + .expect("node is required for renderer tests"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} From 07237ec9fc88976f284574a5e009f0c7069c4113 Mon Sep 17 00:00:00 2001 From: ViceEye Date: Sun, 11 Oct 2026 12:49:52 +0100 Subject: [PATCH 2/9] =?UTF-8?q?fix(update):=20=E5=85=BC=E5=AE=B9=20Windows?= =?UTF-8?q?=20=E8=B7=AF=E5=BE=84=E5=88=86=E9=9A=94=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/update/macos.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/update/macos.rs b/crates/codex-plus-core/src/update/macos.rs index 0e1c7e0df..38fe1bdab 100644 --- a/crates/codex-plus-core/src/update/macos.rs +++ b/crates/codex-plus-core/src/update/macos.rs @@ -33,8 +33,11 @@ fn installed_root_from_executable(executable: &Path) -> anyhow::Result .join(BUNDLES[0].executable); // Finder 可能保留目录大小写;是否为新版界面由 CodexPlusUnifiedApp 标记校验。 if !expected - .to_string_lossy() - .eq_ignore_ascii_case(&executable.to_string_lossy()) + .components() + .map(|component| component.as_os_str().to_ascii_lowercase()) + .eq(executable + .components() + .map(|component| component.as_os_str().to_ascii_lowercase())) { let app_name = executable .parent() From 298f20600c8b71ef66e5fc499c6a17a04e4ce82f Mon Sep 17 00:00:00 2001 From: ViceEye Date: Sun, 11 Oct 2026 13:42:37 +0100 Subject: [PATCH 3/9] =?UTF-8?q?test(install):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E8=B7=AF=E5=BE=84=E4=B8=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=A3=B3=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/tests/installers.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/codex-plus-core/tests/installers.rs b/crates/codex-plus-core/tests/installers.rs index 880a6efb2..ccb36a135 100644 --- a/crates/codex-plus-core/tests/installers.rs +++ b/crates/codex-plus-core/tests/installers.rs @@ -163,11 +163,16 @@ fn windows_installer_waits_for_normal_exit_and_cancels_without_writing_apps() { } #[test] -fn macos_dmg_includes_applications_shortcut_and_only_one_app() { +fn macos_dmg_includes_applications_shortcut_and_hidden_legacy_shim() { let script = std::fs::read_to_string("../../scripts/installer/macos/package-dmg.sh").unwrap(); assert!(script.contains("ln -s /Applications \"$STAGE/Applications\"")); assert!(script.contains("Codex++.app")); - assert!(!script.contains("Codex++ 管理工具.app")); + assert!(script.contains( + r#"create_app "Codex++ 管理工具" "CodexPlusPlusManager" "$BINARY_DIR/codex-plus-legacy-shim" "com.bigpizzav3.codexplusplus.manager" "true""# + )); + assert!(script.contains(r#"printf '%s\n' "Codex++ 管理工具.app" > "$STAGE/.hidden""#)); + assert!(script.contains(r#"chflags hidden "$STAGE/Codex++ 管理工具.app""#)); + assert!(!script.contains(r#"$BINARY_DIR/codex-plus-plus-manager""#)); } #[test] @@ -189,10 +194,9 @@ fn legacy_macos_layout_redirects_companions_to_unified_app() { "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager", ] { for binary in [APP_BINARY, MANAGER_BINARY] { - assert!( - companion_binary_path_from_exe(Path::new(old), binary) - .to_string_lossy() - .eq_ignore_ascii_case("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus") + assert_eq!( + companion_binary_path_from_exe(Path::new(old), binary), + Path::new("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus") ); assert_eq!( macos_companion_bundle_identifier_from_exe(Path::new(old), binary), From 582735a5cb66b8b5e4403abc94d3b576a73a6297 Mon Sep 17 00:00:00 2001 From: ViceEye Date: Sun, 11 Oct 2026 13:46:17 +0100 Subject: [PATCH 4/9] =?UTF-8?q?test(tools):=20=E4=BD=BF=E7=94=A8=E6=9C=AA?= =?UTF-8?q?=E7=9F=A5=E5=B7=A5=E5=85=B7=E9=AA=8C=E8=AF=81=E5=88=86=E7=89=87?= =?UTF-8?q?=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/tests/tool_shards.rs | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/codex-plus-core/tests/tool_shards.rs b/crates/codex-plus-core/tests/tool_shards.rs index db0de13c9..41811fd1f 100644 --- a/crates/codex-plus-core/tests/tool_shards.rs +++ b/crates/codex-plus-core/tests/tool_shards.rs @@ -154,11 +154,11 @@ fn unknown_tool_shard_survives_load_and_save() { std::fs::write( &path, r#"{ - "activeTool": "claude", + "activeTool": "future-tool", "tools": { - "claude": { - "relayProfiles": [ { "id": "claude-a", "name": "Claude 供应商", "relayMode": "pureApi" } ], - "activeRelayId": "claude-a" + "future-tool": { + "relayProfiles": [ { "id": "future-a", "name": "未来工具供应商", "relayMode": "pureApi" } ], + "activeRelayId": "future-a" } } }"#, @@ -168,16 +168,16 @@ fn unknown_tool_shard_survives_load_and_save() { let store = SettingsStore::new(path.clone()); let settings = store.load().unwrap(); - assert_eq!(settings.active_tool, ToolId::Unknown("claude".into())); - let claude = settings.tool_config(&ToolId::Unknown("claude".into())); - assert_eq!(claude.active_relay_id, "claude-a"); - assert_eq!(claude.relay_profiles[0].name, "Claude 供应商"); + assert_eq!(settings.active_tool, ToolId::Unknown("future-tool".into())); + let future = settings.tool_config(&ToolId::Unknown("future-tool".into())); + assert_eq!(future.active_relay_id, "future-a"); + assert_eq!(future.relay_profiles[0].name, "未来工具供应商"); store.save(&settings).unwrap(); let raw: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(raw["tools"]["claude"]["activeRelayId"], "claude-a"); - assert_eq!(raw["activeTool"], "claude"); + assert_eq!(raw["tools"]["future-tool"]["activeRelayId"], "future-a"); + assert_eq!(raw["activeTool"], "future-tool"); } /// 未知工具的分片在 Codex 保存流程里也不能被抹掉。 @@ -191,9 +191,9 @@ fn unknown_tool_shard_is_preserved_across_codex_save() { "relayProfiles": [ { "id": "codex-a", "name": "Codex 供应商", "relayMode": "pureApi" } ], "activeRelayId": "codex-a", "tools": { - "claude": { - "relayProfiles": [ { "id": "claude-a", "name": "Claude 供应商", "relayMode": "pureApi" } ], - "activeRelayId": "claude-a" + "future-tool": { + "relayProfiles": [ { "id": "future-a", "name": "未来工具供应商", "relayMode": "pureApi" } ], + "activeRelayId": "future-a" } } }"#, @@ -210,8 +210,8 @@ fn unknown_tool_shard_is_preserved_across_codex_save() { let raw: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( - raw["tools"]["claude"]["relayProfiles"][0]["name"], - "Claude 供应商" + raw["tools"]["future-tool"]["relayProfiles"][0]["name"], + "未来工具供应商" ); assert_eq!( raw["tools"]["codex"]["relayProfiles"] From 0097282e7e3cddae9ba28912ef5e35b821ba2827 Mon Sep 17 00:00:00 2001 From: ViceEye Date: Sun, 11 Oct 2026 16:51:24 +0100 Subject: [PATCH 5/9] =?UTF-8?q?test(manager):=20=E5=AF=B9=E9=BD=90=20MCP?= =?UTF-8?q?=20=E5=85=A8=E5=B1=80=E5=AF=BC=E8=88=AA=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 6a255da5c..7cece74d9 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -307,7 +307,10 @@ fn relay_context_management_is_global_not_supplier_scoped() { let styles = std::fs::read_to_string(&styles).expect("read manager styles.css"); assert!(app_tsx.contains("作为全局配置独立管理")); - assert!(app_tsx.contains("label: t(\"MCP&插件\")") || app_tsx.contains("label: \"MCP&插件\"")); + assert!( + app_tsx.contains("id: \"context\", label: t(\"MCP\")") + || app_tsx.contains("id: \"context\", label: \"MCP\"") + ); assert!( app_tsx.contains("title={t(\"Codex MCP&插件\")}") || app_tsx.contains("title=\"Codex MCP&插件\"") From 95343a2003a7681006283fd4a62f2bd9bf59763e Mon Sep 17 00:00:00 2001 From: ViceEye Date: Mon, 12 Oct 2026 11:44:09 +0100 Subject: [PATCH 6/9] =?UTF-8?q?ci(windows):=20=E9=AA=8C=E8=AF=81=20PR=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=9A=84=20NSIS=20=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-build.yml | 47 +++++++++++++++++--- crates/codex-plus-core/tests/installers.rs | 51 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1a6a33961..d3819453e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -26,7 +26,29 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install NSIS - run: choco install nsis -y + shell: pwsh + run: | + $makensis = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + choco install nsis -y --no-progress + # Chocolatey 可能在下载失败后返回成功,必须实际验证编译器。 + $candidates = @( + "${env:ProgramFiles(x86)}\NSIS\makensis.exe", + "$env:ProgramFiles\NSIS\makensis.exe" + ) + $makensis = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $makensis) { + $command = Get-Command makensis.exe -ErrorAction SilentlyContinue + if ($command) { $makensis = $command.Source } + } + if ($makensis) { + & $makensis /VERSION + if ($LASTEXITCODE -eq 0) { break } + } + $makensis = $null + if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + } + if (-not $makensis) { throw "NSIS 安装失败或编译器不可用,停止 PR 构建。" } - name: Install frontend dependencies working-directory: apps/codex-plus-manager @@ -68,13 +90,26 @@ jobs: if (-not $version -or $version -eq "main") { $version = "0.0.0-${{ github.run_number }}" } - $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe" - if (-not (Test-Path $makensis)) { - $makensis = "makensis" + # NSIS 的 64 位安装目录与 Chocolatey PATH 更新均需要兼容。 + $candidates = @( + "${env:ProgramFiles(x86)}\NSIS\makensis.exe", + "$env:ProgramFiles\NSIS\makensis.exe" + ) + $makensis = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $makensis) { + $command = Get-Command makensis.exe -ErrorAction SilentlyContinue + if ($command) { $makensis = $command.Source } + } + if (-not $makensis) { + throw "NSIS 编译器未找到,检查 Chocolatey 安装结果。" } Push-Location scripts/installer/windows - & $makensis "/INPUTCHARSET" "UTF8" "/DVERSION=$version" CodexPlusPlus.nsi - Pop-Location + try { + & $makensis "/INPUTCHARSET" "UTF8" "/DVERSION=$version" CodexPlusPlus.nsi + if ($LASTEXITCODE -ne 0) { throw "NSIS 打包失败,退出码 $LASTEXITCODE" } + } finally { + Pop-Location + } - name: Upload Windows binaries uses: actions/upload-artifact@v4 diff --git a/crates/codex-plus-core/tests/installers.rs b/crates/codex-plus-core/tests/installers.rs index 436e3373c..1a61ccba5 100644 --- a/crates/codex-plus-core/tests/installers.rs +++ b/crates/codex-plus-core/tests/installers.rs @@ -89,6 +89,57 @@ fn installer_name_helpers_and_legacy_constants_keep_api_compatibility() { assert_eq!(MANAGER_BUNDLE_ID, "com.bigpizzav3.codexplusplus.manager"); } +#[test] +fn windows_workflows_verify_nsis_and_support_both_program_files_directories() { + for workflow in ["pr-build.yml", "release-assets.yml"] { + let text = std::fs::read_to_string(format!("../../.github/workflows/{workflow}")) + .unwrap() + .replace("\r\n", "\n"); + let install = text + .split_once(" - name: Install NSIS\n") + .unwrap() + .1 + .split(" - name:") + .next() + .unwrap(); + let build = text + .split_once(" - name: Build Windows installer\n") + .unwrap() + .1 + .split(" - name:") + .next() + .unwrap(); + for step in [install, build] { + assert!(step.contains("shell: pwsh"), "{workflow}"); + assert!( + step.contains(r"${env:ProgramFiles(x86)}\NSIS\makensis.exe"), + "{workflow}" + ); + assert!( + step.contains(r"$env:ProgramFiles\NSIS\makensis.exe"), + "{workflow}" + ); + assert!(step.contains("Get-Command makensis.exe"), "{workflow}"); + assert!( + step.contains("Test-Path -LiteralPath $_ -PathType Leaf"), + "{workflow}" + ); + assert!(step.contains("if (-not $makensis) {"), "{workflow}"); + } + assert!(install.contains("& $makensis /VERSION"), "{workflow}"); + assert!( + install.contains("if ($LASTEXITCODE -eq 0) { break }"), + "{workflow}" + ); + assert!(install.contains("$attempt -le 3"), "{workflow}"); + assert!( + build.contains("if ($LASTEXITCODE -ne 0) { throw"), + "{workflow}" + ); + assert!(build.contains("} finally {"), "{workflow}"); + } +} + #[test] fn windows_installer_writes_the_same_uninstall_key_as_the_runtime() { let nsi = std::fs::read_to_string("../../scripts/installer/windows/CodexPlusPlus.nsi").unwrap(); From 9cc41ff37cdade71987a63ee68cd2f7cacefb519 Mon Sep 17 00:00:00 2001 From: ViceEye Date: Mon, 12 Oct 2026 12:00:24 +0100 Subject: [PATCH 7/9] =?UTF-8?q?test(usage):=20=E9=9A=94=E7=A6=BB=E7=BB=AD?= =?UTF-8?q?=E8=AF=BB=E6=B5=8B=E8=AF=95=E7=9A=84=E7=B4=A2=E5=BC=95=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-data/src/whale_history.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-data/src/whale_history.rs b/crates/codex-plus-data/src/whale_history.rs index 47b2f32bd..577f74178 100644 --- a/crates/codex-plus-data/src/whale_history.rs +++ b/crates/codex-plus-data/src/whale_history.rs @@ -1449,12 +1449,21 @@ mod tests { } writeln!(append, "{}", context(&now, "model-a", "turn")).unwrap(); writeln!(append, "{}", tokens(&now, 10, 0, 5)).unwrap(); - let first = f.query(json!({})); + // 固定同一索引,避免并行测试的其他 home 淘汰全局缓存中的续读进度。 + let home = fs::canonicalize(f.temp.path()).unwrap(); + let mut index = HistoryIndex::new(); + let query = Query::parse(&json!({}), Local::now().date_naive()).unwrap(); + let mut read = || { + index.update(&home); + let now = Local::now(); + index.response(&query, now.date_naive(), now.timestamp_millis()) + }; + let first = read(); assert_eq!(first["complete"], false); assert_eq!(first["periods"]["all"]["usage"], Value::Null); let mut latest = first; for _ in 0..5 { - latest = f.query(json!({})); + latest = read(); if latest["complete"] == true { break; } From 985297bf5d6deb5b078bdbe8d86a7afe6e926abf Mon Sep 17 00:00:00 2001 From: ViceEye Date: Tue, 13 Oct 2026 13:09:51 +0100 Subject: [PATCH 8/9] =?UTF-8?q?fix(launcher):=20=E9=9A=8F=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=81=9C=E6=AD=A2=E7=8B=AC=E7=AB=8B=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E7=9B=91=E7=9D=A3=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将取消信号和任务句柄归属 LaunchHandle,退出及重启先停止并等待任务,防止旧会话重连复用端口。 Refs #2383 --- apps/codex-plus-launcher/src/main.rs | 14 +- assets/inject/native-api-quota-gate.test.cjs | 45 ++ crates/codex-plus-core/src/bridge.rs | 424 ++++++++++++++++--- crates/codex-plus-core/src/launcher.rs | 93 +++- crates/codex-plus-core/tests/launcher.rs | 88 ++++ 5 files changed, 589 insertions(+), 75 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 3c644f197..029ab79ed 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -518,6 +518,7 @@ async fn run_embedded_session( || repair_session_index_automatically(hooks.index_repair_checks_setting()), ) .await; + handle.stop_isolated_api_quota_gate().await; stopped_by_host = host_stop; result } @@ -1818,9 +1819,16 @@ mod tests { body.matches("repair_session_index_automatically(").count(), 1 ); - assert!(body.contains( - "wait_for_session_exit_or_stop(handle.wait_for_codex_exit(), &mut stop)" - )); + assert!( + body.contains("wait_for_session_exit_or_stop(handle.wait_for_codex_exit(), &mut stop)") + ); + let quota_stop = body + .find("handle.stop_isolated_api_quota_gate().await") + .unwrap(); + let helper_stop = body + .find("hooks.shutdown_helper(options.helper_port).await") + .unwrap(); + assert!(quota_stop < helper_stop); let launch_start = source.find("async fn launcher_session(").unwrap(); let launch_end = source[launch_start..] .find("async fn activate_and_write_running_status(") diff --git a/assets/inject/native-api-quota-gate.test.cjs b/assets/inject/native-api-quota-gate.test.cjs index be5bed0c9..c94882d5c 100644 --- a/assets/inject/native-api-quota-gate.test.cjs +++ b/assets/inject/native-api-quota-gate.test.cjs @@ -97,4 +97,49 @@ if (require.main === module) { vm.runInNewContext(fs.readFileSync(require.resolve("./api-quota-gate.js"), "utf8"), context); return context.module.exports; })); + if (process.argv[2]) { + const assert = require("node:assert/strict"); + const callbacks = new Map(); + const timers = new Set(); + const updates = []; + const requests = []; + const events = { + addEventListener(name, callback) { callbacks.set(name, callback); }, + removeEventListener(name, callback) { + assert.equal(callbacks.get(name), callback); + callbacks.delete(name); + }, + }; + let nextTimer = 0; + const context = { + module: { exports: {} }, + window: { + ...events, + codexPlusNativeQuotaSettings(payload) { requests.push(JSON.parse(payload)); }, + __codexPlusNativeQuotaRuntime: { update(settings) { updates.push(settings); } }, + }, + document: { ...events, visibilityState: "visible" }, + setTimeout() { timers.add(++nextTimer); return nextTimer; }, + clearTimeout(id) { timers.delete(id); }, + }; + vm.runInNewContext(process.argv[2], context); + const bootstrap = context.window.__codexPlusNativeQuotaBootstrap; + assert.equal(callbacks.size, 3); + assert.equal(timers.size, 1); + assert.equal(requests.length, 1); + bootstrap.dispose(); + assert.equal(callbacks.size, 0); + assert.equal(timers.size, 0); + assert.equal(updates.at(-1), null); + assert.equal(context.window.__codexPlusNativeQuotaBootstrap, undefined); + bootstrap.request(); + bootstrap.accept(requests[0].sequence, { activeRelayId: "mix" }); + assert.equal(requests.length, 1); + assert.deepEqual(updates, [null]); + vm.runInNewContext(process.argv[2], context); + assert.equal(requests.length, 2); + assert.equal(callbacks.size, 3); + context.window.__codexPlusNativeQuotaBootstrap.dispose(); + console.log("Native quota bootstrap cancellation and reinstallation passed"); + } } diff --git a/crates/codex-plus-core/src/bridge.rs b/crates/codex-plus-core/src/bridge.rs index e92c25a8d..d046bfcbd 100644 --- a/crates/codex-plus-core/src/bridge.rs +++ b/crates/codex-plus-core/src/bridge.rs @@ -308,8 +308,14 @@ fn native_quota_bootstrap_script() -> String { } let sequence = 0; let timer = null; + let disposed = false; + const request = () => state.request(); + const visible = () => { + if (document.visibilityState === "visible") state.request(); + }; const state = { request() { + if (disposed) return; const seq = ++sequence; clearTimeout(timer); timer = setTimeout(() => { @@ -323,27 +329,41 @@ fn native_quota_bootstrap_script() -> String { } }, accept(seq, settings) { - if (seq !== sequence) return; + if (disposed || seq !== sequence) return; clearTimeout(timer); window.__codexPlusApiQuotaGate.installNative(settings); }, + dispose() { + disposed = true; + sequence += 1; + clearTimeout(timer); + window.removeEventListener("focus", request); + document.removeEventListener("visibilitychange", visible); + document.removeEventListener("DOMContentLoaded", request); + window.__codexPlusNativeQuotaRuntime?.update(null); + delete window.__codexPlusNativeQuotaBootstrap; + }, }; window.__codexPlusNativeQuotaBootstrap = state; - window.addEventListener("focus", () => state.request()); - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") state.request(); - }); - document.addEventListener("DOMContentLoaded", () => state.request(), { once: true }); + window.addEventListener("focus", request); + document.addEventListener("visibilitychange", visible); + document.addEventListener("DOMContentLoaded", request, { once: true }); state.request(); })(); "#, ) } -pub async fn supervise_isolated_api_quota_gate(debug_port: u16) { +pub async fn supervise_isolated_api_quota_gate( + debug_port: u16, + mut shutdown: tokio::sync::watch::Receiver, +) { let mut retry_delay = std::time::Duration::from_millis(500); loop { - match install_isolated_api_quota_gate_session(debug_port).await { + if *shutdown.borrow() || shutdown.has_changed().is_err() { + return; + } + match install_isolated_api_quota_gate_session(debug_port, shutdown.clone()).await { Ok(()) => { retry_delay = std::time::Duration::from_millis(500); } @@ -354,71 +374,123 @@ pub async fn supervise_isolated_api_quota_gate(debug_port: u16) { ); } } - tokio::time::sleep(retry_delay).await; + tokio::select! { + biased; + _ = shutdown.wait_for(|stop| *stop) => return, + _ = tokio::time::sleep(retry_delay) => {} + } if retry_delay < std::time::Duration::from_secs(5) { - retry_delay = retry_delay.saturating_mul(2); + retry_delay = retry_delay + .saturating_mul(2) + .min(std::time::Duration::from_secs(5)); } } } -async fn install_isolated_api_quota_gate_session(debug_port: u16) -> anyhow::Result<()> { - let targets = crate::cdp::list_targets(debug_port).await?; - let target = crate::cdp::pick_injectable_codex_page_target(&targets)?; - let websocket_url = target - .web_socket_debugger_url - .as_deref() - .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL"))?; - install_isolated_api_quota_gate_target_url(websocket_url).await +async fn install_isolated_api_quota_gate_session( + debug_port: u16, + mut shutdown: tokio::sync::watch::Receiver, +) -> anyhow::Result<()> { + let websocket_url = tokio::select! { + biased; + _ = shutdown.wait_for(|stop| *stop) => return Ok(()), + result = async { + let targets = crate::cdp::list_targets(debug_port).await?; + let target = crate::cdp::pick_injectable_codex_page_target(&targets)?; + target.web_socket_debugger_url.clone() + .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL")) + } => result?, + }; + install_isolated_api_quota_gate_target_url(&websocket_url, shutdown).await } pub async fn install_isolated_api_quota_gate(websocket_url: &str) -> anyhow::Result<()> { - install_isolated_api_quota_gate_target_url(websocket_url).await + let (_shutdown, receiver) = tokio::sync::watch::channel(false); + install_isolated_api_quota_gate_target_url(websocket_url, receiver).await } -async fn install_isolated_api_quota_gate_target_url(websocket_url: &str) -> anyhow::Result<()> { - let socket = connect_cdp_websocket(websocket_url).await?; +async fn install_isolated_api_quota_gate_target_url( + websocket_url: &str, + mut shutdown: tokio::sync::watch::Receiver, +) -> anyhow::Result<()> { + let socket = tokio::select! { + biased; + _ = shutdown.wait_for(|stop| *stop) => return Ok(()), + result = connect_cdp_websocket(websocket_url) => result?, + }; let mut session = CdpSession::new(socket); - session - .send_command(next_message_id(), "Runtime.enable", json!({})) - .await?; - session - .send_command( - next_message_id(), - "Runtime.addBinding", - json!({ "name": NATIVE_QUOTA_SETTINGS_BINDING }), - ) - .await?; - let script = native_quota_bootstrap_script(); - let registration = session - .send_command( - next_message_id(), - "Page.addScriptToEvaluateOnNewDocument", - json!({ "source": script }), - ) - .await?; - let identifier = registration.pointer("/result/identifier") - .and_then(Value::as_str) - .context("native quota new-document script was not registered")? - .to_string(); - let initialized = session - .send_command( - next_message_id(), - "Runtime.evaluate", - runtime_evaluate_params(&script), - ) - .await - .and_then(ensure_runtime_evaluate_succeeded); - if let Err(error) = initialized { - session.remove_registered_scripts(&[identifier]).await; + let mut registered_scripts = Vec::new(); + let mut binding_added = false; + let mut pending_registration = None; + let result = tokio::select! { + biased; + _ = shutdown.wait_for(|stop| *stop) => Ok(()), + result = async { + session.send_command(next_message_id(), "Runtime.enable", json!({})).await?; + binding_added = true; + session.send_command( + next_message_id(), "Runtime.addBinding", + json!({ "name": NATIVE_QUOTA_SETTINGS_BINDING }), + ).await?; + let script = native_quota_bootstrap_script(); + let registration_id = next_message_id(); + pending_registration = Some(registration_id); + let registration = session.send_command( + registration_id, "Page.addScriptToEvaluateOnNewDocument", + json!({ "source": script }), + ).await?; + let identifier = registration.pointer("/result/identifier") + .and_then(Value::as_str) + .context("native quota new-document script was not registered")? + .to_string(); + registered_scripts.push(identifier); + pending_registration = None; + let initialized = session.send_command( + next_message_id(), "Runtime.evaluate", runtime_evaluate_params(&script), + ).await?; + ensure_runtime_evaluate_succeeded(initialized)?; + serve_native_quota_settings(&mut session).await + } => result, + }; + // 取消只终止工作 future,随后有界清理同一条 CDP 连接,不再探测或绑定新实例。 + let _ = tokio::time::timeout(Duration::from_secs(2), async { + // 取消可能发生在注册已送达、但返回标识尚未收到时;先回收这份回复。 + if let Some(id) = pending_registration { + if let Ok(Ok(reply)) = tokio::time::timeout( + Duration::from_millis(500), + session.wait_for_id(id, "Page.addScriptToEvaluateOnNewDocument".to_string()), + ) + .await + { + if let Some(identifier) = + reply.pointer("/result/identifier").and_then(Value::as_str) + { + registered_scripts.push(identifier.to_string()); + } + } + } + session.remove_registered_scripts(®istered_scripts).await; + if !registered_scripts.is_empty() { + let _ = session + .send_command_without_wait( + next_message_id(), + "Runtime.evaluate", + runtime_evaluate_params("window.__codexPlusNativeQuotaBootstrap?.dispose()"), + ) + .await; + } + if binding_added { + let _ = session + .send_command_without_wait( + next_message_id(), + "Runtime.removeBinding", + json!({ "name": NATIVE_QUOTA_SETTINGS_BINDING }), + ) + .await; + } session.close().await; - return Err(error); - } - - // binding 和新文档脚本归属于此会话,不能初始化后立即断开。 - // 会话结束或断开时清理注册脚本并关闭。 - let result = serve_native_quota_settings(&mut session).await; - session.remove_registered_scripts(&[identifier]).await; - session.close().await; + }) + .await; result } @@ -438,15 +510,25 @@ where { continue; } - let Some(context_id) = event.pointer("/params/executionContextId") - .and_then(Value::as_u64) else { continue }; - let Some(payload) = event.pointer("/params/payload") - .and_then(Value::as_str) else { continue }; - let Ok(payload) = serde_json::from_str::(payload) else { continue }; - let Some(sequence) = payload.get("sequence").and_then(Value::as_u64) else { continue }; + let Some(context_id) = event + .pointer("/params/executionContextId") + .and_then(Value::as_u64) + else { + continue; + }; + let Some(payload) = event.pointer("/params/payload").and_then(Value::as_str) else { + continue; + }; + let Ok(payload) = serde_json::from_str::(payload) else { + continue; + }; + let Some(sequence) = payload.get("sequence").and_then(Value::as_u64) else { + continue; + }; let expression = format!( "window.__codexPlusNativeQuotaBootstrap?.accept({}, {})", - sequence, native_quota_settings(), + sequence, + native_quota_settings(), ); let mut params = runtime_evaluate_params(&expression); params["contextId"] = json!(context_id); @@ -1509,6 +1591,212 @@ fn next_message_id() -> u64 { NEXT_MESSAGE_ID.fetch_add(1, Ordering::Relaxed) + 1 } +#[cfg(test)] +mod native_quota_lifecycle_tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn native_quota_bootstrap_disposal_restores_policy_and_removes_callbacks() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets/inject/native-api-quota-gate.test.cjs"); + let output = std::process::Command::new("node") + .arg(path) + .arg(native_quota_bootstrap_script()) + .output() + .expect("node is required for renderer tests"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[tokio::test] + async fn cancelled_native_quota_supervisor_does_not_connect() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let (shutdown, receiver) = tokio::sync::watch::channel(true); + supervise_isolated_api_quota_gate(listener.local_addr().unwrap().port(), receiver).await; + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + drop(shutdown); + } + + #[tokio::test] + async fn native_quota_supervisor_cancels_pending_discovery() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let (shutdown, receiver) = tokio::sync::watch::channel(false); + let task = tokio::spawn(supervise_isolated_api_quota_gate( + listener.local_addr().unwrap().port(), + receiver, + )); + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0; 2048]; + assert!(stream.read(&mut request).await.unwrap() > 0); + shutdown.send_replace(true); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + assert_eq!(stream.read(&mut request).await.unwrap(), 0); + } + + #[tokio::test] + async fn native_quota_supervisor_cancels_backoff_without_reconnecting() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let (shutdown, receiver) = tokio::sync::watch::channel(false); + let task = tokio::spawn(supervise_isolated_api_quota_gate( + listener.local_addr().unwrap().port(), + receiver, + )); + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0; 2048]; + stream.read(&mut request).await.unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]") + .await + .unwrap(); + stream.shutdown().await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + shutdown.send_replace(true); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(550), listener.accept()) + .await + .is_err() + ); + } + + async fn cancelled_session(cancel_at: &str) -> Vec { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, receiver) = tokio::sync::watch::channel(false); + let cancel_at = cancel_at.to_string(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let mut commands = Vec::new(); + let mut cancelled = false; + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + if matches!(message, Message::Close(_)) { + break; + } + continue; + }; + let command: Value = serde_json::from_str(&text).unwrap(); + let method = command["method"].as_str().unwrap(); + commands.push(method.to_string()); + if method == cancel_at && !cancelled { + cancelled = true; + shutdown.send_replace(true); + if method != "Page.addScriptToEvaluateOnNewDocument" { + continue; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let result = if method == "Page.addScriptToEvaluateOnNewDocument" { + json!({ "identifier": "owned-quota-script" }) + } else { + if method == "Page.removeScriptToEvaluateOnNewDocument" { + assert_eq!(command["params"]["identifier"], "owned-quota-script"); + } + if method == "Runtime.removeBinding" { + assert_eq!(command["params"]["name"], NATIVE_QUOTA_SETTINGS_BINDING); + } + json!({}) + }; + let reply = json!({ "id": command["id"], "result": result }); + if cancelled && method != "Page.addScriptToEvaluateOnNewDocument" { + continue; + } + if socket + .send(Message::Text(reply.to_string().into())) + .await + .is_err() + { + break; + } + if cancel_at == "serving" && method == "Runtime.evaluate" && !cancelled { + cancelled = true; + tokio::time::sleep(Duration::from_millis(20)).await; + shutdown.send_replace(true); + } + } + assert!(cancelled); + commands + }); + tokio::time::timeout( + Duration::from_secs(3), + install_isolated_api_quota_gate_target_url( + &format!("ws://{address}/devtools/page/quota-lifecycle"), + receiver, + ), + ) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), server) + .await + .unwrap() + .unwrap() + } + + #[tokio::test] + async fn native_quota_cancellation_during_initialization_closes_session() { + assert_eq!( + cancelled_session("Runtime.enable").await, + ["Runtime.enable"] + ); + } + + #[tokio::test] + async fn native_quota_cancellation_recovers_in_flight_registration() { + let commands = cancelled_session("Page.addScriptToEvaluateOnNewDocument").await; + assert!(commands.contains(&"Page.removeScriptToEvaluateOnNewDocument".to_string())); + assert!(commands.contains(&"Runtime.removeBinding".to_string())); + } + + #[tokio::test] + async fn native_quota_cancellation_removes_registered_script_and_binding() { + let commands = cancelled_session("Runtime.evaluate").await; + assert_eq!( + commands, + [ + "Runtime.enable", + "Runtime.addBinding", + "Page.addScriptToEvaluateOnNewDocument", + "Runtime.evaluate", + "Page.removeScriptToEvaluateOnNewDocument", + "Runtime.evaluate", + "Runtime.removeBinding", + ] + ); + } + + #[tokio::test] + async fn native_quota_cancellation_while_serving_removes_script_and_binding() { + let commands = cancelled_session("serving").await; + assert!(commands.contains(&"Page.removeScriptToEvaluateOnNewDocument".to_string())); + assert!(commands.contains(&"Runtime.removeBinding".to_string())); + } +} + #[cfg(test)] mod app_server_capture_tests { use super::*; diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 468c70856..f97dab1e4 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -184,9 +184,50 @@ pub struct LaunchHandle { pub status_store: StatusStore, status_started_at_ms: u64, helper_started: bool, + isolated_quota_gate: Option>, hooks: Arc, } +struct IsolatedQuotaGateRuntime { + shutdown: tokio::sync::watch::Sender, + task: Mutex>>, +} + +impl IsolatedQuotaGateRuntime { + fn start(debug_port: u16) -> Arc { + let (shutdown, receiver) = tokio::sync::watch::channel(false); + let task = tokio::spawn(crate::bridge::supervise_isolated_api_quota_gate( + debug_port, receiver, + )); + Arc::new(Self { + shutdown, + task: Mutex::new(Some(task)), + }) + } + + async fn stop(&self) { + self.shutdown.send_replace(true); + let mut owned_task = self.task.lock().await; + if let Some(mut task) = owned_task.take() { + // CDP 清理有自己的超时;最后兜底等待并终止任务,不能留下端口重连。 + if tokio::time::timeout(std::time::Duration::from_secs(4), &mut task) + .await + .is_err() + { + task.abort(); + let _ = task.await; + } + } + } +} + +impl Drop for IsolatedQuotaGateRuntime { + fn drop(&mut self) { + // 最后一个会话 handle 被释放时也必须取消,包括等待 future 被提前丢弃。 + self.shutdown.send_replace(true); + } +} + impl std::fmt::Debug for LaunchHandle { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -201,11 +242,18 @@ impl std::fmt::Debug for LaunchHandle { } impl LaunchHandle { + pub async fn stop_isolated_api_quota_gate(&self) { + if let Some(runtime) = &self.isolated_quota_gate { + runtime.stop().await; + } + } + pub async fn wait_for_codex_exit(&self) -> anyhow::Result<()> { let result = self .hooks .wait_for_codex_exit(&self.launch, self.debug_port) .await; + self.stop_isolated_api_quota_gate().await; if self.helper_started { self.hooks.shutdown_helper(self.helper_port).await; } @@ -737,10 +785,6 @@ where hooks.start_bridge_watchdog(debug_port, helper_port).await?; } - if !settings.enhancements_enabled { - tokio::spawn(crate::bridge::supervise_isolated_api_quota_gate(debug_port)); - } - if !settings.enhancements_enabled || !injection_degraded { timeline.mark("ready", 100); let status = launch_status( @@ -755,6 +799,9 @@ where hooks.write_status("running").await; } + // 全部可失败的启动步骤完成后才创建,错误清理路径不会遗留监督任务。 + let isolated_quota_gate = + (!settings.enhancements_enabled).then(|| IsolatedQuotaGateRuntime::start(debug_port)); Ok(LaunchHandle { debug_port, helper_port, @@ -763,6 +810,7 @@ where status_store: status_store.clone(), status_started_at_ms, helper_started, + isolated_quota_gate, hooks: Arc::clone(&hooks), }) } @@ -4076,6 +4124,43 @@ mod tests { use super::*; use std::sync::atomic::AtomicUsize; + #[tokio::test] + async fn isolated_quota_runtime_stops_and_joins_before_restart() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let runtime = IsolatedQuotaGateRuntime::start(listener.local_addr().unwrap().port()); + let clone = Arc::clone(&runtime); + runtime.stop().await; + assert!(*runtime.shutdown.borrow()); + assert!(runtime.task.lock().await.is_none()); + clone.stop().await; + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn isolated_quota_runtime_cancels_only_when_last_owner_is_dropped() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let runtime = IsolatedQuotaGateRuntime::start(listener.local_addr().unwrap().port()); + let mut receiver = runtime.shutdown.subscribe(); + let task = runtime.task.lock().await.take().unwrap(); + let clone = Arc::clone(&runtime); + drop(runtime); + assert!(!*receiver.borrow()); + drop(clone); + let _ = receiver.wait_for(|stop| *stop).await; + tokio::time::timeout(std::time::Duration::from_secs(3), task) + .await + .unwrap() + .unwrap(); + } + // 提权分支依赖 issue #2351 的手动复现步骤验收(勾选兼容性「以管理员身份运行」 // 后应得到指引错误)。这里不断言当前进程的提权状态:GitHub 的 Windows runner // 本身就以提权令牌运行,断言「CI shell 非提权」必然失败。 diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 925932111..5bb64dfd7 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -2154,6 +2154,94 @@ async fn degraded_packaged_launch_captures_identity_before_monitoring_and_keeps_ assert_eq!(handle.status_store.load_latest().unwrap().unwrap().status, "stopped"); } +#[tokio::test] +async fn isolated_quota_gate_is_not_started_when_status_save_fails() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let hooks = FakeHooks::new(Arc::new(Mutex::new(Vec::new()))).with_settings(BackendSettings { + enhancements_enabled: false, + provider_sync_enabled: false, + ..BackendSettings::default() + }); + let result = launch_and_inject_with_hooks( + LaunchOptions { + app_dir: Some(app_dir), + debug_port: listener.local_addr().unwrap().port(), + status_store: StatusStore::new(temp.path().join("unwritable/status.json")), + ..LaunchOptions::default() + }, + &hooks, + ); + std::fs::write(temp.path().join("unwritable"), "not a directory").unwrap(); + assert!(result.await.is_err()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), listener.accept(),) + .await + .is_err() + ); +} + +#[tokio::test] +async fn isolated_quota_gate_stops_on_exit_monitor_error_and_explicit_restart() { + use tokio::io::AsyncReadExt; + for (wait_error, explicit_stop) in [(false, false), (true, false), (false, true)] { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let debug_port = listener.local_addr().unwrap().port(); + let events = Arc::new(Mutex::new(Vec::new())); + let mut hooks = FakeHooks::new(events).with_settings(BackendSettings { + enhancements_enabled: false, + provider_sync_enabled: false, + ..BackendSettings::default() + }); + hooks.wait_error = wait_error; + let handle = launch_and_inject_with_hooks( + LaunchOptions { + app_dir: Some(app_dir), + debug_port, + status_store: StatusStore::new(temp.path().join("latest-status.json")), + ..LaunchOptions::default() + }, + &hooks, + ) + .await + .unwrap(); + let (mut stream, _) = + tokio::time::timeout(std::time::Duration::from_secs(2), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut buffer = [0; 2048]; + stream.read(&mut buffer).await.unwrap(); + if explicit_stop { + handle.stop_isolated_api_quota_gate().await; + } else { + assert_eq!(handle.wait_for_codex_exit().await.is_err(), wait_error); + } + // 同一端口仍被保留,旧会话不允许再探测或注入它。 + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(1), stream.read(&mut buffer),) + .await + .unwrap() + .unwrap(), + 0 + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(550), listener.accept(),) + .await + .is_err() + ); + } +} + #[tokio::test] async fn proxy_only_packaged_launch_captures_identity_without_attempting_renderer_injection() { let temp = tempfile::tempdir().unwrap(); From b186241dd0136accb670c14706845e3db52408c7 Mon Sep 17 00:00:00 2001 From: ViceEye Date: Tue, 13 Oct 2026 13:31:24 +0100 Subject: [PATCH 9/9] =?UTF-8?q?feat(quota):=20=E5=A2=9E=E5=8A=A0=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A2=9D=E5=BA=A6=E8=A7=A3=E9=94=81=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-manager/src/App.tsx | 40 +++++++++++++------ .../src/enhancement-settings.test.ts | 14 +++++++ apps/codex-plus-manager/src/i18n-en.ts | 2 + apps/codex-plus-manager/src/styles.css | 8 ++++ assets/inject/api-quota-gate.js | 3 +- assets/inject/native-api-quota-gate.test.cjs | 11 +++++ crates/codex-plus-core/src/bridge.rs | 1 + crates/codex-plus-core/src/launcher.rs | 23 ++++++++++- crates/codex-plus-core/src/settings.rs | 4 ++ crates/codex-plus-core/tests/launcher.rs | 33 +++++++++++++++ .../tests/standalone_quota_settings.rs | 38 ++++++++++++++++++ 11 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 crates/codex-plus-core/tests/standalone_quota_settings.rs diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 01221b9ac..8d3d2626b 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -300,6 +300,7 @@ type BackendSettings = WhaleBalanceSettings & { providerSyncLastSelectedProvider: string; relayProfilesEnabled: boolean; enhancementsEnabled: boolean; + codexAppStandaloneQuotaUnlock: boolean; codexAppPluginMarketplaceUnlock: boolean; codexAppModelWhitelistUnlock: boolean; codexAppSessionDelete: boolean; @@ -1058,6 +1059,7 @@ const defaultSettings: BackendSettings = { providerSyncLastSelectedProvider: "", relayProfilesEnabled: true, enhancementsEnabled: true, + codexAppStandaloneQuotaUnlock: false, codexAppPluginMarketplaceUnlock: true, codexAppModelWhitelistUnlock: true, codexAppSessionDelete: true, @@ -5705,18 +5707,32 @@ function EnhanceScreen({
- +
+ + +
{dirty ? t("有未保存修改") : t("已保存")}