ESM conversion:
- package.json: add "type": "module"; drop stale "main": "index.ts"
- tsconfig.json: switch module/moduleResolution to "nodenext"
- plugin/index.ts: replace `module.exports = { register }` and
`module.exports.X = X` with `export default definePluginEntry({ ... })`
plus named ESM re-exports; replace `require('os')`/`require('path')`
with proper imports.
- plugin/tools/pcexec.ts: replace `require('child_process')` with import
from "node:child_process".
- plugin/commands/ego-mgr-slash.ts: replace `require('path')` with
proper path import.
- All relative imports/exports across plugin/ now carry .js extensions
as required by Node ESM (nodenext module resolution).
Plugin SDK convention update:
- Wrap default export with definePluginEntry({ id, name, description,
register }) per the current openclaw authoring contract.
- Type api parameter as OpenClawPluginApi (was `any`); the non-standard
api.registerSlashCommand call is preserved behind a guarded any-cast,
so the plugin remains a no-op for slash commands when the host doesn't
expose that hook (matches the previous defensive guard).
- Add openclaw as a devDependency (file:/usr/lib/node_modules/openclaw)
so tsc can resolve openclaw/plugin-sdk/* subpath types at build time.
- Modernize openclaw.plugin.json: drop entry/version, add
activation.onStartup so gateway_start fires for this plugin at boot,
declare contracts.tools listing pcexec/proxy-pcexec/safe_restart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
9.3 KiB
TypeScript
338 lines
9.3 KiB
TypeScript
import { spawn, execSync, SpawnOptions } from 'node:child_process';
|
|
|
|
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 secret-mgr (and legacy pass_mgr) invocations from a command string.
|
|
*
|
|
* Supports:
|
|
* Current: $(secret-mgr get-secret --key <key>) / `secret-mgr get-secret --key <key>`
|
|
* Legacy: $(pass_mgr get-secret --key <key>) / `pass_mgr get-secret --key <key>`
|
|
* Legacy: $(pass_mgr get <key>) / `pass_mgr get <key>`
|
|
*
|
|
* Returns array of { fullMatch, subcommand, key, binary } where subcommand is
|
|
* "get" | "get-secret".
|
|
*/
|
|
function extractSecretMgrGets(
|
|
command: string,
|
|
): Array<{ key: string; fullMatch: string; subcommand: string; binary: string }> {
|
|
const results: Array<{ key: string; fullMatch: string; subcommand: string; binary: string }> = [];
|
|
const seen = new Set<string>();
|
|
|
|
// secret-mgr get-secret --key <key>
|
|
const secretMgrPatterns = [
|
|
/\$\(\s*secret-mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g,
|
|
/`\s*secret-mgr\s+get-secret\s+--key\s+(\S+)\s*`/g,
|
|
];
|
|
|
|
// Legacy pass_mgr get-secret --key <key>
|
|
const newPatterns = [
|
|
/\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g,
|
|
/`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g,
|
|
];
|
|
|
|
// Legacy pass_mgr get <key>
|
|
const legacyPatterns = [
|
|
/\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g,
|
|
/`\s*pass_mgr\s+get\s+(\S+)\s*`/g,
|
|
];
|
|
|
|
for (const pattern of secretMgrPatterns) {
|
|
let match;
|
|
while ((match = pattern.exec(command)) !== null) {
|
|
if (!seen.has(match[0])) {
|
|
seen.add(match[0]);
|
|
results.push({
|
|
key: match[1],
|
|
fullMatch: match[0],
|
|
subcommand: 'get-secret',
|
|
binary: 'secret-mgr',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const pattern of newPatterns) {
|
|
let match;
|
|
while ((match = pattern.exec(command)) !== null) {
|
|
if (!seen.has(match[0])) {
|
|
seen.add(match[0]);
|
|
results.push({
|
|
key: match[1],
|
|
fullMatch: match[0],
|
|
subcommand: 'get-secret',
|
|
binary: 'pass_mgr',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const pattern of legacyPatterns) {
|
|
let match;
|
|
while ((match = pattern.exec(command)) !== null) {
|
|
if (!seen.has(match[0])) {
|
|
seen.add(match[0]);
|
|
results.push({
|
|
key: match[1],
|
|
fullMatch: match[0],
|
|
subcommand: 'get',
|
|
binary: 'pass_mgr',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Execute secret-mgr (or legacy pass_mgr) to retrieve a secret.
|
|
* Uses the same env vars that the caller passes so pcguard checks pass.
|
|
*/
|
|
async function fetchPassword(
|
|
subcommand: string,
|
|
key: string,
|
|
env: Record<string, string>,
|
|
binary: string = 'secret-mgr',
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
// Prefer SECRET_MGR_PATH, fall back to PASS_MGR_PATH for legacy compat
|
|
const binaryPath = env.SECRET_MGR_PATH || env.PASS_MGR_PATH || process.env.SECRET_MGR_PATH || process.env.PASS_MGR_PATH || 'secret-mgr';
|
|
const args =
|
|
subcommand === 'get-secret'
|
|
? ['get-secret', '--key', key]
|
|
: ['get', key];
|
|
|
|
const child = spawn(binaryPath, args, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, ...env },
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
child.on('close', (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`secret-mgr ${subcommand} failed: ${stderr || stdout}`));
|
|
} else {
|
|
resolve(stdout.trim());
|
|
}
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sanitize output by replacing passwords with ######
|
|
*/
|
|
function sanitizeOutput(output: string, passwords: string[]): string {
|
|
let sanitized = output;
|
|
for (const password of passwords) {
|
|
if (password) {
|
|
const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
sanitized = sanitized.replace(new RegExp(escaped, 'g'), '######');
|
|
}
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Pre-resolve secret-mgr (and legacy pass_mgr) invocations, replace them inline, and collect passwords.
|
|
*/
|
|
async function replaceSecretMgrGets(
|
|
command: string,
|
|
env: Record<string, string>,
|
|
): Promise<{ command: string; passwords: string[] }> {
|
|
const matches = extractSecretMgrGets(command);
|
|
const passwords: string[] = [];
|
|
let replaced = command;
|
|
|
|
for (const { key, fullMatch, subcommand, binary } of matches) {
|
|
const pw = await fetchPassword(subcommand, key, env, binary);
|
|
passwords.push(pw);
|
|
replaced = replaced.split(fullMatch).join(pw);
|
|
}
|
|
|
|
return { command: replaced, passwords };
|
|
}
|
|
|
|
/**
|
|
* Safe exec wrapper that handles secret-mgr get commands and sanitizes output.
|
|
*/
|
|
export async function pcexec(
|
|
command: string,
|
|
options: PcExecOptions = {},
|
|
): Promise<PcExecResult> {
|
|
// Build environment
|
|
const env: Record<string, string> = {};
|
|
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
if (options.env) Object.assign(env, options.env);
|
|
|
|
// Pre-resolve passwords
|
|
let finalCommand = command;
|
|
let passwords: string[] = [];
|
|
|
|
const resolved = await replaceSecretMgrGets(command, env);
|
|
finalCommand = resolved.command;
|
|
passwords = resolved.passwords;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const spawnOptions: SpawnOptions = {
|
|
cwd: options.cwd,
|
|
env,
|
|
shell: options.shell,
|
|
windowsHide: options.windowsHide,
|
|
uid: options.uid,
|
|
gid: options.gid,
|
|
};
|
|
|
|
const child = spawn('bash', ['-c', finalCommand], spawnOptions);
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let killed = false;
|
|
let timeoutId: NodeJS.Timeout | null = null;
|
|
|
|
if (options.timeout && options.timeout > 0) {
|
|
timeoutId = setTimeout(() => {
|
|
killed = true;
|
|
child.kill(options.killSignal || 'SIGTERM');
|
|
}, options.timeout);
|
|
}
|
|
|
|
child.stdout?.on('data', (data) => {
|
|
stdout += data.toString();
|
|
if (options.maxBuffer && stdout.length > options.maxBuffer) {
|
|
child.kill(options.killSignal || 'SIGTERM');
|
|
}
|
|
});
|
|
|
|
child.stderr?.on('data', (data) => {
|
|
stderr += data.toString();
|
|
if (options.maxBuffer && stderr.length > options.maxBuffer) {
|
|
child.kill(options.killSignal || 'SIGTERM');
|
|
}
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
|
|
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}\n${stderr}`) as PcExecError;
|
|
error.code = code ?? undefined;
|
|
error.signal = signal ?? undefined;
|
|
error.stdout = sanitizedStdout;
|
|
error.stderr = sanitizedStderr;
|
|
error.killed = killed;
|
|
reject(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 — password substitution is NOT supported here
|
|
* (use async pcexec for secret-mgr integration).
|
|
*/
|
|
export function pcexecSync(
|
|
command: string,
|
|
options: PcExecOptions = {},
|
|
): PcExecResult {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
if (options.env) Object.assign(env, options.env);
|
|
|
|
try {
|
|
const stdout = execSync(command, {
|
|
cwd: options.cwd,
|
|
env,
|
|
shell: options.shell as any,
|
|
encoding: 'utf8',
|
|
windowsHide: options.windowsHide,
|
|
uid: options.uid,
|
|
gid: options.gid,
|
|
maxBuffer: options.maxBuffer,
|
|
timeout: options.timeout,
|
|
killSignal: options.killSignal,
|
|
});
|
|
|
|
return { stdout: stdout.toString(), stderr: '', exitCode: 0, command };
|
|
} catch (err: any) {
|
|
const error = new Error(`Command failed: ${command}`) as PcExecError;
|
|
error.code = err.status;
|
|
error.signal = err.signal;
|
|
error.stdout = err.stdout?.toString() || '';
|
|
error.stderr = err.stderr?.toString() || '';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default pcexec;
|