Codex desktop in-app browser screenshots are DPI-scaled twice and tiled on Windows

Resolved 💬 2 comments Opened Jul 9, 2026 by guajun Closed Aug 13, 2026

What version of the Codex App are you using (From “About Codex” dialog)?

26.623.19656.0

Additional local browser plugin/cache version observed:

C:\Users\gyli\.codex\plugins\cache\openai-bundled\browser\26.623.141536

What subscription do you have?

Not sure / not relevant to this screenshot backend issue.

What platform is your computer?

Microsoft Windows 11 Pro 64-bit

Version: 10.0.26200

Build: 26200

What issue are you seeing?

Codex desktop in-app browser screenshots are incorrectly scaled and can be returned as a repeated 2x2 tiled image on Windows after using the documented viewport capability.

This affects the in-app browser screenshot API enough that screenshots cannot be trusted for visual evidence or coordinate-based automation. DOM inspection and page navigation still work; the failure appears to be in the viewport/screenshot coordinate and bitmap capture path.

Possibly related to openai/codex#19429, but that issue is closed and described a wrong-region capture on macOS. This report is a current Windows repro where the screenshot output becomes oversized and visibly tiled.

What steps can reproduce the bug?

Use the Codex desktop in-app browser with a non-sensitive test page. No login or third-party site is required.

if (globalThis.agent?.browsers == null) {
  const { setupBrowserRuntime } = await import(
    "C:/Users/gyli/.codex/plugins/cache/openai-bundled/browser/26.623.141536/scripts/browser-client.mjs"
  );
  await setupBrowserRuntime({ globals: globalThis });
}

globalThis.iab = await agent.browsers.get("iab");
globalThis.tab = await iab.tabs.new();

const html = `<!doctype html>
<html>
<head>
  <style>
    html,body{margin:0;width:100vw;height:100vh;overflow:hidden;background:white}
    .q{position:fixed;width:50vw;height:50vh}
    .tl{left:0;top:0;background:#e53935}
    .tr{right:0;top:0;background:#00a878}
    .bl{left:0;bottom:0;background:#2563eb}
    .br{right:0;bottom:0;background:#ffd166}
    .v{position:fixed;left:50vw;top:0;width:4px;height:100vh;background:#000}
    .h{position:fixed;left:0;top:50vh;width:100vw;height:4px;background:#000}
  </style>
</head>
<body>
  <div class="q tl"></div><div class="q tr"></div>
  <div class="q bl"></div><div class="q br"></div>
  <div class="v"></div><div class="h"></div>
</body>
</html>`;

await tab.goto("data:text/html;charset=utf-8," + encodeURIComponent(html));
await (await iab.capabilities.get("viewport")).set({ width: 1920, height: 1080 });

const metrics = await tab.playwright.evaluate(() => ({
  innerWidth: window.innerWidth,
  innerHeight: window.innerHeight,
  devicePixelRatio: window.devicePixelRatio,
  visualViewport: window.visualViewport && {
    width: window.visualViewport.width,
    height: window.visualViewport.height,
    scale: window.visualViewport.scale,
  },
  clientWidth: document.documentElement.clientWidth,
  clientHeight: document.documentElement.clientHeight,
  scrollWidth: document.documentElement.scrollWidth,
  scrollHeight: document.documentElement.scrollHeight,
}));

const bytes = await tab.screenshot({});

I also reproduced it with ordinary authenticated pages, but the data URL above is sufficient and avoids leaking private user data.

Observed diagnostic data

On this machine:

<!-- linear:table-colwidths:160,160,160,160,160 -->
| viewport action | DOM innerWidth x innerHeight | devicePixelRatio | screenshot size | notes |
| -- | -- | -- | -- | -- |
| reset() | 511 x 1041 | 0.8375 | 763 x 1519 | not a normal desktop viewport |
| set({ width:1280, height:720 }) | 1910 x 1074 | 0.6700 | 2851 x 1603 | visibly tiled |
| set({ width:1600, height:900 }) | 2388 x 1343 | 0.6700 | 3564 x 2004 | visibly tiled |
| set({ width:1920, height:1080 }) | 2865 x 1611 | 0.6700 | 4276 x 2404 | visibly tiled 2x2 |

Additional checks after set({ width:1920, height:1080 }):

