Skip to content

Commit 276feb3

Browse files
fix: support Codex 26.727 renderer markers
Recognize the new CSS-module shell and header markers while preserving the shell-plus-sidebar identity boundary. Keep home verification strict with guarded replacement signals after home-icon removal, and enforce the renderer discovery timeout across nested CDP operations.
1 parent cd71dfd commit 276feb3

17 files changed

Lines changed: 376 additions & 283 deletions

macos/assets/dream-skin.css

Lines changed: 114 additions & 114 deletions
Large diffs are not rendered by default.

macos/assets/renderer-inject.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Canonical cross-platform renderer. Run tools/sync-runtime-assets.mjs after editing.
22
((cssText, artDataUrl, themeConfig) => {
3-
const SELECTOR_CONTRACT = {"schema":"codex-dream-skin-selectors/1","selectors":[{"key":"shell-main","selector":"main.main-surface","tier":"L1","scope":"all","required":true},{"key":"left-panel","selector":"aside.app-shell-left-panel","tier":"L1","scope":"all","required":true},{"key":"header-tint","selector":"header.app-header-tint","tier":"L1","scope":"all","required":true},{"key":"home-icon","selector":"[data-testid=\"home-icon\"]","tier":"L1","scope":"home","required":true},{"key":"home-route","selector":"[role=\"main\"]:has([data-testid=\"home-icon\"])","tier":"L1","scope":"home","required":true},{"key":"home-route-css","selector":"[role=\"main\"]","tier":"L1","scope":"home","required":true},{"key":"home-banners","selector":".home-banners","tier":"L2","scope":"home","required":false},{"key":"composer-chrome","selector":".composer-surface-chrome","tier":"L2","scope":"home+thread","required":false},{"key":"composer-toolbar","selector":".composer-surface-chrome [class*=\"_footer_\"]","tier":"L2","scope":"home+thread","required":false},{"key":"home-utility","selector":"[class*=\"_homeUtilityBar_\"]","tier":"L2","scope":"home","required":false},{"key":"game-source","selector":"[data-feature=\"game-source\"]","tier":"L2","scope":"home","required":false},{"key":"home-suggestions","selector":".group\\/home-suggestions","tier":"L2","scope":"home","required":false},{"key":"project-selector","selector":".group\\/project-selector","tier":"L2","scope":"home config","required":false},{"key":"markdown","selector":"[class*=\"_markdown\"]","tier":"L2","scope":"thread","required":false},{"key":"thread-surface","selector":".thread-scroll-container","tier":"L2","scope":"thread","required":false},{"key":"message","selector":"[data-message-author-role]","tier":"L2","scope":"thread","required":false},{"key":"appearance-radio","selector":"input[name=\"appearance-theme\"]","tier":"L2","scope":"settings","required":false},{"key":"overlay-menu","selector":"[role=\"menu\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-dialog","selector":"[role=\"dialog\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-popper","selector":"[data-radix-popper-content-wrapper]","tier":"L2","scope":"overlay","required":false}],"stableTestids":["app-shell-header-context-menu-surface","home-icon","theme-preview"]};
3+
const SELECTOR_CONTRACT = {"schema":"codex-dream-skin-selectors/1","selectors":[{"key":"shell-main","selector":":is(main.main-surface, main[class*=\"_MainContentSurface_\"])","tier":"L1","scope":"all","required":true},{"key":"left-panel","selector":"aside.app-shell-left-panel","tier":"L1","scope":"all","required":true},{"key":"header-tint","selector":":is(header.app-header-tint, header[class*=\"_Header_\"])","tier":"L1","scope":"all","required":true},{"key":"home-icon","selector":"[data-testid=\"home-icon\"]","tier":"L1","scope":"home","required":false},{"key":"home-route","selector":"[role=\"main\"]:has([data-testid=\"home-icon\"], [class*=\"_homeUtilityBar_\"], .group\\/home-suggestions)","tier":"L1","scope":"home","required":true},{"key":"home-route-css","selector":"[role=\"main\"]","tier":"L1","scope":"home","required":true},{"key":"home-banners","selector":".home-banners","tier":"L2","scope":"home","required":false},{"key":"composer-chrome","selector":".composer-surface-chrome","tier":"L2","scope":"home+thread","required":false},{"key":"composer-toolbar","selector":".composer-surface-chrome [class*=\"_footer_\"]","tier":"L2","scope":"home+thread","required":false},{"key":"home-utility","selector":"[class*=\"_homeUtilityBar_\"]","tier":"L2","scope":"home","required":false},{"key":"game-source","selector":"[data-feature=\"game-source\"]","tier":"L2","scope":"home","required":false},{"key":"home-suggestions","selector":".group\\/home-suggestions","tier":"L2","scope":"home","required":false},{"key":"project-selector","selector":".group\\/project-selector","tier":"L2","scope":"home config","required":false},{"key":"markdown","selector":"[class*=\"_markdown\"]","tier":"L2","scope":"thread","required":false},{"key":"thread-surface","selector":".thread-scroll-container","tier":"L2","scope":"thread","required":false},{"key":"message","selector":"[data-message-author-role]","tier":"L2","scope":"thread","required":false},{"key":"appearance-radio","selector":"input[name=\"appearance-theme\"]","tier":"L2","scope":"settings","required":false},{"key":"overlay-menu","selector":"[role=\"menu\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-dialog","selector":"[role=\"dialog\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-popper","selector":"[data-radix-popper-content-wrapper]","tier":"L2","scope":"overlay","required":false}],"stableTestids":["app-shell-header-context-menu-surface","home-icon","theme-preview"]};
44
const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__";
55
const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__";
66
const STYLE_REGISTRY_KEY = "__CODEX_DREAM_SKIN_STYLE_SHEETS__";

macos/assets/selectors.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"schema": "codex-dream-skin-selectors/1",
3-
"description": "双端统一选择器契约(初稿)。scope 与 required 均来自 2026-07-21 双端 DOM 快照实测,非猜测。doctor / CI 应逐条 querySelector 并按 tier 判级:L1 缺失=皮肤主行为受损,L2 缺失=精修静默降级。",
3+
"description": "双端统一选择器契约。scope 与 required 来自脱敏 DOM 快照实测,非猜测。doctor / CI 应逐条 querySelector 并按 tier 判级:L1 缺失=皮肤主行为受损,L2 缺失=精修静默降级。",
44
"verifiedAgainst": {
5-
"date": "2026-07-21",
6-
"chromium": "150.0.7871.124",
7-
"codexVersionMac": "26.715.61943",
5+
"date": "2026-07-31",
6+
"chromium": "150.0.7871.182",
7+
"codexVersionMac": "26.715.61943 and 26.727.40816",
88
"codexVersionWindows": "版本号未记录;bundle hash 与 mac 不同(ModelPickerTrigger mac=1fm6a win=1w4ob),确认存在版本错位",
99
"fixtures": [
1010
"runtime/dom-fixtures/codex-dom-fixture-darwin-watch.json (15 状态)",
@@ -26,11 +26,11 @@
2626
"config": "依赖用户配置才出现(如未配置项目则无项目选择器)"
2727
},
2828
"selectors": [
29-
{ "key": "shell-main", "selector": "main.main-surface", "tier": "L1", "scope": "all", "required": true, "notes": "全局主表面。mac 设置页缺失(win 存在),缺失时整页仅 L0" },
29+
{ "key": "shell-main", "selector": ":is(main.main-surface, main[class*=\"_MainContentSurface_\"])", "tier": "L1", "scope": "all", "required": true, "notes": "全局主表面。兼容旧稳定类与 26.727+ CSS Modules 前缀;必须与 left-panel 组合用于窗口身份校验。mac 设置页缺失(win 存在),缺失时整页仅 L0" },
3030
{ "key": "left-panel", "selector": "aside.app-shell-left-panel", "tier": "L1", "scope": "all", "required": true, "notes": "左侧栏;收起/重建时会移除节点,勿在其消失时清皮" },
31-
{ "key": "header-tint", "selector": "header.app-header-tint", "tier": "L1", "scope": "all", "required": true, "notes": "顶栏;皮肤不得覆盖其 position/z-index(既有回归约束)" },
32-
{ "key": "home-icon", "selector": "[data-testid=\"home-icon\"]", "tier": "L1", "scope": "home", "required": true, "notes": "首页判定信号,双端一致" },
33-
{ "key": "home-route", "selector": "[role=\"main\"]:has([data-testid=\"home-icon\"])", "tier": "L1", "scope": "home", "required": true, "notes": "首页容器。实测 [role=main] 仅存在于首页——它不是通用路由锚点,thread 页没有" },
31+
{ "key": "header-tint", "selector": ":is(header.app-header-tint, header[class*=\"_Header_\"])", "tier": "L1", "scope": "all", "required": true, "notes": "顶栏;兼容旧稳定类与 26.727+ CSS Modules 前缀。皮肤不得覆盖其 position/z-index(既有回归约束)" },
32+
{ "key": "home-icon", "selector": "[data-testid=\"home-icon\"]", "tier": "L1", "scope": "home", "required": false, "notes": "旧版首页判定及 hero 定位信号;26.727 已移除,因此不再单独阻断 L1" },
33+
{ "key": "home-route", "selector": "[role=\"main\"]:has([data-testid=\"home-icon\"], [class*=\"_homeUtilityBar_\"], .group\\/home-suggestions)", "tier": "L1", "scope": "home", "required": true, "notes": "首页容器。兼容旧 home-icon 与 26.727 的 utility/suggestions 信号;实测 [role=main] 仅存在于首页,thread 页没有" },
3434
{ "key": "home-route-css", "selector": "[role=\"main\"]", "tier": "L1", "scope": "home", "required": true, "notes": "home-route 的 CSS 可组合别名:不含 :has()。CSS 禁止 :has() 嵌套,home-route 原选择器一旦写进 :has()/:not(:has()) 整条规则会被解析器丢弃(v1.3.1 全窗口首页与任务页氛围背景因此失效)。依据与 home-route 相同的实测:[role=main] 仅存在于首页;该锚点缺失时 CSS 路由门控整体失效,故必须按 L1 必需项监控" },
3535
{ "key": "home-banners", "selector": ".home-banners", "tier": "L2", "scope": "home", "required": false, "notes": "Codex 26.721+ 新增的原生首页 banner 插槽,位于 home-route 第一个子节点内、通常为空(0 子节点)靠原生 empty:hidden 折叠。真正的首页内容(标题/建议卡/输入框)在这个版本上已变成该插槽外层容器的兄弟节点,不再是它的后代(见 #244 实测:home-route > div:first-child 被撑到 100% 高度会把内容挤出视口)。用于判定新旧 home DOM 形态,皮肤据此把 first-child 链路的强制尺寸限定在旧形态或改走兄弟节点" },
3636
{ "key": "composer-chrome", "selector": ".composer-surface-chrome", "tier": "L2", "scope": "home+thread", "required": false, "notes": "输入框壳。win 实测存在无 composer 的 detail 路由,不可当全局锚点" },

macos/scripts/injector.mjs

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,14 @@ class CdpSession {
371371
this.closed = false;
372372
}
373373

374-
async open() {
374+
async open(timeoutMs = 25000) {
375+
const deadline = Date.now() + Math.max(1, timeoutMs);
376+
const remaining = (maximum) => Math.max(1, Math.min(maximum, deadline - Date.now()));
375377
await new Promise((resolve, reject) => {
376378
const timeout = setTimeout(() => {
377379
try { this.ws.close(); } catch {}
378380
reject(new Error("CDP WebSocket open timed out"));
379-
}, 5000);
381+
}, remaining(5000));
380382
this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true });
381383
this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true });
382384
});
@@ -390,8 +392,8 @@ class CdpSession {
390392
}
391393
this.pending.clear();
392394
});
393-
await this.send("Runtime.enable");
394-
await this.send("Page.enable");
395+
await this.send("Runtime.enable", {}, remaining(10000));
396+
await this.send("Page.enable", {}, remaining(10000));
395397
return this;
396398
}
397399

