feat: Add process abort control and improve auth detection

This commit is contained in:
gsxdsm
2026-02-18 20:48:37 -08:00
parent 4ee160fae4
commit 15ca1eb6d3
24 changed files with 706 additions and 498 deletions

View File

@@ -9,11 +9,9 @@
* the requireGitRepoOnly middleware in index.ts
*/
import path from 'path';
import fs from 'fs/promises';
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -32,7 +30,7 @@ export function createStageFilesHandler() {
return;
}
if (!files || files.length === 0) {
if (!Array.isArray(files) || files.length === 0) {
res.status(400).json({
success: false,
error: 'files array required and must not be empty',
@@ -40,6 +38,16 @@ export function createStageFilesHandler() {
return;
}
for (const file of files) {
if (typeof file !== 'string' || file.trim() === '') {
res.status(400).json({
success: false,
error: 'Each element of files must be a non-empty string',
});
return;
}
}
if (operation !== 'stage' && operation !== 'unstage') {
res.status(400).json({
success: false,
@@ -48,73 +56,17 @@ export function createStageFilesHandler() {
return;
}
// Canonicalize the worktree root by resolving symlinks so that
// path-traversal checks are reliable even when symlinks are involved.
let canonicalRoot: string;
try {
canonicalRoot = await fs.realpath(worktreePath);
} catch {
res.status(400).json({
success: false,
error: 'worktreePath does not exist or is not accessible',
});
return;
}
// Validate and sanitize each file path to prevent path traversal attacks.
// Each file entry is resolved against the canonicalized worktree root and
// must remain within that root directory.
const base = canonicalRoot + path.sep;
const sanitizedFiles: string[] = [];
for (const file of files) {
// Reject absolute paths
if (path.isAbsolute(file)) {
res.status(400).json({
success: false,
error: `Invalid file path (absolute paths not allowed): ${file}`,
});
return;
}
// Reject entries containing '..'
if (file.includes('..')) {
res.status(400).json({
success: false,
error: `Invalid file path (path traversal not allowed): ${file}`,
});
return;
}
// Resolve the file path against the canonicalized worktree root and
// ensure the result stays within the worktree directory.
const resolved = path.resolve(canonicalRoot, file);
if (resolved !== canonicalRoot && !resolved.startsWith(base)) {
res.status(400).json({
success: false,
error: `Invalid file path (outside worktree directory): ${file}`,
});
return;
}
// Forward only the original relative path to git — git interprets
// paths relative to its working directory (canonicalRoot / worktreePath),
// so we do not need to pass the resolved absolute path.
sanitizedFiles.push(file);
}
if (operation === 'stage') {
// Stage the specified files
await execGitCommand(['add', '--', ...sanitizedFiles], worktreePath);
} else {
// Unstage the specified files
await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], worktreePath);
}
const result = await stageFiles(worktreePath, files, operation);
res.json({
success: true,
result: {
operation,
filesCount: sanitizedFiles.length,
},
result,
});
} catch (error) {
if (error instanceof StageFilesValidationError) {
res.status(400).json({ success: false, error: error.message });
return;
}
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}