/**
* Error classes for preserving context in error chains
*
* These custom errors ensure we never lose debug information when catching
* and re-throwing errors. All include:
* - Original error as 'cause' (preserves stack trace)
* - Contextual information (host, command, operation)
* - Structured message format
*/
/**
* Base error for host operations (SSH, Docker API)
*/
export class HostOperationError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly operation: string,
public readonly cause?: unknown
) {
const fullMessage = `[Host: ${hostName}] [Op: ${operation}] ${message}`;
super(fullMessage);
this.name = "HostOperationError";
// Preserve original error cause for debugging
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* SSH command execution error with full context
*/
export class SSHCommandError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly command: string,
public readonly exitCode?: number,
public readonly stderr?: string,
public readonly stdout?: string,
public readonly cause?: unknown
) {
const fullMessage = [
`[SSH] [Host: ${hostName}] ${message}`,
`Command: ${command}`,
exitCode !== undefined ? `Exit code: ${exitCode}` : null,
stderr ? `Stderr: ${stderr}` : null
]
.filter(Boolean)
.join("\n");
super(fullMessage);
this.name = "SSHCommandError";
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* Docker Compose operation error
*/
export class ComposeOperationError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly project: string,
public readonly action: string,
public readonly cause?: unknown
) {
const fullMessage = `[Compose] [Host: ${hostName}] [Project: ${project}] [Action: ${action}] ${message}`;
super(fullMessage);
this.name = "ComposeOperationError";
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* Additional context for error logging
*/
export interface ErrorContext {
requestId?: string;
userId?: string;
operation?: string;
metadata?: Record<string, unknown>;
}
/**
* Fields that may contain sensitive operational details and should be redacted in logs
*/
const SENSITIVE_PARAM_FIELDS = new Set([
'command', // Shell commands may contain credentials or secrets
'path', // File paths may reveal system structure
'target', // File/directory targets
'grep', // Search patterns may contain sensitive data
'label_filter', // Docker labels may contain internal info
'name_filter', // Container/image names may be sensitive
'args', // Command arguments
'env', // Environment variables (if we add this feature)
'source', // Source paths
'destination', // Destination paths
]);
/**
* Sanitize params for safe logging by redacting potentially sensitive fields
*
* Preserves safe operational fields (action, subaction, host, response_format, etc.)
* while redacting fields that may contain sensitive data (paths, commands, filters).
*
* @param params - Parameters to sanitize
* @returns Sanitized copy of params with sensitive fields redacted
*
* @example
* sanitizeParams({
* action: 'scout',
* command: 'cat /etc/passwd',
* host: 'web-01'
* })
* // Returns: { action: 'scout', command: '[REDACTED]', host: 'web-01' }
*/
export function sanitizeParams(params: unknown): unknown {
// Handle non-object types
if (typeof params !== 'object' || params === null) {
return params;
}
// Handle arrays (unlikely but defensive)
if (Array.isArray(params)) {
return '[REDACTED]';
}
// Sanitize object fields
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (SENSITIVE_PARAM_FIELDS.has(key)) {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
// Nested objects are redacted entirely (avoiding deep traversal)
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Log error with structured context
*
* NEVER use this to silently swallow errors - always re-throw after logging
* if the error should propagate.
*
* @param error - Error to log (any type)
* @param context - Additional context information
*/
export function logError(error: unknown, context?: ErrorContext): void {
const timestamp = new Date().toISOString();
const parts: string[] = [`[${timestamp}]`];
if (context?.requestId) {
parts.push(`[Request: ${context.requestId}]`);
}
if (context?.operation) {
parts.push(`[Operation: ${context.operation}]`);
}
// Extract error details
if (error instanceof HostOperationError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Op: ${error.operation}]`);
} else if (error instanceof SSHCommandError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Command: ${error.command}]`);
} else if (error instanceof ComposeOperationError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Project: ${error.project}]`);
parts.push(`[Action: ${error.action}]`);
}
if (error instanceof Error) {
parts.push(error.name);
parts.push(error.message);
console.error(parts.join(" "));
if (error.stack) {
console.error(error.stack);
}
if (context?.metadata) {
console.error("Metadata:", JSON.stringify(context.metadata, null, 2));
}
} else {
parts.push(String(error));
console.error(parts.join(" "));
}
}