Skip to content

Windows cached install can leave broken npm workspace bin shims for omo ulw-loop #74

Description

@changa9090

Windows cached install can leave broken npm workspace bin shims for omo ulw-loop

Summary

On Windows, the LazyCodex/OMO cached plugin install can leave npm-generated workspace bin shims under the plugin cache's node_modules/.bin. Those shims are not the installer-owned command surface and can point through workspace package paths that are missing or invalid after cache promotion.

The user-visible symptom in this Codex session was that omo ulw-loop status --json was not reliably invokable from Git Bash:

  • omo ulw-loop status --json failed with omo: command not found.
  • The cache-local npm shim at ~/.codex/plugins/cache/sisyphuslabs/omo/4.13.0/node_modules/.bin/omo-ulw-loop pointed at a missing package path: node_modules/@code-yeongyu/codex-ulw-loop/dist/cli.js.
  • The actual cached component CLI worked when invoked directly from components/ulw-loop/dist/cli.js, so the component itself was present and functional.

Current source exposes the ULW component as the omo bin, but the failure class is the same: npm can create node_modules/.bin/omo / omo.cmd inside the cache, while the installer already creates the authoritative Codex bin links through linkCachedPluginBins().

Environment

  • OS: Windows, Git Bash in Codex Desktop
  • LazyCodex/OMO cache observed locally: sisyphuslabs/omo/4.13.0
  • LazyCodex repo checked: code-yeongyu/lazycodex at 4a15494
  • Source owner checked: code-yeongyu/oh-my-openagent via LazyCodex src submodule at 65715d1
  • Upstream OpenAI Codex comparison: clean openai/codex checkout had no lazycodex, omo, or ulw-loop implementation surface

Reproduction Evidence

In the affected Windows Codex project session:

omo ulw-loop status --json
# /usr/bin/bash: line 1: omo: command not found

The cache-local npm shim was also invalid:

~/.codex/plugins/cache/sisyphuslabs/omo/4.13.0/node_modules/.bin/omo-ulw-loop
→ node_modules/@code-yeongyu/codex-ulw-loop/dist/cli.js
→ missing target

Direct component invocation succeeded:

"/c/Program Files/nodejs/node" \
  /c/Users/chang/.codex/plugins/cache/sisyphuslabs/omo/4.13.0/components/ulw-loop/dist/cli.js \
  ulw-loop status --session-id airport-visa-20260624 --json

Observed JSON summary:

{
  "ok": true,
  "summary": {
    "total": 12,
    "pending": 12,
    "in_progress": 0
  }
}

Root Cause

packages/omo-codex/scripts/install/cache.mjs copies a plugin source tree into a versioned cache, rewrites local file dependencies, then runs:

await maybeRunNpmInstall(tempPath, runCommand, ["install", "--omit=dev"]);

