Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions docs/harness-plugin-layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ Source: https://github.com/agentplugins/agent-plugins-spec/blob/main/spec/1.0.0.
- MCP loads **only** from root `mcp.json` — inline config / alternate paths forbidden.
- Skill discovery is **non-recursive** (immediate children of `skills/`).

> The spec requires `$schema`, but our **shipped** `plugin.json` currently omits
> it so Codex routes to `.codex-plugin/` (see [Codex](#codex)). It goes back once
> Codex can pass user config through the spec.
> `$schema` is required, and `validate` enforces it. It was omitted for a while so
> Codex would route to `.codex-plugin/`; Codex 0.150.0 removed that need (see
> [Codex](#codex)).

---

Expand Down Expand Up @@ -107,29 +107,35 @@ environment variables to an MCP server: `env_vars` is rejected, `${VAR}` in `env
is not expanded, and the ambient env is cleared
([openai/codex#36854](https://github.com/openai/codex/issues/36854)). So we
generate Codex's **legacy** `.codex-plugin/` layout in every case, which does
forward `env_vars`.
forward `env_vars`. Since
[0.150.0](https://github.com/openai/codex/commit/40b7560169c7274147a47f9b0c75db89fe016d34)
Codex reads the spec manifest and *overlays* those `env_vars` onto servers of the
same name, so both files are used together.

```
<plugin-root>/
├── plugin.json # spec manifest, but WITHOUT $schema (see below)
├── plugin.json # spec manifest, with $schema
├── .codex-plugin/
│ ├── plugin.json # legacy manifest; mcpServers -> "./.codex-plugin/.mcp.json"
│ └── .mcp.json # legacy MCP: command/args + env_vars (forwarded from user env)
└── skills/
└── <name>/SKILL.md
```

- **Omit `$schema` from the root `plugin.json`.** Codex treats a root manifest as
Agent Plugin only when its `$schema` is an `agent-plugins.org` URI; without it,
Codex falls through to `.codex-plugin/`.
- **Keep `$schema` in the root `plugin.json`.** Codex treats a root manifest as
Agent Plugin only when its `$schema` is an `agent-plugins.org` URI. It then takes
the `env_vars` from `.codex-plugin/` and applies them to the matching spec
servers, so server names must match across `mcp.json` and
`.codex-plugin/.mcp.json`. **Requires Codex 0.150.0+**; older versions ignore the
overlay, leaving user config unforwarded.
- The `mcpServers` path in `.codex-plugin/plugin.json` is **root-relative**
(`./.codex-plugin/.mcp.json`), not relative to `.codex-plugin/`.
- `.codex-plugin/.mcp.json` uses `env_vars: [...]` so the user's environment
reaches the server, the capability the spec `mcp.json` lacks.
- Install/discovery still needs a marketplace descriptor (`.claude-plugin/marketplace.json`).

> When Codex can pass user config through the spec, add `$schema` back and drop
> this generator; Codex (and the other spec-only clients) then read the spec directly.
> Once the spec itself can forward user env vars, drop this generator; Codex and
> the other spec clients then need nothing but `plugin.json` + `mcp.json`.

---

Expand Down
7 changes: 4 additions & 3 deletions src/agent_plugin_sync/generators/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

Codex reads the Agent Plugin spec, but its spec `mcp.json` cannot forward user
environment variables to an MCP server (`env_vars` is rejected, `${VAR}` in `env`
is not expanded, ambient env is cleared). So we target Codex's **legacy**
is not expanded, ambient env is cleared). So we also emit Codex's **legacy**
`.codex-plugin/` format, whose `env_vars` list forwards named vars from the user
environment. For Codex to use these files the root `plugin.json` must omit
`$schema`; see docs/harness-plugin-layouts.md.
environment. Codex 0.150.0+ overlays those `env_vars` onto the spec servers of the
same name, so the root `plugin.json` keeps its `$schema`; see
docs/harness-plugin-layouts.md.

Config vars (`com.google.cloud.data.agent-plugins.config`) become `env_vars` on
each server. The manifest references the sibling MCP file with a **root-relative**
Expand Down
17 changes: 9 additions & 8 deletions src/agent_plugin_sync/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,16 @@ def validate_plugin(root: pathlib.Path) -> ValidationResult:
# Malformed top level; can't check the bucket meaningfully after this.
return ValidationResult(ok=False, errors=_format(e, "plugin.json"))

# Codex reads a root manifest as Agent Plugin (and so uses the spec mcp.json,
# which cannot forward user env vars) only when its $schema is an agent-plugins
# URI. We route Codex to the generated .codex-plugin/ instead, so $schema must
# be absent; with it present, that generated file is silently ignored.
schema = raw.get("$schema", "")
if isinstance(schema, str) and schema.startswith(_AGENT_PLUGIN_SCHEMA_PREFIX):
# A spec client recognises the manifest by its $schema, so it must be an
# agent-plugins URI. Codex 0.150.0 and later still forward user env vars in that
# case: they overlay the generated .codex-plugin/ env_vars onto the spec servers
# (openai/codex#40363). Older Codex ignores that overlay, so plugins targeting
# 0.149.x and earlier must stay on an older release of this tool.
schema = raw.get("$schema")
if not (isinstance(schema, str) and schema.startswith(_AGENT_PLUGIN_SCHEMA_PREFIX)):
errors.append(
"plugin.json: omit $schema; with it, Codex reads the spec mcp.json and ignores "
"the generated .codex-plugin/ (its env_vars)"
f"plugin.json: $schema must be an Agent Plugin spec URI ({_AGENT_PLUGIN_SCHEMA_PREFIX}...); "
"without it a spec client does not recognise the manifest"
)

bucket = plugin.extensions.get(agent_plugin_sync.PLUGIN_EXTENSION_NS, {})
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def _make(
codex: dict | None = None,
mcp: dict | None = None,
skills: bool = False,
schema: bool = False,
schema: bool = True,
) -> pathlib.Path:
root = pathlib.Path(root)
ns: dict = {}
Expand All @@ -43,8 +43,8 @@ def _make(
if codex is not None:
ns["codex"] = codex

# $schema is omitted by default: with it, Codex ignores the generated
# .codex-plugin/. `schema=True` opts in to exercise that validation error.
# $schema is present by default: a spec client needs it to recognise the
# manifest. `schema=False` opts out to exercise that validation error.
plugin: dict = {
"name": name,
"version": "0.1.0",
Expand Down
8 changes: 4 additions & 4 deletions tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ def test_accepts_a_well_formed_source(self, make_plugin, tmp_path):
# Assert
assert result.ok, result.errors

def test_rejects_agent_plugin_schema_on_root_manifest(self, make_plugin, tmp_path):
"""A root plugin.json $schema routes Codex to the spec mcp.json, silently
ignoring the generated .codex-plugin/, so it must be omitted."""
def test_rejects_root_manifest_without_agent_plugin_schema(self, make_plugin, tmp_path):
"""Without an agent-plugins $schema a spec client does not recognise the
manifest, so it is required."""
# Arrange
root = make_plugin(tmp_path, schema=True)
root = make_plugin(tmp_path, schema=False)

# Act
result = validate.validate_plugin(root)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading