mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
refactor: setup project wizards with only three steps
This commit is contained in:
@@ -7,7 +7,8 @@ import crypto from 'crypto';
|
||||
import { userDb } from '@/shared/database/repositories/users.js';
|
||||
import { apiKeysDb } from '@/shared/database/repositories/api-keys.js';
|
||||
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
||||
import { addProjectManually } from '../../../projects.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import { getWorkspaceNameFromPath } from '@/modules/projects/projects.utils.js';
|
||||
import { queryClaudeSDK } from '../../../claude-sdk.js';
|
||||
import { spawnCursor } from '../../../cursor-cli.js';
|
||||
import { queryCodex } from '../../../openai-codex.js';
|
||||
@@ -902,20 +903,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the project (or use existing registration)
|
||||
let project;
|
||||
try {
|
||||
project = await addProjectManually(finalProjectPath);
|
||||
console.log('📦 Project registered:', project);
|
||||
} catch (error) {
|
||||
// If project already exists, that's fine - continue with the existing registration
|
||||
if (error.message && error.message.includes('Project already configured')) {
|
||||
console.log('📦 Using existing project registration for:', finalProjectPath);
|
||||
project = { path: finalProjectPath };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Register the workspace path in the refactored metadata table.
|
||||
workspaceOriginalPathsDb.createWorkspacePath(finalProjectPath, getWorkspaceNameFromPath(finalProjectPath));
|
||||
console.log('📦 Workspace registered:', finalProjectPath);
|
||||
|
||||
// Set up writer based on streaming mode
|
||||
if (stream) {
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
renameProject,
|
||||
deleteSession,
|
||||
deleteProject,
|
||||
addProjectManually,
|
||||
searchConversations
|
||||
} from '../../../projects.js';
|
||||
import { applyCustomSessionNames, sessionNamesDb } from '@/shared/database/repositories/session-names.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import { authenticateToken } from '../auth/auth.middleware.js';
|
||||
import { WORKSPACES_ROOT, validateWorkspacePath } from './projects.routes.js';
|
||||
import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -100,8 +100,18 @@ router.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Project path is required' });
|
||||
}
|
||||
|
||||
const project = await addProjectManually(projectPath.trim());
|
||||
res.json({ success: true, project });
|
||||
const resolvedPath = path.resolve(projectPath.trim());
|
||||
const validation = await validateWorkspacePath(resolvedPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid workspace path',
|
||||
details: validation.error
|
||||
});
|
||||
}
|
||||
|
||||
const safePath = validation.resolvedPath || resolvedPath;
|
||||
workspaceOriginalPathsDb.createWorkspacePath(safePath, getWorkspaceNameFromPath(safePath));
|
||||
res.json({ success: true, message: 'Workspace saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
|
||||
@@ -2,9 +2,9 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import spawn from 'cross-spawn';
|
||||
import os from 'os';
|
||||
import { addProjectManually } from '../../../projects.js';
|
||||
import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import { getWorkspaceNameFromPath, validateWorkspacePath } from './projects.utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,177 +13,65 @@ function sanitizeGitError(message, token) {
|
||||
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||
}
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/usr',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/var',
|
||||
'/boot',
|
||||
'/root',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates that a path is safe for workspace operations
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
export async function validateWorkspacePath(requestedPath) {
|
||||
router.patch('/workspace-name', async (req, res) => {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
let absolutePath = path.resolve(requestedPath);
|
||||
const { path: workspacePath, customWorkspaceName } = req.body;
|
||||
|
||||
// Check if path is a forbidden system directory
|
||||
const normalizedPath = path.normalize(absolutePath);
|
||||
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot use system-critical directories as workspace locations'
|
||||
};
|
||||
if (!workspacePath || !String(workspacePath).trim()) {
|
||||
return res.status(400).json({ error: 'path is required' });
|
||||
}
|
||||
|
||||
// Additional check for paths starting with forbidden directories
|
||||
for (const forbidden of FORBIDDEN_PATHS) {
|
||||
if (normalizedPath === forbidden ||
|
||||
normalizedPath.startsWith(forbidden + path.sep)) {
|
||||
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||
// but /var itself and most /var subdirectories should be blocked
|
||||
if (forbidden === '/var' &&
|
||||
(normalizedPath.startsWith('/var/tmp') ||
|
||||
normalizedPath.startsWith('/var/folders'))) {
|
||||
continue; // Allow these specific cases
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot create workspace in system directory: ${forbidden}`
|
||||
};
|
||||
}
|
||||
const normalizedPath = path.resolve(String(workspacePath).trim());
|
||||
const validation = await validateWorkspacePath(normalizedPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid workspace path',
|
||||
details: validation.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to resolve the real path (following symlinks)
|
||||
let realPath;
|
||||
try {
|
||||
// Check if path exists to resolve real path
|
||||
await fs.access(absolutePath);
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Path doesn't exist yet - check parent directory
|
||||
let parentPath = path.dirname(absolutePath);
|
||||
try {
|
||||
const parentRealPath = await fs.realpath(parentPath);
|
||||
const safePath = validation.resolvedPath || normalizedPath;
|
||||
const normalizedCustomName =
|
||||
typeof customWorkspaceName === 'string' && customWorkspaceName.trim()
|
||||
? customWorkspaceName.trim()
|
||||
: null;
|
||||
|
||||
// Reconstruct the full path with real parent
|
||||
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||
} catch (parentError) {
|
||||
if (parentError.code === 'ENOENT') {
|
||||
// Parent doesn't exist either - use the absolute path as-is
|
||||
// We'll validate it's within allowed root
|
||||
realPath = absolutePath;
|
||||
} else {
|
||||
throw parentError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the workspace root to its real path
|
||||
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||
|
||||
// Ensure the resolved path is contained within the allowed workspace root
|
||||
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realPath !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
|
||||
};
|
||||
}
|
||||
|
||||
// Additional symlink check for existing paths
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Verify symlink target is also within allowed root
|
||||
const linkTarget = await fs.readlink(absolutePath);
|
||||
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||
const realTarget = await fs.realpath(resolvedTarget);
|
||||
|
||||
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realTarget !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Symlink target is outside the allowed workspace root'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - that's fine for new workspace creation
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
resolvedPath: realPath
|
||||
};
|
||||
workspaceOriginalPathsDb.updateCustomWorkspaceName(safePath, normalizedCustomName);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Workspace name updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Path validation failed: ${error.message}`
|
||||
};
|
||||
console.error('Error updating workspace name:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update workspace name',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new workspace
|
||||
* Create or add a workspace
|
||||
* POST /api/projects/create-workspace
|
||||
*
|
||||
* Body:
|
||||
* - workspaceType: 'existing' | 'new'
|
||||
* - path: string (workspace path)
|
||||
* - githubUrl?: string (optional, for new workspaces)
|
||||
* - githubTokenId?: number (optional, ID of stored token)
|
||||
* - newGithubToken?: string (optional, one-time token)
|
||||
*/
|
||||
router.post('/create-workspace', async (req, res) => {
|
||||
try {
|
||||
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
||||
const { path: workspacePath } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!workspaceType || !workspacePath) {
|
||||
return res.status(400).json({ error: 'workspaceType and path are required' });
|
||||
if (!workspacePath) {
|
||||
return res.status(400).json({ error: 'path is required' });
|
||||
}
|
||||
|
||||
if (!['existing', 'new'].includes(workspaceType)) {
|
||||
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
|
||||
// Cloning must go through the SSE clone endpoint.
|
||||
if (req.body.githubUrl || req.body.githubTokenId || req.body.newGithubToken) {
|
||||
return res.status(400).json({
|
||||
error: 'Git clone options are not supported on /create-workspace. Use /clone-progress instead.',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path safety before any operations
|
||||
@@ -197,107 +85,27 @@ router.post('/create-workspace', async (req, res) => {
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
// Handle existing workspace
|
||||
if (workspaceType === 'existing') {
|
||||
// Check if the path exists
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Workspace path does not exist' });
|
||||
}
|
||||
// Add existing workspace or create it if it doesn't exist.
|
||||
let workspaceAlreadyExists = false;
|
||||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||
}
|
||||
workspaceAlreadyExists = true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Add the existing workspace to the project list
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'Existing workspace added successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle new workspace creation
|
||||
if (workspaceType === 'new') {
|
||||
// Create the directory if it doesn't exist
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
// If GitHub URL is provided, clone the repository
|
||||
if (githubUrl) {
|
||||
let githubToken = null;
|
||||
|
||||
// Get GitHub token if needed
|
||||
if (githubTokenId) {
|
||||
// Fetch token from database
|
||||
const token = await getGithubTokenById(githubTokenId, req.user.id);
|
||||
if (!token) {
|
||||
// Clean up created directory
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
return res.status(404).json({ error: 'GitHub token not found' });
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
// Extract repo name from URL for the clone destination
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
return res.status(409).json({
|
||||
error: 'Directory already exists',
|
||||
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
||||
});
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Clone the repository into a subfolder
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
||||
} catch (error) {
|
||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||
try {
|
||||
const stats = await fs.stat(clonePath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
// Directory doesn't exist or cleanup failed - ignore
|
||||
}
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
|
||||
// Add the cloned repo path to the project list
|
||||
const project = await addProjectManually(clonePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created and repository cloned successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new workspace to the project list (no clone)
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created successfully'
|
||||
});
|
||||
}
|
||||
workspaceOriginalPathsDb.createWorkspacePath(absolutePath, getWorkspaceNameFromPath(absolutePath));
|
||||
return res.json({
|
||||
success: true,
|
||||
message: workspaceAlreadyExists ? 'Workspace added successfully' : 'Workspace created successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
@@ -347,13 +155,25 @@ router.get('/clone-progress', async (req, res) => {
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
try {
|
||||
const existingPathStats = await fs.stat(absolutePath);
|
||||
if (!existingPathStats.isDirectory()) {
|
||||
sendEvent('error', { message: 'Path exists but is not a directory' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let githubToken = null;
|
||||
if (githubTokenId) {
|
||||
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||
if (!token) {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
sendEvent('error', { message: 'GitHub token not found' });
|
||||
res.end();
|
||||
return;
|
||||
@@ -419,10 +239,10 @@ router.get('/clone-progress', async (req, res) => {
|
||||
gitProcess.on('close', async (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const project = await addProjectManually(clonePath);
|
||||
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
||||
workspaceOriginalPathsDb.createWorkspacePath(clonePath, getWorkspaceNameFromPath(clonePath));
|
||||
sendEvent('complete', { message: 'Repository cloned successfully' });
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
||||
sendEvent('error', { message: `Clone succeeded but failed to register workspace: ${error.message}` });
|
||||
}
|
||||
} else {
|
||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||
@@ -465,71 +285,4 @@ router.get('/clone-progress', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to clone a GitHub repository
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
let errorMessage = 'Git clone failed';
|
||||
|
||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your GitHub token.';
|
||||
} else if (stderr.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (stderr.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (stderr) {
|
||||
errorMessage = stderr;
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
reject(new Error('Git is not installed or not in PATH'));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
159
server/src/modules/projects/projects.utils.ts
Normal file
159
server/src/modules/projects/projects.utils.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/usr',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/var',
|
||||
'/boot',
|
||||
'/root',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin',
|
||||
];
|
||||
|
||||
export const getWorkspaceNameFromPath = (workspacePath: string): string => {
|
||||
const trimmed = workspacePath.trim();
|
||||
const normalizedPath = path.normalize(trimmed).replace(/[\\/]+$/, '');
|
||||
const baseName = path.basename(normalizedPath);
|
||||
return baseName || normalizedPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a path is safe for workspace operations.
|
||||
*/
|
||||
export async function validateWorkspacePath(requestedPath: string): Promise<{
|
||||
valid: boolean;
|
||||
resolvedPath?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
const absolutePath = path.resolve(requestedPath);
|
||||
|
||||
// Check if path is a forbidden system directory
|
||||
const normalizedPath = path.normalize(absolutePath);
|
||||
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot use system-critical directories as workspace locations',
|
||||
};
|
||||
}
|
||||
|
||||
// Additional check for paths starting with forbidden directories
|
||||
for (const forbidden of FORBIDDEN_PATHS) {
|
||||
if (normalizedPath === forbidden || normalizedPath.startsWith(forbidden + path.sep)) {
|
||||
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||
// but /var itself and most /var subdirectories should be blocked
|
||||
if (
|
||||
forbidden === '/var' &&
|
||||
(normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot create workspace in system directory: ${forbidden}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to resolve the real path (following symlinks)
|
||||
let realPath: string;
|
||||
try {
|
||||
// Check if path exists to resolve real path
|
||||
await fs.access(absolutePath);
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Path doesn't exist yet - check parent directory
|
||||
const parentPath = path.dirname(absolutePath);
|
||||
try {
|
||||
const parentRealPath = await fs.realpath(parentPath);
|
||||
|
||||
// Reconstruct the full path with real parent
|
||||
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||
} catch (parentError: any) {
|
||||
if (parentError.code === 'ENOENT') {
|
||||
// Parent doesn't exist either - use the absolute path as-is
|
||||
// We'll validate it's within allowed root
|
||||
realPath = absolutePath;
|
||||
} else {
|
||||
throw parentError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the workspace root to its real path
|
||||
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||
|
||||
// Ensure the resolved path is contained within the allowed workspace root
|
||||
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && realPath !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Additional symlink check for existing paths
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Verify symlink target is also within allowed root
|
||||
const linkTarget = await fs.readlink(absolutePath);
|
||||
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||
const realTarget = await fs.realpath(resolvedTarget);
|
||||
|
||||
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && realTarget !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Symlink target is outside the allowed workspace root',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - that's fine for new workspace creation
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
resolvedPath: realPath,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Path validation failed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ import { getConnection } from '@/shared/database/connection.js';
|
||||
import type { WorkspaceOriginalPathRow } from '@/shared/database/types.js';
|
||||
|
||||
export const workspaceOriginalPathsDb = {
|
||||
createWorkspacePath(workspacePath: string): void {
|
||||
createWorkspacePath(workspacePath: string, customWorkspaceName: string | null = null): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
INSERT INTO workspace_original_paths (workspace_path)
|
||||
VALUES (?)
|
||||
ON CONFLICT(workspace_path) DO NOTHING
|
||||
`).run(workspacePath);
|
||||
INSERT INTO workspace_original_paths (workspace_path, custom_workspace_name)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(workspace_path) DO UPDATE SET
|
||||
custom_workspace_name = CASE
|
||||
WHEN workspace_original_paths.custom_workspace_name IS NULL OR workspace_original_paths.custom_workspace_name = ''
|
||||
THEN excluded.custom_workspace_name
|
||||
ELSE workspace_original_paths.custom_workspace_name
|
||||
END
|
||||
`).run(workspacePath, customWorkspaceName);
|
||||
},
|
||||
|
||||
getCustomWorkspaceName(workspacePath: string): string | null {
|
||||
@@ -30,4 +35,4 @@ export const workspaceOriginalPathsDb = {
|
||||
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
|
||||
`).run(workspacePath, customWorkspaceName);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user