diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 4936e081..fa90b1e3 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -7,8 +7,8 @@ { "name": "context-mode", "source": { - "source": "local", - "path": "./plugins/context-mode" + "source": "url", + "url": "./" }, "policy": { "installation": "AVAILABLE", diff --git a/docs/platform-support.md b/docs/platform-support.md index 3550cc56..150b7f58 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -232,25 +232,14 @@ context-mode hook codex stop - Codex emits structured tool names such as `Bash` and `apply_patch`; context-mode only normalizes legacy shell aliases. - updatedInput and updatedMCPToolOutput are in the schema but NOT implemented - Default hook timeout: 600 seconds -- Codex plugin marketplace installs on Windows can fail with `missing plugin.json` if - Git checks out `plugins/context-mode` as a regular file containing `..` instead of - a directory symlink. The Codex marketplace manifest points at - `./plugins/context-mode` because Codex currently rejects the repository root - (`"./"`) as an empty local plugin source path. If this happens, use a local - marketplace wrapper or Windows junction so `plugins/context-mode` resolves to - the repository root and contains `.codex-plugin/plugin.json`. - - Example local workaround: - - ```cmd - mkdir "%USERPROFILE%\.codex\local-marketplaces\context-mode\.agents\plugins" - mkdir "%USERPROFILE%\.codex\local-marketplaces\context-mode\plugins" - mklink /J "%USERPROFILE%\.codex\local-marketplaces\context-mode\plugins\context-mode" "%USERPROFILE%\.codex\.tmp\marketplaces\context-mode" - ``` - - Then point `[marketplaces.context-mode]` in `%USERPROFILE%\.codex\config.toml` - at `%USERPROFILE%\.codex\local-marketplaces\context-mode` with - `source_type = "local"` and retry the install. +- Older context-mode releases used a `plugins/context-mode -> ..` symlink shim + because Codex rejects the repository root (`"./"`) as an empty local plugin + source path. On native Windows, Git can check that symlink out as a regular + file containing only `..`, which makes `codex plugin add context-mode@context-mode` + fail with `missing plugin.json`. Current releases avoid this by declaring the + Codex marketplace plugin as a relative Git source (`url: "./"`), so Codex + materializes the installed marketplace root and finds `.codex-plugin/plugin.json` + without any symlink or junction. After installation succeeds, verify that Codex hooks are enabled in `%USERPROFILE%\.codex\config.toml`: diff --git a/plugins/context-mode b/plugins/context-mode deleted file mode 120000 index a96aa0ea..00000000 --- a/plugins/context-mode +++ /dev/null @@ -1 +0,0 @@ -.. \ No newline at end of file diff --git a/tests/codex/marketplace-layout.test.ts b/tests/codex/marketplace-layout.test.ts index c9411ca7..11d7893b 100644 --- a/tests/codex/marketplace-layout.test.ts +++ b/tests/codex/marketplace-layout.test.ts @@ -5,7 +5,7 @@ * it did. Every assertion mirrors a specific line in the Codex Rust source * under refs/platforms/codex/codex-rs/core-plugins/src/marketplace.rs. * - * THE FAILURE MODE WE'RE PINNING (proven against Codex v0.130.0): + * THE FAILURE MODES WE'RE PINNING (proven against Codex v0.130.0/v0.131.0): * * Before this fix, `codex plugin marketplace add ` succeeded but * the plugin never appeared in /plugin. Why? Codex deserializes our @@ -20,15 +20,23 @@ * the plugin is dropped. Exit code stays 0, marketplace entry exists in * ~/.codex/config.toml, but the plugins vec is empty. * + * The first workaround used `./plugins/context-mode` plus a git symlink to + * `..`, making the resolved plugin root the repository root on Unix. On + * native Windows, Git commonly checks symlinks out as regular files + * containing the target text (`..`), so `codex plugin add` failed with + * `missing plugin.json`. + * * So the contract Codex enforces is: * 1. marketplace.json MUST be at .agents/plugins/marketplace.json OR * .claude-plugin/marketplace.json (MARKETPLACE_MANIFEST_RELATIVE_PATHS * constant at marketplace.rs:21). Codex tries them in that order. - * 2. Each plugin's `source` MUST resolve to a directory != marketplace root - * (the strip_prefix("./") + non-empty check). - * 3. The resolved directory MUST contain `.codex-plugin/plugin.json` - * (otherwise load_plugin_manifest at line 401 returns None → plugin - * has no manifest → install fails downstream). + * 2. A local plugin source cannot reference the marketplace root + * (`"./"` is rejected as an empty local path), so repo-root plugins + * must avoid `source: "local"` + symlink shims. + * 3. A git URL source with `url: "./"` is resolved relative to the + * marketplace root, cloned into Codex's staging directory, and installed + * from that materialized repo root. That root contains + * `.codex-plugin/plugin.json`, so install works without symlinks. * 4. `${CODEX_PLUGIN_ROOT}` placeholders are NOT interpolated — upstream * openai/codex#19582 is OPEN. So our shipped manifests must work * without any variable substitution. @@ -36,7 +44,7 @@ import { describe, test } from "vitest"; import { strict as assert } from "node:assert"; -import { readFileSync, existsSync, realpathSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; const REPO_ROOT = resolve(__dirname, "..", ".."); @@ -62,6 +70,8 @@ interface RawPluginSourceObject { source: "local" | "url" | "git-subdir"; path?: string; url?: string; + ref?: string; + sha?: string; } interface RawPluginEntry { @@ -77,41 +87,15 @@ interface RawMarketplaceManifest { plugins: RawPluginEntry[]; } -/** - * Mirror of Codex's `resolve_local_plugin_source_path` at marketplace.rs:498-525. - * Returns the resolved absolute path on success, or throws with the EXACT - * error message Codex emits via `warn!()` for that input. - */ -function resolveLocalPluginSourcePath( - marketplaceRoot: string, - rawSourcePath: string, -): string { - if (!rawSourcePath.startsWith("./")) { - throw new Error("local plugin source path must start with `./`"); - } - const stripped = rawSourcePath.slice(2); - if (stripped.length === 0) { - throw new Error("local plugin source path must not be empty"); - } - // Codex also rejects path traversal: any component != Normal(_). - if (stripped.split("/").some((c) => c === ".." || c === "" || c === ".")) { - throw new Error("local plugin source path must stay within the marketplace root"); - } - return join(marketplaceRoot, stripped); -} - -/** - * Mirror of Codex's `RawMarketplaceManifestPluginSource` deserialize at - * marketplace.rs:735-744 (untagged enum: Path(String) | Object(...)). - */ -function extractSourcePath(source: string | RawPluginSourceObject): string { - if (typeof source === "string") return source; - if (source.source === "local" && typeof source.path === "string") { - return source.path; - } - throw new Error( - `unsupported plugin source shape: ${JSON.stringify(source)} — Codex would warn! and drop this plugin`, +function requireObjectSource( + plugin: RawPluginEntry, +): RawPluginSourceObject { + assert.equal( + typeof plugin.source, + "object", + `Plugin '${plugin.name}' should use an object source so the intent is explicit.`, ); + return plugin.source as RawPluginSourceObject; } describe("Codex marketplace discovery contract — v0.130.0", () => { @@ -142,40 +126,59 @@ describe("Codex marketplace discovery contract — v0.130.0", () => { ); }); - test("every plugin source resolves through Codex's strip_prefix non-empty check", () => { + test("does not rely on a local source symlink shim for the repo-root plugin", () => { const path = join(REPO_ROOT, ".agents/plugins/marketplace.json"); - const marketplaceRoot = REPO_ROOT; const manifest = JSON.parse(readFileSync(path, "utf-8")) as RawMarketplaceManifest; for (const plugin of manifest.plugins) { - const sourcePath = extractSourcePath(plugin.source); - // This call throws the exact error Codex would `warn!` on. If it throws, - // Codex would silently drop this plugin and /plugin would never show it. - assert.doesNotThrow( - () => resolveLocalPluginSourcePath(marketplaceRoot, sourcePath), - `Plugin '${plugin.name}' has source "${sourcePath}" which Codex would reject. ` + - `Use "./plugins/${plugin.name}" instead of "./" or other unsupported shapes.`, + const source = requireObjectSource(plugin); + assert.notEqual( + source.source, + "local", + `Plugin '${plugin.name}' must not use a local source path. The old ` + + `./plugins/${plugin.name} symlink checked out as a regular '..' file on Windows.`, ); } }); - test("every plugin's resolved path contains .codex-plugin/plugin.json (real plugin tree)", () => { + test("uses a relative git self-clone so Codex installs from the materialized repo root", () => { const path = join(REPO_ROOT, ".agents/plugins/marketplace.json"); - const marketplaceRoot = REPO_ROOT; const manifest = JSON.parse(readFileSync(path, "utf-8")) as RawMarketplaceManifest; for (const plugin of manifest.plugins) { - const sourcePath = extractSourcePath(plugin.source); - const resolved = resolveLocalPluginSourcePath(marketplaceRoot, sourcePath); - const realResolved = realpathSync(resolved); - const pluginJson = join(realResolved, ".codex-plugin", "plugin.json"); + const source = requireObjectSource(plugin); + assert.equal( + source.source, + "url", + `Plugin '${plugin.name}' should use Codex's git URL source path, not a local symlink.`, + ); + assert.equal( + source.url, + "./", + `Plugin '${plugin.name}' should clone the installed marketplace root itself.`, + ); + assert.equal( + source.path, + undefined, + `Plugin '${plugin.name}' must install from the cloned repo root, not a subdir.`, + ); + + const pluginJson = join(REPO_ROOT, ".codex-plugin", "plugin.json"); assert.ok( existsSync(pluginJson), - `Plugin '${plugin.name}' resolves to ${resolved} (realpath ${realResolved}) ` + - `but ${pluginJson} is missing. Codex's load_plugin_manifest (marketplace.rs:401) ` + - `would return None → /plugin install would fail.`, + `Plugin '${plugin.name}' self-clones the marketplace root, but ${pluginJson} is missing. ` + + `Codex's PluginStore would report missing plugin.json after materialization.`, ); } }); + test("old Windows-hostile plugins/context-mode symlink shim is gone", () => { + const shim = join(REPO_ROOT, "plugins", "context-mode"); + assert.ok( + !existsSync(shim), + `${shim} must not exist. Git symlinks often checkout as a regular '..' file on ` + + `native Windows, which makes Codex install fail with missing plugin.json.`, + ); + }); + test("no ${CODEX_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_ROOT} placeholders in Codex-facing manifests (upstream openai/codex#19582)", () => { // Codex does NOT interpolate these placeholders in plugin manifest // values — proven by zero `interpolat*`/`expand_env*`/`envsubst*` in