diff --git a/src/main/skills/skill-install-discovery-verification.test.ts b/src/main/skills/skill-install-discovery-verification.test.ts new file mode 100644 index 00000000000..2fc007e9b7d --- /dev/null +++ b/src/main/skills/skill-install-discovery-verification.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillInstallResult } from '../../shared/skill-install-contract' +import type { SkillDiscoveryResult } from '../../shared/skills' + +const { discoverSkillsInWslMock } = vi.hoisted(() => ({ + discoverSkillsInWslMock: vi.fn() +})) + +vi.mock('./skill-discovery-wsl', () => ({ + discoverSkillsInWsl: discoverSkillsInWslMock +})) + +import { verifySkillInstallDiscovery } from './skill-install-discovery-verification' + +function installResult(paths: string[]): SkillInstallResult { + return { + operationId: 'operation-wsl', + status: 'installed', + name: 'review', + packageDigest: 'a'.repeat(64), + canonicalPath: paths[0], + placements: paths.map((path, index) => ({ + provider: index === 0 ? 'agent-skills' : 'claude', + path, + topology: index === 0 ? 'canonical-copy' : 'provider-alias', + status: 'installed' + })) + } +} + +function discovery(rootPaths: string[]): SkillDiscoveryResult { + return { + skills: [ + { + id: 'review', + name: 'review', + description: 'Review', + providers: ['agent-skills', 'claude'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: rootPaths[0], + rootPaths, + directoryPath: `${rootPaths[0]}/review`, + skillFilePath: `${rootPaths[0]}/review/SKILL.md`, + installed: true, + fileCount: 1, + updatedAt: null + } + ], + sources: [], + scannedAt: 1 + } +} + +beforeEach(() => { + discoverSkillsInWslMock.mockReset() +}) + +describe('WSL install discovery verification', () => { + it('matches UNC placements against distro-native discovery paths', async () => { + discoverSkillsInWslMock.mockResolvedValue( + discovery(['/home/alice/.agents/skills', '/home/alice/.claude/skills']) + ) + const result = await verifySkillInstallDiscovery({ + result: installResult([ + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.agents\\skills\\review', + '\\\\wsl$\\ubuntu\\home\\alice\\.claude\\skills\\review' + ]), + scope: 'global', + homeDirectory: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + wslDistro: 'Ubuntu' + }) + + expect(result.status).toBe('installed') + expect(result.placements).not.toContainEqual(expect.objectContaining({ status: 'failed' })) + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/home/alice', + cwd: '/home/alice' + }) + }) + + it('normalizes Windows-backed workspace paths to drvfs paths', async () => { + discoverSkillsInWslMock.mockResolvedValue(discovery(['/mnt/c/work/repo/.agents/skills'])) + const result = await verifySkillInstallDiscovery({ + result: installResult(['C:\\work\\repo\\.agents\\skills\\review']), + scope: 'workspace', + homeDirectory: 'C:\\Users\\alice', + workspaceDirectory: 'C:\\work\\repo', + wslDistro: 'Ubuntu' + }) + + expect(result.status).toBe('installed') + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/mnt/c/Users/alice', + cwd: '/mnt/c/work/repo' + }) + }) + + it('fails verification without scanning a mismatched UNC distro', async () => { + const result = await verifySkillInstallDiscovery({ + result: installResult(['\\\\wsl.localhost\\Debian\\home\\alice\\.agents\\skills\\review']), + scope: 'global', + homeDirectory: '\\\\wsl.localhost\\Debian\\home\\alice', + wslDistro: 'Ubuntu' + }) + + expect(result).toMatchObject({ + status: 'failed', + errorCategory: 'skill-discovery-verification-failed' + }) + expect(discoverSkillsInWslMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/skills/skill-install-discovery-verification.ts b/src/main/skills/skill-install-discovery-verification.ts index 9276f1ba3be..debdd783887 100644 --- a/src/main/skills/skill-install-discovery-verification.ts +++ b/src/main/skills/skill-install-discovery-verification.ts @@ -1,14 +1,39 @@ import { dirname, posix, resolve } from 'node:path' import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract' import type { SkillDiscoveryResult } from '../../shared/skills' +import { parseWslUncPath } from '../../shared/wsl-paths' import { discoverSkills } from './discovery' import { discoverSkillsInWsl } from './skill-discovery-wsl' +function toWslDiscoveryPath(path: string, distro: string): string { + if (path.includes('\0')) { + throw new Error('skill-install-wsl-path-invalid') + } + const parsed = parseWslUncPath(path) + if (parsed) { + if (parsed.distro.toLocaleLowerCase('en-US') !== distro.toLocaleLowerCase('en-US')) { + throw new Error('skill-install-wsl-distro-mismatch') + } + return posix.normalize(parsed.linuxPath) + } + const slashed = path.replace(/\\/g, '/') + const drive = slashed.match(/^([A-Za-z]):\/(.*)$/) + if (drive) { + return posix.join('/mnt', drive[1].toLocaleLowerCase('en-US'), drive[2]) + } + if (!posix.isAbsolute(slashed)) { + throw new Error('skill-install-wsl-path-invalid') + } + return posix.normalize(slashed) +} + function normalizedPath(path: string, wslDistro?: string): string { - const normalized = wslDistro ? path.replace(/\/+$/, '') : resolve(path) + const normalized = wslDistro ? toWslDiscoveryPath(path, wslDistro) : resolve(path) + const withoutTrailingSlash = + wslDistro && normalized !== '/' ? normalized.replace(/\/+$/, '') : normalized return !wslDistro && process.platform === 'win32' - ? normalized.toLocaleLowerCase('en-US') - : normalized + ? withoutTrailingSlash.toLocaleLowerCase('en-US') + : withoutTrailingSlash } function placementIsDiscovered( @@ -19,7 +44,7 @@ function placementIsDiscovered( ): boolean { const placementPath = normalizedPath(placement.path, wslDistro) const placementRoot = normalizedPath( - wslDistro ? posix.dirname(placement.path) : dirname(placement.path), + wslDistro ? posix.dirname(placementPath) : dirname(placement.path), wslDistro ) return discovery.skills.some((skill) => { @@ -44,8 +69,8 @@ async function discoverInstalledSkill(input: { if (input.wslDistro) { return discoverSkillsInWsl({ distro: input.wslDistro, - homeDir: input.homeDirectory, - cwd: input.workspaceDirectory ?? input.homeDirectory + homeDir: toWslDiscoveryPath(input.homeDirectory, input.wslDistro), + cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, input.wslDistro) }) } return discoverSkills({