review-provider.mjs silently no-ops when invoked via a symlinked path (cross-model review inert) #12

Closed
opened 2026-07-18 12:02:59 +00:00 by crenshawdev · 1 comment
crenshawdev commented 2026-07-18 12:02:59 +00:00 (Migrated from github.com)

Summary

cadence-core/bin/review-provider.mjs silently no-ops (exits 0, zero bytes on stdout and stderr) when invoked through a symlinked path — e.g. when the plugin cache dir, or a parent like ~/.claude, is a symlink. Because this script is the only place cross-model review / consult / detect-models happen, the entire cross-model review subsystem silently falls back to claude-subagent with no error. A configured openai/gemini reviewer looks enrolled but is inert.

Root cause

The run-as-script guard at the tail of review-provider.mjs:

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
  main().catch(...)
}

Node resolves ESM import.meta.url through realpath, but process.argv[1] is left as-typed (path.resolve does not follow symlinks). When the script is invoked via a symlinked path — which is what ${CLAUDE_PLUGIN_ROOT} expands to when the plugin cache lives under a symlink — the two sides differ:

  • process.argv[1]/home/user/.claude/plugins/cache/cadence/.../review-provider.mjs
  • fileURLToPath(import.meta.url)/data/.../.claude/plugins/cache/cadence/.../review-provider.mjs

The guard is false, main() never runs, and the process exits 0 with no output. emit() is never reached, so there is not even a {ok:false} line — the caller sees empty stdout and degrades to claude-subagent without surfacing anything.

Reproduction

ln -s /real/path/to/plugin /tmp/symlinked-plugin
node /tmp/symlinked-plugin/cadence-core/bin/review-provider.mjs detect-models --provider openai
# -> exits 0, prints nothing (expected: {"ok":true,"provider":"openai","models":[...]})

Invoking via the realpath prints the expected JSON, confirming the guard as the cause.

Impact

  • Cross-model review (plan / diff / risk_surface / phase_diff / pre_ship triggers) and consult silently do nothing on any setup where the plugin is reached through a symlink.
  • /cad-config --review detection also returns empty, so tier assignment appears to hang / produce nothing.
  • Failure is invisible: no error, exit 0, silent fallback — the config reports as enrolled.

Suggested fix

Compare through realpath (and guard against argv[1] not resolving):

const runAsScript = (() => {
  if (!process.argv[1]) return false;
  try {
    return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
  } catch {
    return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
  }
})();
if (runAsScript) { main().catch(...) }

Only review-provider.mjs is affected among the runtime bins — config.mjs and route.mjs use import.meta.url for HERE/dirname only and have no argv guard, so they work fine through a symlink.

Found on cadence 1.1.0, Node ESM, Linux, with ~/.claude symlinked to another volume.

## Summary `cadence-core/bin/review-provider.mjs` silently no-ops (exits 0, zero bytes on stdout and stderr) when invoked through a symlinked path — e.g. when the plugin cache dir, or a parent like `~/.claude`, is a symlink. Because this script is the only place cross-model `review` / `consult` / `detect-models` happen, the entire cross-model review subsystem silently falls back to `claude-subagent` with no error. A configured `openai`/`gemini` reviewer looks enrolled but is inert. ## Root cause The run-as-script guard at the tail of `review-provider.mjs`: ```js if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { main().catch(...) } ``` Node resolves ESM `import.meta.url` through **realpath**, but `process.argv[1]` is left as-typed (`path.resolve` does not follow symlinks). When the script is invoked via a symlinked path — which is what `${CLAUDE_PLUGIN_ROOT}` expands to when the plugin cache lives under a symlink — the two sides differ: - `process.argv[1]` → `/home/user/.claude/plugins/cache/cadence/.../review-provider.mjs` - `fileURLToPath(import.meta.url)` → `/data/.../.claude/plugins/cache/cadence/.../review-provider.mjs` The guard is false, `main()` never runs, and the process exits 0 with no output. `emit()` is never reached, so there is not even a `{ok:false}` line — the caller sees empty stdout and degrades to `claude-subagent` without surfacing anything. ## Reproduction ``` ln -s /real/path/to/plugin /tmp/symlinked-plugin node /tmp/symlinked-plugin/cadence-core/bin/review-provider.mjs detect-models --provider openai # -> exits 0, prints nothing (expected: {"ok":true,"provider":"openai","models":[...]}) ``` Invoking via the realpath prints the expected JSON, confirming the guard as the cause. ## Impact - Cross-model review (`plan` / `diff` / `risk_surface` / `phase_diff` / `pre_ship` triggers) and consult silently do nothing on any setup where the plugin is reached through a symlink. - `/cad-config --review` detection also returns empty, so tier assignment appears to hang / produce nothing. - Failure is invisible: no error, exit 0, silent fallback — the config reports as enrolled. ## Suggested fix Compare through realpath (and guard against argv[1] not resolving): ```js const runAsScript = (() => { if (!process.argv[1]) return false; try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); } })(); if (runAsScript) { main().catch(...) } ``` Only `review-provider.mjs` is affected among the runtime bins — `config.mjs` and `route.mjs` use `import.meta.url` for `HERE`/dirname only and have no argv guard, so they work fine through a symlink. Found on cadence 1.1.0, Node ESM, Linux, with `~/.claude` symlinked to another volume.
crenshawdev commented 2026-07-23 13:35:54 +00:00 (Migrated from github.com)

Fixed in v1.2.0 (REV-01). The run-as-script guard now compares realpaths on both sides, so review-provider.mjs no longer no-ops when the plugin is installed through a symlink — cross-model review/consult/detect-models reach the real provider instead of degrading silently. A symlink regression test invokes the script through a link and asserts a non-empty JSON line, and an empty/unusable provider result now surfaces one line before the claude-subagent fallback. Shipped in #34, tag v1.2.0.

Fixed in v1.2.0 (REV-01). The run-as-script guard now compares realpaths on both sides, so `review-provider.mjs` no longer no-ops when the plugin is installed through a symlink — cross-model review/consult/detect-models reach the real provider instead of degrading silently. A symlink regression test invokes the script through a link and asserts a non-empty JSON line, and an empty/unusable provider result now surfaces one line before the claude-subagent fallback. Shipped in #34, tag v1.2.0.
Sign in to join this conversation.
No description provided.