import { spawn, SpawnOptions } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(require('child_process').exec); export interface PcExecOptions { /** Current working directory */ cwd?: string; /** Environment variables */ env?: Record; /** Timeout in milliseconds */ timeout?: number; /** Maximum buffer size for stdout/stderr */ maxBuffer?: number; /** Kill signal */ killSignal?: NodeJS.Signals; /** Shell to use */ shell?: string | boolean; /** UID to run as */ uid?: number; /** GID to run as */ gid?: number; /** Window style (Windows only) */ windowsHide?: boolean; } export interface PcExecResult { /** Standard output */ stdout: string; /** Standard error */ stderr: string; /** Exit code */ exitCode: number; /** Command that was executed */ command: string; } export interface PcExecError extends Error { /** Exit code */ code?: number; /** Signal that terminated the process */ signal?: string; /** Standard output */ stdout: string; /** Standard error */ stderr: string; /** Killed by timeout */ killed?: boolean; } /** * Extract pass_mgr get commands from a command string * Supports formats like: * - $(pass_mgr get key) * - `pass_mgr get key` * - pass_mgr get key (direct invocation) */ function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: string }> { const results: Array<{ key: string; fullMatch: string }> = []; // Patterns for both old and new pass_mgr formats: // Old: $(pass_mgr get key), `pass_mgr get key`, pass_mgr get key // New: $(pass_mgr get-secret --key key), `pass_mgr get-secret --key key` const patterns = [ // New format: pass_mgr get-secret --key /\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g, /pass_mgr\s+get-secret\s+--key\s+(\S+)/g, // Old format (backward compat): pass_mgr get /\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get\s+(\S+)\s*`/g, /pass_mgr\s+get\s+(\S+)/g, ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(command)) !== null) { results.push({ key: match[1], fullMatch: match[0], }); } } return results; } /** * Execute pass_mgr get and return the password */ async function getPassword(key: string): Promise { return new Promise((resolve, reject) => { const passMgrPath = process.env.PASS_MGR_PATH || 'pass_mgr'; const child = spawn(passMgrPath, ['get-secret', '--key', key], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, AGENT_WORKSPACE: process.env.AGENT_WORKSPACE || '', AGENT_ID: process.env.AGENT_ID || '', }, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stderr.on('data', (data) => { stderr += data.toString(); }); child.on('close', (code) => { if (code !== 0) { reject(new Error(`pass_mgr get failed: ${stderr || stdout}`)); } else { resolve(stdout.trim()); } }); child.on('error', (err) => { reject(err); }); }); } /** * Sanitize output by replacing passwords with ###### */ function sanitizeOutput(output: string, passwords: string[]): string { let sanitized = output; for (const password of passwords) { if (password) { // Escape special regex characters const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(escaped, 'g'); sanitized = sanitized.replace(regex, '######'); } } return sanitized; } /** * Replace pass_mgr get commands with actual passwords in command */ async function replacePassMgrGets(command: string): Promise<{ command: string; passwords: string[] }> { const passMgrGets = extractPassMgrGets(command); const passwords: string[] = []; let replacedCommand = command; for (const { key, fullMatch } of passMgrGets) { try { const password = await getPassword(key); passwords.push(password); replacedCommand = replacedCommand.replace(fullMatch, password); } catch (err) { throw new Error(`Failed to get password for key '${key}': ${err}`); } } return { command: replacedCommand, passwords }; } /** * Safe exec wrapper that handles pass_mgr get commands and sanitizes output * * @param command - Command to execute * @param options - Execution options * @returns Promise resolving to execution result */ export async function pcexec(command: string, options: PcExecOptions = {}): Promise { // Set up environment with workspace/agent info const env: Record = {}; // Copy process.env, filtering out undefined values for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { env[key] = value; } } // Merge options.env if (options.env) { Object.assign(env, options.env); } if (process.env.AGENT_WORKSPACE) { env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE; } if (process.env.AGENT_ID) { env.AGENT_ID = process.env.AGENT_ID; } // Extract and replace pass_mgr get commands let finalCommand = command; let passwords: string[] = []; try { const result = await replacePassMgrGets(command); finalCommand = result.command; passwords = result.passwords; } catch (err) { throw err; } return new Promise((resolve, reject) => { const spawnOptions: SpawnOptions = { cwd: options.cwd, env, // Don't use shell by default - we're already using bash -c explicitly shell: options.shell, windowsHide: options.windowsHide, uid: options.uid, gid: options.gid, }; // Use bash for better compatibility const child = spawn('bash', ['-c', finalCommand], spawnOptions); let stdout = ''; let stderr = ''; let killed = false; let timeoutId: NodeJS.Timeout | null = null; // Set up timeout if (options.timeout && options.timeout > 0) { timeoutId = setTimeout(() => { killed = true; child.kill(options.killSignal || 'SIGTERM'); }, options.timeout); } // Handle stdout child.stdout?.on('data', (data) => { stdout += data.toString(); // Check maxBuffer if (options.maxBuffer && stdout.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); // Handle stderr child.stderr?.on('data', (data) => { stderr += data.toString(); // Check maxBuffer if (options.maxBuffer && stderr.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); // Handle process close child.on('close', (code, signal) => { if (timeoutId) { clearTimeout(timeoutId); } // Sanitize output const sanitizedStdout = sanitizeOutput(stdout, passwords); const sanitizedStderr = sanitizeOutput(stderr, passwords); if (code === 0) { resolve({ stdout: sanitizedStdout, stderr: sanitizedStderr, exitCode: 0, command: finalCommand, }); } else { const error = new Error(`Command failed: ${command}`) as PcExecError; error.code = code ?? undefined; error.signal = signal ?? undefined; error.stdout = sanitizedStdout; error.stderr = sanitizedStderr; error.killed = killed; reject(error); } }); // Handle process error child.on('error', (err) => { if (timeoutId) { clearTimeout(timeoutId); } const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError; error.stdout = sanitizeOutput(stdout, passwords); error.stderr = sanitizeOutput(stderr, passwords); reject(error); }); }); } /** * Synchronous version of pcexec * Note: Password sanitization is still applied */ export function pcexecSync(command: string, options: PcExecOptions = {}): PcExecResult { const { execSync } = require('child_process'); // Set up environment const env: Record = {}; // Copy process.env, filtering out undefined values for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { env[key] = value; } } // Merge options.env if (options.env) { Object.assign(env, options.env); } if (process.env.AGENT_WORKSPACE) { env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE; } if (process.env.AGENT_ID) { env.AGENT_ID = process.env.AGENT_ID; } // For sync version, we need to pre-resolve passwords // This is a limitation - passwords will be in command const passMgrGets = extractPassMgrGets(command); let finalCommand = command; const passwords: string[] = []; // Note: In sync version, we can't async fetch passwords // So we use the original command and rely on the user to not use pass_mgr gets in sync mode // Or they need to resolve passwords beforehand const execOptions: any = { cwd: options.cwd, env, // Don't use shell by default shell: options.shell, encoding: 'utf8', windowsHide: options.windowsHide, uid: options.uid, gid: options.gid, maxBuffer: options.maxBuffer, timeout: options.timeout, killSignal: options.killSignal, }; try { const stdout = execSync(finalCommand, execOptions); const sanitizedStdout = sanitizeOutput(stdout.toString(), passwords); return { stdout: sanitizedStdout, stderr: '', exitCode: 0, command: finalCommand, }; } catch (err: any) { const sanitizedStdout = sanitizeOutput(err.stdout?.toString() || '', passwords); const sanitizedStderr = sanitizeOutput(err.stderr?.toString() || '', passwords); const error = new Error(`Command failed: ${command}`) as PcExecError; error.code = err.status; error.signal = err.signal; error.stdout = sanitizedStdout; error.stderr = sanitizedStderr; throw error; } } // Default export export default pcexec;