For workspace plugins, npm can create root node_modules/.bin/* shims for package-owned bins. Those shims are internal npm install artifacts, not the installer-managed command links. They can point through workspace package paths such as node_modules/@code-yeongyu/codex-ulw-loop/dist/cli.js, which are not reliable after marketplace cache copy/promotion.

The installer already has the correct command-link mechanism:

linkCachedPluginBins({ binDir, pluginRoot: plugin.path, platform })

That function discovers package bins inside the cached plugin and links them to the real component target, such as components/ulw-loop/dist/cli.js.

Proposed Fix

After the cached npm install --omit=dev, remove npm-created .bin shims whose command names are owned by packages inside the cached plugin. Leave unrelated dependency shims intact.

This makes the installer-managed bin links the single reliable command surface and prevents agents/users from discovering a cache-local shim that points at an invalid workspace path.

Verified Patch

Patch applies to the source owner checkout code-yeongyu/oh-my-openagent at 65715d1:

diff --git a/packages/omo-codex/scripts/install-cache-copy.test.mjs b/packages/omo-codex/scripts/install-cache-copy.test.mjs
index 8d407ea..3efe875 100644
--- a/packages/omo-codex/scripts/install-cache-copy.test.mjs
+++ b/packages/omo-codex/scripts/install-cache-copy.test.mjs
@@ -4,7 +4,7 @@ import { basename, join } from "node:path";
 import test from "node:test";
 
 import { installCachedPlugin } from "./install/cache.mjs";
-import { makeTempDir } from "./install-test-fixtures.mjs";
+import { makeTempDir, writeJson } from "./install-test-fixtures.mjs";
 
 test("#given source plugin has a stale npm lockfile #when caching plugin #then lockfile is regenerated rather than copied", async () => {
 	// given
@@ -29,6 +29,50 @@ test("#given source plugin has a stale npm lockfile #when caching plugin #then l
 	await assert.rejects(stat(join(installed.path, "package-lock.json")));
 });
 
+test("#given npm creates workspace bin shims in the cache #when caching plugin #then plugin-owned shims are removed", async () => {
+	// given
+	const root = await makeTempDir();
+	const codexHome = join(root, "codex-home");
+	const sourceRoot = join(root, "plugin");
+	const componentRoot = join(sourceRoot, "components", "ulw-loop");
+	await mkdir(join(componentRoot, "dist"), { recursive: true });
+	await writeJson(join(sourceRoot, "package.json"), {
+		name: "@scope/omo",
+		version: "0.1.0",
+		workspaces: ["components/ulw-loop"],
+	});
+	await writeJson(join(componentRoot, "package.json"), {
+		name: "@code-yeongyu/codex-ulw-loop",
+		version: "0.1.0",
+		bin: {
+			omo: "./dist/cli.js",
+		},
+	});
+	await writeFile(join(componentRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
+
+	// when
+	const installed = await installCachedPlugin({
+		codexHome,
+		marketplaceName: "debug",
+		name: "omo",
+		sourcePath: sourceRoot,
+		version: "0.1.0",
+		runCommand: async (_command, args, options) => {
+			if (args.join(" ") !== "install --omit=dev") return;
+			const npmBinDir = join(options.cwd, "node_modules", ".bin");
+			await mkdir(npmBinDir, { recursive: true });
+			await writeFile(join(npmBinDir, "omo"), "#!/bin/sh\nnode ../@code-yeongyu/codex-ulw-loop/dist/cli.js \"$@\"\n");
+			await writeFile(join(npmBinDir, "omo.cmd"), '@echo off\r\nnode "%~dp0\\..\\@code-yeongyu\\codex-ulw-loop\\dist\\cli.js" %*\r\n');
+			await writeFile(join(npmBinDir, "other-tool.cmd"), "@echo off\r\necho preserved\r\n");
+		},
+	});
+
+	// then
+	await assert.rejects(stat(join(installed.path, "node_modules", ".bin", "omo")));
+	await assert.rejects(stat(join(installed.path, "node_modules", ".bin", "omo.cmd")));
+	assert.equal(await readFile(join(installed.path, "node_modules", ".bin", "other-tool.cmd"), "utf8"), "@echo off\r\necho preserved\r\n");
+});
+
 test("#given existing cache #when npm install fails #then previous active cache is preserved", async () => {
 	// given
 	const root = await makeTempDir();
diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs
index 5f10b51..3a43e88 100644
--- a/packages/omo-codex/scripts/install/cache.mjs
+++ b/packages/omo-codex/scripts/install/cache.mjs
@@ -19,6 +19,7 @@ export async function installCachedPlugin({ buildSource = true, codexHome, marke
 		await copyDirectory(sourcePath, tempPath, shouldCopyPluginPath);
 		await rewriteCachedPackageLocalFileDependencies(tempPath, sourcePath);
 		await maybeRunNpmInstall(tempPath, runCommand, ["install", "--omit=dev"]);
+		await removeCachedManagedNpmBinShims(tempPath);
 		await rewriteCachedMcpManifest(tempPath, sourcePath);
 		await rewriteCachedManifestRoot(tempPath, tempPath, targetPath);
 		await promoteDirectory(tempPath, targetPath, renameDirectory);
@@ -87,6 +88,19 @@ async function maybeRunNpmBuild(cwd, runCommand) {
 	await runCommand("npm", ["run", "build"], { cwd });
 }
 
+async function removeCachedManagedNpmBinShims(pluginRoot) {
+	const binLinks = await discoverPackageBins(pluginRoot);
+	if (binLinks.length === 0) return;
+	const npmBinDir = join(pluginRoot, "node_modules", ".bin");
+	if (!(await exists(npmBinDir))) return;
+	const managedBinNames = new Set(binLinks.map((link) => link.name));
+	for (const name of managedBinNames) {
+		for (const suffix of ["", ".cmd", ".ps1"]) {
+			await rm(join(npmBinDir, `${name}${suffix}`), { force: true });
+		}
+	}
+}
+
 function createTempSiblingPath(targetPath) {
 	return join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`);
 }

Verification

Red check before the fix:

node --test scripts/install-cache-copy.test.mjs
✖ #given npm creates workspace bin shims in the cache #when caching plugin #then plugin-owned shims are removed
AssertionError [ERR_ASSERTION]: Missing expected rejection.

Green checks after the fix:

node --test scripts/install-cache-copy.test.mjs scripts/install-mcp-runtime.test.mjs
tests 9, pass 9, fail 0
node --test --test-name-pattern "Windows platform|custom Windows command shim" scripts/install-bin-links.test.mjs
tests 2, pass 2, fail 0

I also attempted a broader installer test run. The unrelated Linux-symlink cases failed on this Windows host with EPERM: operation not permitted, symlink ..., which is a local Windows symlink privilege limitation rather than a regression from this patch. The Windows command-shim cases passed.

Related Issues

I did not find an exact open duplicate for the cache-local npm workspace shim problem.

Adjacent but distinct:


This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions