diff --git a/ui/server/routes/commands.js b/ui/server/routes/commands.js index 238b20cec..8aec3dbac 100644 --- a/ui/server/routes/commands.js +++ b/ui/server/routes/commands.js @@ -21,6 +21,34 @@ const __dirname = path.dirname(__filename); const router = express.Router(); +function resolveUserCommandRoots() { + const pilotHome = resolvePilotHome(process.env); + return { + userCommandsDir: path.join(pilotHome, 'commands'), + userSkillsDir: path.join(pilotHome, 'skills'), + }; +} + +function isUnderDirectory(base, target) { + const rel = path.relative(path.resolve(base), path.resolve(target)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function isSkillCommandFile(commandPath, projectPath) { + const resolvedPath = path.resolve(commandPath); + if (path.basename(resolvedPath) !== 'SKILL.md') { + return false; + } + + const { userSkillsDir } = resolveUserCommandRoots(); + const skillRoots = [userSkillsDir]; + if (projectPath) { + skillRoots.push(path.join(projectPath, '.pilotdeck', 'skills')); + } + + return skillRoots.some((root) => isUnderDirectory(root, resolvedPath)); +} + /** * Slash commands curated to always appear at the top of the menu in this exact * order, regardless of usage history. Names that don't resolve to a real @@ -885,7 +913,7 @@ Custom commands can be created in: router.post('/list', async (req, res) => { try { const { projectPath } = req.body; - const homeDir = os.homedir(); + const { userCommandsDir, userSkillsDir } = resolveUserCommandRoots(); const customCommandSources = []; @@ -899,8 +927,6 @@ router.post('/list', async (req, res) => { customCommandSources.push(...projectCommands, ...projectSkills); } - const userCommandsDir = path.join(homeDir, '.pilotdeck', 'commands'); - const userSkillsDir = path.join(homeDir, '.pilotdeck', 'skills'); const [userCommands, userSkills] = await Promise.all([ scanCommandsDirectory(userCommandsDir, userCommandsDir, 'user'), scanSkillsDirectory(userSkillsDir, 'user'), @@ -976,9 +1002,12 @@ router.post('/load', async (req, res) => { // Security: Prevent path traversal. Allow paths under any const resolvedPath = path.resolve(commandPath); - const inHome = resolvedPath.startsWith(path.resolve(os.homedir())); + const { userCommandsDir, userSkillsDir } = resolveUserCommandRoots(); + const inUserCommandRoot = [userCommandsDir, userSkillsDir].some((root) => + isUnderDirectory(root, resolvedPath), + ); const inPilotdeckSubdir = /\.pilotdeck\/(commands|skills)\//.test(resolvedPath); - if (!inHome && !inPilotdeckSubdir) { + if (!inUserCommandRoot && !inPilotdeckSubdir) { return res.status(403).json({ error: 'Access denied', message: 'Command must be in a .pilotdeck/commands or .pilotdeck/skills directory' @@ -1078,7 +1107,7 @@ router.post('/execute', async (req, res) => { // server-side and submitted as raw user input — that would dump the whole // SKILL.md body into chat. Instead, passthrough the slash text so the // proxy's slash parser invokes SkillTool with the procedural body. - if (commandPath && /\/\.pilotdeck\/skills\/[^/]+\/SKILL\.md$/i.test(commandPath)) { + if (commandPath && isSkillCommandFile(commandPath, context?.projectPath)) { const passthroughContent = buildPassthroughContent(); return res.json({ type: 'custom', @@ -1100,9 +1129,10 @@ router.post('/execute', async (req, res) => { // Security: validate commandPath is within allowed directories. { const resolvedPath = path.resolve(commandPath); + const { userCommandsDir, userSkillsDir } = resolveUserCommandRoots(); const allowedBases = [ - path.resolve(path.join(os.homedir(), '.pilotdeck', 'commands')), - path.resolve(path.join(os.homedir(), '.pilotdeck', 'skills')), + path.resolve(userCommandsDir), + path.resolve(userSkillsDir), ]; if (context?.projectPath) { allowedBases.push( diff --git a/ui/server/routes/commands.test.js b/ui/server/routes/commands.test.js new file mode 100644 index 000000000..5d1a2b296 --- /dev/null +++ b/ui/server/routes/commands.test.js @@ -0,0 +1,105 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import router from './commands.js'; + +async function postRoute(routePath, body = {}) { + const layer = router.stack.find( + (entry) => entry.route?.path === routePath && entry.route?.methods?.post, + ); + if (!layer) { + throw new Error(`POST ${routePath} handler not found`); + } + + let statusCode = 200; + let payload; + const response = { + status(code) { + statusCode = code; + return this; + }, + json(value) { + payload = value; + return this; + }, + }; + + await layer.route.stack[0].handle({ body }, response); + return { statusCode, payload }; +} + +describe('commands route skill discovery', () => { + it('uses PILOT_HOME for user commands and skill commands', async () => { + const root = mkdtempSync(join(tmpdir(), 'pilotdeck-command-list-')); + const previousHome = process.env.HOME; + const previousPilotHome = process.env.PILOT_HOME; + + try { + const defaultHome = join(root, 'default-home'); + const pilotHome = join(root, 'pilot-home'); + const defaultSkill = join(defaultHome, '.pilotdeck', 'skills', 'home_only_repro'); + const pilotSkill = join(pilotHome, 'skills', 'pilot_only_repro'); + const pilotCommandDir = join(pilotHome, 'commands'); + mkdirSync(defaultSkill, { recursive: true }); + mkdirSync(pilotSkill, { recursive: true }); + mkdirSync(pilotCommandDir, { recursive: true }); + writeFileSync(join(defaultSkill, 'SKILL.md'), '# Home only repro\n', 'utf8'); + writeFileSync(join(pilotSkill, 'SKILL.md'), '# Pilot only repro\n', 'utf8'); + writeFileSync(join(pilotCommandDir, 'pilot_command_repro.md'), 'Pilot command\n', 'utf8'); + + process.env.HOME = defaultHome; + process.env.PILOT_HOME = pilotHome; + + const { statusCode, payload } = await postRoute('/list'); + const customNames = payload.custom.map((cmd) => cmd.name); + + expect(statusCode).toBe(200); + expect(customNames).toContain('/pilot_only_repro'); + expect(customNames).toContain('/pilot_command_repro'); + expect(customNames).not.toContain('/home_only_repro'); + + const pilotSkillCommand = payload.custom.find((cmd) => cmd.name === '/pilot_only_repro'); + const loadedSkill = await postRoute('/load', { + commandPath: pilotSkillCommand.path, + }); + + expect(loadedSkill.statusCode).toBe(200); + expect(loadedSkill.payload.content.trim()).toBe('# Pilot only repro'); + + const skillExecution = await postRoute('/execute', { + commandName: '/pilot_only_repro', + commandPath: pilotSkillCommand.path, + context: {}, + }); + + expect(skillExecution.statusCode).toBe(200); + expect(skillExecution.payload.content).toBe('/pilot_only_repro'); + expect(skillExecution.payload.metadata).toEqual({ type: 'skill', passthrough: true }); + + const pilotCommand = payload.custom.find((cmd) => cmd.name === '/pilot_command_repro'); + const commandExecution = await postRoute('/execute', { + commandName: '/pilot_command_repro', + commandPath: pilotCommand.path, + context: {}, + }); + + expect(commandExecution.statusCode).toBe(200); + expect(commandExecution.payload.content.trim()).toBe('Pilot command'); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + + if (previousPilotHome === undefined) { + delete process.env.PILOT_HOME; + } else { + process.env.PILOT_HOME = previousPilotHome; + } + + rmSync(root, { recursive: true, force: true }); + } + }); +});