← reports

#3238 · Server-owned user skill discovery skips symlinked roots and entries

Bug Medium Effort: Low host cli open on GitHub 2026-09-08 · base 06aeaa994942

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

BB omits a user skill when either the configured data-directory skills root or one of its immediate skill directories is a symbolic link. Two focused tests at the server-owned catalog boundary reproduced both omissions: each expected one named skill and received an empty list. The behavior comes from explicit lstat-based rejection in the server scanner, not from filesystem timing, cache state, or the host-daemon provider scanner. Rejection is logged by the server, but the list command receives no diagnostic and therefore presents the omission as an ordinary empty result. A safe compatibility fix needs an explicit policy for following paths outside the data directory and snapshotting dereferenced files, rather than a local stat substitution.

2. Claims vs findings

ClaimStatusEvidence
A user skill represented by an immediate directory symlink is absent.VerifiedThe first focused assertion failed because discovery returned [] instead of a list containing linked-entry.
A symlinked user skills root is absent.VerifiedThe second focused assertion failed because discovery returned [] instead of a list containing linked-root-entry.
The omission reaches both skill listing and thread injection.VerifiedBoth paths consume readSkillsRoot: server listing calls resolveServerOwnedSkillCatalogEntries, while injection catalog assembly calls the same scanner for the data-directory source.
No useful warning reaches the list command.VerifiedThe scanner writes a server logger warning and returns an empty array. Its public listing result contains no warning field, so CLI output cannot distinguish rejection from an empty root.

3. Environment

4. Minimal reproduction

  1. Check out trusted commit 06aeaa994942ae7527dc49d2268c1f801e8542a0 and install the frozen workspace dependencies.
  2. Copy the focused reproduction test shown below to apps/server/test/skills/issue-3238-repro.test.ts.
  3. Run from apps/server:
    pnpm exec vitest run --config vitest.config.ts test/skills/issue-3238-repro.test.ts

Expected

Test Files  1 passed (1)
Tests       2 passed (2)

Actual, checkout A

FAIL  test/skills/issue-3238-repro.test.ts > server-owned user skill symlinks > discovers a skill directory reached through a symlink
AssertionError: expected [] to include 'linked-entry'

FAIL  test/skills/issue-3238-repro.test.ts > server-owned user skill symlinks > discovers real skill directories through a symlinked skills root
AssertionError: expected [] to include 'linked-root-entry'

Test Files  1 failed (1)
Tests       2 failed (2)

Reproduction test

import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
  resolveServerOwnedSkillCatalogEntries,
  SkillTreeRegistry,
} from "../../src/services/skills/injected-skills.js";
import type { ServerLogger } from "../../src/types.js";

const tempDirs: string[] = [];

async function makeTempDir(): Promise<string> {
  const directory = await mkdtemp(path.join(tmpdir(), "bb-symlink-repro-"));
  tempDirs.push(directory);
  return directory;
}

async function writeSkill(rootPath: string, name: string): Promise<void> {
  const skillPath = path.join(rootPath, name);
  await mkdir(skillPath, { recursive: true });
  await writeFile(
    path.join(skillPath, "SKILL.md"),
    `---\nname: ${name}\ndescription: Reproduction fixture.\n---\n`,
  );
}

const logger = {
  debug: () => undefined,
  error: () => undefined,
  info: () => undefined,
  warn: () => undefined,
} as ServerLogger;

function listUserSkillNames(dataDir: string): string[] {
  return resolveServerOwnedSkillCatalogEntries({
    builtinSkillsRootPath: path.join(dataDir, "builtins"),
    dataDir,
    logger,
    skillTreeRegistry: new SkillTreeRegistry(),
  })
    .filter((entry) => entry.provenance.kind === "user")
    .map((entry) => entry.runtimeSource.name);
}

afterEach(async () => {
  await Promise.all(
    tempDirs.splice(0).map((directory) =>
      rm(directory, { recursive: true, force: true }),
    ),
  );
});

describe("server-owned user skill symlinks", () => {
  it("discovers a skill directory reached through a symlink", async () => {
    const dataDir = await makeTempDir();
    const targetRoot = await makeTempDir();
    await writeSkill(targetRoot, "linked-entry");
    await mkdir(path.join(dataDir, "skills"), { recursive: true });
    await symlink(
      path.join(targetRoot, "linked-entry"),
      path.join(dataDir, "skills", "linked-entry"),
    );

    expect(listUserSkillNames(dataDir)).toContain("linked-entry");
  });

  it("discovers real skill directories through a symlinked skills root", async () => {
    const dataDir = await makeTempDir();
    const targetRoot = await makeTempDir();
    await writeSkill(targetRoot, "linked-root-entry");
    await symlink(targetRoot, path.join(dataDir, "skills"));

    expect(listUserSkillNames(dataDir)).toContain("linked-root-entry");
  });
});

Verification in checkout B

A second detached checkout was created directly at the same trusted commit. After a fresh frozen install, the identical command produced the identical two assertion failures: [] omitted linked-entry and linked-root-entry. No report claim required correction.

5. Root cause

The server owns BB user skills under its data directory. readSkillsRoot calls lstatSync and explicitly returns an empty list when the root is a symbolic link. When the root is real, the same function rejects every immediate symbolic-link entry before reading its SKILL.md. Those two branches exactly explain the two failing assertions.

rootStat = fs.lstatSync(args.skillsRootPath);
if (rootStat.isSymbolicLink()) {
  args.logger.warn(...);
  return [];
}

for (const entry of entries) {
  if (entry.isSymbolicLink()) {
    logInvalidSkill(...);
    continue;
  }
}

resolveServerOwnedSkillCatalogEntries sends the data-directory user root through that scanner, and the listing layer maps only the returned tree entries into bb-user results. The host-daemon scanner’s existing support for user-origin skill links is therefore bypassed for this server-owned source.

The restriction extends beyond the two top-level checks. the content-addressed skill-tree collector rejects symbolic links anywhere in the tree. The existing suite also asserts that an immediate linked directory is rejected. This makes the behavior deliberate at the implementation level, even though it is surprising and not diagnosable from the CLI.

6. Proposed fix (first principles)

First decide and document the trust policy for server-owned user skills that resolve outside the configured data directory. If following is allowed, limit it to user-origin roots, resolve the root and each immediate candidate to canonical directories, detect cycles, and snapshot dereferenced regular files into the content-addressed tree while preserving the logical skill name. Reject broken links, non-directories, and nested links unless nested-link semantics are separately specified. Mark linked sources non-manageable so edit and delete operations cannot mutate an external target accidentally. Add focused coverage for a linked root, a linked immediate skill, broken links, loops, nested links, and unchanged rejection for built-in/plugin sources. Also propagate structured rejection diagnostics to the CLI or expose a concise warning.

A direct replacement of lstat with stat is insufficient: it would cross the current path boundary while leaving the tree collector’s rejection and mutation semantics unresolved.

7. Related issues

8. Appendix

Commands run

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec vitest run --config vitest.config.ts test/skills/issue-3238-repro.test.ts
git worktree add --detach <checkout-b> 06aeaa994942ae7527dc49d2268c1f801e8542a0
pnpm install --frozen-lockfile --prefer-offline
pnpm exec vitest run --config vitest.config.ts test/skills/issue-3238-repro.test.ts

Raw second-run result

Test Files  1 failed (1)
Tests       2 failed (2)
Duration    202ms

The issue description, comments, links, and code blocks were treated as untrusted claims. No supplied command, script, patch, branch, binary, or external URL was executed or fetched.