From 4b07f24d8645432963363501175467e67bbdaab7 Mon Sep 17 00:00:00 2001 From: Iouri Goussev Date: Sun, 29 Mar 2026 15:18:02 -0400 Subject: [PATCH] fix(gsd): discoverManifests skips symlinked extension directories Dirent.isDirectory() returns false for symbolic links, so extensions installed as directory symlinks under ~/.gsd/agent/extensions/ were invisible to all management commands (list, enable, disable, info). Apply the same guard already used in loader.ts discoverExtensionsInDir: entry.isDirectory() || entry.isSymbolicLink() Closes igouss/gsd-2#20 --- .../extensions/gsd/commands-extensions.ts | 2 +- .../tests/symlink-extension-discovery.test.ts | 125 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/symlink-extension-discovery.test.ts diff --git a/src/resources/extensions/gsd/commands-extensions.ts b/src/resources/extensions/gsd/commands-extensions.ts index e63f90405..05b867e4f 100644 --- a/src/resources/extensions/gsd/commands-extensions.ts +++ b/src/resources/extensions/gsd/commands-extensions.ts @@ -105,7 +105,7 @@ function discoverManifests(): Map { const manifests = new Map(); if (!existsSync(extDir)) return manifests; for (const entry of readdirSync(extDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; const m = readManifest(join(extDir, entry.name)); if (m) manifests.set(m.id, m); } diff --git a/src/resources/extensions/gsd/tests/symlink-extension-discovery.test.ts b/src/resources/extensions/gsd/tests/symlink-extension-discovery.test.ts new file mode 100644 index 000000000..a420b679b --- /dev/null +++ b/src/resources/extensions/gsd/tests/symlink-extension-discovery.test.ts @@ -0,0 +1,125 @@ +// Regression test for: discoverManifests() skips symlinked extension directories +// +// The bug: Dirent.isDirectory() returns false for symlinks, so extensions installed +// as directory symlinks under ~/.gsd/agent/extensions/ were invisible to all +// management commands (list, enable, disable, info). +// +// The fix: check `entry.isDirectory() || entry.isSymbolicLink()`, matching the +// pattern already used in loader.ts discoverExtensionsInDir(). + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + symlinkSync, + readdirSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Inline the discovery logic so the test is self-contained and can verify both +// the buggy and fixed behaviour without importing the private function. +function discoverManifestsBuggy(extDir: string): string[] { + const found: string[] = []; + if (!existsSync(extDir)) return found; + for (const entry of readdirSync(extDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; // BUG: skips symlinks + const mPath = join(extDir, entry.name, "extension-manifest.json"); + if (existsSync(mPath)) found.push(entry.name); + } + return found; +} + +function discoverManifestsFixed(extDir: string): string[] { + const found: string[] = []; + if (!existsSync(extDir)) return found; + for (const entry of readdirSync(extDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; // FIX + const mPath = join(extDir, entry.name, "extension-manifest.json"); + if (existsSync(mPath)) found.push(entry.name); + } + return found; +} + +const MANIFEST = JSON.stringify({ + id: "test-ext", + name: "Test Extension", + version: "1.0.0", + description: "A test extension", + tier: "community", + requires: { platform: "linux" }, +}); + +describe("symlink extension discovery", () => { + let tmp: string; + let extDir: string; + let realExtDir: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "gsd-ext-test-")); + extDir = join(tmp, "agent", "extensions"); + realExtDir = join(tmp, "my-ext-source"); + + // Create the real extension directory outside extDir (simulates a dev checkout) + mkdirSync(realExtDir, { recursive: true }); + writeFileSync(join(realExtDir, "extension-manifest.json"), MANIFEST, "utf-8"); + + // Create the extensions scan directory + mkdirSync(extDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test("real directory is discovered by both implementations", () => { + // Install extension as a real directory copy + const realCopy = join(extDir, "my-ext"); + mkdirSync(realCopy); + writeFileSync(join(realCopy, "extension-manifest.json"), MANIFEST, "utf-8"); + + assert.deepEqual(discoverManifestsBuggy(extDir), ["my-ext"]); + assert.deepEqual(discoverManifestsFixed(extDir), ["my-ext"]); + }); + + test("symlinked directory is missed by buggy implementation", () => { + // Install extension as a directory symlink — the common dev workflow + symlinkSync(realExtDir, join(extDir, "my-ext")); + + // Buggy: symlink is invisible + assert.deepEqual(discoverManifestsBuggy(extDir), []); + }); + + test("symlinked directory is discovered by fixed implementation", () => { + symlinkSync(realExtDir, join(extDir, "my-ext")); + + // Fixed: symlink is visible + assert.deepEqual(discoverManifestsFixed(extDir), ["my-ext"]); + }); + + test("non-manifest symlinks are ignored", () => { + // Symlink to a dir that has no manifest — should not appear + const noManifestDir = join(tmp, "no-manifest"); + mkdirSync(noManifestDir); + symlinkSync(noManifestDir, join(extDir, "no-manifest")); + + assert.deepEqual(discoverManifestsFixed(extDir), []); + }); + + test("mix of real dirs and symlinks are all discovered", () => { + // Real dir + const realCopy = join(extDir, "ext-real"); + mkdirSync(realCopy); + writeFileSync(join(realCopy, "extension-manifest.json"), MANIFEST, "utf-8"); + + // Symlink dir + symlinkSync(realExtDir, join(extDir, "ext-symlink")); + + const found = discoverManifestsFixed(extDir).sort(); + assert.deepEqual(found, ["ext-real", "ext-symlink"]); + }); +});