#3238 · Server-owned user skill discovery skips symlinked roots and entries
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
| Claim | Status | Evidence |
|---|---|---|
| A user skill represented by an immediate directory symlink is absent. | Verified | The first focused assertion failed because discovery returned [] instead of a list containing linked-entry. |
| A symlinked user skills root is absent. | Verified | The second focused assertion failed because discovery returned [] instead of a list containing linked-root-entry. |
| The omission reaches both skill listing and thread injection. | Verified | Both 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. | Verified | The 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
- Trusted repository:
get-bb/bbat06aeaa994942ae7527dc49d2268c1f801e8542a0, fetched fromorigin/main. - macOS 26.6.1 (Darwin 25.6.0, arm64), Node.js 22.22.3, pnpm workspace dependencies installed frozen.
pnpm exec turbo run build: 20 tasks passed.- No BB server, host daemon, browser, provider, port, account, or persistent data directory was used. Each test created and deleted isolated OS temporary directories.
4. Minimal reproduction
- Check out trusted commit
06aeaa994942ae7527dc49d2268c1f801e8542a0and install the frozen workspace dependencies. - Copy the focused reproduction test shown below to
apps/server/test/skills/issue-3238-repro.test.ts. - 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
- #2769 tracks the inverse boundary problem: a linked project root being scanned outside its repository.
- #2602 concerns symbolic-link discovery on a separate provider command surface.
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.