diff --git a/apps/host-daemon/src/command-discovery.ts b/apps/host-daemon/src/command-discovery.ts index 9a9c16579d..592806f4bc 100644 --- a/apps/host-daemon/src/command-discovery.ts +++ b/apps/host-daemon/src/command-discovery.ts @@ -10,6 +10,7 @@ import type { HostProviderCommand, SkillRootKind, } from "@bb/host-daemon-contract"; +import { hashInstalledSkillDirectory } from "./injected-skills.js"; const SKILL_FILE_NAME = "SKILL.md"; const MARKDOWN_FILE_EXTENSION = ".md"; @@ -505,19 +506,28 @@ export type SkillScanRoot = CommandScanRoot & { }; interface DiscoverSkillsArgs { + includeContentHashes?: boolean; roots: readonly SkillScanRoot[]; } -function buildSkillRecord( +async function buildSkillRecord( root: SkillScanRoot, match: SkillFileMatch, -): DiscoveredSkill { + includeContentHashes: boolean, +): Promise { const rootPath = "rootPath" in root ? root.rootPath : path.dirname(root.filePath); const logicalPath = path .relative(rootPath, match.filePath) .split(path.sep) .join("/"); + const contentHash = + includeContentHashes + ? await hashInstalledSkillDirectory({ + name: match.name, + skillDirectoryPath: path.dirname(match.filePath), + }) + : null; return { id: `skill_${createHash("sha256") .update(`${root.identitySeed}\0${logicalPath}`) @@ -527,6 +537,7 @@ function buildSkillRecord( filePath: match.filePath, rootKind: root.rootKind, linked: match.linked, + ...(contentHash === null ? {} : { contentHash }), }; } @@ -537,7 +548,13 @@ export async function discoverSkills( const budget = { remainingEntries: MAX_SCAN_ENTRY_COUNT }; for (const root of args.roots) { for (const match of await scanSkillFiles({ budget, root })) { - records.push(buildSkillRecord(root, match)); + records.push( + await buildSkillRecord( + root, + match, + args.includeContentHashes === true, + ), + ); } } const uniqueRecords: DiscoveredSkill[] = []; diff --git a/apps/host-daemon/src/command-handlers/list-skills.test.ts b/apps/host-daemon/src/command-handlers/list-skills.test.ts index 1a5834a899..6a11a4af99 100644 --- a/apps/host-daemon/src/command-handlers/list-skills.test.ts +++ b/apps/host-daemon/src/command-handlers/list-skills.test.ts @@ -153,6 +153,56 @@ describe("resolveSkillScanRoots + discoverSkills", () => { expect(byName(skills, "user-agent")?.filePath).toBe(files["user-agent"]); }); + it("adds a whole-skill content hash only when requested", async () => { + const fixture = await makeWorkspaceFixture(); + const skillFilePath = path.join( + fixture.homeDir, + ".agent", + "skills", + "review", + "SKILL.md", + ); + await writeSkill(skillFilePath, "review"); + const roots = await resolveSkillScanRoots({ + providerId: "test-provider", + cwd: fixture.cwd, + homeDir: fixture.homeDir, + nativeRoots: AGENT_SKILL_ROOTS, + }); + + const unrequested = await discoverSkills({ roots }); + expect(byName(unrequested, "review")?.contentHash).toBeUndefined(); + + const first = await discoverSkills({ + roots, + includeContentHashes: true, + }); + const firstHash = byName(first, "review")?.contentHash; + expect(firstHash).toMatch(/^[a-f0-9]{64}$/u); + + await writeFile( + path.join(path.dirname(skillFilePath), ".bb-registry-skill.json"), + "{\"registry\":\"metadata\"}\n", + "utf8", + ); + const withRegistryMetadata = await discoverSkills({ + roots, + includeContentHashes: true, + }); + expect(byName(withRegistryMetadata, "review")?.contentHash).toBe(firstHash); + + await writeFile( + path.join(path.dirname(skillFilePath), "references.md"), + "changed reference content\n", + "utf8", + ); + const second = await discoverSkills({ + roots, + includeContentHashes: true, + }); + expect(byName(second, "review")?.contentHash).not.toBe(firstHash); + }); + it("keeps native skill IDs stable when the workspace root moves", async () => { const firstRoot = path.join(tempRoot, "checkout-a", ".bb", "skills"); const secondRoot = path.join(tempRoot, "checkout-b", ".bb", "skills"); diff --git a/apps/host-daemon/src/command-handlers/list-skills.ts b/apps/host-daemon/src/command-handlers/list-skills.ts index e3ff74e3c6..3aa3268d3d 100644 --- a/apps/host-daemon/src/command-handlers/list-skills.ts +++ b/apps/host-daemon/src/command-handlers/list-skills.ts @@ -114,7 +114,10 @@ export async function listHostSkills( providerId: command.providerId, nativeRoots: command.nativeRoots, }); - const skills = await discoverSkills({ roots }); + const skills = await discoverSkills({ + roots, + includeContentHashes: command.includeContentHashes === true, + }); return { skills }; } diff --git a/apps/host-daemon/src/injected-skills.ts b/apps/host-daemon/src/injected-skills.ts index 2fbb3cb3f0..0c7d144f57 100644 --- a/apps/host-daemon/src/injected-skills.ts +++ b/apps/host-daemon/src/injected-skills.ts @@ -17,6 +17,7 @@ const STORE_LAST_USED_MARKER = ".last-used"; export const MAX_SKILL_STORE_TREES = 64; const STALE_TEMP_STAGING_DIR_AGE_MS = 60 * 60 * 1000; const SKILL_FILE_NAME = "SKILL.md"; +const REGISTRY_SKILL_PROVENANCE_FILE_NAME = ".bb-registry-skill.json"; const SKILL_NAME_PATTERN = /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u; const MAX_STAGED_SKILL_FILES = 1_000; const MAX_STAGED_SKILL_BYTES = 10 * 1024 * 1024; @@ -274,6 +275,12 @@ async function walkSkillTree(args: WalkSkillTreeArgs): Promise { ).sort(sortDirentsByName); for (const entry of entries) { + if ( + args.currentPath === args.rootPath && + entry.name === REGISTRY_SKILL_PROVENANCE_FILE_NAME + ) { + continue; + } const sourcePath = path.join(args.currentPath, entry.name); if (!isPathWithinRoot(args.rootPath, sourcePath)) { throw new Error(`Skill tree entry escapes source root: ${sourcePath}`); diff --git a/apps/server/src/services/providers/native-roots.ts b/apps/server/src/services/providers/native-roots.ts index f1c5145e5c..6386db87f7 100644 --- a/apps/server/src/services/providers/native-roots.ts +++ b/apps/server/src/services/providers/native-roots.ts @@ -108,7 +108,8 @@ export function createProviderListingBudget( } export type ProviderNativeRootsDeps = WorkSessionDeps & - Pick; + Pick & + Partial>; export function providerHasNativeRootSurface( registration: ProviderRegistration, @@ -186,6 +187,9 @@ export async function resolveProviderResolvedNativeRoots( if (!registration.resolvesNativeRoots) { return EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS; } + if (deps.providerNativeRoots === undefined) { + return callResolveNativeRoots(deps, args); + } const pluginId = registration.pluginId; const key = cacheKey({ pluginId, @@ -236,6 +240,7 @@ type ProviderNativeRootScanResult = >; interface ScanProviderNativeRootsArgs { + includeContentHashes?: boolean; registration: ProviderRegistration; hostId: string; cwd: string | null; @@ -271,6 +276,12 @@ export async function scanProviderNativeRoots( command: args.type === "host.list_commands" ? { type: "host.list_commands", ...scan } - : { type: "host.list_skills", ...scan }, + : { + type: "host.list_skills", + ...scan, + ...(args.includeContentHashes === true + ? { includeContentHashes: true } + : {}), + }, }); } diff --git a/apps/server/src/services/skills/shared-skills.ts b/apps/server/src/services/skills/shared-skills.ts index d62c779840..3774d51e27 100644 --- a/apps/server/src/services/skills/shared-skills.ts +++ b/apps/server/src/services/skills/shared-skills.ts @@ -8,14 +8,89 @@ import type { DiscoveredSkill } from "@bb/host-daemon-contract"; import type { SkillSummary } from "@bb/server-contract"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import type { LoggedWorkSessionDeps } from "../../types.js"; +import type { ServerLogger } from "../../types.js"; import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; import type { SharedInjectedSkillSource } from "./injected-skills.js"; +import type { ResolvedSkillCatalogEntry } from "./injected-skills.js"; interface ResolvedSharedSkills { runtimeSources: SharedInjectedSkillSource[]; summaries: SkillSummary[]; } +function contentHashForRuntimeSource( + source: ResolvedSkillCatalogEntry["runtimeSource"], +): string | null { + if (source.kind === "tree") return source.treeHash; + if (source.kind === "host-path") return source.contentHash ?? null; + return null; +} + +export function excludeNativeSkillDuplicates( + logger: ServerLogger, + args: { + nativeSkills: readonly DiscoveredSkill[]; + providerId: string; + skillCatalog: readonly ResolvedSkillCatalogEntry[]; + }, +): ResolvedSkillCatalogEntry[] { + const nativeHashesByName = new Map>(); + for (const skill of args.nativeSkills) { + if ( + skill.rootKind !== "provider-project" && + skill.rootKind !== "provider-user" + ) { + continue; + } + if (skill.contentHash === undefined) continue; + const hashes = nativeHashesByName.get(skill.name) ?? new Set(); + hashes.add(skill.contentHash); + nativeHashesByName.set(skill.name, hashes); + } + + return args.skillCatalog.filter((entry) => { + if (entry.provenance.kind === "project") return true; + const sourceHash = contentHashForRuntimeSource(entry.runtimeSource); + if (sourceHash === null) return true; + const nativeHashes = nativeHashesByName.get(entry.runtimeSource.name); + if (nativeHashes === undefined) { + return true; + } + if (nativeHashes.has(sourceHash)) { + if (nativeHashes.size > 1) { + logger.warn( + { + contentHash: sourceHash, + name: entry.runtimeSource.name, + nativeContentHashes: [...nativeHashes].sort(), + providerId: args.providerId, + }, + "Provider native skills have conflicting content; suppressing identical injected skill", + ); + } + logger.debug( + { + contentHash: sourceHash, + name: entry.runtimeSource.name, + providerId: args.providerId, + }, + "Injected skill already loaded from provider native roots", + ); + return false; + } + logger.warn( + { + injectedContentHash: sourceHash, + name: entry.runtimeSource.name, + nativeContentHashes: [...nativeHashes].sort(), + providerId: args.providerId, + }, + "Injected skill conflicts with a provider native skill; preserving both", + ); + return true; + }); +} + export function hostPathDirname(filePath: string): string { return /^[a-zA-Z]:[\\/]/u.test(filePath) ? path.win32.dirname(filePath) @@ -48,6 +123,9 @@ function toSharedSkill( runtimeSource: { kind: "host-path", sourceType, + ...(skill.contentHash === undefined + ? {} + : { contentHash: skill.contentHash }), name: skill.name, description: skill.description, sourceRootPath: hostPathDirname(skill.filePath), @@ -69,7 +147,11 @@ function toSharedSkill( export async function resolveSharedSkills( deps: LoggedWorkSessionDeps, - args: { hostId: string; cwd: string | null }, + args: { + cwd: string | null; + hostId: string; + includeContentHashes?: boolean; + }, ): Promise { const roots = deps.config.sharedSkillRoots; if (roots.user.length === 0 && roots.project.length === 0) { @@ -82,6 +164,9 @@ export async function resolveSharedSkills( type: "host.list_skills", providerId: "bb-shared", cwd: args.cwd, + ...(args.includeContentHashes === true + ? { includeContentHashes: true } + : {}), nativeRoots: { skills: normalizeProviderNativeRoots(roots), commands: EMPTY_PROVIDER_NATIVE_ROOTS, diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts index ebd705fe70..38c5f8c2c9 100644 --- a/apps/server/src/services/threads/thread-runtime-config.ts +++ b/apps/server/src/services/threads/thread-runtime-config.ts @@ -35,7 +35,14 @@ import { import { resolveSkillCatalog } from "../skills/skill-catalog.js"; import { discoverPluginSkillIds } from "../skills/injected-skills.js"; import { resolveWorkspaceProjectSkills } from "../skills/workspace-skills.js"; -import { resolveSharedSkills } from "../skills/shared-skills.js"; +import { + excludeNativeSkillDuplicates, + resolveSharedSkills, +} from "../skills/shared-skills.js"; +import { + providerHasNativeRootSurface, + scanProviderNativeRoots, +} from "../providers/native-roots.js"; import { UPDATE_ENVIRONMENT_DIRECTORY_TOOL } from "./thread-environment-directory.js"; import { DATA_DIR_AGENT_INSTRUCTIONS_RELATIVE_PATH, @@ -180,6 +187,7 @@ export async function resolveThreadRuntimeCommandConfig( resolveSharedSkills(deps, { hostId: args.environment.hostId, cwd: workspacePath, + includeContentHashes: true, }), readWorkspaceAgentInstructions(deps, { hostId: args.environment.hostId, @@ -237,11 +245,51 @@ export async function resolveThreadRuntimeCommandConfig( hostId: host.id, }, }); - const injectedSkillSources = resolveSkillCatalog(deps, { + const skillCatalog = resolveSkillCatalog(deps, { projectSkillSources, sharedSkillSources: sharedSkills.runtimeSources, pluginSkillSelections: conditionalConfiguration.selectedSkillIdsByPlugin, - }).map((entry) => entry.runtimeSource); + }); + const providerRegistration = deps.providerRegistry.get(args.thread.providerId); + const nativeSkills = + providerRegistration === null || + !providerHasNativeRootSurface(providerRegistration) || + !skillCatalog.some( + (entry) => + entry.provenance.kind !== "project" && + (entry.runtimeSource.sourceType === "data-dir" || + entry.runtimeSource.sourceType === "shared-project" || + entry.runtimeSource.sourceType === "shared-user"), + ) + ? null + : await scanProviderNativeRoots(deps, { + type: "host.list_skills", + includeContentHashes: true, + registration: providerRegistration, + hostId: args.environment.hostId, + cwd: workspacePath, + }) + .then((result) => result.skills) + .catch((error) => { + deps.logger.warn( + { + err: error, + providerId: args.thread.providerId, + threadId: args.thread.id, + }, + "Unable to inspect provider native skill content; retaining injected skills", + ); + return null; + }); + const injectedSkillSources = ( + nativeSkills === null + ? skillCatalog + : excludeNativeSkillDuplicates(deps.logger, { + nativeSkills, + providerId: args.thread.providerId, + skillCatalog, + }) + ).map((entry) => entry.runtimeSource); const dataDirAgentInstructions = readDataDirAgentInstructions( deps.logger, deps.config.dataDir, diff --git a/apps/server/test/helpers/commands.ts b/apps/server/test/helpers/commands.ts index 4212f35c8f..2498ca77eb 100644 --- a/apps/server/test/helpers/commands.ts +++ b/apps/server/test/helpers/commands.ts @@ -271,6 +271,51 @@ function respondToProviderModelListCommand( return true; } +function respondToSkillListCommand( + deps: Pick, + args: RegisterTestHostRpcCaptureArgs, + message: HostDaemonOnlineRpcRequestMessage, +): boolean { + if (message.command.type !== "host.list_skills") return false; + + deps.hub.recordHostOnlineRpcResponse({ + message: hostDaemonOnlineRpcResponseMessageSchema.parse({ + type: "host-rpc.response", + requestId: message.requestId, + commandType: message.command.type, + ok: true, + result: { skills: [] }, + }), + sessionId: args.sessionId, + }); + return true; +} + +function respondToProviderNativeRootsCommand( + deps: Pick, + args: RegisterTestHostRpcCaptureArgs, + message: HostDaemonOnlineRpcRequestMessage, +): boolean { + if ( + message.command.type !== "plugin.host.call" || + message.command.method !== "resolveNativeRoots" + ) { + return false; + } + + deps.hub.recordHostOnlineRpcResponse({ + message: hostDaemonOnlineRpcResponseMessageSchema.parse({ + type: "host-rpc.response", + requestId: message.requestId, + commandType: message.command.type, + ok: true, + result: { output: { skills: [], commands: [] } }, + }), + sessionId: args.sessionId, + }); + return true; +} + function buildDefaultGitSourceInspectionResult(): HostDaemonOnlineRpcResult<"host.inspect_git_source"> { return { checkout: { @@ -398,6 +443,12 @@ export function registerTestHostRpcCapture( if (respondToRuntimeWorkspaceFileCommand(deps, args, message)) { return; } + if (respondToProviderNativeRootsCommand(deps, args, message)) { + return; + } + if (respondToSkillListCommand(deps, args, message)) { + return; + } if (respondToProviderModelListCommand(deps, args, message)) { return; } diff --git a/apps/server/test/skills/shared-skills.test.ts b/apps/server/test/skills/shared-skills.test.ts new file mode 100644 index 0000000000..85f60f985d --- /dev/null +++ b/apps/server/test/skills/shared-skills.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DiscoveredSkill } from "@bb/host-daemon-contract"; +import type { ServerLogger } from "../../src/types.js"; +import { excludeNativeSkillDuplicates } from "../../src/services/skills/shared-skills.js"; + +function discoveredSkill(args: { + contentHash?: string; + filePath: string; + name: string; + rootKind: DiscoveredSkill["rootKind"]; +}): DiscoveredSkill { + return { + ...(args.contentHash === undefined + ? {} + : { contentHash: args.contentHash }), + id: `skill_${"a".repeat(64)}`, + name: args.name, + description: "Test skill.", + filePath: args.filePath, + rootKind: args.rootKind, + linked: false, + }; +} + +describe("excludeNativeSkillDuplicates", () => { + it("removes a non-project injected skill with the same whole-tree hash", () => { + const logger: ServerLogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + const contentHash = "b".repeat(64); + const result = excludeNativeSkillDuplicates(logger, { + providerId: "codex", + nativeSkills: [ + discoveredSkill({ + contentHash, + filePath: "/home/test/.agents/skills/coder/SKILL.md", + name: "coder", + rootKind: "provider-user", + }), + ], + skillCatalog: [ + { + provenance: { kind: "user" }, + runtimeSource: { + kind: "tree", + sourceType: "data-dir", + name: "coder", + description: "Test skill.", + treeHash: contentHash, + entryPath: "SKILL.md", + }, + }, + ], + }); + + expect(result).toEqual([]); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("preserves and warns about a same-name skill with different content", () => { + const logger: ServerLogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + const result = excludeNativeSkillDuplicates(logger, { + providerId: "codex", + nativeSkills: [ + discoveredSkill({ + contentHash: "b".repeat(64), + filePath: "/home/test/.codex/skills/coder/SKILL.md", + name: "coder", + rootKind: "provider-user", + }), + ], + skillCatalog: [ + { + provenance: { kind: "user" }, + runtimeSource: { + kind: "tree", + sourceType: "data-dir", + name: "coder", + description: "Shared test skill.", + treeHash: "c".repeat(64), + entryPath: "SKILL.md", + }, + }, + ], + }); + + expect(result).toHaveLength(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + name: "coder", + providerId: "codex", + }), + "Injected skill conflicts with a provider native skill; preserving both", + ); + }); + + it("preserves a project skill even when its native counterpart is identical", () => { + const logger: ServerLogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + const contentHash = "b".repeat(64); + const result = excludeNativeSkillDuplicates(logger, { + providerId: "codex", + nativeSkills: [ + discoveredSkill({ + contentHash, + filePath: "/home/test/.agents/skills/coder/SKILL.md", + name: "coder", + rootKind: "provider-user", + }), + ], + skillCatalog: [ + { + provenance: { kind: "project" }, + runtimeSource: { + kind: "workspace-path", + sourceType: "project", + name: "coder", + description: "Project override.", + sourceRootPath: "/workspace/.bb/skills/coder", + skillFilePath: "/workspace/.bb/skills/coder/SKILL.md", + }, + }, + ], + }); + + expect(result).toHaveLength(1); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it("surfaces differing native content while suppressing an identical injected copy", () => { + const logger: ServerLogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + const matchingHash = "b".repeat(64); + const result = excludeNativeSkillDuplicates(logger, { + providerId: "codex", + nativeSkills: [ + discoveredSkill({ + contentHash: matchingHash, + filePath: "/home/test/.agents/skills/coder/SKILL.md", + name: "coder", + rootKind: "provider-user", + }), + discoveredSkill({ + contentHash: "c".repeat(64), + filePath: "/workspace/.agents/skills/coder/SKILL.md", + name: "coder", + rootKind: "provider-project", + }), + ], + skillCatalog: [ + { + provenance: { kind: "user" }, + runtimeSource: { + kind: "tree", + sourceType: "data-dir", + name: "coder", + description: "Test skill.", + treeHash: matchingHash, + entryPath: "SKILL.md", + }, + }, + ], + }); + + expect(result).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + contentHash: matchingHash, + name: "coder", + nativeContentHashes: [matchingHash, "c".repeat(64)], + providerId: "codex", + }), + "Provider native skills have conflicting content; suppressing identical injected skill", + ); + }); +}); diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index 0a2abc2018..61298d7b18 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -99,6 +99,7 @@ function registerRemoteRuntimeFileResponder( args: { files: ReadonlyMap; hostId: string; + providerSkills?: DiscoveredSkill[]; sessionId: string; sharedSkills?: DiscoveredSkill[]; }, @@ -148,7 +149,12 @@ function registerRemoteRuntimeFileResponder( if (command.type === "host.list_skills") { return { ok: true, - result: { skills: args.sharedSkills ?? [] }, + result: { + skills: + command.providerId === "bb-shared" + ? (args.sharedSkills ?? []) + : (args.providerSkills ?? []), + }, }; } throw new Error(`Unexpected remote runtime RPC ${command.type}`); @@ -764,9 +770,19 @@ describe("thread runtime config", () => { name: "project-helper", rootPath: path.join(workspacePath, ".bb", "skills"), }); - const { host } = seedHostSession(harness.deps, { + const { host, session } = seedHostSession(harness.deps, { id: "host-runtime-injected-skills", }); + registerRemoteRuntimeFileResponder(harness, { + files: new Map([ + [ + path.join(projectSourceRootPath, "SKILL.md"), + await readFile(path.join(projectSourceRootPath, "SKILL.md"), "utf8"), + ], + ]), + hostId: host.id, + sessionId: session.id, + }); const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, }); @@ -1507,6 +1523,206 @@ describe("thread runtime config", () => { ); }); + it("keeps a byte-identical BB user skill out of injection when the selected provider loads it natively", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-runtime-native-shared-skill", + }); + const workspacePath = "/remote/runtime-native-shared-skill"; + const userSkillPath = await writeRuntimeSkill({ + name: "coder", + rootPath: path.join(harness.config.dataDir, "skills"), + }); + const contentHash = readSkillTreeManifest(userSkillPath).treeHash; + const nativeSkill = { + id: `skill_${"c".repeat(64)}`, + name: "coder", + description: "Implement a bounded change.", + contentHash, + filePath: "/home/test/.agents/skills/coder/SKILL.md", + rootKind: "provider-user" as const, + linked: false, + }; + const responder = registerRemoteRuntimeFileResponder(harness, { + hostId: host.id, + sessionId: session.id, + files: new Map(), + providerSkills: [nativeSkill], + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: workspacePath, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: workspacePath, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + providerId: "codex", + }); + + const runtimeConfig = await resolveThreadRuntimeCommandConfig( + harness.deps, + { thread, environment, model: "test-model" }, + ); + + expect(runtimeConfig.injectedSkillSources).not.toContainEqual( + expect.objectContaining({ name: "coder" }), + ); + expect(responder.requests).toContainEqual( + expect.objectContaining({ + command: expect.objectContaining({ + providerId: "codex", + includeContentHashes: true, + type: "host.list_skills", + }), + }), + ); + }); + }); + + it("keeps an identical shared user skill out of injection when its provider-native copy uses another path", async () => { + await withTestHarness( + { + sharedSkillRoots: { user: [".bb/skills"], project: [] }, + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-runtime-native-shared-user-skill", + }); + const workspacePath = "/remote/runtime-native-shared-user-skill"; + const contentHash = "d".repeat(64); + const responder = registerRemoteRuntimeFileResponder(harness, { + hostId: host.id, + sessionId: session.id, + files: new Map(), + providerSkills: [ + { + id: `skill_${"e".repeat(64)}`, + name: "coder", + description: "Implement a bounded change.", + contentHash, + filePath: "/home/test/.agents/skills/coder/SKILL.md", + rootKind: "provider-user", + linked: false, + }, + ], + sharedSkills: [ + { + id: `skill_${"f".repeat(64)}`, + name: "coder", + description: "Implement a bounded change.", + contentHash, + filePath: "/home/test/.bb/skills/coder/SKILL.md", + rootKind: "shared-user", + linked: false, + }, + ], + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: workspacePath, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: workspacePath, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + providerId: "codex", + }); + + const runtimeConfig = await resolveThreadRuntimeCommandConfig( + harness.deps, + { thread, environment, model: "test-model" }, + ); + + expect(runtimeConfig.injectedSkillSources).not.toContainEqual( + expect.objectContaining({ name: "coder" }), + ); + expect(responder.requests).toContainEqual( + expect.objectContaining({ + command: expect.objectContaining({ + providerId: "bb-shared", + includeContentHashes: true, + type: "host.list_skills", + }), + }), + ); + }, + ); + }); + + it("leaves injected skills available without native discovery for a provider with no native roots", async () => { + await withTestHarness( + { + extraProviders: [ + await configuredAcpProvider({ + id: "no-native-roots", + displayName: "No Native Roots", + command: "no-native-roots-agent", + modelCli: { + listArgs: ["models"], + selectFlag: "--model", + primaryModels: ["model-a"], + }, + }), + ], + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-runtime-no-native-roots", + }); + const workspacePath = "/remote/runtime-no-native-roots"; + await writeRuntimeSkill({ + name: "release-notes", + rootPath: path.join(harness.config.dataDir, "skills"), + }); + const responder = registerRemoteRuntimeFileResponder(harness, { + hostId: host.id, + sessionId: session.id, + files: new Map(), + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: workspacePath, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: workspacePath, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + providerId: "acp-no-native-roots", + }); + + const runtimeConfig = await resolveThreadRuntimeCommandConfig( + harness.deps, + { thread, environment, model: "model-a" }, + ); + + expect(runtimeConfig.injectedSkillSources).toContainEqual( + expect.objectContaining({ name: "release-notes" }), + ); + expect(responder.requests).not.toContainEqual( + expect.objectContaining({ + command: expect.objectContaining({ + providerId: "acp-no-native-roots", + type: "host.list_skills", + }), + }), + ); + }, + ); + }); + it("appends data-dir AGENTS.md instructions before workspace instructions", async () => { await withTestHarness(async (harness) => { const hostId = "host-runtime-data-dir-agents-md"; diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index b2b1952e95..87c7609e69 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.48"; +export const PLUGIN_SDK_VERSION = "0.4.49"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 35fcfe4af4..4d10f15704 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -144,6 +144,7 @@ export const hostDaemonInjectedSkillSourceSchema = z.discriminatedUnion( .extend({ kind: z.literal("host-path"), sourceType: z.enum(["shared-user", "shared-project"]), + contentHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(), sourceRootPath: z.string().min(1), skillFilePath: z.string().min(1), }) @@ -658,6 +659,7 @@ const skillRootKindSchema = z.enum([ export type SkillRootKind = z.infer; const discoveredSkillSchema = z.object({ + contentHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(), id: z.string().regex(/^skill_[a-f0-9]{64}$/u), name: z.string(), description: z.string().nullable(), @@ -670,6 +672,7 @@ export type DiscoveredSkill = z.infer; const hostListSkillsCommandSchema = z .object({ type: z.literal("host.list_skills"), + includeContentHashes: z.boolean().optional(), providerId: z.string().min(1), cwd: z.string().min(1).nullable(), nativeRoots: providerNativeRootSetSchema, diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 51c950c19c..fd8062e887 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 183 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 184 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 1cc2190e07..bab02fd28f 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -659,6 +659,8 @@ function terminalDataBase64(byteLength: number): string { } const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = { + "hostDaemonOnlineRpcCommandSchema.includeContentHashes": + "host.list_skills only computes whole-skill fingerprints when the caller needs native/injected duplicate suppression.", "hostDaemonCommandSchema.checkout": "environment.provision only includes checkout instructions for unmanaged workspaces that requested a branch mutation.", "hostDaemonCommandSchema.targetPath": @@ -975,7 +977,7 @@ const CONTRIBUTED_ENV = [ describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(183); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(184); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d38d40eca3..f5b0caa3f4 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.48", + "version": "0.4.49", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues"