diff --git a/src/lib/__tests__/wizard-tools.test.ts b/src/lib/__tests__/wizard-tools.test.ts index 771751234..12b1c648b 100644 --- a/src/lib/__tests__/wizard-tools.test.ts +++ b/src/lib/__tests__/wizard-tools.test.ts @@ -3,6 +3,8 @@ import * as http from 'http'; import * as os from 'os'; import * as path from 'path'; import { zipSync } from 'fflate'; +import { scan } from '@posthog/warlock'; +import { analytics } from '@utils/analytics'; import { ASK_BATCH_THRESHOLD, ASK_CANCELLED_NOTE, @@ -1392,6 +1394,43 @@ describe('downloadSkill (e2e over HTTP)', () => { await server.close(); } }); + + // The engine is WASM and has failed to instantiate in the field. Reported as + // `extract` it reads as a corrupt archive, which the pure-JS unzip cannot + // produce, and the run is told to check directory permissions instead. + it('reports a scanner engine failure as the scan step, not extract', async () => { + const zip = dummyZip(); + const server = await startServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/zip' }); + res.end(Buffer.from(zip)); + }); + const captured = vi.spyOn(analytics, 'wizardCapture').mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {}, + ); + vi.mocked(scan).mockRejectedValueOnce(new Error('WebAssembly.Module()')); + + try { + const result = await downloadSkill( + { + id: 'dummy', + name: 'Dummy', + downloadUrl: `${server.baseUrl}/skill.zip`, + }, + tmpDir, + { triage: undefined }, + ); + + expect(result.success).toBe(false); + expect(captured).toHaveBeenCalledWith( + 'skill install failed', + expect.objectContaining({ step: 'scan', skill_id: 'dummy' }), + ); + } finally { + captured.mockRestore(); + await server.close(); + } + }); }); describe('fetchSkillMenu', () => { diff --git a/src/lib/wizard-tools/tools.ts b/src/lib/wizard-tools/tools.ts index 06b35c55c..77d99c9e3 100644 --- a/src/lib/wizard-tools/tools.ts +++ b/src/lib/wizard-tools/tools.ts @@ -208,7 +208,7 @@ export async function downloadSkill( const skillDir = skillsRoot ? path.join(installDir, skillsRoot, skillEntry.id) : path.join(installDir, '.claude', 'skills', skillEntry.id); - let step: 'download' | 'extract' = 'download'; + let step: 'download' | 'extract' | 'scan' = 'download'; try { fs.mkdirSync(skillDir, { recursive: true }); @@ -226,6 +226,11 @@ export async function downloadSkill( // Same scan the Bash-install hook runs — TS-path installs (linear // pre-install, MCP/pi install_skill, orchestrator cache + reference) // must not skip it. + // + // The scan is its own step: it runs the YARA-X WASM engine, and an engine + // that fails to load throws from here. Left as `extract` that lands on the + // event as an unzip failure, which the pure-JS unzip cannot produce. + step = 'scan'; const poisonReason = await scanInstalledSkill(skillDir, triage); if (poisonReason) { fs.rmSync(skillDir, { recursive: true, force: true });