Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions ui/server/routes/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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'),
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand Down
105 changes: 105 additions & 0 deletions ui/server/routes/commands.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});