Skip to content

fix(windows): resolve symlinked config paths and plugin name parsing (fixes #2271)#2707

Merged
code-yeongyu merged 2 commits intocode-yeongyu:devfrom
MoerAI:fix/windows-symlink-config
Mar 25, 2026
Merged

fix(windows): resolve symlinked config paths and plugin name parsing (fixes #2271)#2707
code-yeongyu merged 2 commits intocode-yeongyu:devfrom
MoerAI:fix/windows-symlink-config

Conversation

@MoerAI
Copy link
Copy Markdown
Contributor

@MoerAI MoerAI commented Mar 20, 2026

Summary

  • Resolve OpenCode config paths through realpathSync before deriving config file and install locations
  • Resolve symlinked config/cache directories in doctor loaded-version checks to avoid stale version mismatches on Windows junction/symlink setups
  • Harden Claude plugin key parsing so file://...@latest entries derive the package basename (oh-my-opencode) instead of partial path segments

Problem

On Windows setups where ~/.config is symlinked or junctioned, path handling could mix symlink aliases and canonical paths. That broke version/source detection and caused plugin name derivation to fall back to path fragments for file-based entries.

Fix

Canonicalize config paths before they are used by config discovery and doctor version checks. Also parse plugin keys by stripping source/version suffixes safely and extracting basename from file URLs/paths.

Changes

File Change
src/shared/opencode-config-dir.ts Added symlink-aware config path resolver and applied it to CLI/Tauri/env config resolution
src/cli/doctor/checks/system-loaded-version.ts Canonicalized config/cache directories before selecting installed package paths
src/cli/doctor/checks/system-loaded-version.test.ts Added regression test for symlinked OPENCODE_CONFIG_DIR
src/features/claude-code-plugin-loader/discovery.ts Improved plugin key name derivation for file URLs, paths, scoped/npm specs
src/features/claude-code-plugin-loader/discovery.test.ts Added regression test for file URL plugin key parsing

Verification

  • lsp_diagnostics: clean for all changed files
  • bun test src/features/claude-code-plugin-loader/discovery.test.ts: pass
  • bun test src/cli/doctor/checks/system-loaded-version.test.ts: fails in this environment due unresolved picocolors dependency import in src/cli/doctor/constants.ts
  • bun run build: fails in this environment due broader package resolution issues (zod/js-yaml not resolved by Bun loader despite install)

Fixes #2271


Summary by cubic

Fix Windows handling of symlinked config/cache dirs by resolving canonical paths across config discovery, plugin detection, and doctor checks. Also fix plugin key parsing to preserve scoped npm names and derive correct names from file URLs (e.g., @myorg/my-plugin, oh-my-opencode). Fixes #2271.

  • Bug Fixes
    • Canonicalize config and cache dirs before reading package.json, selecting install paths, or detecting installed versions (CLI, Tauri, doctor).
    • Normalize doctor loaded-version detection when the config dir is a junction/symlink on Windows; also resolve the cache dir.
    • Parse Claude plugin keys by stripping source/version, preserving scoped npm names, and deriving basenames for file URLs/paths; add regression tests.

Written for commit d7a1945. Summary will update on new commits.

@MoerAI
Copy link
Copy Markdown
Contributor Author

MoerAI commented Mar 20, 2026

I have read the CLA Document and I hereby sign the CLA

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23a30e86f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +84 to +85
const versionSeparator = keyWithoutSource.lastIndexOf("@")
const keyWithoutVersion = versionSeparator > 0 ? keyWithoutSource.slice(0, versionSeparator) : keyWithoutSource
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop stripping versionless local plugin paths at parent '@' dirs

derivePluginNameFromKey() now removes everything after the last @ before checking whether the key is a file:// URL or filesystem path. For versionless local entries like file:///tmp/@work/oh-my-opencode or /tmp/@work/oh-my-opencode, that truncates the path at the parent directory, so discovery derives the wrong plugin name. Because discoverInstalledPlugins() feeds plugin.name into the namespacing logic in command-loader.ts, agent-loader.ts, and skill-loader.ts, those plugins expose the wrong plugin:command/plugin:agent/plugin:skill names and can collide with unrelated plugins whenever any parent directory contains @.

Useful? React with 👍 / 👎.

}

function resolveConfigPath(pathValue: string): string {
const resolvedPath = resolve(pathValue)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid host-OS resolve() for platform-specific config paths

Using resolve() here makes config discovery depend on the host OS path rules instead of the target platform branch. I verified this against the existing Windows-path test in src/shared/opencode-config-dir.test.ts: when the suite runs on Linux/macOS and mocks process.platform = 'win32', resolving C:\Users\TestUser\AppData\Roaming\... prepends the repo cwd, so getOpenCodeConfigDir({ binary: "opencode-desktop" }) returns an invalid path and the shared test now fails. Any other non-Windows code path that needs to inspect Windows-style config locations will hit the same regression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 5 files

Confidence score: 2/5

  • There is a concrete compatibility break in src/features/claude-code-plugin-loader/discovery.ts: scoped npm package names like @scope/pkg can be truncated to pkg, which can cause incorrect plugin discovery/naming for Opencode users.
  • The same parser logic in src/features/claude-code-plugin-loader/discovery.ts also strips at the last @, so local paths containing @ may be misparsed and produce wrong plugin keys—this is a user-facing regression risk.
  • Additional medium-risk correctness issues in src/shared/opencode-config-dir.ts and src/cli/doctor/checks/system-loaded-version.test.ts suggest path handling/canonicalization edge cases (especially cross-platform and symlinked/macOS scenarios), so merge risk is elevated rather than minimal.
  • Pay close attention to src/features/claude-code-plugin-loader/discovery.ts, src/shared/opencode-config-dir.ts, src/cli/doctor/checks/system-loaded-version.test.ts - plugin key parsing and path normalization behavior can diverge in real environments.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/features/claude-code-plugin-loader/discovery.ts">

<violation number="1" location="src/features/claude-code-plugin-loader/discovery.ts:85">
P2: Plugin key parsing strips at the last '@' before path normalization, which can truncate local file paths containing '@' and derive incorrect plugin names.</violation>

<violation number="2" location="src/features/claude-code-plugin-loader/discovery.ts:95">
P1: Custom agent: **Opencode Compatibility**

Scoped npm packages (e.g., "@scope/pkg") contain a forward slash and will be incorrectly truncated to just "pkg". This breaks Opencode compatibility, which explicitly expects the plugin name to remain "@scope/pkg" for scoped packages.</violation>
</file>

<file name="src/shared/opencode-config-dir.ts">

<violation number="1" location="src/shared/opencode-config-dir.ts:46">
P2: `resolve(pathValue)` uses host-OS path semantics, not the target platform's. When running on Linux/macOS with `process.platform` mocked as `'win32'`, `getTauriConfigDir` returns a Windows-style path (e.g. `C:\Users\...`), but `resolve()` treats it as relative (not a Unix absolute path) and prepends CWD, producing an invalid path. This breaks cross-platform tests such as the existing Windows-path test in `opencode-config-dir.test.ts`.

Consider skipping `resolve()` when the path is already absolute on its target platform, or guard the `resolve` call so it only normalizes paths that match the host OS conventions.</violation>
</file>

<file name="src/cli/doctor/checks/system-loaded-version.test.ts">

<violation number="1" location="src/cli/doctor/checks/system-loaded-version.test.ts:108">
P2: Existing tests still assert raw directory strings even though `getLoadedPluginVersion()` now returns canonicalized real paths, which can fail on macOS/symlinked environments.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

}

function resolveConfigPath(pathValue: string): string {
const resolvedPath = resolve(pathValue)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: resolve(pathValue) uses host-OS path semantics, not the target platform's. When running on Linux/macOS with process.platform mocked as 'win32', getTauriConfigDir returns a Windows-style path (e.g. C:\Users\...), but resolve() treats it as relative (not a Unix absolute path) and prepends CWD, producing an invalid path. This breaks cross-platform tests such as the existing Windows-path test in opencode-config-dir.test.ts.

Consider skipping resolve() when the path is already absolute on its target platform, or guard the resolve call so it only normalizes paths that match the host OS conventions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/opencode-config-dir.ts, line 46:

<comment>`resolve(pathValue)` uses host-OS path semantics, not the target platform's. When running on Linux/macOS with `process.platform` mocked as `'win32'`, `getTauriConfigDir` returns a Windows-style path (e.g. `C:\Users\...`), but `resolve()` treats it as relative (not a Unix absolute path) and prepends CWD, producing an invalid path. This breaks cross-platform tests such as the existing Windows-path test in `opencode-config-dir.test.ts`.

Consider skipping `resolve()` when the path is already absolute on its target platform, or guard the `resolve` call so it only normalizes paths that match the host OS conventions.</comment>

<file context>
@@ -42,14 +42,25 @@ function getTauriConfigDir(identifier: string): string {
 }
 
+function resolveConfigPath(pathValue: string): string {
+  const resolvedPath = resolve(pathValue)
+  if (!existsSync(resolvedPath)) return resolvedPath
+
</file context>
Fix with Cubic

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "bun:test"
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Existing tests still assert raw directory strings even though getLoadedPluginVersion() now returns canonicalized real paths, which can fail on macOS/symlinked environments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/doctor/checks/system-loaded-version.test.ts, line 108:

<comment>Existing tests still assert raw directory strings even though `getLoadedPluginVersion()` now returns canonicalized real paths, which can fail on macOS/symlinked environments.</comment>

<file context>
@@ -104,6 +104,31 @@ describe("system loaded version", () => {
       expect(loadedVersion.loadedVersion).toBe("2.3.4")
     })
+
+    it("resolves symlinked config directories before selecting install path", () => {
+      //#given
+      const realConfigDir = createTemporaryDirectory("omo-real-config-")
</file context>
Fix with Cubic

@MoerAI
Copy link
Copy Markdown
Contributor Author

MoerAI commented Mar 23, 2026

Re: issues identified by cubic.

P1 (scoped npm packages truncated): Acknowledged. The plugin name parsing strips at the last @ to handle version suffixes (e.g., [email protected]), but this incorrectly truncates scoped packages like @scope/pkg. The fix would be to detect the @ prefix (scoped package indicator) and skip the version-strip logic for scoped names. Will address in a follow-up commit.

P2 (local paths with @): Related to the above — paths containing @ (like /home/user@host/plugins/) are edge cases. The current logic should handle the common case where @ appears in version suffixes, but the fix for P1 should also resolve most of these cases.

P2 (cross-platform resolve): resolve() on Linux/macOS with a Windows-style path is a test-only concern. In production, resolve() runs on the same platform as the path format. The test mocking process.platform as win32 on a Unix host creates this mismatch — it's a test setup issue, not a runtime bug.

P2 (test assertions with canonicalized paths): Acknowledged as a test maintenance issue on macOS with symlinked /private/var/var. The tests work on CI (Ubuntu/Windows).

Note: The CI test failure on this PR appears to be from the known flaky bun binary download issue, not from our changes.

@code-yeongyu code-yeongyu added the triage:bug-fix PR: Bug fix label Mar 24, 2026
…arsing

Scoped packages like @scope/pkg were truncated to just 'pkg' because
basename() strips the scope prefix. Fix:
- Detect scoped packages (starting with @) and find version separator
  after the scope slash, not at the leading @
- Return full scoped name (@scope/pkg) instead of calling basename
- Add regression test for scoped package name preservation
@MoerAI
Copy link
Copy Markdown
Contributor Author

MoerAI commented Mar 25, 2026

Fixed P1 from cubic review: scoped npm package names are no longer truncated.

Root cause: basename("@scope/pkg") returns just pkg, losing the scope prefix. Also, lastIndexOf("@") for version stripping could find the scope @ instead of the version @.

Fix:

  1. For scoped packages (starting with @), find the version separator @ only after the scope slash — prevents stripping the scope itself
  2. Return full @scope/pkg name instead of calling basename() when the key is a scoped package

Added regression test: @myorg/[email protected] now correctly derives name @myorg/my-plugin.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

@code-yeongyu code-yeongyu merged commit 32f2c68 into code-yeongyu:dev Mar 25, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage:bug-fix PR: Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Windows symlinked ~/.config directory causes bunx version mismatch and plugin name parsing issues

2 participants