Unbounded plugin-catalog pagination loops: repeated next_page_token causes infinite requests and OOM (guard exists for skills, missing here)

Open 💬 1 comment Opened Jul 31, 2026 by lawrencecchen
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

The three plugin-catalog pagination loops in codex-rs/core-plugins/src/remote.rs (fetch_remote_plugin_catalog, fetch_shared_workspace_plugins, fetch_installed_plugins_for_scope) accumulate entries with no page cap, no total-size cap, and no duplicate-cursor check:

let mut plugins = Vec::new();
let mut page_token = None;
loop {
    let response =
        get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection)
            .await?;
    plugins.extend(response.plugins);
    let Some(next_page_token) = response.pagination.next_page_token else {
        break;
    };
    page_token = Some(next_page_token);
}

Any server that returns a repeated (or endlessly rotating) next_page_token on ps/plugins/list drives the client into an infinite request loop with unbounded memory growth. No malicious server is required: we hit this through an HTTP proxy whose response cache had a keying bug that replayed page 1 (cursor included) to page-2 requests. Codex re-sent the cursor it had just been handed back, forever.

Measured impact from that incident (macOS, codex app-server): 3,598 identical ps/plugins/list requests in a single turn, 5.3 GB streamed to one client, client RSS 15-18 GB, and the memory pressure caused kernel panics on the host.

This is the same defect class as #35450 (MCP resource pagination), but a weaker instance: the skills orchestrator loop in codex-rs/ext/skills/src/provider/orchestrator.rs already defends itself with a seen_cursors duplicate check and a MAX_RESOURCE_PAGES budget, so #35450 is only about rotating cursors. The plugin-catalog loops have neither defense, so they fall to the trivial repeated-cursor case the orchestrator already guards against.

Secondary issue: per-page memory cost makes long walks catastrophic

While reproducing this we measured that the walk's memory cost scales with the number of pages fetched, far beyond the size of the data: a cold catalog crawl of ~2,300 entries across 14 pages (~9 MB of JSON on disk afterward) drove codex app-server RSS to 20+ GB, while the same data delivered as one page cost ~125-190 MB. Something per-page appears to be retained for the duration of the walk. This turns "many pages" from slow into fatal, and it means the unbounded loop above OOMs quickly even at modest catalog sizes.

Why proxies always hit the walk

RemotePluginCatalogCacheKey in codex-rs/core-plugins/src/remote/catalog_cache.rs leads with chatgpt_base_url, so the 3-hour disk cache is warm only for the exact base URL it was populated through. Any proxy, gateway, staging host, or port change is a permanently cold key and re-triggers the full multi-page walk, which is how the loop above gets exercised in the first place. Keying on account/workspace identity (already in the struct) without the base URL would let equivalent sources share a warm cache; if the URL must stay in the key for safety, bounding the walk matters even more.

Suggested fix

Mirror the orchestrator's existing pattern in the three plugin loops:

  1. Error on a duplicate next_page_token (a HashSet of seen cursors) — kills the repeated-cursor loop on iteration two.
  2. A page/entry budget with an explicit error on breach — kills rotating-cursor loops and bounds memory regardless of server behavior.

Happy to provide the proxy-side reproduction setup (any cache or test server that echoes a fixed next_page_token reproduces it immediately).

View original on GitHub ↗

1 Comment

github-actions[bot] contributor · 28 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #35450

Powered by Codex Action