Unbounded plugin-catalog pagination loops: repeated next_page_token causes infinite requests and OOM (guard exists for skills, missing here)
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:
- Error on a duplicate
next_page_token(aHashSetof seen cursors) — kills the repeated-cursor loop on iteration two. - 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).
1 Comment
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action