Merge branch 'main' into feature/shared-packages

This commit is contained in:
Test User
2025-12-20 23:55:03 -05:00
76 changed files with 1898 additions and 893 deletions

View File

@@ -0,0 +1,23 @@
/**
* Re-export secure file system utilities from @automaker/platform
* This file exists for backward compatibility with existing imports
*/
import { secureFs } from "@automaker/platform";
export const {
access,
readFile,
writeFile,
mkdir,
readdir,
stat,
rm,
unlink,
copyFile,
appendFile,
rename,
lstat,
joinPath,
resolvePath,
} = secureFs;

View File

@@ -0,0 +1,69 @@
/**
* Middleware for validating path parameters against ALLOWED_ROOT_DIRECTORY
* Provides a clean, reusable way to validate paths without repeating the same
* try-catch block in every route handler
*/
import type { Request, Response, NextFunction } from "express";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
/**
* Creates a middleware that validates specified path parameters in req.body
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
* @example
* router.post('/create', validatePathParams('projectPath'), handler);
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
*
* Special syntax:
* - 'paramName?' - Optional parameter (only validated if present)
* - 'paramName[]' - Array parameter (validates each element)
*/
export function validatePathParams(...paramNames: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
for (const paramName of paramNames) {
// Handle optional parameters (paramName?)
if (paramName.endsWith("?")) {
const actualName = paramName.slice(0, -1);
const value = req.body[actualName];
if (value) {
validatePath(value);
}
continue;
}
// Handle array parameters (paramName[])
if (paramName.endsWith("[]")) {
const actualName = paramName.slice(0, -2);
const values = req.body[actualName];
if (Array.isArray(values) && values.length > 0) {
for (const value of values) {
validatePath(value);
}
}
continue;
}
// Handle regular parameters
const value = req.body[paramName];
if (value) {
validatePath(value);
}
}
next();
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({
success: false,
error: error.message,
});
return;
}
// Re-throw unexpected errors
throw error;
}
};
}

View File

@@ -5,6 +5,7 @@
import { Router } from "express";
import { AgentService } from "../../services/agent-service.js";
import type { EventEmitter } from "../../lib/events.js";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createStartHandler } from "./routes/start.js";
import { createSendHandler } from "./routes/send.js";
import { createHistoryHandler } from "./routes/history.js";
@@ -18,8 +19,8 @@ export function createAgentRoutes(
): Router {
const router = Router();
router.post("/start", createStartHandler(agentService));
router.post("/send", createSendHandler(agentService));
router.post("/start", validatePathParams("workingDirectory?"), createStartHandler(agentService));
router.post("/send", validatePathParams("workingDirectory?", "imagePaths[]"), createSendHandler(agentService));
router.post("/history", createHistoryHandler(agentService));
router.post("/stop", createStopHandler(agentService));
router.post("/clear", createClearHandler(agentService));

View File

@@ -6,7 +6,6 @@ import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { createLogger } from "@automaker/utils";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger("Agent");
export function createSendHandler(agentService: AgentService) {

View File

@@ -6,7 +6,6 @@ import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { createLogger } from "@automaker/utils";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger("Agent");
export function createStartHandler(agentService: AgentService) {

View File

@@ -6,6 +6,7 @@
import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js";
@@ -21,18 +22,19 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));
router.post("/verify-feature", createVerifyFeatureHandler(autoModeService));
router.post("/resume-feature", createResumeFeatureHandler(autoModeService));
router.post("/context-exists", createContextExistsHandler(autoModeService));
router.post("/analyze-project", createAnalyzeProjectHandler(autoModeService));
router.post("/status", validatePathParams("projectPath?"), createStatusHandler(autoModeService));
router.post("/run-feature", validatePathParams("projectPath"), createRunFeatureHandler(autoModeService));
router.post("/verify-feature", validatePathParams("projectPath"), createVerifyFeatureHandler(autoModeService));
router.post("/resume-feature", validatePathParams("projectPath"), createResumeFeatureHandler(autoModeService));
router.post("/context-exists", validatePathParams("projectPath"), createContextExistsHandler(autoModeService));
router.post("/analyze-project", validatePathParams("projectPath"), createAnalyzeProjectHandler(autoModeService));
router.post(
"/follow-up-feature",
validatePathParams("projectPath", "imagePaths[]"),
createFollowUpFeatureHandler(autoModeService)
);
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
router.post("/commit-feature", validatePathParams("projectPath", "worktreePath?"), createCommitFeatureHandler(autoModeService));
router.post("/approve-plan", validatePathParams("projectPath"), createApprovePlanHandler(autoModeService));
return router;
}