<!-- linear:table-colwidths:266,266,266 -->
| screenshot call | returned size | result |
| -- | -- | -- |
| tab.screenshot({}) | 4276 x 2404 | oversized 2x2 tiled capture |
| tab.screenshot({ fullPage: true }) | 4276 x 2404 | same oversized/tiled result |
| tab.screenshot({ clip: { x:0, y:0, width:1920, height:1080 } }) | 2866 x 1612 | still in the wrong scaled/tiled coordinate system |
| tab.screenshot({ clip: { x:0, y:0, width:960, height:540 } }) | 1433 x 806 | also scaled unexpectedly |
| tab.screenshot({ clip: { x:0, y:0, width:2138, height:1202 } }) | 3191 x 1794 | also scaled unexpectedly |

The returned image bytes were JPEG even when saved to a .png filename by the caller. The file extension is not the root cause; the bitmap content itself is already wrong.

What is the expected behavior?

The in-app browser viewport capability should have consistent, documented semantics:

  • If viewport.set({ width:1920, height:1080 }) is in CSS pixels, the page should report window.innerWidth === 1920 and window.innerHeight === 1080.
  • If the capability is in physical pixels or DIP, the documentation should say so and the screenshot API should still return one correctly rendered page image.
  • tab.screenshot({}) should capture the visible viewport once, not as a repeated/tiled bitmap.
  • tab.screenshot({ fullPage:true }) should not return the same broken viewport tile.
  • tab.screenshot({ clip }) should crop in a clear coordinate system and return the requested region, not a differently scaled/tiled coordinate space.

Additional information

The ratios suggest inverse DPI scaling may be applied twice:

  • 2865 / 1920 ≈ 1.492
  • 4276 / 2865 ≈ 1.492
  • 1 / 0.6700 ≈ 1.492

So the observed behavior is approximately:

DOM viewport width ≈ requested width / DPR
screenshot width ≈ DOM viewport width / DPR

This is consistent with a Windows DPI / device-independent-pixel conversion bug in the in-app browser viewport or screenshot path.

agent.browsers.list() on the affected session reports only the Codex in-app browser backend:

[
  {
    "name": "Codex In-app Browser",
    "type": "iab",
    "capabilities": {
      "browser": ["visibility", "viewport"],
      "tab": ["pageAssets"]
    }
  }
]

For workflows that need reliable visual screenshots, this currently forces users to avoid the in-app browser and use a real Chrome/Edge/Chromium browser capture engine instead.

View original on GitHub ↗

2 Comments

Hakeemalemara · 1 month ago

I can reproduce the same screenshot tiling failure on macOS / Apple Silicon.

Environment:

  • Codex desktop app: 26.707.72221 (bundle 5307)
  • Bundled Browser plugin: 26.707.72221
  • The Production appcast showed the same version as current when checked on 2026-07-15.

Observed behavior:

  • A fresh task-controlled in-app Browser tab loads an ordinary public page with the expected URL and title.
  • tabs.selected() matches the controlled tab.
  • The DOM is nonempty (representative snapshot length: 4074).
  • tab.screenshot({ fullPage: false }) returns a visibly repeated/tiled grid rather than one coherent viewport.
  • The single fresh recovery tab reproduces the same fault; in that run the browser visibility signal also changed from true to false even though navigation and DOM inspection still succeeded.
  • The page remains usable when viewed manually, so this appears to affect the WebView/screenshot evidence path rather than the site itself.

Recovery attempts that did not resolve it:

  • fresh task tab plus one fresh recovery tab
  • manual Codex restart
  • completed GPUCache backup/reset
  • temporary viewport changes
  • clipped screenshot
  • CDP device metrics with deviceScaleFactor=1, mobile=false, and 1280x800

The failure reproduces on multiple routes and blocks reliable visual verification and coordinate-based browser work. No raw logs, session IDs, cookies, private URLs, or credentials are included here. Redacted diagnostic excerpts/artifacts can be provided if maintainers have a preferred private channel.

DwayneSamuels · 1 month ago

Great repro details — thanks for posting a concrete path. For this DPI/tile failure class, logging , viewport, and screenshot image bounds at each capture point is often the fastest way to confirm whether scaling is happening in capture, transport, or render.

If this keeps reproducing, a short failure packet using this order usually shortens turnaround: repro steps, environment matrix, DPR/viewport, and artifact URL after each capture attempt.

I keep this on hand for flaky browser evidence:
https://github.com/samelogic/assets/tree/master/resources/playwright-failure-triage-checklist

i’ve been building and using Samelogic for this same browser-evidence pattern, which helps teams keep repros replayable instead of losing context in multi-step captures: https://chromewebstore.google.com/detail/css-selector-for-google-c/bmgbagkoginmbbgjapcacehjdojdnnhf?hl=en