@@ -478,9 +480,9 @@ class CdpSession {
478480
}
479481
}
480482

481-
async function listAppTargets(port) {
483+
async function listAppTargets(port, timeoutMs = 2000) {
482484
const controller = new AbortController();
483-
const timeout = setTimeout(() => controller.abort(), 2000);
485+
const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(2000, timeoutMs)));
484486
try {
485487
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
486488
redirect: "error",
@@ -495,7 +497,7 @@ async function listAppTargets(port) {
495497
}
496498
}
497499

498-
async function probeSession(session) {
500+
async function probeSession(session, timeoutMs = 10000) {
499501
return session.evaluate(`(() => {
500502
const markers = {
501503
shell: Boolean(document.querySelector(${selectorLiteral("shell-main")})),
@@ -510,7 +512,7 @@ async function probeSession(session) {
510512
codex: location.protocol === 'app:' &&
511513
((markers.shell && markers.sidebar) || settings || markers.main),
512514
};
513-
})()`);
515+
})()`, timeoutMs);
514516
}
515517

516518
async function waitForCodexProbe(session, timeoutMs = 1800) {
@@ -524,22 +526,25 @@ async function waitForCodexProbe(session, timeoutMs = 1800) {
524526
return probe;
525527
}
526528

527-
async function connectTarget(target, port) {
528-
return new CdpSession(target, port).open();
529+
async function connectTarget(target, port, timeoutMs) {
530+
return new CdpSession(target, port).open(timeoutMs);
529531
}
530532

531-
async function connectCodexTargets(port, timeoutMs) {
533+
export async function connectCodexTargets(port, timeoutMs) {
532534
const deadline = Date.now() + timeoutMs;
535+
const remaining = (maximum = Number.POSITIVE_INFINITY) =>
536+
Math.max(1, Math.min(maximum, deadline - Date.now()));
533537
let lastError;
534538
while (Date.now() < deadline) {
535539
try {
536-
const targets = await listAppTargets(port);
540+
const targets = await listAppTargets(port, remaining(2000));
537541
const connected = [];
538542
for (const target of targets) {
543+
if (Date.now() >= deadline) break;
539544
let session;
540545
try {
541-
session = await connectTarget(target, port);
542-
const probe = await probeSession(session);
546+
session = await connectTarget(target, port, remaining());
547+
const probe = await probeSession(session, remaining(10000));
543548
if (probe?.codex) connected.push({ target, session, probe });
544549
else session.close();
545550
} catch (error) {
@@ -552,7 +557,8 @@ async function connectCodexTargets(port, timeoutMs) {
552557
} catch (error) {
553558
lastError = error;
554559
}
555-
await new Promise((resolve) => setTimeout(resolve, 350));
560+
const delay = Math.min(350, Math.max(0, deadline - Date.now()));
561+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
556562
}
557563
throw new Error(`No verified ChatGPT renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`);
558564
}

macos/tests/injector-bootstrap.test.mjs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
99
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
1010
const source = await fs.readFile(injectorPath, "utf8");
1111

12-
function createFixture() {
12+
function createFixture({ shellVariant = "legacy" } = {}) {
1313
const domReady = [];
1414
const timers = new Map();
1515
const intervals = new Map();
@@ -24,7 +24,10 @@ function createFixture() {
2424
get documentElement() { return root; },
2525
addEventListener(type, callback) { if (type === "DOMContentLoaded") domReady.push(callback); },
2626
querySelector(selector) {
27-
if (selector === "main.main-surface") return markers.shell ? {} : null;
27+
if (
28+
(shellVariant === "legacy" && selector.includes("main.main-surface")) ||
29+
(shellVariant === "css-module" && selector.includes('main[class*="_MainContentSurface_"]'))
30+
) return markers.shell ? {} : null;
2831
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
2932
if (selector === "[role=\"main\"]") return markers.main ? {} : null;
3033
if (selector.includes("appearance-theme") || selector.includes("theme-preview")) {
@@ -68,6 +71,19 @@ guarded.markers.sidebar = true;
6871
guarded.tick();
6972
assert.deepEqual(guarded.context.window.installs, ["guarded"]);
7073

74+
const currentCodex = createFixture({ shellVariant: "css-module" });
75+
currentCodex.markers.shell = true;
76+
currentCodex.markers.sidebar = true;
77+
vm.runInNewContext(
78+
earlyPayloadFor('window.installs.push("current-codex")', "current-codex"),
79+
currentCodex.context,
80+
);
81+
assert.deepEqual(
82+
currentCodex.context.window.installs,
83+
["current-codex"],
84+
"Codex 26.727 CSS-module shell markers must be recognized without weakening sidebar identity.",
85+
);
86+
7187
const generations = createFixture();
7288
generations.makeNotReady();
7389
generations.markers.shell = true;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import assert from "node:assert/strict";
2+
import { connectCodexTargets } from "../scripts/injector.mjs";
3+
4+
const originalFetch = globalThis.fetch;
5+
const originalWebSocket = globalThis.WebSocket;
6+
7+
class NeverOpeningWebSocket {
8+
constructor() {
9+
this.listeners = new Map();
10+
}
11+
12+
addEventListener(type, listener) {
13+
const listeners = this.listeners.get(type) ?? [];
14+
listeners.push(listener);
15+
this.listeners.set(type, listeners);
16+
}
17+
18+
close() {}
19+
}
20+
21+
globalThis.fetch = async () => ({
22+
ok: true,
23+
async json() {
24+
return [{
25+
type: "page",
26+
id: "current-main",
27+
url: "app://-/index.html",
28+
webSocketDebuggerUrl: "ws://127.0.0.1:9341/devtools/page/current-main",
29+
}];
30+
},
31+
});
32+
globalThis.WebSocket = NeverOpeningWebSocket;
33+
34+
try {
35+
const startedAt = Date.now();
36+
await assert.rejects(
37+
connectCodexTargets(9341, 80),
38+
/No verified ChatGPT renderer/,
39+
);
40+
const elapsed = Date.now() - startedAt;
41+
assert.ok(
42+
elapsed < 500,
43+
`The 80ms discovery budget must cap the full target connection attempt (elapsed ${elapsed}ms).`,
44+
);
45+
} finally {
46+
globalThis.fetch = originalFetch;
47+
globalThis.WebSocket = originalWebSocket;
48+
}
49+
50+
console.log("PASS: renderer discovery honors its global timeout budget.");

macos/tests/run-tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ fi
173173
"$NODE" "$ROOT/scripts/injector.mjs" --check-payload >/dev/null
174174
"$NODE" "$ROOT/tests/image-metadata.test.mjs"
175175
"$NODE" "$ROOT/tests/injector-bootstrap.test.mjs"
176+
"$NODE" "$ROOT/tests/injector-connection-timeout.test.mjs"
176177
"$NODE" "$ROOT/tests/window-readiness.test.mjs"
177178
"$NODE" "$ROOT/tests/renderer-inject.test.mjs"
178179
"$NODE" "$ROOT/tests/safe-css-validator.test.mjs"

macos/tests/runtime-css-nested-has.test.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const files = [
1616
"windows/assets/dream-skin.css",
1717
];
1818

19+
const selectorContract = JSON.parse(readFileSync(join(root, "tools/selectors.json"), "utf8"));
20+
const selectorByKey = new Map(selectorContract.selectors.map(({ key, selector }) => [key, selector]));
21+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22+
const generatedMarkdownSelector = [
23+
`${escapeRegExp(selectorByKey.get("shell-main"))}:not\\(:has\\(${escapeRegExp(selectorByKey.get("home-route-css"))}\\)\\)`,
24+
escapeRegExp(selectorByKey.get("markdown")),
25+
].join(" ");
26+
1927
const findNestedHas = (css) => {
2028
const findings = [];
2129
for (let index = css.indexOf(":has("); index !== -1; index = css.indexOf(":has(", index + 1)) {
@@ -50,7 +58,7 @@ for (const file of files) {
5058
const css = readFileSync(join(root, file), "utf8");
5159
const selectorToken = file.startsWith("runtime/")
5260
? "__DREAM_SELECTOR_SHELL_MAIN__:not\\(:has\\(__DREAM_SELECTOR_HOME_ROUTE_CSS__\\)\\) __DREAM_SELECTOR_MARKDOWN__"
53-
: "main\\.main-surface:not\\(:has\\(\\[role=\"main\"\\]\\)\\) \\[class\\*=\"_markdown\"\\]";
61+
: generatedMarkdownSelector;
5462
const fullMode = ':is\\([^)]*\\[data-dream-task-mode="full"\\][^)]*\\[data-dream-art-task-mode="full"\\][^)]*\\)\\[data-dream-art-wide="true"\\]';
5563
const markdownRule = new RegExp(`${fullMode}\\s*\\n?\\s*${selectorToken}\\s*\\{\\s*\\n?\\s*color:\\s*var\\(--ds-text\\)\\s*!important;`);
5664
const lightShadowRule = new RegExp(`\\[data-dream-shell="light"\\]${fullMode}\\s*\\n?\\s*${selectorToken}\\s*\\{\\s*\\n?\\s*text-shadow:`);

tools/doctor-selectors.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const brokenHome = resultFor("home", ["shell-main", "left-panel", "header-tint",
2222
assert.equal(brokenHome.pass, false);
2323
assert.equal(brokenHome.exitCode, 1);
2424

25+
const currentCodexHome = resultFor("home", [
26+
"shell-main", "left-panel", "header-tint", "home-route", "home-route-css",
27+
]);
28+
assert.equal(
29+
currentCodexHome.pass,
30+
true,
31+
"Codex 26.727 removed home-icon; the remaining guarded home route must still satisfy L1.",
32+
);
33+
2534
const settings = resultFor("settings", ["appearance-radio"]);
2635
assert.equal(settings.pass, true);
2736
assert.equal(settings.tiers.L1.length, 0, "Settings must not inherit home/all L1 requirements");

0 commit comments

Comments
 (0)