View File

@@ -4,6 +4,7 @@
import { Router } from "express";
import { FeatureLoader } from "../../services/feature-loader.js";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createListHandler } from "./routes/list.js";
import { createGetHandler } from "./routes/get.js";
import { createCreateHandler } from "./routes/create.js";
@@ -15,11 +16,11 @@ import { createGenerateTitleHandler } from "./routes/generate-title.js";
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
const router = Router();
router.post("/list", createListHandler(featureLoader));
router.post("/get", createGetHandler(featureLoader));
router.post("/create", createCreateHandler(featureLoader));
router.post("/update", createUpdateHandler(featureLoader));
router.post("/delete", createDeleteHandler(featureLoader));
router.post("/list", validatePathParams("projectPath"), createListHandler(featureLoader));
router.post("/get", validatePathParams("projectPath"), createGetHandler(featureLoader));
router.post("/create", validatePathParams("projectPath"), createCreateHandler(featureLoader));
router.post("/update", validatePathParams("projectPath"), createUpdateHandler(featureLoader));
router.post("/delete", validatePathParams("projectPath"), createDeleteHandler(featureLoader));
router.post("/agent-output", createAgentOutputHandler(featureLoader));
router.post("/generate-title", createGenerateTitleHandler());

View File

@@ -5,7 +5,6 @@
import type { Request, Response } from "express";
import { FeatureLoader } from "../../../services/feature-loader.js";
import type { Feature } from "@automaker/types";
import { addAllowedPath } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createCreateHandler(featureLoader: FeatureLoader) {
@@ -17,18 +16,13 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
};
if (!projectPath || !feature) {
res
.status(400)
.json({
success: false,
error: "projectPath and feature are required",
});
res.status(400).json({
success: false,
error: "projectPath and feature are required",
});
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const created = await featureLoader.create(projectPath, feature);
res.json({ success: true, feature: created });
} catch (error) {

View File

@@ -4,7 +4,6 @@
import type { Request, Response } from "express";
import { FeatureLoader } from "../../../services/feature-loader.js";
import { addAllowedPath } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createListHandler(featureLoader: FeatureLoader) {
@@ -19,9 +18,6 @@ export function createListHandler(featureLoader: FeatureLoader) {
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const features = await featureLoader.getAll(projectPath);
res.json({ success: true, features });
} catch (error) {

View File

@@ -6,6 +6,11 @@ import type { Request, Response } from "express";
import fs from "fs/promises";
import os from "os";
import path from "path";
import {
getAllowedRootDirectory,
isPathAllowed,
PathNotAllowedError,
} from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createBrowseHandler() {
@@ -13,8 +18,14 @@ export function createBrowseHandler() {
try {
const { dirPath } = req.body as { dirPath?: string };
// Default to home directory if no path provided
const targetPath = dirPath ? path.resolve(dirPath) : os.homedir();
// Default to ALLOWED_ROOT_DIRECTORY if set, otherwise home directory
const defaultPath = getAllowedRootDirectory() || os.homedir();
const targetPath = dirPath ? path.resolve(dirPath) : defaultPath;
// Validate that the path is allowed
if (!isPathAllowed(targetPath)) {
throw new PathNotAllowedError(dirPath || targetPath);
}
// Detect available drives on Windows
const detectDrives = async (): Promise<string[]> => {
@@ -100,6 +111,12 @@ export function createBrowseHandler() {
}
}
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Browse directories failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "@automaker/platform";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createDeleteHandler() {
@@ -22,6 +22,12 @@ export function createDeleteHandler() {
res.json({ success: true });
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Delete file failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createExistsHandler() {
@@ -17,10 +18,13 @@ export function createExistsHandler() {
return;
}
// For exists, we check but don't require the path to be pre-allowed
// This allows the UI to validate user-entered paths
const resolvedPath = path.resolve(filePath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(filePath);
}
try {
await fs.access(resolvedPath);
res.json({ success: true, exists: true });
@@ -28,6 +32,12 @@ export function createExistsHandler() {
res.json({ success: true, exists: false });
}
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Check exists failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -6,7 +6,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "@automaker/platform";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createMkdirHandler() {
@@ -21,12 +21,16 @@ export function createMkdirHandler() {
const resolvedPath = path.resolve(dirPath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(dirPath);
}
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
addAllowedPath(resolvedPath);
res.json({ success: true });
return;
}
@@ -47,11 +51,14 @@ export function createMkdirHandler() {
// Path doesn't exist, create it
await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths for tracking
addAllowedPath(resolvedPath);
res.json({ success: true });
} catch (error: any) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Handle ELOOP specifically
if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected");

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "@automaker/platform";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
// Optional files that are expected to not exist in new projects
@@ -39,6 +39,12 @@ export function createReadHandler() {
res.json({ success: true, content });
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
if (shouldLog) {

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "@automaker/platform";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createReaddirHandler() {
@@ -28,6 +28,12 @@ export function createReaddirHandler() {
res.json({ success: true, entries: result });
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Read directory failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -5,7 +5,6 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createResolveDirectoryHandler() {
@@ -30,7 +29,6 @@ export function createResolveDirectoryHandler() {
const resolvedPath = path.resolve(directoryName);
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
addAllowedPath(resolvedPath);
res.json({
success: true,
path: resolvedPath,
@@ -102,7 +100,6 @@ export function createResolveDirectoryHandler() {
}
// Found matching directory
addAllowedPath(candidatePath);
res.json({
success: true,
path: candidatePath,

View File

@@ -5,7 +5,6 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "@automaker/platform";
@@ -43,9 +42,6 @@ export function createSaveBoardBackgroundHandler() {
// Write file
await fs.writeFile(filePath, buffer);
// Add board directory to allowed paths
addAllowedPath(boardDir);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {

View File

@@ -5,7 +5,6 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import { getImagesDir } from "@automaker/platform";
@@ -45,9 +44,6 @@ export function createSaveImageHandler() {
// Write file
await fs.writeFile(filePath, buffer);
// Add automaker directory to allowed paths
addAllowedPath(imagesDir);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath } from "@automaker/platform";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createStatHandler() {
@@ -30,6 +30,12 @@ export function createStatHandler() {
},
});
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Get file stats failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath, isPathAllowed } from "@automaker/platform";
import { isPathAllowed } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createValidatePathHandler() {
@@ -31,9 +31,6 @@ export function createValidatePathHandler() {
return;
}
// Add to allowed paths
addAllowedPath(resolvedPath);
res.json({
success: true,
path: resolvedPath,

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { validatePath } from "@automaker/platform";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { mkdirSafe } from "@automaker/utils";
import { getErrorMessage, logError } from "../common.js";
@@ -30,6 +30,12 @@ export function createWriteHandler() {
res.json({ success: true });
} catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Write file failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -3,14 +3,15 @@
*/
import { Router } from "express";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createDiffsHandler } from "./routes/diffs.js";
import { createFileDiffHandler } from "./routes/file-diff.js";
export function createGitRoutes(): Router {
const router = Router();
router.post("/diffs", createDiffsHandler());
router.post("/file-diff", createFileDiffHandler());
router.post("/diffs", validatePathParams("projectPath"), createDiffsHandler());
router.post("/file-diff", validatePathParams("projectPath", "filePath"), createFileDiffHandler());
return router;
}

View File

@@ -17,10 +17,6 @@ export function createProvidersHandler() {
available: statuses.claude?.installed || false,
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
},
google: {
available: !!process.env.GOOGLE_API_KEY,
hasApiKey: !!process.env.GOOGLE_API_KEY,
},
};
res.json({ success: true, providers });

View File

@@ -14,6 +14,7 @@
import { Router } from "express";
import type { SettingsService } from "../../services/settings-service.js";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createGetGlobalHandler } from "./routes/get-global.js";
import { createUpdateGlobalHandler } from "./routes/update-global.js";
import { createGetCredentialsHandler } from "./routes/get-credentials.js";
@@ -57,8 +58,8 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
router.put("/credentials", createUpdateCredentialsHandler(settingsService));
// Project settings
router.post("/project", createGetProjectHandler(settingsService));
router.put("/project", createUpdateProjectHandler(settingsService));
router.post("/project", validatePathParams("projectPath"), createGetProjectHandler(settingsService));
router.put("/project", validatePathParams("projectPath"), createUpdateProjectHandler(settingsService));
// Migration from localStorage
router.post("/migrate", createMigrateHandler(settingsService));

View File

@@ -5,7 +5,7 @@
* Each provider shows: `{ configured: boolean, masked: string }`
* Masked shows first 4 and last 4 characters for verification.
*
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
* Response: `{ "success": true, "credentials": { anthropic } }`
*/
import type { Request, Response } from "express";

View File

@@ -1,11 +1,11 @@
/**
* PUT /api/settings/credentials - Update API credentials
*
* Updates API keys for Anthropic, Google, or OpenAI. Partial updates supported.
* Updates API keys for Anthropic. Partial updates supported.
* Returns masked credentials for verification without exposing full keys.
*
* Request body: `Partial<Credentials>` (usually just apiKeys)
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
* Response: `{ "success": true, "credentials": { anthropic } }`
*/
import type { Request, Response } from "express";

View File

@@ -12,7 +12,6 @@ export function createApiKeysHandler() {
success: true,
hasAnthropicKey:
!!getApiKey("anthropic") || !!process.env.ANTHROPIC_API_KEY,
hasGoogleKey: !!getApiKey("google") || !!process.env.GOOGLE_API_KEY,
});
} catch (error) {
logError(error, "Get API keys failed");

View File

@@ -64,15 +64,13 @@ export function createDeleteApiKeyHandler() {
// Map provider to env key name
const envKeyMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY",
openai: "OPENAI_API_KEY",
};
const envKey = envKeyMap[provider];
if (!envKey) {
res.status(400).json({
success: false,
error: `Unknown provider: ${provider}`,
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
});
return;
}

View File

@@ -36,9 +36,12 @@ export function createStoreApiKeyHandler() {
process.env.ANTHROPIC_API_KEY = apiKey;
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
logger.info("[Setup] Stored API key as ANTHROPIC_API_KEY");
} else if (provider === "google") {
process.env.GOOGLE_API_KEY = apiKey;
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
} else {
res.status(400).json({
success: false,
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
});
return;
}
res.json({ success: true });

View File

@@ -4,6 +4,7 @@
import { Router } from "express";
import type { EventEmitter } from "../../lib/events.js";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createGenerateHandler } from "./routes/generate.js";
import { createStopHandler } from "./routes/stop.js";
import { createStatusHandler } from "./routes/status.js";
@@ -11,7 +12,7 @@ import { createStatusHandler } from "./routes/status.js";
export function createSuggestionsRoutes(events: EventEmitter): Router {
const router = Router();
router.post("/generate", createGenerateHandler(events));
router.post("/generate", validatePathParams("projectPath"), createGenerateHandler(events));
router.post("/stop", createStopHandler());
router.get("/status", createStatusHandler());

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from "express";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import { addAllowedPath } from "@automaker/platform";
import { isPathAllowed } from "@automaker/platform";
import { logger, getErrorMessage, logError } from "../common.js";
export function createCloneHandler() {
@@ -63,6 +63,24 @@ export function createCloneHandler() {
return;
}
// Validate that parent directory is within allowed root directory
if (!isPathAllowed(resolvedParent)) {
res.status(403).json({
success: false,
error: `Parent directory not allowed: ${parentDir}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Validate that project path will be within allowed root directory
if (!isPathAllowed(resolvedProject)) {
res.status(403).json({
success: false,
error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Check if directory already exists
try {
await fs.access(projectPath);
@@ -186,9 +204,6 @@ export function createCloneHandler() {
});
});
// Add to allowed paths
addAllowedPath(projectPath);
logger.info(`[Templates] Successfully cloned template to ${projectPath}`);
res.json({

View File

@@ -4,47 +4,53 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import { addAllowedPath } from "@automaker/platform";
import path from "path";
import {
getAllowedRootDirectory,
getDataDirectory,
} from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createConfigHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const workspaceDir = process.env.WORKSPACE_DIR;
const allowedRootDirectory = getAllowedRootDirectory();
const dataDirectory = getDataDirectory();
if (!workspaceDir) {
if (!allowedRootDirectory) {
// When ALLOWED_ROOT_DIRECTORY is not set, return DATA_DIR as default directory
res.json({
success: true,
configured: false,
defaultDir: dataDirectory || null,
});
return;
}
// Check if the directory exists
try {
const stats = await fs.stat(workspaceDir);
const resolvedWorkspaceDir = path.resolve(allowedRootDirectory);
const stats = await fs.stat(resolvedWorkspaceDir);
if (!stats.isDirectory()) {
res.json({
success: true,
configured: false,
error: "WORKSPACE_DIR is not a valid directory",
error: "ALLOWED_ROOT_DIRECTORY is not a valid directory",
});
return;
}
// Add workspace dir to allowed paths
addAllowedPath(workspaceDir);
res.json({
success: true,
configured: true,
workspaceDir,
workspaceDir: resolvedWorkspaceDir,
defaultDir: resolvedWorkspaceDir,
});
} catch {
res.json({
success: true,
configured: false,
error: "WORKSPACE_DIR path does not exist",
error: "ALLOWED_ROOT_DIRECTORY path does not exist",
});
}
} catch (error) {

View File

@@ -5,51 +5,49 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "@automaker/platform";
import { getAllowedRootDirectory } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
export function createDirectoriesHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const workspaceDir = process.env.WORKSPACE_DIR;
const allowedRootDirectory = getAllowedRootDirectory();
if (!workspaceDir) {
if (!allowedRootDirectory) {
res.status(400).json({
success: false,
error: "WORKSPACE_DIR is not configured",
error: "ALLOWED_ROOT_DIRECTORY is not configured",
});
return;
}
const resolvedWorkspaceDir = path.resolve(allowedRootDirectory);
// Check if directory exists
try {
await fs.stat(workspaceDir);
await fs.stat(resolvedWorkspaceDir);
} catch {
res.status(400).json({
success: false,
error: "WORKSPACE_DIR path does not exist",
error: "Workspace directory path does not exist",
});
return;
}
// Add workspace dir to allowed paths
addAllowedPath(workspaceDir);
// Read directory contents
const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
const entries = await fs.readdir(resolvedWorkspaceDir, {
withFileTypes: true,
});
// Filter to directories only and map to result format
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({
name: entry.name,
path: path.join(workspaceDir, entry.name),
path: path.join(resolvedWorkspaceDir, entry.name),
}))
.sort((a, b) => a.name.localeCompare(b.name));
// Add each directory to allowed paths
directories.forEach((dir) => addAllowedPath(dir.path));
res.json({
success: true,
directories,

View File

@@ -3,6 +3,7 @@
*/
import { Router } from "express";
import { validatePathParams } from "../../middleware/validate-paths.js";
import { createInfoHandler } from "./routes/info.js";
import { createStatusHandler } from "./routes/status.js";
import { createListHandler } from "./routes/list.js";
@@ -32,27 +33,27 @@ import { createListDevServersHandler } from "./routes/list-dev-servers.js";
export function createWorktreeRoutes(): Router {
const router = Router();
router.post("/info", createInfoHandler());
router.post("/status", createStatusHandler());
router.post("/info", validatePathParams("projectPath"), createInfoHandler());
router.post("/status", validatePathParams("projectPath"), createStatusHandler());
router.post("/list", createListHandler());
router.post("/diffs", createDiffsHandler());
router.post("/file-diff", createFileDiffHandler());
router.post("/merge", createMergeHandler());
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/diffs", validatePathParams("projectPath"), createDiffsHandler());
router.post("/file-diff", validatePathParams("projectPath", "filePath"), createFileDiffHandler());
router.post("/merge", validatePathParams("projectPath"), createMergeHandler());
router.post("/create", validatePathParams("projectPath"), createCreateHandler());
router.post("/delete", validatePathParams("projectPath", "worktreePath"), createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/pr-info", createPRInfoHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());
router.post("/commit", validatePathParams("worktreePath"), createCommitHandler());
router.post("/push", validatePathParams("worktreePath"), createPushHandler());
router.post("/pull", validatePathParams("worktreePath"), createPullHandler());
router.post("/checkout-branch", createCheckoutBranchHandler());
router.post("/list-branches", createListBranchesHandler());
router.post("/list-branches", validatePathParams("worktreePath"), createListBranchesHandler());
router.post("/switch-branch", createSwitchBranchHandler());
router.post("/open-in-editor", createOpenInEditorHandler());
router.post("/open-in-editor", validatePathParams("worktreePath"), createOpenInEditorHandler());
router.get("/default-editor", createGetDefaultEditorHandler());
router.post("/init-git", createInitGitHandler());
router.post("/init-git", validatePathParams("projectPath"), createInitGitHandler());
router.post("/migrate", createMigrateHandler());
router.post("/start-dev", createStartDevHandler());
router.post("/start-dev", validatePathParams("projectPath", "worktreePath"), createStartDevHandler());
router.post("/stop-dev", createStopDevHandler());
router.post("/list-dev-servers", createListDevServersHandler());

View File

@@ -4,12 +4,17 @@
*/
import path from "path";
import fs from "fs/promises";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import type { ExecuteOptions } from "@automaker/types";
import { readImageAsBase64, buildPromptWithImages, isAbortError } from "@automaker/utils";
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
} from "@automaker/utils";
import { ProviderFactory } from "../providers/provider-factory.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
interface Message {
id: string;
@@ -59,7 +64,7 @@ export class AgentService {
}
async initialize(): Promise<void> {
await fs.mkdir(this.stateDir, { recursive: true });
await secureFs.mkdir(this.stateDir, { recursive: true });
}
/**
@@ -77,11 +82,22 @@ export class AgentService {
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new Error(
`Working directory ${effectiveWorkingDirectory} is not allowed`
);
}
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || process.cwd(),
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
});
}
@@ -388,7 +404,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
const data = (await secureFs.readFile(sessionFile, "utf-8")) as string;
return JSON.parse(data);
} catch {
return [];
@@ -399,7 +415,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.writeFile(
await secureFs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
@@ -412,7 +428,10 @@ export class AgentService {
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = await fs.readFile(this.metadataFile, "utf-8");
const data = (await secureFs.readFile(
this.metadataFile,
"utf-8"
)) as string;
return JSON.parse(data);
} catch {
return {};
@@ -420,7 +439,7 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.writeFile(
await secureFs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
@@ -458,11 +477,29 @@ export class AgentService {
const sessionId = this.generateId();
const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory =
workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
}
// Validate that projectPath is allowed if provided
if (projectPath) {
const resolvedProjectPath = path.resolve(projectPath);
if (!isPathAllowed(resolvedProjectPath)) {
throw new PathNotAllowedError(projectPath);
}
}
const session: SessionMetadata = {
id: sessionId,
name,
projectPath,
workingDirectory: workingDirectory || projectPath || process.cwd(),
workingDirectory: resolvedWorkingDirectory,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
model,
@@ -521,7 +558,7 @@ export class AgentService {
// Delete session file
try {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
await fs.unlink(sessionFile);
await secureFs.unlink(sessionFile);
} catch {
// File may not exist
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@
*/
import path from "path";
import fs from "fs/promises";
import type { Feature } from "@automaker/types";
import { createLogger } from "@automaker/utils";
import * as secureFs from "../lib/secure-fs.js";
import {
getFeaturesDir,
getFeatureDir,
@@ -39,8 +39,12 @@ export class FeatureLoader {
*/
private async deleteOrphanedImages(
projectPath: string,
oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined,
newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined
oldPaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined,
newPaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined
): Promise<void> {
if (!oldPaths || oldPaths.length === 0) {
return;
@@ -59,8 +63,8 @@ export class FeatureLoader {
if (!newPathSet.has(oldPath)) {
try {
// Paths are now absolute
await fs.unlink(oldPath);
logger.info(`Deleted orphaned image: ${oldPath}`);
await secureFs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
logger.warn(
@@ -87,10 +91,11 @@ export class FeatureLoader {
}
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
await secureFs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
const updatedPaths: Array<
string | { path: string; [key: string]: unknown }
> = [];
for (const imagePath of imagePaths) {
try {
@@ -110,7 +115,7 @@ export class FeatureLoader {
// Check if file exists
try {
await fs.access(fullOriginalPath);
await secureFs.access(fullOriginalPath);
} catch {
logger.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
@@ -123,14 +128,14 @@ export class FeatureLoader {
const newPath = path.join(featureImagesDir, filename);
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
logger.info(
await secureFs.copyFile(fullOriginalPath, newPath);
console.log(
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
);
// Try to delete the original temp file
try {
await fs.unlink(fullOriginalPath);
await secureFs.unlink(fullOriginalPath);
} catch {
// Ignore errors when deleting temp file
}
@@ -163,14 +168,20 @@ export class FeatureLoader {
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
return path.join(
this.getFeatureDir(projectPath, featureId),
"feature.json"
);
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
return path.join(
this.getFeatureDir(projectPath, featureId),
"agent-output.md"
);
}
/**
@@ -189,13 +200,15 @@ export class FeatureLoader {
// Check if features directory exists
try {
await fs.access(featuresDir);
await secureFs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const entries = (await secureFs.readdir(featuresDir, {
withFileTypes: true,
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
@@ -205,7 +218,10 @@ export class FeatureLoader {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(
featureJsonPath,
"utf-8"
)) as string;
const feature = JSON.parse(content);
if (!feature.id) {
@@ -252,7 +268,10 @@ export class FeatureLoader {
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(
featureJsonPath,
"utf-8"
)) as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -281,7 +300,7 @@ export class FeatureLoader {
await ensureAutomakerDir(projectPath);
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
// Migrate images from temp directory to feature directory
const migratedImagePaths = await this.migrateImages(
@@ -300,7 +319,7 @@ export class FeatureLoader {
};
// Write feature.json
await fs.writeFile(
await secureFs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
@@ -352,7 +371,7 @@ export class FeatureLoader {
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
await secureFs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
@@ -368,8 +387,8 @@ export class FeatureLoader {
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
logger.info(`Deleted feature ${featureId}`);
await secureFs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
logger.error(
@@ -389,7 +408,10 @@ export class FeatureLoader {
): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
const content = (await secureFs.readFile(
agentOutputPath,
"utf-8"
)) as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -412,10 +434,10 @@ export class FeatureLoader {
content: string
): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
await secureFs.writeFile(agentOutputPath, content, "utf-8");
}
/**
@@ -427,7 +449,7 @@ export class FeatureLoader {
): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
await secureFs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;

View File

@@ -7,9 +7,9 @@
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import fs from "fs/promises";
import path from "path";
import { createLogger } from "@automaker/utils";
import * as secureFs from "../lib/secure-fs.js";
import {
getGlobalSettingsPath,
getCredentialsPath,
@@ -47,12 +47,12 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
try {
await fs.writeFile(tempPath, content, "utf-8");
await fs.rename(tempPath, filePath);
await secureFs.writeFile(tempPath, content, "utf-8");
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
@@ -65,7 +65,7 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, "utf-8");
const content = (await secureFs.readFile(filePath, "utf-8")) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -81,7 +81,7 @@ async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
await secureFs.access(filePath);
return true;
} catch {
return false;
@@ -232,9 +232,7 @@ export class SettingsService {
* @param updates - Partial Credentials (usually just apiKeys)
* @returns Promise resolving to complete updated Credentials object
*/
async updateCredentials(
updates: Partial<Credentials>
): Promise<Credentials> {
async updateCredentials(updates: Partial<Credentials>): Promise<Credentials> {
await ensureDataDir(this.dataDir);
const credentialsPath = getCredentialsPath(this.dataDir);
@@ -270,8 +268,6 @@ export class SettingsService {
*/
async getMaskedCredentials(): Promise<{
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
@@ -285,14 +281,6 @@ export class SettingsService {
configured: !!credentials.apiKeys.anthropic,
masked: maskKey(credentials.apiKeys.anthropic),
},
google: {
configured: !!credentials.apiKeys.google,
masked: maskKey(credentials.apiKeys.google),
},
openai: {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
};
}
@@ -563,8 +551,7 @@ export class SettingsService {
// Get theme from project object
const project = projects.find((p) => p.path === projectPath);
if (project?.theme) {
projectSettings.theme =
project.theme as ProjectSettings["theme"];
projectSettings.theme = project.theme as ProjectSettings["theme"];
}
if (boardBackgroundByProject?.[projectPath]) {
@@ -586,7 +573,9 @@ export class SettingsService {
migratedProjectCount++;
}
} catch (e) {
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
errors.push(
`Failed to migrate project settings for ${projectPath}: ${e}`
);
}
}

View File

@@ -22,7 +22,7 @@ export type {
BoardBackgroundSettings,
WorktreeInfo,
ProjectSettings,
} from '@automaker/types';
} from "@automaker/types";
export {
DEFAULT_KEYBOARD_SHORTCUTS,
@@ -32,4 +32,4 @@ export {
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '@automaker/types';
} from "@automaker/types";