feat(codex-runtime): skip unavailable plugins during migration (#25437)

Followup to PR #24182 — caught when scanning OpenClaw for recent codex
fixes we hadn't considered. OpenClaw learned the hard way (#80815) that
migrating plugins which codex itself reports as unavailable produces
config that fails at activation time.

Our /codex-runtime codex_app_server enable path queries codex's
plugin/list and migrates everything where installed=true. We were
trusting codex's installation state and ignoring its availability
field. So a plugin that's installed=true but availability=UNAVAILABLE
(broken local install) or REQUIRES_AUTH (OAuth expired or never
completed) would get an [plugins."<n>@openai-curated"] entry in
~/.codex/config.toml — and the user's first codex turn after enabling
the runtime would fail because codex refuses to activate it.

Fix: filter on availability in _query_codex_plugins(). Only emit
plugins where availability is empty (older codex versions without the
field — preserve backward compat) or explicitly AVAILABLE.

Tests:
  test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases:
    - good-plugin (installed=True, availability=AVAILABLE) → migrated
    - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped
    - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped
    - legacy-plugin (installed=True, no availability field) → migrated
      (older codex versions; preserve backward compat)

Docs:
  Added bullet to 'What's NOT migrated' list in the docs page calling
  out the availability filter and why.

Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning):
  - #81591 (load Codex for selectable models): we resolve runtime
    per-call already, no startup-time gating to fix
  - #81510 (cron compatibility): we documented cron as untested; their
    fix is for OpenClaw-specific cron orchestration shape
  - #81223 (rotate incompatible context-engine threads): we don't
    have a Lossless context engine equivalent
  - #80688 (constrain sandbox): we don't have an outer-sandbox concept
  - #80616 (release on turn_aborted): we already handle status=
    interrupted in turn/completed correctly
  - #80278 (expose activeModel in plugin SDK): not our surface
  - #80792 (default destructive_actions on): we don't expose that knob

56 codex-runtime migration tests still green (+1 new).
This commit is contained in:
Teknium 2026-05-13 22:20:27 -07:00 committed by GitHub
parent f7ad2f1115
commit d5775fe988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 67 additions and 2 deletions

View file

@ -397,6 +397,22 @@ def _query_codex_plugins(
installed = bool(plugin.get("installed", False)) installed = bool(plugin.get("installed", False))
if not installed: if not installed:
continue continue
# Skip plugins codex itself reports as unavailable (broken
# install, missing OAuth, removed from marketplace, etc.).
# Cf. openclaw/openclaw#80815 — OpenClaw learned to gate
# migration on app readiness to avoid writing config that
# would fail at activation time. Our migration writes to
# codex's config.toml directly, so a broken plugin would
# surface as a codex error on first use. Skipping it here
# keeps the migrated config clean and the user's first
# codex turn from failing.
availability = str(plugin.get("availability") or "").upper()
if availability and availability != "AVAILABLE":
logger.debug(
"skipping plugin %s: availability=%s",
plugin.get("name"), availability,
)
continue
name = str(plugin.get("name") or "") name = str(plugin.get("name") or "")
if not name: if not name:
continue continue

View file

@ -353,7 +353,7 @@ class TestMigrate:
], None ], None
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query) monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query)
report = migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) report = migrate({}, codex_home=tmp_path, discover_plugins=True)
text = (tmp_path / "config.toml").read_text() text = (tmp_path / "config.toml").read_text()
assert '[plugins."github@openai-curated"]' in text assert '[plugins."github@openai-curated"]' in text
assert '[plugins."google-calendar@openai-curated"]' in text assert '[plugins."google-calendar@openai-curated"]' in text
@ -361,6 +361,54 @@ class TestMigrate:
assert "google-calendar@openai-curated" in report.migrated_plugins assert "google-calendar@openai-curated" in report.migrated_plugins
assert "github@openai-curated" in report.migrated_plugins assert "github@openai-curated" in report.migrated_plugins
def test_plugin_discovery_skips_unavailable_plugins(self):
"""Plugins where codex reports availability != AVAILABLE should
be skipped they're broken/uninstallable on codex's side, so
migrating them would write config that fails at activation
time. Cf. openclaw#80815."""
from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins
from unittest.mock import patch
# Fake a plugin/list response where one plugin is unavailable
fake_response = {
"marketplaces": [{
"name": "openai-curated",
"plugins": [
{"name": "good-plugin", "installed": True,
"enabled": True, "availability": "AVAILABLE"},
{"name": "broken-plugin", "installed": True,
"enabled": True, "availability": "UNAVAILABLE"},
{"name": "auth-pending", "installed": True,
"enabled": True, "availability": "REQUIRES_AUTH"},
# Plugin without availability field — pass through
# (older codex versions or marketplaces that don't
# set it should still work).
{"name": "legacy-plugin", "installed": True,
"enabled": True},
]
}]
}
class FakeClient:
def __init__(self, **kw): pass
def initialize(self, **kw): pass
def request(self, method, params, timeout=None):
return fake_response
def close(self): pass
def __enter__(self): return self
def __exit__(self, *a): pass
with patch("agent.transports.codex_app_server.CodexAppServerClient",
FakeClient):
plugins, err = _query_codex_plugins()
assert err is None
names = [p["name"] for p in plugins]
assert "good-plugin" in names
assert "legacy-plugin" in names # no field → don't skip
assert "broken-plugin" not in names
assert "auth-pending" not in names
def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch): def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch):
"""If codex isn't installed or RPC fails, MCP migration still """If codex isn't installed or RPC fails, MCP migration still
completes. The error surfaces in the report but doesn't abort.""" completes. The error surfaces in the report but doesn't abort."""

View file

@ -340,7 +340,8 @@ Plugins installed via `codex plugin` (Linear, GitHub, Gmail, Calendar, Canva, et
This means: when your friend says "I have Calendar and GitHub set up in my Codex CLI" and they enable Hermes' codex runtime, Hermes activates those automatically. No re-configuration needed. This means: when your friend says "I have Calendar and GitHub set up in my Codex CLI" and they enable Hermes' codex runtime, Hermes activates those automatically. No re-configuration needed.
What's NOT migrated: What's NOT migrated:
- Plugins not yet installed in Codex CLI. Install them via `codex plugin` first. - Plugins you haven't installed yet — install them in Codex first.
- Plugins where codex reports `availability != AVAILABLE` (broken install, expired OAuth, removed from marketplace, etc.). These are skipped to avoid writing config that would fail at activation time.
- ChatGPT app marketplace entries (the per-account `app/list` results — these are already enabled inside codex by virtue of your account auth). - ChatGPT app marketplace entries (the per-account `app/list` results — these are already enabled inside codex by virtue of your account auth).
- Plugin OAuth — you authorize each plugin once in Codex itself; Hermes doesn't touch credentials. - Plugin OAuth — you authorize each plugin once in Codex itself; Hermes doesn't touch credentials.