Skip to content

plugin: a local plugin whose directory symlink is retargeted is silently dropped until restart #53763

Open

Description

@nkoynov

Description

A plugin in ~/.config/opencode/plugins/ that is a symlink to a directory disappears from a running server once the symlink is pointed at another directory (as nix/home-manager, stow or a dotfiles script do on update). The next plugin reload in each Location, and every Location created afterwards, leaves it out: no "failed" entry in /api/plugin, no log line. When the plugin registers a provider, every turn then fails with "Model unavailable". A restart loads the new target fine.

Cause: ConfigPluginSource.scan (packages/core/src/config/plugin/source.ts) drops a directory plugin unless FSUtil.contains(fs.resolve(target), fs.resolve(entry)). fs.resolve is a fresh realpathSync and sees the new target, but entry comes from Host.resolve → Bun.resolveSync, and Bun caches a symlinked directory's resolution for the life of the process, so it still points into the old target. With Bun 1.4.2:

// plugins/x -> A
Bun.resolveSync(`${dir}/index`, dir) // A/index.js
// ln -sfn B plugins/x
Bun.resolveSync(`${dir}/index`, dir) // still A/index.js; realpathSync(dir) is B

Resolving the entry from the directory's real path (Host.resolve({ directory: fs.resolve(target) }), in scan and in PluginModule.load) would make both sides agree, and a plugin the containment check rejects could be reported as failed rather than dropped. Related: #52240 (the same Bun caching, for in-place edits under a symlinked config directory).

OpenCode version

2.0.24 (Bun 1.4.2)

Steps to reproduce

  1. Two directories /tmp/p/A and /tmp/p/B, each with index.js: export default { id: "demo", setup() {} }.
  2. ln -s /tmp/p/A ~/.config/opencode/plugins/demo, start opencode serve, open a session; GET /api/plugin lists demo.
  3. ln -sfn /tmp/p/B ~/.config/opencode/plugins/demo.
  4. GET /api/plugin no longer lists demo; a session in a new directory doesn't have it either.

Operating System

NixOS 26.11

Activity

  1. github-actions commented on Oct 7, 2026

    @github-actions
    Contributor

    This issue might be related to existing issues. Please check:

    • #52240: reports stale plugin reloads when the config directory is symlinked, caused by Bun module-cache resolution.
    • #51602: reports the same symlink-path versus real-path Bun module-cache mismatch for symlinked local plugin files.
  2. opencode-agent commented on Oct 7, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on v2.0.24:

    1. Created /tmp/p/A and /tmp/p/B, each with index.js exporting { id: "demo", setup() {} }, and symlinked ~/.config/opencode/plugins/demo -> /tmp/p/A.
    2. Started opencode2 serve. GET /api/plugin listed demo (local, /tmp/p/A/index.js, active).
    3. Ran ln -sfn /tmp/p/B ~/.config/opencode/plugins/demo.
    4. After the reload, demo was gone from /api/plugin for the existing directory, and a new directory didn't have it either. There was no failed entry and no log line.
    5. After restarting the server, demo loaded from /tmp/p/B/index.js as expected.

    This fits your analysis: the entrypoint still resolves into the old target while the realpath of the directory points at the new one, so the containment check in ConfigPluginSource.scan drops the plugin without reporting anything.

  3. argszero commented on Oct 8, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I read the scan path: ConfigPluginSource.scan builds the host entrypoints from operation.target (the symlink) and then checks them with FSUtil.contains(fs.resolve(operation.target), fs.resolve(entrypoint)). The entrypoint comes from Host.resolve -> Bun.resolveSync, which keeps the symlinked directory's resolution cached for the life of the process, while fs.resolve is a fresh realpathSync that sees the new target. After a retarget the two sides disagree, the containment check rejects the plugin, and it is dropped with no failed entry and no log line. The same shape is at packages/core/src/plugin/module.ts:115.

    Plan: resolve the plugin directory to its real path before Host.resolve at both call sites, so the entrypoint resolution and the containment check are computed against the same directory, and make a plugin rejected by the containment check show up as failed rather than silently omitted. I'll add a regression test that retargets a directory symlink and asserts the plugin stays listed.

    Targeting v2, since packages/core/src/config/plugin/source.ts does not exist on dev.

  4. nkoynov commented on Oct 8, 2026

    @nkoynov
    Author

    That matches what I found. Once the PR is up I can test it on NixOS against the home-manager setup where this bit us.

  5. argszero commented on Oct 8, 2026

    @argszero
    Contributor

    Opened #53945 for this (base v2).

    The fix resolves the plugin directory before asking the runtime for its entrypoints, and passes the resolved path to both the entrypoint resolution and the containment check, so the two can no longer disagree after a retarget. The same resolution is needed in PluginModule.load, which resolves the entrypoint again when loading — without it a retargeted plugin would keep loading the previous target's code. I also added a log line to the containment-rejection path, since it drops a plugin that exists and lives inside the config. It should now say why.

    The regression test repoints a directory symlink and asserts the plugin stays listed across two scans; it fails on the unmodified source (the second scan returns nothing).

    @nkoynov you mentioned you could test this against the home-manager setup where it bit you — a build of that branch would be the strongest confirmation, since my repro is a bare symlink retarget.

  6. nkoynov commented on Oct 8, 2026

    @nkoynov
    Author

    Tested both versions of #53945 in a NixOS VM, with a direct retarget and a home-manager style two-link chain: the base drops the plugin, the PR keeps it and runs the new target. Details on the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions