From 5c759ab3fcbb71f49178b2444017fed47bfe5ee6 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:08:38 -0700 Subject: [PATCH 1/6] fix(manifest): also block Agent Plugins $schema while skill frontmatter is non-conformant oh-my-pi >= 17.3 routes on the same $schema prefix to a strict provider that rejects any SKILL.md with keys outside the Agent Skills closed set or a non-string allowed-tools; 30 of 33 skills vanished (#1411). #1426 already removed the $schema; this makes the guard require conformant frontmatter too, so the schema cannot return once only the size condition clears. Refs #1411 Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- docs/specs/agent-plugins.md | 8 ++-- tests/codex-skill-prompt-budget.test.ts | 64 +++++++++++++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/specs/agent-plugins.md b/docs/specs/agent-plugins.md index b2543bc16..671ba9061 100644 --- a/docs/specs/agent-plugins.md +++ b/docs/specs/agent-plugins.md @@ -10,7 +10,7 @@ Root `plugin.json` follows the Agent Plugins 1.0.0 manifest authoring rules (fie https://agent-plugins.org/schemas/1.0.0/plugin.schema.json ``` -**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). Restore the `$schema` only once that allowlist is empty. +**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). **A second, independent reason (#1411):** oh-my-pi (omp) >= 17.3 routes on the same `$schema` prefix to its strict `agent-plugins` discovery provider, which rejects any `SKILL.md` whose frontmatter has a key outside the Agent Skills closed set (`argument-hint`, `disable-model-invocation`) or a non-string `allowed-tools` — 30 of 33 skills failed to load. Without the `$schema`, omp's lenient legacy provider loads all of them. The same test therefore also forbids the `$schema` while any skill's frontmatter is non-conformant. Restore the `$schema` only once both conditions clear (allowlist empty and every frontmatter conformant — the latter most likely via the deferred conformant-emission converter target below). Layout already matches the portable package shape: root manifest + `skills//SKILL.md`. No `mcp.json` (valid — MCP is optional). @@ -28,7 +28,7 @@ Agent Plugins discovers skills via the [Agent Skills](https://agentskills.io/spe **What is not proven** -- That shipping Agent Plugins clients run `skills-ref` at load time, or skip skills with extra top-level keys. Many clients may ignore unknown fields and still load the skill. Runtime impact is **unverified** until exercised against a concrete client. +- ~~That shipping Agent Plugins clients run `skills-ref` at load time, or skip skills with extra top-level keys.~~ **Now proven (#1411):** omp 17.3.5's `validateAgentSkillFrontmatter` mirrors `skills-ref` and rejects the skill; the manifest posture above is what keeps omp on its lenient path. **Source policy** @@ -46,9 +46,9 @@ Agent Plugins discovers skills via the [Agent Skills](https://agentskills.io/spe ## Re-verify when -- Every `SKILL.md` fits Codex's 8000-byte prompt bound (then restore `$schema`) +- Every `SKILL.md` fits Codex's 8000-byte prompt bound **and** every frontmatter is Agent Skills-conformant (then restore `$schema`) - Codex changes `MAX_SKILL_PROMPT_BYTES` or applies it to legacy/host skills ([openai/codex#37463](https://github.com/openai/codex/issues/37463)) - Agent Plugins leaves Working Draft / publishes a new schema version - Adding top-level fields to root `plugin.json` -- A concrete Agent Plugins client is observed to skip or reject skills with Claude-only frontmatter +- A concrete Agent Plugins client is observed to skip or reject skills with Claude-only frontmatter (observed 2026-08-17: omp 17.3.5, #1411) - Shipping an `agent-plugins` converter target diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts index 526423147..0c0c402a4 100644 --- a/tests/codex-skill-prompt-budget.test.ts +++ b/tests/codex-skill-prompt-budget.test.ts @@ -8,12 +8,25 @@ import path from "node:path" * then injects only the first MAX_SKILL_PROMPT_BYTES (8000) of each SKILL.md into the * model-visible prompt (#1412). Legacy manifests (`.codex-plugin/plugin.json`) are exempt. * - * Until every skill entrypoint fits, the root manifest must not carry that `$schema`, - * and no skill may newly cross the bound. Shrink OVER_BUDGET as skills are restructured; - * when it is empty, the `$schema` may return. + * oh-my-pi >= 17.3 (#1411) routes on the same `$schema` prefix to a strict provider that + * rejects any SKILL.md whose frontmatter has a key outside the Agent Skills closed set or a + * non-string `allowed-tools`; 30 of 33 skills fail that today and silently vanish. + * + * So the root manifest must not carry that `$schema` until BOTH hold: every skill entrypoint + * fits the byte bound, and every skill's frontmatter is Agent Skills-conformant. Shrink + * OVER_BUDGET as skills are restructured; the `$schema` may return only when both are clear. */ const CODEX_MAX_SKILL_PROMPT_BYTES = 8_000 const AGENT_PLUGINS_SCHEMA_PREFIX = "https://agent-plugins.org/schemas/" +/** Agent Skills closed frontmatter field set, as enforced by skills-ref and omp. */ +const AGENT_SKILLS_FRONTMATTER_KEYS = new Set([ + "name", + "description", + "license", + "compatibility", + "metadata", + "allowed-tools", +]) /** * Skills known to exceed the bound. Membership is a set on purpose: an over-budget skill is @@ -58,6 +71,20 @@ function crlfByteSize(contents: string): number { return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) } +/** Why a SKILL.md's frontmatter would be rejected by a strict Agent Skills client, or null. */ +function frontmatterNonconformance(contents: string): string | null { + const match = contents.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return "missing frontmatter" + const keys = match[1] + .split(/\r?\n/) + .filter((line) => /^[A-Za-z][\w-]*:/.test(line)) + .map((line) => line.slice(0, line.indexOf(":"))) + const unknown = keys.filter((key) => !AGENT_SKILLS_FRONTMATTER_KEYS.has(key)) + if (unknown.length > 0) return `unknown key(s): ${unknown.join(", ")}` + if (/^allowed-tools:\s*$/m.test(match[1])) return "allowed-tools is not a string" + return null +} + function skillSizes(): Map { const sizes = new Map() for (const name of readdirSync(skillsDir)) { @@ -92,16 +119,35 @@ describe("Codex skill prompt budget (#1412)", () => { expect(stale).toEqual([]) }) - test("root plugin.json omits the Agent Plugins $schema while any skill is over budget", () => { + test("root plugin.json omits the Agent Plugins $schema while any skill is over budget or non-conformant", () => { const manifest = JSON.parse( readFileSync(path.join(repoRoot, "plugin.json"), "utf8"), ) as Record const schema = typeof manifest.$schema === "string" ? manifest.$schema : "" - const anyOverBudget = [...sizes.values()].some( - (size) => size > CODEX_MAX_SKILL_PROMPT_BYTES, - ) - if (anyOverBudget) { - expect(schema.startsWith(AGENT_PLUGINS_SCHEMA_PREFIX)).toBe(false) + const blockers: string[] = [] + for (const [name, size] of sizes) { + if (size > CODEX_MAX_SKILL_PROMPT_BYTES) blockers.push(`${name}: ${size} bytes`) + const why = frontmatterNonconformance( + readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf8"), + ) + if (why) blockers.push(`${name}: ${why}`) + } + if (blockers.length > 0) { + expect( + schema.startsWith(AGENT_PLUGINS_SCHEMA_PREFIX), + `$schema present but blocked by:\n${blockers.join("\n")}`, + ).toBe(false) } }) + + test("frontmatter predicate matches the strict-client rejection set", () => { + const rejected = [...sizes.keys()].filter((name) => + frontmatterNonconformance(readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf8")), + ) + expect(rejected).toContain("ce-plan") + expect(rejected).toContain("ce-setup") + expect(rejected).toContain("ce-proof") + expect(rejected).not.toContain("ce-commit") + expect(rejected).not.toContain("ce-worktree") + }) }) From ec2beb981127741afd30d82ae5345bee64667fc8 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:13:34 -0700 Subject: [PATCH 2/6] fix(manifest): parse frontmatter YAML for the allowed-tools type check A regex only caught the block-list spelling; flow lists and null slipped through as conformant. Use the repo's frontmatter parser and check the parsed type. Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- tests/codex-skill-prompt-budget.test.ts | 28 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts index 0c0c402a4..0d9269cc0 100644 --- a/tests/codex-skill-prompt-budget.test.ts +++ b/tests/codex-skill-prompt-budget.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { readdirSync, readFileSync, statSync } from "node:fs" import path from "node:path" +import { parseFrontmatter } from "../src/utils/frontmatter" /** * Codex >= 0.147 (openai/codex#37027) classifies a plugin as an Agent Plugin when the @@ -73,15 +74,14 @@ function crlfByteSize(contents: string): number { /** Why a SKILL.md's frontmatter would be rejected by a strict Agent Skills client, or null. */ function frontmatterNonconformance(contents: string): string | null { - const match = contents.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match) return "missing frontmatter" - const keys = match[1] - .split(/\r?\n/) - .filter((line) => /^[A-Za-z][\w-]*:/.test(line)) - .map((line) => line.slice(0, line.indexOf(":"))) + const { data } = parseFrontmatter(contents) + const keys = Object.keys(data) + if (keys.length === 0) return "missing frontmatter" const unknown = keys.filter((key) => !AGENT_SKILLS_FRONTMATTER_KEYS.has(key)) if (unknown.length > 0) return `unknown key(s): ${unknown.join(", ")}` - if (/^allowed-tools:\s*$/m.test(match[1])) return "allowed-tools is not a string" + if ("allowed-tools" in data && typeof data["allowed-tools"] !== "string") { + return "allowed-tools is not a string" + } return null } @@ -140,6 +140,20 @@ describe("Codex skill prompt budget (#1412)", () => { } }) + test("frontmatter predicate rejects any non-string allowed-tools spelling", () => { + const fm = (yaml: string) => `---\nname: x\ndescription: y\n${yaml}\n---\nbody\n` + expect(frontmatterNonconformance(fm("allowed-tools: [Read, Write]"))).toBe( + "allowed-tools is not a string", + ) + expect(frontmatterNonconformance(fm("allowed-tools:\n - Read"))).toBe( + "allowed-tools is not a string", + ) + expect(frontmatterNonconformance(fm("allowed-tools: null"))).toBe( + "allowed-tools is not a string", + ) + expect(frontmatterNonconformance(fm("allowed-tools: Read Write"))).toBeNull() + }) + test("frontmatter predicate matches the strict-client rejection set", () => { const rejected = [...sizes.keys()].filter((name) => frontmatterNonconformance(readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf8")), From 8f496b0e22c1ecfc4c6c04a50506e05dc207b4e0 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:18:03 -0700 Subject: [PATCH 3/6] fix(manifest): type-check every permitted Agent Skills frontmatter field Mirror the strict validator fully (name, description, license, compatibility, metadata, allowed-tools) so the $schema gate cannot pass a skill a strict client would still reject. Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- tests/codex-skill-prompt-budget.test.ts | 47 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts index 0d9269cc0..f967810d5 100644 --- a/tests/codex-skill-prompt-budget.test.ts +++ b/tests/codex-skill-prompt-budget.test.ts @@ -72,14 +72,40 @@ function crlfByteSize(contents: string): number { return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) } -/** Why a SKILL.md's frontmatter would be rejected by a strict Agent Skills client, or null. */ +/** + * Why a SKILL.md's frontmatter would be rejected by a strict Agent Skills client, or null. + * Mirrors omp's `validateAgentSkillFrontmatter` (itself a port of skills-ref). + */ function frontmatterNonconformance(contents: string): string | null { const { data } = parseFrontmatter(contents) const keys = Object.keys(data) if (keys.length === 0) return "missing frontmatter" const unknown = keys.filter((key) => !AGENT_SKILLS_FRONTMATTER_KEYS.has(key)) if (unknown.length > 0) return `unknown key(s): ${unknown.join(", ")}` - if ("allowed-tools" in data && typeof data["allowed-tools"] !== "string") { + const { name, description, license, compatibility, metadata } = data + const allowedTools = data["allowed-tools"] + if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) { + return "name is not a lowercase hyphenated string of at most 64 chars" + } + if (typeof description !== "string" || description.trim() === "" || description.length > 1024) { + return "description is not a non-empty string of at most 1024 chars" + } + if (license !== undefined && typeof license !== "string") return "license is not a string" + if ( + compatibility !== undefined && + (typeof compatibility !== "string" || compatibility.length > 500) + ) { + return "compatibility is not a string of at most 500 chars" + } + if (metadata !== undefined) { + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { + return "metadata is not a map" + } + if (Object.values(metadata).some((v) => typeof v !== "string")) { + return "metadata values are not all strings" + } + } + if (allowedTools !== undefined && typeof allowedTools !== "string") { return "allowed-tools is not a string" } return null @@ -140,8 +166,23 @@ describe("Codex skill prompt budget (#1412)", () => { } }) - test("frontmatter predicate rejects any non-string allowed-tools spelling", () => { + test("frontmatter predicate type-checks every permitted field", () => { const fm = (yaml: string) => `---\nname: x\ndescription: y\n${yaml}\n---\nbody\n` + expect(frontmatterNonconformance(fm("metadata: text"))).toBe("metadata is not a map") + expect(frontmatterNonconformance(fm("metadata:\n k: 1"))).toBe( + "metadata values are not all strings", + ) + expect(frontmatterNonconformance(fm("license: false"))).toBe("license is not a string") + expect(frontmatterNonconformance(fm("compatibility: [a]"))).toBe( + "compatibility is not a string of at most 500 chars", + ) + expect(frontmatterNonconformance(fm("license: MIT\ncompatibility: any\nmetadata:\n k: v"))).toBeNull() + expect(frontmatterNonconformance("---\nname: Bad_Name\ndescription: y\n---\n")).toBe( + "name is not a lowercase hyphenated string of at most 64 chars", + ) + expect(frontmatterNonconformance("---\nname: x\ndescription: ''\n---\n")).toBe( + "description is not a non-empty string of at most 1024 chars", + ) expect(frontmatterNonconformance(fm("allowed-tools: [Read, Write]"))).toBe( "allowed-tools is not a string", ) From e618493d5151cf1833cd46b0070f6742a005086e Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:26:29 -0700 Subject: [PATCH 4/6] fix(manifest): reject empty compatibility in the frontmatter predicate Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- tests/codex-skill-prompt-budget.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts index f967810d5..4ec0cbc23 100644 --- a/tests/codex-skill-prompt-budget.test.ts +++ b/tests/codex-skill-prompt-budget.test.ts @@ -93,9 +93,9 @@ function frontmatterNonconformance(contents: string): string | null { if (license !== undefined && typeof license !== "string") return "license is not a string" if ( compatibility !== undefined && - (typeof compatibility !== "string" || compatibility.length > 500) + (typeof compatibility !== "string" || compatibility.length === 0 || compatibility.length > 500) ) { - return "compatibility is not a string of at most 500 chars" + return "compatibility is not a string of 1-500 chars" } if (metadata !== undefined) { if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { @@ -174,7 +174,10 @@ describe("Codex skill prompt budget (#1412)", () => { ) expect(frontmatterNonconformance(fm("license: false"))).toBe("license is not a string") expect(frontmatterNonconformance(fm("compatibility: [a]"))).toBe( - "compatibility is not a string of at most 500 chars", + "compatibility is not a string of 1-500 chars", + ) + expect(frontmatterNonconformance(fm("compatibility: ''"))).toBe( + "compatibility is not a string of 1-500 chars", ) expect(frontmatterNonconformance(fm("license: MIT\ncompatibility: any\nmetadata:\n k: v"))).toBeNull() expect(frontmatterNonconformance("---\nname: Bad_Name\ndescription: y\n---\n")).toBe( From 8451ee55459a32b62b9ec157c400b9e32b3c8732 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:28:07 -0700 Subject: [PATCH 5/6] docs(specs): root manifest stays schema-less; strict clients get an emitted package Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- docs/specs/agent-plugins.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/agent-plugins.md b/docs/specs/agent-plugins.md index 671ba9061..314df02d7 100644 --- a/docs/specs/agent-plugins.md +++ b/docs/specs/agent-plugins.md @@ -10,7 +10,7 @@ Root `plugin.json` follows the Agent Plugins 1.0.0 manifest authoring rules (fie https://agent-plugins.org/schemas/1.0.0/plugin.schema.json ``` -**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). **A second, independent reason (#1411):** oh-my-pi (omp) >= 17.3 routes on the same `$schema` prefix to its strict `agent-plugins` discovery provider, which rejects any `SKILL.md` whose frontmatter has a key outside the Agent Skills closed set (`argument-hint`, `disable-model-invocation`) or a non-string `allowed-tools` — 30 of 33 skills failed to load. Without the `$schema`, omp's lenient legacy provider loads all of them. The same test therefore also forbids the `$schema` while any skill's frontmatter is non-conformant. Restore the `$schema` only once both conditions clear (allowlist empty and every frontmatter conformant — the latter most likely via the deferred conformant-emission converter target below). +**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). **A second, independent reason (#1411):** oh-my-pi (omp) >= 17.3 routes on the same `$schema` prefix to its strict `agent-plugins` discovery provider, which rejects any `SKILL.md` whose frontmatter has a key outside the Agent Skills closed set (`argument-hint`, `disable-model-invocation`) or a non-string `allowed-tools` — 30 of 33 skills failed to load. Without the `$schema`, omp's lenient legacy provider loads all of them. The same test therefore also forbids the `$schema` while any skill's frontmatter is non-conformant. **Posture (decided 2026-08-17): the root manifest stays schema-less indefinitely.** Restoring `$schema` at the root is a non-goal, not a milestone: omp's routing has no per-host override (verified in 17.3.5 — `legacyProviderAllowed` locks the lenient provider out on the `$schema` prefix alone, and any other Agent Plugins `$schema` value is fatally invalid rather than a fallback), and the Claude Code top-level keys are load-bearing, so no root manifest can satisfy both. If a strict Agent Plugins client ever needs conformance, serve it an emitted package (the converter target below, with Claude keys relocated under `metadata:` and `allowed-tools` as a string) from its own marketplace `source`, and leave the root Claude-native. The test above is therefore a permanent tripwire, not a temporary one. Layout already matches the portable package shape: root manifest + `skills//SKILL.md`. No `mcp.json` (valid — MCP is optional). @@ -46,7 +46,8 @@ Agent Plugins discovers skills via the [Agent Skills](https://agentskills.io/spe ## Re-verify when -- Every `SKILL.md` fits Codex's 8000-byte prompt bound **and** every frontmatter is Agent Skills-conformant (then restore `$schema`) +- A strict Agent Plugins client we ship to needs conformance (then add an emitted conformant package for it — do not add `$schema` to the root) +- omp adds a per-host override / lenient fallback for `$schema` packages - Codex changes `MAX_SKILL_PROMPT_BYTES` or applies it to legacy/host skills ([openai/codex#37463](https://github.com/openai/codex/issues/37463)) - Agent Plugins leaves Working Draft / publishes a new schema version - Adding top-level fields to root `plugin.json` From 73fe936b8db0bd106e31c3bb71be74dd0aca871f Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:34:32 -0700 Subject: [PATCH 6/6] fix(manifest): assert the root manifest is schema-less unconditionally The posture is now that the root plugin.json never carries the Agent Plugins $schema, so the conditional gate and its frontmatter predicate are replaced by a plain assertion. The size ratchet stays. Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- docs/specs/agent-plugins.md | 2 +- tests/codex-skill-prompt-budget.test.ts | 129 +++--------------------- 2 files changed, 13 insertions(+), 118 deletions(-) diff --git a/docs/specs/agent-plugins.md b/docs/specs/agent-plugins.md index 314df02d7..0ee3e2d19 100644 --- a/docs/specs/agent-plugins.md +++ b/docs/specs/agent-plugins.md @@ -10,7 +10,7 @@ Root `plugin.json` follows the Agent Plugins 1.0.0 manifest authoring rules (fie https://agent-plugins.org/schemas/1.0.0/plugin.schema.json ``` -**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). **A second, independent reason (#1411):** oh-my-pi (omp) >= 17.3 routes on the same `$schema` prefix to its strict `agent-plugins` discovery provider, which rejects any `SKILL.md` whose frontmatter has a key outside the Agent Skills closed set (`argument-hint`, `disable-model-invocation`) or a non-string `allowed-tools` — 30 of 33 skills failed to load. Without the `$schema`, omp's lenient legacy provider loads all of them. The same test therefore also forbids the `$schema` while any skill's frontmatter is non-conformant. **Posture (decided 2026-08-17): the root manifest stays schema-less indefinitely.** Restoring `$schema` at the root is a non-goal, not a milestone: omp's routing has no per-host override (verified in 17.3.5 — `legacyProviderAllowed` locks the lenient provider out on the `$schema` prefix alone, and any other Agent Plugins `$schema` value is fatally invalid rather than a fallback), and the Claude Code top-level keys are load-bearing, so no root manifest can satisfy both. If a strict Agent Plugins client ever needs conformance, serve it an emitted package (the converter target below, with Claude keys relocated under `metadata:` and `allowed-tools` as a string) from its own marketplace `source`, and leave the root Claude-native. The test above is therefore a permanent tripwire, not a temporary one. +**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it asserts the root manifest carries no Agent Plugins `$schema` at all, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). **A second, independent reason (#1411):** oh-my-pi (omp) >= 17.3 routes on the same `$schema` prefix to its strict `agent-plugins` discovery provider, which rejects any `SKILL.md` whose frontmatter has a key outside the Agent Skills closed set (`argument-hint`, `disable-model-invocation`) or a non-string `allowed-tools` — 30 of 33 skills failed to load. Without the `$schema`, omp's lenient legacy provider loads all of them. **Posture (decided 2026-08-17): the root manifest stays schema-less indefinitely.** Restoring `$schema` at the root is a non-goal, not a milestone: omp's routing has no per-host override (verified in 17.3.5 — `legacyProviderAllowed` locks the lenient provider out on the `$schema` prefix alone, and any other Agent Plugins `$schema` value is fatally invalid rather than a fallback), and the Claude Code top-level keys are load-bearing, so no root manifest can satisfy both. If a strict Agent Plugins client ever needs conformance, serve it an emitted package (the converter target below, with Claude keys relocated under `metadata:` and `allowed-tools` as a string) from its own marketplace `source`, and leave the root Claude-native. The `$schema` assertion is therefore unconditional. Layout already matches the portable package shape: root manifest + `skills//SKILL.md`. No `mcp.json` (valid — MCP is optional). diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts index 4ec0cbc23..f1310622b 100644 --- a/tests/codex-skill-prompt-budget.test.ts +++ b/tests/codex-skill-prompt-budget.test.ts @@ -1,33 +1,23 @@ import { describe, expect, test } from "bun:test" import { readdirSync, readFileSync, statSync } from "node:fs" import path from "node:path" -import { parseFrontmatter } from "../src/utils/frontmatter" /** - * Codex >= 0.147 (openai/codex#37027) classifies a plugin as an Agent Plugin when the - * root `plugin.json` carries an `https://agent-plugins.org/schemas/...` `$schema`, and - * then injects only the first MAX_SKILL_PROMPT_BYTES (8000) of each SKILL.md into the - * model-visible prompt (#1412). Legacy manifests (`.codex-plugin/plugin.json`) are exempt. + * Two shipping hosts route on a root `plugin.json` `$schema` under the Agent Plugins prefix, + * and both break this plugin when they do: * - * oh-my-pi >= 17.3 (#1411) routes on the same `$schema` prefix to a strict provider that - * rejects any SKILL.md whose frontmatter has a key outside the Agent Skills closed set or a - * non-string `allowed-tools`; 30 of 33 skills fail that today and silently vanish. + * - Codex >= 0.147 (openai/codex#37027, #1412) injects only the first MAX_SKILL_PROMPT_BYTES + * (8000) of each Agent Plugin SKILL.md; 26 skills exceed that. + * - oh-my-pi >= 17.3 (#1411) rejects any SKILL.md whose frontmatter has a key outside the + * Agent Skills closed set; the Claude Code keys `argument-hint` / `disable-model-invocation` + * are load-bearing, so 30 skills vanish. omp has no per-host override. * - * So the root manifest must not carry that `$schema` until BOTH hold: every skill entrypoint - * fits the byte bound, and every skill's frontmatter is Agent Skills-conformant. Shrink - * OVER_BUDGET as skills are restructured; the `$schema` may return only when both are clear. + * So the root manifest stays schema-less unconditionally (docs/specs/agent-plugins.md); a + * strict client that needs conformance gets a separately emitted package. Independently, no + * new skill may cross Codex's byte bound, and OVER_BUDGET shrinks as skills are restructured. */ const CODEX_MAX_SKILL_PROMPT_BYTES = 8_000 const AGENT_PLUGINS_SCHEMA_PREFIX = "https://agent-plugins.org/schemas/" -/** Agent Skills closed frontmatter field set, as enforced by skills-ref and omp. */ -const AGENT_SKILLS_FRONTMATTER_KEYS = new Set([ - "name", - "description", - "license", - "compatibility", - "metadata", - "allowed-tools", -]) /** * Skills known to exceed the bound. Membership is a set on purpose: an over-budget skill is @@ -72,45 +62,6 @@ function crlfByteSize(contents: string): number { return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) } -/** - * Why a SKILL.md's frontmatter would be rejected by a strict Agent Skills client, or null. - * Mirrors omp's `validateAgentSkillFrontmatter` (itself a port of skills-ref). - */ -function frontmatterNonconformance(contents: string): string | null { - const { data } = parseFrontmatter(contents) - const keys = Object.keys(data) - if (keys.length === 0) return "missing frontmatter" - const unknown = keys.filter((key) => !AGENT_SKILLS_FRONTMATTER_KEYS.has(key)) - if (unknown.length > 0) return `unknown key(s): ${unknown.join(", ")}` - const { name, description, license, compatibility, metadata } = data - const allowedTools = data["allowed-tools"] - if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) { - return "name is not a lowercase hyphenated string of at most 64 chars" - } - if (typeof description !== "string" || description.trim() === "" || description.length > 1024) { - return "description is not a non-empty string of at most 1024 chars" - } - if (license !== undefined && typeof license !== "string") return "license is not a string" - if ( - compatibility !== undefined && - (typeof compatibility !== "string" || compatibility.length === 0 || compatibility.length > 500) - ) { - return "compatibility is not a string of 1-500 chars" - } - if (metadata !== undefined) { - if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { - return "metadata is not a map" - } - if (Object.values(metadata).some((v) => typeof v !== "string")) { - return "metadata values are not all strings" - } - } - if (allowedTools !== undefined && typeof allowedTools !== "string") { - return "allowed-tools is not a string" - } - return null -} - function skillSizes(): Map { const sizes = new Map() for (const name of readdirSync(skillsDir)) { @@ -145,67 +96,11 @@ describe("Codex skill prompt budget (#1412)", () => { expect(stale).toEqual([]) }) - test("root plugin.json omits the Agent Plugins $schema while any skill is over budget or non-conformant", () => { + test("root plugin.json never carries an Agent Plugins $schema", () => { const manifest = JSON.parse( readFileSync(path.join(repoRoot, "plugin.json"), "utf8"), ) as Record const schema = typeof manifest.$schema === "string" ? manifest.$schema : "" - const blockers: string[] = [] - for (const [name, size] of sizes) { - if (size > CODEX_MAX_SKILL_PROMPT_BYTES) blockers.push(`${name}: ${size} bytes`) - const why = frontmatterNonconformance( - readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf8"), - ) - if (why) blockers.push(`${name}: ${why}`) - } - if (blockers.length > 0) { - expect( - schema.startsWith(AGENT_PLUGINS_SCHEMA_PREFIX), - `$schema present but blocked by:\n${blockers.join("\n")}`, - ).toBe(false) - } - }) - - test("frontmatter predicate type-checks every permitted field", () => { - const fm = (yaml: string) => `---\nname: x\ndescription: y\n${yaml}\n---\nbody\n` - expect(frontmatterNonconformance(fm("metadata: text"))).toBe("metadata is not a map") - expect(frontmatterNonconformance(fm("metadata:\n k: 1"))).toBe( - "metadata values are not all strings", - ) - expect(frontmatterNonconformance(fm("license: false"))).toBe("license is not a string") - expect(frontmatterNonconformance(fm("compatibility: [a]"))).toBe( - "compatibility is not a string of 1-500 chars", - ) - expect(frontmatterNonconformance(fm("compatibility: ''"))).toBe( - "compatibility is not a string of 1-500 chars", - ) - expect(frontmatterNonconformance(fm("license: MIT\ncompatibility: any\nmetadata:\n k: v"))).toBeNull() - expect(frontmatterNonconformance("---\nname: Bad_Name\ndescription: y\n---\n")).toBe( - "name is not a lowercase hyphenated string of at most 64 chars", - ) - expect(frontmatterNonconformance("---\nname: x\ndescription: ''\n---\n")).toBe( - "description is not a non-empty string of at most 1024 chars", - ) - expect(frontmatterNonconformance(fm("allowed-tools: [Read, Write]"))).toBe( - "allowed-tools is not a string", - ) - expect(frontmatterNonconformance(fm("allowed-tools:\n - Read"))).toBe( - "allowed-tools is not a string", - ) - expect(frontmatterNonconformance(fm("allowed-tools: null"))).toBe( - "allowed-tools is not a string", - ) - expect(frontmatterNonconformance(fm("allowed-tools: Read Write"))).toBeNull() - }) - - test("frontmatter predicate matches the strict-client rejection set", () => { - const rejected = [...sizes.keys()].filter((name) => - frontmatterNonconformance(readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf8")), - ) - expect(rejected).toContain("ce-plan") - expect(rejected).toContain("ce-setup") - expect(rejected).toContain("ce-proof") - expect(rejected).not.toContain("ce-commit") - expect(rejected).not.toContain("ce-worktree") + expect(schema.startsWith(AGENT_PLUGINS_SCHEMA_PREFIX)).toBe(false) }) })