Merge branch 'main' into feat/add-unit-testing

This commit is contained in:
Shirone
2025-12-13 19:53:00 +01:00
committed by GitHub
21 changed files with 3933 additions and 30 deletions

View File

@@ -30,9 +30,11 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.js";
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
import { getTerminalService } from "./services/terminal-service.js";
// Load environment variables
dotenv.config();
@@ -116,13 +118,34 @@ app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
app.use("/api/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes());
app.use("/api/terminal", createTerminalRoutes());
// Create HTTP server
const server = createServer(app);
// WebSocket server for streaming events
const wss = new WebSocketServer({ server, path: "/api/events" });
// WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on("upgrade", (request, socket, head) => {
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
if (pathname === "/api/events") {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
} else if (pathname === "/api/terminal/ws") {
terminalWss.handleUpgrade(request, socket, head, (ws) => {
terminalWss.emit("connection", ws, request);
});
} else {
socket.destroy();
}
});
// Events WebSocket connection handler
wss.on("connection", (ws: WebSocket) => {
console.log("[WebSocket] Client connected");
@@ -144,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => {
});
});
// Track WebSocket connections per session
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Terminal WebSocket connection handler
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const token = url.searchParams.get("token");
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled");
return;
}
// Validate token if password is required
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
}
if (!sessionId) {
console.log("[Terminal WS] No session ID provided");
ws.close(4002, "Session ID required");
return;
}
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, "Session not found");
return;
}
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
// Subscribe to terminal data
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data }));
}
});
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
}
});
// Handle incoming messages
ws.on("message", (message) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
case "resize":
// Resize terminal
if (msg.cols && msg.rows) {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
case "ping":
// Respond to ping
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
}
} catch (error) {
console.error("[Terminal WS] Error processing message:", error);
}
});
ws.on("close", () => {
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
}
}
});
ws.on("error", (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
// Send initial connection success
ws.send(JSON.stringify({
type: "connected",
sessionId,
shell: session.shell,
cwd: session.cwd,
}));
// Send scrollback buffer to replay previous output
const scrollback = terminalService.getScrollback(sessionId);
if (scrollback && scrollback.length > 0) {
ws.send(JSON.stringify({
type: "scrollback",
data: scrollback,
}));
}
});
// Start server
server.listen(PORT, () => {
const terminalStatus = isTerminalEnabled()
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
: "disabled";
console.log(`
╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣
║ HTTP API: http://localhost:${PORT}
║ WebSocket: ws://localhost:${PORT}/api/events ║
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
║ Health: http://localhost:${PORT}/api/health ║
║ Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝
`);
});
@@ -160,6 +321,7 @@ server.listen(PORT, () => {
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log("Server closed");
process.exit(0);
@@ -168,6 +330,7 @@ process.on("SIGTERM", () => {
process.on("SIGINT", () => {
console.log("SIGINT received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log("Server closed");
process.exit(0);

View File

@@ -355,6 +355,9 @@ Format your response as markdown. Be specific and actionable.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
@@ -502,6 +505,9 @@ Generate 5-15 features that build on each other logically.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result for features");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {

View File

@@ -0,0 +1,312 @@
/**
* Terminal routes with password protection
*
* Provides REST API for terminal session management and authentication.
* WebSocket connections for real-time I/O are handled separately in index.ts.
*/
import { Router, Request, Response, NextFunction } from "express";
import { getTerminalService } from "../services/terminal-service.js";
// Read env variables lazily to ensure dotenv has loaded them
function getTerminalPassword(): string | undefined {
return process.env.TERMINAL_PASSWORD;
}
function getTerminalEnabledConfig(): boolean {
return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default
}
// In-memory session tokens (would use Redis in production)
const validTokens: Map<string, { createdAt: Date; expiresAt: Date }> = new Map();
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Generate a secure random token
*/
function generateToken(): string {
return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`;
}
/**
* Clean up expired tokens
*/
function cleanupExpiredTokens(): void {
const now = new Date();
validTokens.forEach((data, token) => {
if (data.expiresAt < now) {
validTokens.delete(token);
}
});
}
// Clean up expired tokens every 5 minutes
setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
/**
* Validate a terminal session token
*/
export function validateTerminalToken(token: string | undefined): boolean {
if (!token) return false;
const tokenData = validTokens.get(token);
if (!tokenData) return false;
if (tokenData.expiresAt < new Date()) {
validTokens.delete(token);
return false;
}
return true;
}
/**
* Check if terminal requires password
*/
export function isTerminalPasswordRequired(): boolean {
return !!getTerminalPassword();
}
/**
* Check if terminal is enabled
*/
export function isTerminalEnabled(): boolean {
return getTerminalEnabledConfig();
}
/**
* Terminal authentication middleware
* Checks for valid session token if password is configured
*/
export function terminalAuthMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
// Check if terminal is enabled
if (!getTerminalEnabledConfig()) {
res.status(403).json({
success: false,
error: "Terminal access is disabled",
});
return;
}
// If no password configured, allow all requests
if (!getTerminalPassword()) {
next();
return;
}
// Check for session token
const token =
(req.headers["x-terminal-token"] as string) ||
(req.query.token as string);
if (!validateTerminalToken(token)) {
res.status(401).json({
success: false,
error: "Terminal authentication required",
passwordRequired: true,
});
return;
}
next();
}
export function createTerminalRoutes(): Router {
const router = Router();
const terminalService = getTerminalService();
/**
* GET /api/terminal/status
* Get terminal status (enabled, password required, platform info)
*/
router.get("/status", (_req, res) => {
res.json({
success: true,
data: {
enabled: getTerminalEnabledConfig(),
passwordRequired: !!getTerminalPassword(),
platform: terminalService.getPlatformInfo(),
},
});
});
/**
* POST /api/terminal/auth
* Authenticate with password to get a session token
*/
router.post("/auth", (req, res) => {
if (!getTerminalEnabledConfig()) {
res.status(403).json({
success: false,
error: "Terminal access is disabled",
});
return;
}
const terminalPassword = getTerminalPassword();
// If no password required, return immediate success
if (!terminalPassword) {
res.json({
success: true,
data: {
authenticated: true,
passwordRequired: false,
},
});
return;
}
const { password } = req.body;
if (!password || password !== terminalPassword) {
res.status(401).json({
success: false,
error: "Invalid password",
});
return;
}
// Generate session token
const token = generateToken();
const now = new Date();
validTokens.set(token, {
createdAt: now,
expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS),
});
res.json({
success: true,
data: {
authenticated: true,
token,
expiresIn: TOKEN_EXPIRY_MS,
},
});
});
/**
* POST /api/terminal/logout
* Invalidate a session token
*/
router.post("/logout", (req, res) => {
const token =
(req.headers["x-terminal-token"] as string) ||
req.body.token;
if (token) {
validTokens.delete(token);
}
res.json({
success: true,
});
});
// Apply terminal auth middleware to all routes below
router.use(terminalAuthMiddleware);
/**
* GET /api/terminal/sessions
* List all active terminal sessions
*/
router.get("/sessions", (_req, res) => {
const sessions = terminalService.getAllSessions();
res.json({
success: true,
data: sessions,
});
});
/**
* POST /api/terminal/sessions
* Create a new terminal session
*/
router.post("/sessions", (req, res) => {
try {
const { cwd, cols, rows, shell } = req.body;
const session = terminalService.createSession({
cwd,
cols: cols || 80,
rows: rows || 24,
shell,
});
res.json({
success: true,
data: {
id: session.id,
cwd: session.cwd,
shell: session.shell,
createdAt: session.createdAt,
},
});
} catch (error) {
console.error("[Terminal] Error creating session:", error);
res.status(500).json({
success: false,
error: "Failed to create terminal session",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
/**
* DELETE /api/terminal/sessions/:id
* Kill a terminal session
*/
router.delete("/sessions/:id", (req, res) => {
const { id } = req.params;
const killed = terminalService.killSession(id);
if (!killed) {
res.status(404).json({
success: false,
error: "Session not found",
});
return;
}
res.json({
success: true,
});
});
/**
* POST /api/terminal/sessions/:id/resize
* Resize a terminal session
*/
router.post("/sessions/:id/resize", (req, res) => {
const { id } = req.params;
const { cols, rows } = req.body;
if (!cols || !rows) {
res.status(400).json({
success: false,
error: "cols and rows are required",
});
return;
}
const resized = terminalService.resize(id, cols, rows);
if (!resized) {
res.status(404).json({
success: false,
error: "Session not found",
});
return;
}
res.json({
success: true,
});
});
return router;
}

View File

@@ -0,0 +1,401 @@
/**
* Terminal Service
*
* Manages PTY (pseudo-terminal) sessions using node-pty.
* Supports cross-platform shell detection including WSL.
*/
import * as pty from "node-pty";
import { EventEmitter } from "events";
import * as os from "os";
import * as fs from "fs";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
export interface TerminalSession {
id: string;
pty: pty.IPty;
cwd: string;
createdAt: Date;
shell: string;
scrollbackBuffer: string; // Store recent output for replay on reconnect
outputBuffer: string; // Pending output to be flushed
flushTimeout: NodeJS.Timeout | null; // Throttle timer
}
export interface TerminalOptions {
cwd?: string;
shell?: string;
cols?: number;
rows?: number;
env?: Record<string, string>;
}
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
/**
* Detect the best shell for the current platform
*/
detectShell(): { shell: string; args: string[] } {
const platform = os.platform();
// Check if running in WSL
if (platform === "linux" && this.isWSL()) {
// In WSL, prefer the user's configured shell or bash
const userShell = process.env.SHELL || "/bin/bash";
if (fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
return { shell: "/bin/bash", args: ["--login"] };
}
switch (platform) {
case "win32": {
// Windows: prefer PowerShell, fall back to cmd
const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
if (fs.existsSync(pwshCore)) {
return { shell: pwshCore, args: [] };
}
if (fs.existsSync(pwsh)) {
return { shell: pwsh, args: [] };
}
return { shell: "cmd.exe", args: [] };
}
case "darwin": {
// macOS: prefer user's shell, then zsh, then bash
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
if (fs.existsSync("/bin/zsh")) {
return { shell: "/bin/zsh", args: ["--login"] };
}
return { shell: "/bin/bash", args: ["--login"] };
}
case "linux":
default: {
// Linux: prefer user's shell, then bash, then sh
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
if (fs.existsSync("/bin/bash")) {
return { shell: "/bin/bash", args: ["--login"] };
}
return { shell: "/bin/sh", args: [] };
}
}
}
/**
* Detect if running inside WSL (Windows Subsystem for Linux)
*/
isWSL(): boolean {
try {
// Check /proc/version for Microsoft/WSL indicators
if (fs.existsSync("/proc/version")) {
const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase();
return version.includes("microsoft") || version.includes("wsl");
}
// Check for WSL environment variable
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
return true;
}
} catch {
// Ignore errors
}
return false;
}
/**
* Get platform info for the client
*/
getPlatformInfo(): {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
} {
const { shell } = this.detectShell();
return {
platform: os.platform(),
isWSL: this.isWSL(),
defaultShell: shell,
arch: os.arch(),
};
}
/**
* Validate and resolve a working directory path
*/
private resolveWorkingDirectory(requestedCwd?: string): string {
const homeDir = os.homedir();
// If no cwd requested, use home
if (!requestedCwd) {
return homeDir;
}
// Clean up the path
let cwd = requestedCwd.trim();
// Fix double slashes at start (but not for Windows UNC paths)
if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) {
cwd = cwd.slice(1);
}
// Check if path exists and is a directory
try {
const stat = fs.statSync(cwd);
if (stat.isDirectory()) {
return cwd;
}
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
return homeDir;
}
}
/**
* Create a new terminal session
*/
createSession(options: TerminalOptions = {}): TerminalSession {
const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
// Validate and resolve working directory
const cwd = this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
const env: Record<string, string> = {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
TERM_PROGRAM: "automaker-terminal",
...options.env,
};
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
});
const session: TerminalSession = {
id,
pty: ptyProcess,
cwd,
createdAt: new Date(),
shell,
scrollbackBuffer: "",
outputBuffer: "",
flushTimeout: null,
};
this.sessions.set(id, session);
// Flush buffered output to clients (throttled)
const flushOutput = () => {
if (session.outputBuffer.length === 0) return;
// Send in batches if buffer is large
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = "";
session.flushTimeout = null;
}
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend);
};
// Forward data events with throttling
ptyProcess.onData((data) => {
// Append to scrollback buffer
session.scrollbackBuffer += data;
// Trim if too large (keep the most recent data)
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// Buffer output for throttled delivery
session.outputBuffer += data;
// Schedule flush if not already scheduled
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
}
});
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit("exit", id, exitCode);
});
console.log(`[Terminal] Session ${id} created successfully`);
return session;
}
/**
* Write data to a terminal session
*/
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
return true;
}
/**
* Resize a terminal session
*/
resize(sessionId: string, cols: number, rows: number): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
return false;
}
try {
session.pty.resize(cols, rows);
return true;
} catch (error) {
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
return false;
}
}
/**
* Kill a terminal session
*/
killSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
session.pty.kill();
this.sessions.delete(sessionId);
console.log(`[Terminal] Session ${sessionId} killed`);
return true;
} catch (error) {
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
return false;
}
}
/**
* Get a session by ID
*/
getSession(sessionId: string): TerminalSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get scrollback buffer for a session (for replay on reconnect)
*/
getScrollback(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.scrollbackBuffer || null;
}
/**
* Get all active sessions
*/
getAllSessions(): Array<{
id: string;
cwd: string;
createdAt: Date;
shell: string;
}> {
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
cwd: s.cwd,
createdAt: s.createdAt,
shell: s.shell,
}));
}
/**
* Subscribe to data events
*/
onData(callback: DataCallback): () => void {
this.dataCallbacks.add(callback);
return () => this.dataCallbacks.delete(callback);
}
/**
* Subscribe to exit events
*/
onExit(callback: ExitCallback): () => void {
this.exitCallbacks.add(callback);
return () => this.exitCallbacks.delete(callback);
}
/**
* Clean up all sessions
*/
cleanup(): void {
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
session.pty.kill();
} catch {
// Ignore errors during cleanup
}
this.sessions.delete(id);
});
}
}
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
}
return terminalService;
}