Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';3import path from 'node:path';4import type { FastifyInstance } from 'fastify';5import type { SkillInfo } from '@cr/shared';6import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';7import { createSkillsPlugin, itemRel } from './skills.js';8import { parseFrontmatter } from './fsx-skills-scan.js';9import { isolatedClaudeEnv } from './fsx-claude-env.js';1011let tmp: string;12let ws: string;13let proj: string;14let home: string;15let cfg: string; // <home>/.claude16let outside: string;17let app: FastifyInstance;1819function put(file: string, content: string): void {20 mkdirSync(path.dirname(file), { recursive: true });21 writeFileSync(file, content);22}23const list = async (root?: string): Promise<SkillInfo[]> =>24 (await call<SkillInfo[]>(app, 'GET', root ? `/skills?root=${encodeURIComponent(root)}` : '/skills')).body;25const find = (items: SkillInfo[], kind: string, name: string, source = 'user'): SkillInfo | undefined =>26 items.find((i) => i.kind === kind && i.name === name && i.source === source);2728beforeAll(async () => {29 tmp = makeTmp('cr-skills-');30 ws = path.join(tmp, 'ws');31 proj = path.join(ws, 'proj');32 home = path.join(tmp, 'home');33 cfg = path.join(home, '.claude');34 outside = path.join(tmp, 'outside');35 mkdirSync(proj, { recursive: true });36 mkdirSync(outside, { recursive: true });37 put(path.join(cfg, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: "Alpha: does things"\nversion: 1.2.3\n---\n# Alpha\n');38 put(path.join(cfg, 'skills', 'alpha', 'run.sh'), '#!/bin/sh\ntouch "$CR_MARKER"\n');39 put(path.join(cfg, 'skills', 'no-manifest', 'README.md'), 'not a skill');40 put(path.join(cfg, 'skills', 'folded', 'SKILL.md'), '---\nname: folded\ndescription: >\n first line\n second line\nmetadata:\n version: "0.9"\n author: x\n---\nbody\n');41 put(path.join(cfg, 'commands', 'deploy.md'), '---\ndescription: Deploy the thing # not a comment? yes it is\nallowed-tools: Bash\n---\nDo it\n');42 put(path.join(cfg, 'commands', 'git', 'commit.md'), 'Write a commit message\n\nmore\n');43 put(path.join(cfg, 'agents', 'reviewer.md'), "---\nname: reviewer\ndescription: 'Reviews ''code'''\nmodel: haiku\n---\nprompt\n");44 put(path.join(cfg, '.disabled', 'skills', 'parked', 'SKILL.md'), '---\ndescription: parked skill\n---\n');45 put(path.join(cfg, '.disabled', 'agents', 'old.md'), 'old agent\n');46 put(path.join(proj, '.claude', 'skills', 'local-skill', 'SKILL.md'), '---\ndescription: project skill\n---\n');47 put(path.join(proj, '.claude', 'commands', 'build.md'), 'Build it\n');48 put(path.join(proj, '.claude', '.disabled', 'commands', 'off.md'), 'Off\n');49 // project-scope traps: symlinks leaving the root must never be listed50 put(path.join(outside, 'evil-skill', 'SKILL.md'), '---\ndescription: OUTSIDE-SECRET\n---\n');51 put(path.join(outside, 'agents', 'evil.md'), 'OUTSIDE-SECRET agent');52 symlinkSync(path.join(outside, 'evil-skill'), path.join(proj, '.claude', 'skills', 'evil-skill'));53 symlinkSync(path.join(outside, 'agents'), path.join(proj, '.claude', 'agents'));54 app = await buildApp(makeConfig([ws]), createSkillsPlugin({ env: isolatedClaudeEnv(home) }));55});56afterAll(async () => {57 await app.close();58 rmTmp(tmp);59});6061describe('parseFrontmatter', () => {62 it('parses scalars, quotes, block scalars and one nesting level', () => {63 const fm = parseFrontmatter('---\nname: x\ndescription: "a: b"\nlong: |\n l1\n l2\nmetadata:\n version: 2\ntools: [a, b]\n---\nBody');64 expect(fm.fields).toMatchObject({ name: 'x', description: 'a: b', long: 'l1 l2', 'metadata.version': '2', tools: '[a, b]' });65 expect(fm.body).toBe('Body');66 });67 it('is inert on hostile input', () => {68 const fm = parseFrontmatter('---\n__proto__: polluted\nconstructor: x\n!!js/function: "function(){}"\nname: &a *a\n---\n');69 expect(({} as Record<string, unknown>)['polluted']).toBeUndefined();70 expect(Object.getPrototypeOf({})).toBe(Object.prototype);71 expect(fm.fields['name']).toBe('&a *a');72 expect(parseFrontmatter('no frontmatter').fields).toEqual({});73 expect(parseFrontmatter('---\nname: x\nnever closed').fields).toEqual({});74 });75});7677describe('GET /skills', () => {78 it('lists user skills, commands and agents with parsed metadata', async () => {79 const items = await list();80 expect(find(items, 'skill', 'alpha')).toEqual({81 name: 'alpha',82 description: 'Alpha: does things',83 source: 'user',84 kind: 'skill',85 version: '1.2.3',86 location: '~/.claude/skills/alpha',87 enabled: true,88 updateAvailable: null,89 });90 expect(find(items, 'skill', 'folded')).toMatchObject({ description: 'first line second line', version: '0.9' });91 expect(find(items, 'skill', 'no-manifest')).toBeUndefined();92 expect(find(items, 'command', 'deploy')).toMatchObject({ description: 'Deploy the thing', location: '~/.claude/commands/deploy.md' });93 expect(find(items, 'command', 'git:commit')).toMatchObject({ description: 'Write a commit message', version: null });94 expect(find(items, 'agent', 'reviewer')).toMatchObject({ description: "Reviews 'code'", enabled: true });95 expect(find(items, 'skill', 'parked')).toMatchObject({ enabled: false, location: '~/.claude/.disabled/skills/parked' });96 expect(find(items, 'agent', 'old')).toMatchObject({ enabled: false });97 expect(items.every((i) => i.source === 'user' && i.updateAvailable === null)).toBe(true);98 });99 it('adds project items when root is given and never follows project symlinks', async () => {100 const items = await list(proj);101 expect(find(items, 'skill', 'local-skill', 'project')).toMatchObject({ enabled: true, description: 'project skill' });102 expect(find(items, 'command', 'build', 'project')).toBeDefined();103 expect(find(items, 'command', 'off', 'project')).toMatchObject({ enabled: false });104 expect(JSON.stringify(items)).not.toContain('OUTSIDE-SECRET');105 expect(items.some((i) => i.name.includes('evil'))).toBe(false);106 expect(find(items, 'skill', 'alpha')).toBeDefined();107 expect(errCode(await call(app, 'GET', `/skills?root=${encodeURIComponent(outside)}`))).toBe('root_not_allowed');108 });109});110111describe('POST /skills/op disable / enable / remove', () => {112 it('validates names', async () => {113 expect(itemRel('command', 'git:commit')).toBe('commands/git/commit.md');114 expect(itemRel('skill', 'a.b_c-d')).toBe('skills/a.b_c-d');115 for (const name of ['../x', 'a/b', '..', '.hidden', 'a b', 'x:y', '']) {116 const r = await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name, scope: 'user' });117 expect(r.status).toBe(400);118 }119 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'command', name: 'git:..', scope: 'user' })).status).toBe(400);120 expect((await call(app, 'POST', '/skills/op', { op: 'explode', kind: 'skill', name: 'alpha' })).status).toBe(400);121 });122 it('moves a user skill to .disabled and back', async () => {123 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'alpha', scope: 'user' })).status).toBe(200);124 expect(existsSync(path.join(cfg, 'skills', 'alpha'))).toBe(false);125 expect(existsSync(path.join(cfg, '.disabled', 'skills', 'alpha', 'SKILL.md'))).toBe(true);126 expect(find(await list(), 'skill', 'alpha')).toMatchObject({ enabled: false });127 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'alpha', scope: 'user' })).status).toBe(404);128 expect((await call(app, 'POST', '/skills/op', { op: 'enable', kind: 'skill', name: 'alpha', scope: 'user' })).status).toBe(200);129 expect(existsSync(path.join(cfg, 'skills', 'alpha', 'run.sh'))).toBe(true);130 expect(find(await list(), 'skill', 'alpha')).toMatchObject({ enabled: true });131 });132 it('handles nested commands, agents and conflicts', async () => {133 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'command', name: 'git:commit', scope: 'user' })).status).toBe(200);134 expect(existsSync(path.join(cfg, '.disabled', 'commands', 'git', 'commit.md'))).toBe(true);135 put(path.join(cfg, 'commands', 'git', 'commit.md'), 'new one');136 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'enable', kind: 'command', name: 'git:commit', scope: 'user' }))).toBe('exists');137 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'agent', name: 'reviewer', scope: 'user' })).status).toBe(200);138 expect(existsSync(path.join(cfg, '.disabled', 'agents', 'reviewer.md'))).toBe(true);139 });140 it('remove requires confirm and deletes live or disabled items', async () => {141 expect((await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'skill', name: 'parked', scope: 'user' })).status).toBe(400);142 expect(existsSync(path.join(cfg, '.disabled', 'skills', 'parked'))).toBe(true);143 expect((await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'skill', name: 'parked', scope: 'user', confirm: true })).status).toBe(200);144 expect(existsSync(path.join(cfg, '.disabled', 'skills', 'parked'))).toBe(false);145 expect((await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'command', name: 'deploy', scope: 'user', confirm: true })).status).toBe(200);146 expect(existsSync(path.join(cfg, 'commands', 'deploy.md'))).toBe(false);147 expect((await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'skill', name: 'ghost', scope: 'user', confirm: true })).status).toBe(404);148 });149 it('project scope stays inside the project root', async () => {150 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'local-skill', scope: 'project' }))).toBe('bad_request');151 expect((await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'local-skill', scope: 'project', root: proj })).status).toBe(200);152 expect(existsSync(path.join(proj, '.claude', '.disabled', 'skills', 'local-skill', 'SKILL.md'))).toBe(true);153 expect((await call(app, 'POST', '/skills/op', { op: 'enable', kind: 'skill', name: 'local-skill', scope: 'project', root: proj })).status).toBe(200);154 // `.claude/agents` is a symlink to an outside directory: nothing below it may be touched155 const r = await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'agent', name: 'evil', scope: 'project', root: proj, confirm: true });156 expect([r.status, errCode(r)]).toEqual([403, 'path_outside_root']);157 expect(existsSync(path.join(outside, 'agents', 'evil.md'))).toBe(true);158 // an entry that is itself a symlink leaving the root is refused outright (target untouched)159 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'remove', kind: 'skill', name: 'evil-skill', scope: 'project', root: proj, confirm: true }))).toBe('path_outside_root');160 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'evil-skill', scope: 'project', root: proj }))).toBe('path_outside_root');161 expect(existsSync(path.join(outside, 'evil-skill', 'SKILL.md'))).toBe(true);162 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'disable', kind: 'skill', name: 'x', scope: 'project', root: outside }))).toBe('root_not_allowed');163 });164});165166describe('POST /skills/op install', () => {167 const src = (name: string): string => path.join(proj, 'vendor', name);168 it('copies a skill directory from a workspace root without executing anything', async () => {169 put(path.join(src('new-skill'), 'SKILL.md'), '---\ndescription: freshly installed\n---\n');170 put(path.join(src('new-skill'), 'scripts', 'setup.sh'), `#!/bin/sh\ntouch "${path.join(tmp, 'EXECUTED')}"\n`);171 put(path.join(src('new-skill'), '.git', 'config'), 'x');172 const r = await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/new-skill' });173 expect(r.body).toEqual({ ok: true, name: 'new-skill' });174 expect(readFileSync(path.join(cfg, 'skills', 'new-skill', 'scripts', 'setup.sh'), 'utf8')).toContain('touch');175 expect(existsSync(path.join(cfg, 'skills', 'new-skill', '.git'))).toBe(false);176 expect(existsSync(path.join(tmp, 'EXECUTED'))).toBe(false);177 expect(find(await list(), 'skill', 'new-skill')).toMatchObject({ description: 'freshly installed', enabled: true });178 expect(readdirSync(path.join(cfg, 'skills')).filter((n) => n.startsWith('.cr-'))).toEqual([]);179 });180 it('refuses overwrite unless replace:true', async () => {181 put(path.join(src('new-skill'), 'SKILL.md'), '---\ndescription: version two\n---\n');182 put(path.join(cfg, 'skills', 'new-skill', 'stale.txt'), 'stale');183 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/new-skill' }))).toBe('exists');184 expect((await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/new-skill', replace: true })).status).toBe(200);185 expect(find(await list(), 'skill', 'new-skill')?.description).toBe('version two');186 expect(existsSync(path.join(cfg, 'skills', 'new-skill', 'stale.txt'))).toBe(false);187 expect(readdirSync(path.join(cfg, 'skills')).filter((n) => n.startsWith('.cr-'))).toEqual([]);188 });189 it('refuses symlinks inside the source (file, directory, and SKILL.md itself)', async () => {190 writeFileSync(path.join(outside, 'id_rsa'), 'PRIVATE-KEY');191 put(path.join(src('sym-file'), 'SKILL.md'), 'x');192 symlinkSync(path.join(outside, 'id_rsa'), path.join(src('sym-file'), 'key'));193 put(path.join(src('sym-dir'), 'SKILL.md'), 'x');194 mkdirSync(path.join(src('sym-dir'), 'nested'));195 symlinkSync(outside, path.join(src('sym-dir'), 'nested', 'out'));196 mkdirSync(src('sym-manifest'), { recursive: true });197 symlinkSync(path.join(outside, 'id_rsa'), path.join(src('sym-manifest'), 'SKILL.md'));198 for (const n of ['sym-file', 'sym-dir', 'sym-manifest']) {199 const r = await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: `vendor/${n}` });200 expect([n, r.status, errCode(r)]).toEqual([n, 400, 'symlink_in_source']);201 expect(existsSync(path.join(cfg, 'skills', n))).toBe(false);202 }203 expect(readdirSync(path.join(cfg, 'skills')).filter((n) => n.startsWith('.cr-'))).toEqual([]);204 });205 it('refuses sources outside the workspace, bad names, non-skills and oversized trees', async () => {206 put(path.join(outside, 'ext-skill', 'SKILL.md'), 'x');207 symlinkSync(path.join(outside, 'ext-skill'), path.join(proj, 'vendor', 'ext-link'));208 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/ext-link' }))).toBe('path_outside_root');209 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: '../../outside/ext-skill' }))).toBe('bad_path');210 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: outside, sourceDir: 'ext-skill' }))).toBe('root_not_allowed');211 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: path.join(outside, 'ext-skill') }))).toBe('bad_path');212 put(path.join(src('bad name'), 'SKILL.md'), 'x');213 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/bad name' }))).toBe('bad_name');214 mkdirSync(src('empty'), { recursive: true });215 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/empty' }))).toBe('not_a_skill');216 expect((await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/ghost' })).status).toBe(404);217 expect(errCode(await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: '' }))).toBe('bad_request');218 put(path.join(src('huge'), 'SKILL.md'), 'x');219 writeFileSync(path.join(src('huge'), 'blob.bin'), Buffer.alloc(20 * 1024 * 1024 + 1));220 const big = await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/huge' });221 expect([big.status, errCode(big)]).toEqual([413, 'too_large']);222 expect(existsSync(path.join(cfg, 'skills', 'huge'))).toBe(false);223 });224 it('preserves file modes without special bits', async () => {225 put(path.join(src('modes'), 'SKILL.md'), 'x');226 writeFileSync(path.join(src('modes'), 'tool'), '#!/bin/sh\n', { mode: 0o755 });227 expect((await call(app, 'POST', '/skills/op', { op: 'install', root: proj, sourceDir: 'vendor/modes' })).status).toBe(200);228 expect(statSync(path.join(cfg, 'skills', 'modes', 'tool')).mode & 0o7777).toBe(0o755);229 });230});231