feat: rewrite pass_mgr with build-time AES key, update pcexec & install
pass_mgr: - Complete rewrite using build-time AES key (injected via ldflags) - New command format: get-secret/get-username --key, set --key --secret - Admin commands: init, handoff, init-from (rejected when AGENT_* env set) - Inline pcguard check for agent commands - Legacy 'get <key>' kept for backward compat - Storage: pc-pass-store/<agent-id>/<key>.gpg with AES-256-GCM - Admin password stored as SHA-256 hash in .pass_mgr/admin.json pcexec.ts: - Support new 'get-secret --key' pattern alongside legacy 'get <key>' - Pass environment to fetchPassword for pcguard validation - Deduplicate matches, sanitize all resolved passwords from output install.mjs: - Generate random 32-byte hex build secret (.build-secret) - Reuse existing secret on rebuilds - Pass to go build via -ldflags -X main.buildSecret=<secret> README.md: - Document new pass_mgr command format - Document admin handoff/init-from workflow - Document security model limitations - Update project structure
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ vendor/
|
|||||||
|
|
||||||
# Lock files (sub-packages)
|
# Lock files (sub-packages)
|
||||||
plugin/package-lock.json
|
plugin/package-lock.json
|
||||||
|
|
||||||
|
# Build secret (generated by install.mjs)
|
||||||
|
.build-secret
|
||||||
|
|||||||
97
README.md
97
README.md
@@ -8,26 +8,42 @@
|
|||||||
|
|
||||||
OpenClaw plugin for secure password management, safe command execution, and coordinated agent restart.
|
OpenClaw plugin for secure password management, safe command execution, and coordinated agent restart.
|
||||||
|
|
||||||
|
## ⚠️ Security Model
|
||||||
|
|
||||||
|
> **pcexec + pcguard mitigate light model hallucination / misoperation / prompt forgetting.**
|
||||||
|
> They **do not** defend against malicious attacks.
|
||||||
|
> For stronger security, use **sandbox mode** instead of this plugin.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### 1. pass_mgr — Password Manager (Go)
|
### 1. pass\_mgr — Password Manager (Go)
|
||||||
|
|
||||||
AES-256-GCM encryption, per-agent key-based encryption/decryption.
|
AES-256-GCM encryption with a **build-time secret** injected at compile time.
|
||||||
|
Secrets are stored per-agent under `pc-pass-store/<agent-id>/<key>.gpg`.
|
||||||
|
|
||||||
|
**Agent commands** (require pcguard — must run through pcexec):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pass_mgr admin init # Initialize
|
pass_mgr list # List keys for current agent
|
||||||
pass_mgr get <key> # Get password
|
pass_mgr get-secret --key <key> # Output secret
|
||||||
pass_mgr set <key> <password> # Set password (human only)
|
pass_mgr get-username --key <key> # Output username
|
||||||
pass_mgr generate <key> # Generate password
|
pass_mgr set --key <key> --secret <s> [--username <u>] # Set entry
|
||||||
pass_mgr unset <key> # Delete
|
pass_mgr generate --key <key> [--username <u>] # Generate random secret
|
||||||
pass_mgr rotate <key> # Rotate
|
pass_mgr unset --key <key> # Delete entry
|
||||||
|
pass_mgr get <key> # Legacy (maps to get-secret)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin commands** (human-only — rejected if any `AGENT_*` env var is set):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass_mgr admin init # Set admin password (interactive or PC_ADMIN_PASS)
|
||||||
|
pass_mgr admin handoff [file] # Export build secret to file (default: pc-pass-store.secret)
|
||||||
|
pass_mgr admin init-from [file] # Re-encrypt all data from old build secret to current
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. pcguard — Exec Guard (Go)
|
### 2. pcguard — Exec Guard (Go)
|
||||||
|
|
||||||
Validates that a process is running inside a pcexec context by checking environment sentinels (`AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE`). Returns exit code 1 with error message if any check fails.
|
Validates that a process is running inside a pcexec context by checking environment sentinels (`AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE`). Returns exit code 1 if any check fails.
|
||||||
|
|
||||||
Scripts can call `pcguard` at the top to ensure they're executed via pcexec:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -38,7 +54,8 @@ pcguard || exit 1
|
|||||||
### 3. pcexec — Safe Execution Tool (TypeScript)
|
### 3. pcexec — Safe Execution Tool (TypeScript)
|
||||||
|
|
||||||
Drop-in replacement for `exec` that:
|
Drop-in replacement for `exec` that:
|
||||||
- Resolves `$(pass_mgr get key)` inline and sanitizes passwords from output
|
- Resolves `$(pass_mgr get-secret --key <key>)` and legacy `$(pass_mgr get <key>)` inline
|
||||||
|
- Sanitizes all resolved passwords from stdout/stderr
|
||||||
- Injects `AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE` environment variables
|
- Injects `AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE` environment variables
|
||||||
- Appends `$(openclaw path)/bin` to `PATH` (making `pcguard` and `pass_mgr` available)
|
- Appends `$(openclaw path)/bin` to `PATH` (making `pcguard` and `pass_mgr` available)
|
||||||
|
|
||||||
@@ -46,19 +63,6 @@ Drop-in replacement for `exec` that:
|
|||||||
|
|
||||||
Agent state management and coordinated gateway restart.
|
Agent state management and coordinated gateway restart.
|
||||||
|
|
||||||
**Agent States:** idle → busy → focus → freeze → pre-freeze
|
|
||||||
|
|
||||||
**APIs:**
|
|
||||||
- `POST /query-restart` — Query restart readiness
|
|
||||||
- `POST /restart-result` — Report restart result
|
|
||||||
- `GET /status` — Get all statuses
|
|
||||||
|
|
||||||
## ⚠️ Security Limitations
|
|
||||||
|
|
||||||
> **PCEXEC + PCGUARD only mitigate light model hallucination / misoperation / prompt forgetting.**
|
|
||||||
> They **do not** defend against malicious attacks.
|
|
||||||
> For stronger security, use **sandbox mode** instead of this plugin.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -76,8 +80,6 @@ PaddedCell/
|
|||||||
│ └── src/main.go
|
│ └── src/main.go
|
||||||
├── pcguard/ # Go exec guard binary
|
├── pcguard/ # Go exec guard binary
|
||||||
│ └── src/main.go
|
│ └── src/main.go
|
||||||
├── docs/ # Documentation
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
├── dist/padded-cell/ # Build output
|
├── dist/padded-cell/ # Build output
|
||||||
├── install.mjs # Installer
|
├── install.mjs # Installer
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -99,27 +101,46 @@ node install.mjs --build-only
|
|||||||
node install.mjs --uninstall
|
node install.mjs --uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The installer automatically generates a random 32-byte build secret (stored in `.build-secret`, gitignored) and injects it into `pass_mgr` at compile time. Subsequent builds reuse the same secret.
|
||||||
|
|
||||||
### Install paths
|
### Install paths
|
||||||
|
|
||||||
The installer resolves the openclaw base path with this priority:
|
Priority: `--openclaw-profile-path` → `$OPENCLAW_PATH` → `~/.openclaw`
|
||||||
1. `--openclaw-profile-path` CLI argument
|
|
||||||
2. `$OPENCLAW_PATH` environment variable
|
|
||||||
3. `~/.openclaw` (default)
|
|
||||||
|
|
||||||
Binaries go to `$(openclaw path)/bin/`, plugin files to `$(openclaw path)/plugins/padded-cell/`.
|
Binaries → `$(openclaw path)/bin/`, plugin files → `$(openclaw path)/plugins/padded-cell/`.
|
||||||
|
|
||||||
|
## Plugin Update Workflow (admin handoff)
|
||||||
|
|
||||||
|
When you rebuild PaddedCell (which generates a new build secret), existing encrypted data needs re-encryption:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Before updating — export current build secret
|
||||||
|
~/.openclaw/bin/pass_mgr admin handoff
|
||||||
|
|
||||||
|
# 2. Rebuild & reinstall (generates new .build-secret)
|
||||||
|
rm .build-secret
|
||||||
|
node install.mjs
|
||||||
|
|
||||||
|
# 3. After updating — re-encrypt data with new secret
|
||||||
|
~/.openclaw/bin/pass_mgr admin init-from
|
||||||
|
|
||||||
|
# 4. Restart gateway
|
||||||
|
openclaw gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize pass_mgr
|
# Initialize admin password
|
||||||
~/.openclaw/bin/pass_mgr admin init
|
~/.openclaw/bin/pass_mgr admin init
|
||||||
|
|
||||||
# Set and get passwords
|
# Agent sets and gets passwords (via pcexec)
|
||||||
~/.openclaw/bin/pass_mgr set mykey mypassword
|
pass_mgr set --key myservice --secret s3cret --username admin
|
||||||
~/.openclaw/bin/pass_mgr get mykey
|
pass_mgr get-secret --key myservice
|
||||||
|
pass_mgr get-username --key myservice
|
||||||
|
|
||||||
# Use pcguard in scripts
|
# Use in shell commands (pcexec resolves and sanitizes)
|
||||||
pcguard || exit 1
|
curl -u "$(pass_mgr get-username --key myservice):$(pass_mgr get-secret --key myservice)" https://api.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
24
install.mjs
24
install.mjs
@@ -12,7 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync } from 'fs';
|
import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { dirname, join, resolve } from 'path';
|
import { dirname, join, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { homedir, platform } from 'os';
|
import { homedir, platform } from 'os';
|
||||||
@@ -117,14 +118,33 @@ function checkDeps(env) {
|
|||||||
|
|
||||||
// ── Step 3: Build ───────────────────────────────────────────────────────
|
// ── Step 3: Build ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ensureBuildSecret() {
|
||||||
|
const secretFile = join(__dirname, '.build-secret');
|
||||||
|
if (existsSync(secretFile)) {
|
||||||
|
const existing = readFileSync(secretFile, 'utf8').trim();
|
||||||
|
if (existing.length >= 32) {
|
||||||
|
logOk('Reusing existing build secret');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secret = randomBytes(32).toString('hex');
|
||||||
|
writeFileSync(secretFile, secret + '\n', { mode: 0o600 });
|
||||||
|
logOk('Generated new build secret');
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
logStep(3, 6, 'Building components...');
|
logStep(3, 6, 'Building components...');
|
||||||
|
|
||||||
|
// Generate / load build secret for pass_mgr
|
||||||
|
const buildSecret = ensureBuildSecret();
|
||||||
|
|
||||||
// pass_mgr (Go)
|
// pass_mgr (Go)
|
||||||
log(' Building pass_mgr...', 'blue');
|
log(' Building pass_mgr...', 'blue');
|
||||||
const pmDir = join(__dirname, 'pass_mgr');
|
const pmDir = join(__dirname, 'pass_mgr');
|
||||||
exec('go mod tidy', { cwd: pmDir, silent: !options.verbose });
|
exec('go mod tidy', { cwd: pmDir, silent: !options.verbose });
|
||||||
exec('go build -o dist/pass_mgr src/main.go', { cwd: pmDir, silent: !options.verbose });
|
const ldflags = `-X main.buildSecret=${buildSecret}`;
|
||||||
|
exec(`go build -ldflags "${ldflags}" -o dist/pass_mgr src/main.go`, { cwd: pmDir, silent: !options.verbose });
|
||||||
chmodSync(join(pmDir, 'dist', 'pass_mgr'), 0o755);
|
chmodSync(join(pmDir, 'dist', 'pass_mgr'), 0o755);
|
||||||
logOk('pass_mgr');
|
logOk('pass_mgr');
|
||||||
|
|
||||||
|
|||||||
1003
pass_mgr/src/main.go
1003
pass_mgr/src/main.go
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,4 @@
|
|||||||
import { spawn, SpawnOptions } from 'child_process';
|
import { spawn, SpawnOptions } from 'child_process';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execAsync = promisify(require('child_process').exec);
|
|
||||||
|
|
||||||
export interface PcExecOptions {
|
export interface PcExecOptions {
|
||||||
/** Current working directory */
|
/** Current working directory */
|
||||||
@@ -49,29 +46,58 @@ export interface PcExecError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract pass_mgr get commands from a command string
|
* Extract pass_mgr invocations from a command string.
|
||||||
* Supports formats like:
|
*
|
||||||
* - $(pass_mgr get key)
|
* Supports both legacy and new formats:
|
||||||
* - `pass_mgr get key`
|
* Legacy: $(pass_mgr get <key>) / `pass_mgr get <key>`
|
||||||
* - pass_mgr get key (direct invocation)
|
* New: $(pass_mgr get-secret --key <key>) / `pass_mgr get-secret --key <key>`
|
||||||
|
*
|
||||||
|
* Returns array of { fullMatch, subcommand, key } where subcommand is
|
||||||
|
* "get" | "get-secret".
|
||||||
*/
|
*/
|
||||||
function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: string }> {
|
function extractPassMgrGets(
|
||||||
const results: Array<{ key: string; fullMatch: string }> = [];
|
command: string,
|
||||||
|
): Array<{ key: string; fullMatch: string; subcommand: string }> {
|
||||||
|
const results: Array<{ key: string; fullMatch: string; subcommand: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
// Pattern for $(pass_mgr get key) or `pass_mgr get key`
|
// New format: pass_mgr get-secret --key <key>
|
||||||
const patterns = [
|
const newPatterns = [
|
||||||
/\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g,
|
/\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g,
|
||||||
/`\s*pass_mgr\s+get\s+(\S+)\s*`/g,
|
/`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g,
|
||||||
/pass_mgr\s+get\s+(\S+)/g,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
// Legacy format: 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 newPatterns) {
|
||||||
let match;
|
let match;
|
||||||
while ((match = pattern.exec(command)) !== null) {
|
while ((match = pattern.exec(command)) !== null) {
|
||||||
results.push({
|
if (!seen.has(match[0])) {
|
||||||
key: match[1],
|
seen.add(match[0]);
|
||||||
fullMatch: match[0],
|
results.push({
|
||||||
});
|
key: match[1],
|
||||||
|
fullMatch: match[0],
|
||||||
|
subcommand: 'get-secret',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,42 +105,38 @@ function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute pass_mgr get and return the password
|
* Execute pass_mgr to retrieve a secret.
|
||||||
|
* Uses the same env vars that the caller passes so pcguard checks pass.
|
||||||
*/
|
*/
|
||||||
async function getPassword(key: string): Promise<string> {
|
async function fetchPassword(
|
||||||
|
subcommand: string,
|
||||||
|
key: string,
|
||||||
|
env: Record<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const passMgrPath = process.env.PASS_MGR_PATH || 'pass_mgr';
|
const passMgrPath = env.PASS_MGR_PATH || process.env.PASS_MGR_PATH || 'pass_mgr';
|
||||||
const child = spawn(passMgrPath, ['get', key], {
|
const args =
|
||||||
|
subcommand === 'get-secret'
|
||||||
|
? ['get-secret', '--key', key]
|
||||||
|
: ['get', key];
|
||||||
|
|
||||||
|
const child = spawn(passMgrPath, args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: { ...process.env, ...env },
|
||||||
...process.env,
|
|
||||||
AGENT_WORKSPACE: process.env.AGENT_WORKSPACE || '',
|
|
||||||
AGENT_ID: process.env.AGENT_ID || '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||||
child.stdout.on('data', (data) => {
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(`pass_mgr get failed: ${stderr || stdout}`));
|
reject(new Error(`pass_mgr ${subcommand} failed: ${stderr || stdout}`));
|
||||||
} else {
|
} else {
|
||||||
resolve(stdout.trim());
|
resolve(stdout.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
child.on('error', reject);
|
||||||
child.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,90 +147,66 @@ function sanitizeOutput(output: string, passwords: string[]): string {
|
|||||||
let sanitized = output;
|
let sanitized = output;
|
||||||
for (const password of passwords) {
|
for (const password of passwords) {
|
||||||
if (password) {
|
if (password) {
|
||||||
// Escape special regex characters
|
|
||||||
const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const regex = new RegExp(escaped, 'g');
|
sanitized = sanitized.replace(new RegExp(escaped, 'g'), '######');
|
||||||
sanitized = sanitized.replace(regex, '######');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace pass_mgr get commands with actual passwords in command
|
* Pre-resolve pass_mgr invocations, replace them inline, and collect passwords.
|
||||||
*/
|
*/
|
||||||
async function replacePassMgrGets(command: string): Promise<{ command: string; passwords: string[] }> {
|
async function replacePassMgrGets(
|
||||||
const passMgrGets = extractPassMgrGets(command);
|
command: string,
|
||||||
|
env: Record<string, string>,
|
||||||
|
): Promise<{ command: string; passwords: string[] }> {
|
||||||
|
const matches = extractPassMgrGets(command);
|
||||||
const passwords: string[] = [];
|
const passwords: string[] = [];
|
||||||
let replacedCommand = command;
|
let replaced = command;
|
||||||
|
|
||||||
for (const { key, fullMatch } of passMgrGets) {
|
for (const { key, fullMatch, subcommand } of matches) {
|
||||||
try {
|
const pw = await fetchPassword(subcommand, key, env);
|
||||||
const password = await getPassword(key);
|
passwords.push(pw);
|
||||||
passwords.push(password);
|
replaced = replaced.split(fullMatch).join(pw);
|
||||||
replacedCommand = replacedCommand.replace(fullMatch, password);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Failed to get password for key '${key}': ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { command: replacedCommand, passwords };
|
return { command: replaced, passwords };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safe exec wrapper that handles pass_mgr get commands and sanitizes output
|
* 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> {
|
export async function pcexec(
|
||||||
// Set up environment with workspace/agent info
|
command: string,
|
||||||
|
options: PcExecOptions = {},
|
||||||
|
): Promise<PcExecResult> {
|
||||||
|
// Build environment
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
// Copy process.env, filtering out undefined values
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
if (v !== undefined) env[k] = v;
|
||||||
if (value !== undefined) {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (options.env) Object.assign(env, options.env);
|
||||||
|
|
||||||
// Merge options.env
|
// Pre-resolve passwords
|
||||||
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 finalCommand = command;
|
||||||
let passwords: string[] = [];
|
let passwords: string[] = [];
|
||||||
|
|
||||||
try {
|
const resolved = await replacePassMgrGets(command, env);
|
||||||
const result = await replacePassMgrGets(command);
|
finalCommand = resolved.command;
|
||||||
finalCommand = result.command;
|
passwords = resolved.passwords;
|
||||||
passwords = result.passwords;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spawnOptions: SpawnOptions = {
|
const spawnOptions: SpawnOptions = {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env,
|
env,
|
||||||
// Don't use shell by default - we're already using bash -c explicitly
|
|
||||||
shell: options.shell,
|
shell: options.shell,
|
||||||
windowsHide: options.windowsHide,
|
windowsHide: options.windowsHide,
|
||||||
uid: options.uid,
|
uid: options.uid,
|
||||||
gid: options.gid,
|
gid: options.gid,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use bash for better compatibility
|
|
||||||
const child = spawn('bash', ['-c', finalCommand], spawnOptions);
|
const child = spawn('bash', ['-c', finalCommand], spawnOptions);
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -216,7 +214,6 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom
|
|||||||
let killed = false;
|
let killed = false;
|
||||||
let timeoutId: NodeJS.Timeout | null = null;
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// Set up timeout
|
|
||||||
if (options.timeout && options.timeout > 0) {
|
if (options.timeout && options.timeout > 0) {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
killed = true;
|
killed = true;
|
||||||
@@ -224,33 +221,23 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom
|
|||||||
}, options.timeout);
|
}, options.timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle stdout
|
|
||||||
child.stdout?.on('data', (data) => {
|
child.stdout?.on('data', (data) => {
|
||||||
stdout += data.toString();
|
stdout += data.toString();
|
||||||
|
|
||||||
// Check maxBuffer
|
|
||||||
if (options.maxBuffer && stdout.length > options.maxBuffer) {
|
if (options.maxBuffer && stdout.length > options.maxBuffer) {
|
||||||
child.kill(options.killSignal || 'SIGTERM');
|
child.kill(options.killSignal || 'SIGTERM');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle stderr
|
|
||||||
child.stderr?.on('data', (data) => {
|
child.stderr?.on('data', (data) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
|
|
||||||
// Check maxBuffer
|
|
||||||
if (options.maxBuffer && stderr.length > options.maxBuffer) {
|
if (options.maxBuffer && stderr.length > options.maxBuffer) {
|
||||||
child.kill(options.killSignal || 'SIGTERM');
|
child.kill(options.killSignal || 'SIGTERM');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle process close
|
|
||||||
child.on('close', (code, signal) => {
|
child.on('close', (code, signal) => {
|
||||||
if (timeoutId) {
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize output
|
|
||||||
const sanitizedStdout = sanitizeOutput(stdout, passwords);
|
const sanitizedStdout = sanitizeOutput(stdout, passwords);
|
||||||
const sanitizedStderr = sanitizeOutput(stderr, passwords);
|
const sanitizedStderr = sanitizeOutput(stderr, passwords);
|
||||||
|
|
||||||
@@ -272,12 +259,8 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle process error
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
if (timeoutId) {
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError;
|
const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError;
|
||||||
error.stdout = sanitizeOutput(stdout, passwords);
|
error.stdout = sanitizeOutput(stdout, passwords);
|
||||||
error.stderr = sanitizeOutput(stderr, passwords);
|
error.stderr = sanitizeOutput(stderr, passwords);
|
||||||
@@ -287,80 +270,44 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous version of pcexec
|
* Synchronous version — password substitution is NOT supported here
|
||||||
* Note: Password sanitization is still applied
|
* (use async pcexec for pass_mgr integration).
|
||||||
*/
|
*/
|
||||||
export function pcexecSync(command: string, options: PcExecOptions = {}): PcExecResult {
|
export function pcexecSync(
|
||||||
|
command: string,
|
||||||
|
options: PcExecOptions = {},
|
||||||
|
): PcExecResult {
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
// Set up environment
|
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
// Copy process.env, filtering out undefined values
|
if (v !== undefined) env[k] = v;
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (options.env) Object.assign(env, options.env);
|
||||||
// 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 {
|
try {
|
||||||
const stdout = execSync(finalCommand, execOptions);
|
const stdout = execSync(command, {
|
||||||
const sanitizedStdout = sanitizeOutput(stdout.toString(), passwords);
|
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 {
|
return { stdout: stdout.toString(), stderr: '', exitCode: 0, command };
|
||||||
stdout: sanitizedStdout,
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
command: finalCommand,
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
} 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;
|
const error = new Error(`Command failed: ${command}`) as PcExecError;
|
||||||
error.code = err.status;
|
error.code = err.status;
|
||||||
error.signal = err.signal;
|
error.signal = err.signal;
|
||||||
error.stdout = sanitizedStdout;
|
error.stdout = err.stdout?.toString() || '';
|
||||||
error.stderr = sanitizedStderr;
|
error.stderr = err.stderr?.toString() || '';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default export
|
|
||||||
export default pcexec;
|
export default pcexec;
|
||||||
|
|||||||
Reference in New Issue
Block a user