fix(codex): avoid marketplace symlink source on Windows (#748)

This commit is contained in:
NgoQuocViet2001
2026-06-01 13:59:06 +07:00
committed by GitHub
parent b9779025a2
commit b4b1e44a6e
4 changed files with 72 additions and 81 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
{
"name": "context-mode",
"source": {
"source": "local",
"path": "./plugins/context-mode"
"source": "url",
"url": "./"
},
"policy": {
"installation": "AVAILABLE",
+8 -19
View File
@@ -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`:
-1
View File
@@ -1 +0,0 @@
..
+62 -59
View File
@@ -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 <repo>` 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