New commands: - pass_mgr list # list all keys - pass_mgr get-secret --key <key> # get secret - pass_mgr get-username --key <key> # get username - pass_mgr set --key <k> --username <u> --secret <s> # set credential - pass_mgr unset --key <key> # remove credential - pass_mgr generate --key <key> [--username] # generate random secret - pass_mgr rotate --key <key> # rotate secret, keep username - pass_mgr admin init # initialize Also updated pcexec to recognize new get-secret format (with backward compat).
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
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<string, string>;
|
|
/** 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 <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 <key>
|
|
/\$\(\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<string> {
|
|
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<PcExecResult> {
|
|
// Set up environment with workspace/agent info
|
|
const env: Record<string, string> = {};
|
|
|
|
// 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<string, string> = {};
|
|
|
|
// 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;
|