Merge pull request 'feat/refactor-and-pcguard' (#3) from feat/refactor-and-pcguard into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
h z
2026-03-08 21:32:53 +00:00
23 changed files with 1310 additions and 10069 deletions

6
.gitignore vendored
View File

@@ -28,3 +28,9 @@ Thumbs.db
*.a
*.test
vendor/
# Lock files (sub-packages)
plugin/package-lock.json
# Build secret (generated by install.mjs)
.build-secret

181
README.md
View File

@@ -8,130 +8,139 @@
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
### 1. pass_mgr - Password Manager Binary (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
# Initialize
pass_mgr admin init [--key-path <path>]
# Get password
pass_mgr get <key> [--username]
# Generate password (agent can use)
pass_mgr generate <key> [--username <user>]
# Set password (human only)
pass_mgr set <key> <password> [--username <user>]
# Delete password
pass_mgr unset <key>
# Rotate password
pass_mgr rotate <key>
pass_mgr list # List keys for current agent
pass_mgr get-secret --key <key> # Output secret
pass_mgr get-username --key <key> # Output username
pass_mgr set --key <key> --secret <s> [--username <u>] # Set entry
pass_mgr generate --key <key> [--username <u>] # Generate random secret
pass_mgr unset --key <key> # Delete entry
pass_mgr get <key> # Legacy (maps to get-secret)
```
**Security Features:**
- Agents cannot execute `set` (detected via environment variables)
- All operations fail before initialization
- Admin password leak detection (monitors messages/tool calls)
**Admin commands** (human-only — rejected if any `AGENT_*` env var is set):
### 2. pcexec - Safe Execution Tool (TypeScript)
Compatible with OpenClaw native exec interface, automatically handles `pass_mgr get` and sanitizes output.
```typescript
import { pcexec } from 'pcexec';
const result = await pcexec('echo $(pass_mgr get mypassword)', {
cwd: '/workspace',
timeout: 30000,
});
// Passwords in result.stdout will be replaced with ######
```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
```
### 3. safe-restart - Safe Restart Module (TypeScript)
### 2. pcguard — Exec Guard (Go)
Provides agent state management and coordinated restart.
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.
**Agent States:**
- `idle` - Idle
- `busy` - Processing messages
- `focus` - Focus mode (workflow)
- `freeze` - Frozen (not accepting new messages)
- `pre-freeze` - Preparing to freeze
- `pre-freeze-focus` - Preparing to freeze (focus mode)
**APIs:**
- `POST /query-restart` - Query restart readiness
- `POST /restart-result` - Report restart result
- `GET /status` - Get all statuses
**Slash Commands:**
```
/padded-cell-ctrl status
/padded-cell-ctrl enable pass-mgr|safe-restart
/padded-cell-ctrl disable pass-mgr|safe-restart
```bash
#!/bin/bash
pcguard || exit 1
# ... rest of script
```
### 3. pcexec — Safe Execution Tool (TypeScript)
Drop-in replacement for `exec` that:
- 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
- Appends `$(openclaw path)/bin` to `PATH` (making `pcguard` and `pass_mgr` available)
### 4. safe-restart — Coordinated Restart (TypeScript)
Agent state management and coordinated gateway restart.
## Project Structure
```
PaddedCell/
├── pass_mgr/ # Go password manager binary
│ ├── src/
│ └── main.go
── go.mod
├── pcexec/ # TypeScript safe execution tool
│ ├── src/
│ └── index.ts
├── plugin/ # Plugin source (TypeScript)
│ ├── commands/ # Slash commands
├── core/ # Core modules (safe-restart, status, api)
── hooks/ # Lifecycle hooks
│ ├── tools/ # Tool definitions (pcexec)
│ ├── index.ts # Plugin entry point
├── openclaw.plugin.json
│ ├── package.json
│ └── tsconfig.json
├── safe-restart/ # TypeScript safe restart module
── src/
│ │ ├── index.ts
│ ├── status-manager.ts
├── api.ts
│ │ ├── safe-restart.ts
│ │ └── slash-commands.ts
│ ├── package.json
│ └── tsconfig.json
├── docs/ # Documentation
├── PROJECT_PLAN.md # Project plan
├── AGENT_TASKS.md # Task list
├── README.md # This file (English)
└── README.zh-CN.md # Chinese version
├── pass_mgr/ # Go password manager binary
── src/main.go
├── pcguard/ # Go exec guard binary
└── src/main.go
├── dist/padded-cell/ # Build output
├── install.mjs # Installer
└── README.md
```
## Installation
```bash
# Install
node install.mjs --install
# Install (default: ~/.openclaw)
node install.mjs
# Install with custom openclaw profile path
node install.mjs --openclaw-profile-path /path/to/.openclaw
# Build only (no install)
node install.mjs --build-only
# Uninstall
node install.mjs --uninstall
```
## Usage
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.
> 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.
### Install paths
Priority: `--openclaw-profile-path``$OPENCLAW_PATH``~/.openclaw`
### pass_mgr
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
# Initialize (required before first use)
# 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
```bash
# Initialize admin password
~/.openclaw/bin/pass_mgr admin init
# Set password
~/.openclaw/bin/pass_mgr set mykey mypassword
# Agent sets and gets passwords (via pcexec)
pass_mgr set --key myservice --secret s3cret --username admin
pass_mgr get-secret --key myservice
pass_mgr get-username --key myservice
# Get password
~/.openclaw/bin/pass_mgr get mykey
# Use in shell commands (pcexec resolves and sanitizes)
curl -u "$(pass_mgr get-username --key myservice):$(pass_mgr get-secret --key myservice)" https://api.example.com
```
## License

View File

@@ -1,19 +1,19 @@
#!/usr/bin/env node
/**
* PaddedCell Plugin Installer
* PaddedCell Plugin Installer v0.2.0
*
* Usage:
* node install.mjs
* node install.mjs --prefix /usr/local
* node install.mjs --openclaw-profile-path /path/to/.openclaw
* node install.mjs --build-only
* node install.mjs --skip-check
* node install.mjs --uninstall
* node install.mjs --uninstall --prefix /usr/local
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync, writeFileSync, chmodSync, readdirSync, statSync } from 'fs';
import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { randomBytes } from 'crypto';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { homedir, platform } from 'os';
@@ -21,534 +21,309 @@ import { homedir, platform } from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(dirname(__filename));
// Plugin configuration - matches directory name in dist/
const PLUGIN_NAME = 'padded-cell';
const DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
// Parse arguments
const args = process.argv.slice(2);
const options = {
prefix: null,
openclawProfilePath: null,
buildOnly: args.includes('--build-only'),
skipCheck: args.includes('--skip-check'),
verbose: args.includes('--verbose') || args.includes('-v'),
uninstall: args.includes('--uninstall'),
};
// Parse --prefix value
const prefixIndex = args.indexOf('--prefix');
if (prefixIndex !== -1 && args[prefixIndex + 1]) {
options.prefix = resolve(args[prefixIndex + 1]);
// Parse --openclaw-profile-path value
const profileIdx = args.indexOf('--openclaw-profile-path');
if (profileIdx !== -1 && args[profileIdx + 1]) {
options.openclawProfilePath = resolve(args[profileIdx + 1]);
}
// Colors for output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
// Resolve openclaw path: --openclaw-profile-path → $OPENCLAW_PATH → ~/.openclaw
function resolveOpenclawPath() {
if (options.openclawProfilePath) return options.openclawProfilePath;
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
return join(homedir(), '.openclaw');
}
// Colors
const c = {
reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m',
yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m',
};
function log(msg, color = 'reset') { console.log(`${c[color]}${msg}${c.reset}`); }
function logStep(n, total, msg) { log(`[${n}/${total}] ${msg}`, 'cyan'); }
function logOk(msg) { log(`${msg}`, 'green'); }
function logWarn(msg) { log(`${msg}`, 'yellow'); }
function logErr(msg) { log(`${msg}`, 'red'); }
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logStep(step, message) {
log(`[${step}/6] ${message}`, 'cyan');
}
function logSuccess(message) {
log(`${message}`, 'green');
}
function logWarning(message) {
log(`${message}`, 'yellow');
}
function logError(message) {
log(`${message}`, 'red');
}
function exec(command, options = {}) {
const defaultOptions = {
function exec(command, opts = {}) {
return execSync(command, {
cwd: __dirname,
stdio: options.silent ? 'pipe' : 'inherit',
stdio: opts.silent ? 'pipe' : 'inherit',
encoding: 'utf8',
};
return execSync(command, { ...defaultOptions, ...options });
...opts,
});
}
// OpenClaw config helpers
function getOpenclawConfig(pathKey, defaultValue = undefined) {
function getOpenclawConfig(key, def = undefined) {
try {
const out = execSync(`openclaw config get ${pathKey} --json 2>/dev/null || echo "undefined"`, {
encoding: 'utf8',
cwd: __dirname
}).trim();
if (out === 'undefined' || out === '') return defaultValue;
const out = exec(`openclaw config get ${key} --json 2>/dev/null || echo "undefined"`, { silent: true }).trim();
if (out === 'undefined' || out === '') return def;
return JSON.parse(out);
} catch {
return defaultValue;
}
} catch { return def; }
}
function setOpenclawConfig(key, value) {
exec(`openclaw config set ${key} '${JSON.stringify(value)}' --json`, { silent: true });
}
function unsetOpenclawConfig(key) {
try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {}
}
function setOpenclawConfig(pathKey, value) {
const cmd = `openclaw config set ${pathKey} '${JSON.stringify(value)}' --json`;
execSync(cmd, { cwd: __dirname, encoding: 'utf8' });
}
function unsetOpenclawConfig(pathKey) {
try {
execSync(`openclaw config unset ${pathKey}`, { cwd: __dirname, encoding: 'utf8' });
} catch {
// Ignore errors
}
}
// Copy directory recursively
function copyDir(src, dest) {
mkdirSync(dest, { recursive: true });
const entries = readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
for (const entry of readdirSync(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.name === 'node_modules') continue; // skip node_modules
entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
}
}
// ============================================================================
// Step 1: Environment Detection
// ============================================================================
// ── Step 1: Detect ──────────────────────────────────────────────────────
function detectEnvironment() {
logStep(1, 'Detecting environment...');
const env = {
platform: platform(),
nodeVersion: null,
goVersion: null,
openclawDir: join(homedir(), '.openclaw'),
};
logStep(1, 6, 'Detecting environment...');
const env = { platform: platform(), nodeVersion: null, goVersion: null };
// Check Node.js
try {
env.nodeVersion = exec('node --version', { silent: true }).trim();
logSuccess(`Node.js ${env.nodeVersion}`);
} catch {
logError('Node.js not found');
}
// Check Go
try {
env.goVersion = exec('go version', { silent: true }).trim();
logSuccess(`Go ${env.goVersion}`);
} catch {
logError('Go not found');
}
// Check openclaw
try {
const path = exec('which openclaw', { silent: true }).trim();
logSuccess(`openclaw at ${path}`);
// Try to find openclaw config dir
const home = homedir();
const possibleDirs = [
join(home, '.openclaw'),
join(home, '.config', 'openclaw'),
];
for (const dir of possibleDirs) {
if (existsSync(dir)) {
env.openclawDir = dir;
logSuccess(`openclaw config dir: ${dir}`);
break;
}
}
} catch {
logWarning('openclaw CLI not found in PATH');
}
try { env.nodeVersion = exec('node --version', { silent: true }).trim(); logOk(`Node.js ${env.nodeVersion}`); } catch { logErr('Node.js not found'); }
try { env.goVersion = exec('go version', { silent: true }).trim(); logOk(`Go: ${env.goVersion}`); } catch { logErr('Go not found'); }
try { logOk(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); } catch { logWarn('openclaw CLI not in PATH'); }
return env;
}
function checkDependencies(env) {
if (options.skipCheck) {
logWarning('Skipping dependency checks');
return true;
}
// ── Step 2: Check deps ──────────────────────────────────────────────────
logStep(2, 'Checking dependencies...');
let hasErrors = false;
if (!env.nodeVersion) {
logError('Node.js is required. Please install Node.js 18+');
hasErrors = true;
} else {
const majorVersion = parseInt(env.nodeVersion.slice(1).split('.')[0]);
if (majorVersion < 18) {
logError(`Node.js 18+ required, found ${env.nodeVersion}`);
hasErrors = true;
} else {
logSuccess(`Node.js version OK`);
}
}
if (!env.goVersion) {
logError('Go is required. Please install Go 1.22+');
hasErrors = true;
} else {
logSuccess(`Go version OK`);
}
if (hasErrors) {
log('\nPlease install missing dependencies and try again.', 'red');
process.exit(1);
}
return true;
function checkDeps(env) {
if (options.skipCheck) { logStep(2, 6, 'Skipping dep checks'); return; }
logStep(2, 6, 'Checking dependencies...');
let fail = false;
if (!env.nodeVersion || parseInt(env.nodeVersion.slice(1)) < 18) { logErr('Node.js 18+ required'); fail = true; }
if (!env.goVersion) { logErr('Go 1.22+ required'); fail = true; }
if (fail) { log('\nInstall missing deps and retry.', 'red'); process.exit(1); }
logOk('All deps OK');
}
// ============================================================================
// Step 3: Build Components
// ============================================================================
// ── Step 3: Build ───────────────────────────────────────────────────────
async function buildComponents(env) {
logStep(3, 'Building components...');
// Build pass_mgr
log(' Building pass_mgr (Go)...', 'blue');
try {
const passMgrDir = join(__dirname, 'pass_mgr');
exec('go mod tidy', { cwd: passMgrDir, silent: !options.verbose });
exec('go build -o dist/pass_mgr src/main.go', { cwd: passMgrDir, silent: !options.verbose });
const binaryPath = join(passMgrDir, 'dist', 'pass_mgr');
if (!existsSync(binaryPath)) {
throw new Error('pass_mgr binary not found after 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;
}
chmodSync(binaryPath, 0o755);
logSuccess('pass_mgr built successfully');
} catch (err) {
logError(`Failed to build pass_mgr: ${err.message}`);
throw err;
}
// Build pcexec
log(' Building pcexec (TypeScript)...', 'blue');
try {
const pcexecDir = join(__dirname, 'pcexec');
exec('npm install', { cwd: pcexecDir, silent: !options.verbose });
exec('npm run build', { cwd: pcexecDir, silent: !options.verbose });
logSuccess('pcexec built successfully');
} catch (err) {
logError(`Failed to build pcexec: ${err.message}`);
throw err;
}
// Build safe-restart
log(' Building safe-restart (TypeScript)...', 'blue');
try {
const safeRestartDir = join(__dirname, 'safe-restart');
exec('npm install', { cwd: safeRestartDir, silent: !options.verbose });
exec('npm run build', { cwd: safeRestartDir, silent: !options.verbose });
logSuccess('safe-restart built successfully');
} catch (err) {
logError(`Failed to build safe-restart: ${err.message}`);
throw err;
}
const secret = randomBytes(32).toString('hex');
writeFileSync(secretFile, secret + '\n', { mode: 0o600 });
logOk('Generated new build secret');
return secret;
}
// ============================================================================
// Step 4: Install Components
// ============================================================================
async function build() {
logStep(3, 6, 'Building components...');
async function installComponents(env) {
if (options.buildOnly) {
logStep(4, 'Skipping installation (--build-only)');
return null;
}
// Generate / load build secret for pass_mgr
const buildSecret = ensureBuildSecret();
logStep(4, 'Installing components...');
// pass_mgr (Go)
log(' Building pass_mgr...', 'blue');
const pmDir = join(__dirname, 'pass_mgr');
exec('go mod tidy', { 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);
logOk('pass_mgr');
const installDir = options.prefix || env.openclawDir;
const binDir = join(installDir, 'bin');
// pcguard (Go)
log(' Building pcguard...', 'blue');
const pgDir = join(__dirname, 'pcguard');
exec('go mod tidy', { cwd: pgDir, silent: !options.verbose });
exec('go build -o dist/pcguard src/main.go', { cwd: pgDir, silent: !options.verbose });
chmodSync(join(pgDir, 'dist', 'pcguard'), 0o755);
logOk('pcguard');
log(` Install directory: ${installDir}`, 'blue');
log(` Binary directory: ${binDir}`, 'blue');
log(` Dist directory: ${DIST_DIR}`, 'blue');
// Plugin (TypeScript)
log(' Building plugin...', 'blue');
const pluginDir = join(__dirname, 'plugin');
exec('npm install', { cwd: pluginDir, silent: !options.verbose });
exec('npx tsc', { cwd: pluginDir, silent: !options.verbose });
logOk('plugin');
}
// Create dist/padded-cell directory and copy plugin files
log(' Copying plugin files to dist/padded-cell...', 'blue');
mkdirSync(DIST_DIR, { recursive: true });
// ── Step 4: Install ─────────────────────────────────────────────────────
// Copy pcexec
copyDir(join(__dirname, 'pcexec'), join(DIST_DIR, 'pcexec'));
logSuccess('Copied pcexec to dist/padded-cell/');
async function install() {
if (options.buildOnly) { logStep(4, 6, 'Skipping install (--build-only)'); return null; }
logStep(4, 6, 'Installing...');
// Copy safe-restart
copyDir(join(__dirname, 'safe-restart'), join(DIST_DIR, 'safe-restart'));
logSuccess('Copied safe-restart to dist/padded-cell/');
const openclawPath = resolveOpenclawPath();
const binDir = join(openclawPath, 'bin');
const pluginsDir = join(openclawPath, 'plugins');
const destDir = join(pluginsDir, PLUGIN_NAME);
// Create root index.js entry point (copy from source)
copyFileSync(join(__dirname, 'index.js'), join(DIST_DIR, 'index.js'));
logSuccess('Copied index.js entry point');
log(` OpenClaw path: ${openclawPath}`, 'blue');
// Copy openclaw.plugin.json from source
copyFileSync(join(__dirname, 'openclaw.plugin.json'), join(DIST_DIR, 'openclaw.plugin.json'));
logSuccess('Copied openclaw.plugin.json');
// Copy dist/padded-cell → plugins/padded-cell
if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true });
copyDir(SRC_DIST_DIR, destDir);
// Create bin directory and install pass_mgr binary
// Copy openclaw.plugin.json and package.json
copyFileSync(join(__dirname, 'plugin', 'openclaw.plugin.json'), join(destDir, 'openclaw.plugin.json'));
copyFileSync(join(__dirname, 'plugin', 'package.json'), join(destDir, 'package.json'));
logOk(`Plugin files → ${destDir}`);
// Install runtime deps into dest (express, ws)
exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose });
logOk('Runtime deps installed');
// Binaries
mkdirSync(binDir, { recursive: true });
log(' Installing pass_mgr binary...', 'blue');
const passMgrSource = join(__dirname, 'pass_mgr', 'dist', 'pass_mgr');
const passMgrDest = join(binDir, 'pass_mgr');
copyFileSync(passMgrSource, passMgrDest);
chmodSync(passMgrDest, 0o755);
logSuccess(`pass_mgr installed to ${passMgrDest}`);
const bins = [
{ name: 'pass_mgr', src: join(__dirname, 'pass_mgr', 'dist', 'pass_mgr') },
{ name: 'pcguard', src: join(__dirname, 'pcguard', 'dist', 'pcguard') },
];
for (const b of bins) {
const dest = join(binDir, b.name);
copyFileSync(b.src, dest);
chmodSync(dest, 0o755);
logOk(`${b.name}${dest}`);
}
return { passMgrPath: passMgrDest };
return { binDir, destDir };
}
// ============================================================================
// Step 5: Configuration
// ============================================================================
// ── Step 5: Configure ───────────────────────────────────────────────────
async function configure(env) {
if (options.buildOnly) {
logStep(5, 'Skipping configuration (--build-only)');
return;
}
async function configure() {
if (options.buildOnly) { logStep(5, 6, 'Skipping config'); return; }
logStep(5, 6, 'Configuring OpenClaw...');
logStep(5, 'Configuration...');
const openclawPath = resolveOpenclawPath();
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
const passMgrPath = join(openclawPath, 'bin', 'pass_mgr');
const installDir = options.prefix || env.openclawDir;
const passMgrPath = join(installDir, 'bin', 'pass_mgr');
// Check if already initialized
const adminKeyDir = join(homedir(), '.pass_mgr');
const configPath = join(adminKeyDir, 'config.json');
if (existsSync(configPath)) {
logSuccess('pass_mgr already initialized');
} else {
log(' pass_mgr not initialized yet.', 'yellow');
log(` Run "${passMgrPath} admin init" manually after installation.`, 'cyan');
}
// Configure OpenClaw
log('\n Configuring OpenClaw plugin...', 'blue');
try {
// 1. Add plugin path to plugins.load.paths FIRST (required for validation)
const currentPaths = getOpenclawConfig('plugins.load.paths', []);
log(` Current paths: ${JSON.stringify(currentPaths)}`, 'blue');
log(` DIST_DIR: ${DIST_DIR}`, 'blue');
if (!currentPaths.includes(DIST_DIR)) {
currentPaths.push(DIST_DIR);
log(` Adding plugin path...`, 'blue');
try {
setOpenclawConfig('plugins.load.paths', currentPaths);
logSuccess(`Added ${DIST_DIR} to plugins.load.paths`);
} catch (err) {
logError(`Failed to set paths: ${err.message}`);
throw err;
}
} else {
log(' Plugin path already in plugins.load.paths', 'green');
}
// plugins.load.paths
const paths = getOpenclawConfig('plugins.load.paths', []);
if (!paths.includes(destDir)) { paths.push(destDir); setOpenclawConfig('plugins.load.paths', paths); }
logOk(`plugins.load.paths includes ${destDir}`);
// 2. Add to plugins.allow (after path is set)
const allowList = getOpenclawConfig('plugins.allow', []);
if (!allowList.includes(PLUGIN_NAME)) {
allowList.push(PLUGIN_NAME);
setOpenclawConfig('plugins.allow', allowList);
logSuccess(`Added '${PLUGIN_NAME}' to plugins.allow`);
} else {
log(' Already in plugins.allow', 'green');
}
// plugins.allow
const allow = getOpenclawConfig('plugins.allow', []);
if (!allow.includes(PLUGIN_NAME)) { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); }
logOk(`plugins.allow includes ${PLUGIN_NAME}`);
// 3. Add plugin entry
// plugins.entries
const plugins = getOpenclawConfig('plugins', {});
plugins.entries = plugins.entries || {};
plugins.entries[PLUGIN_NAME] = {
enabled: true,
config: {
enabled: true,
passMgrPath: passMgrPath,
},
config: { enabled: true, passMgrPath, openclawProfilePath: openclawPath },
};
setOpenclawConfig('plugins', plugins);
logSuccess(`Configured ${PLUGIN_NAME} plugin entry`);
logOk('Plugin entry configured');
} catch (err) {
logWarning(`Failed to configure OpenClaw: ${err.message}`);
log(' Please manually configure:', 'yellow');
log(` openclaw config set plugins.allow --json '[..., "${PLUGIN_NAME}"]'`, 'cyan');
log(` openclaw config set plugins.load.paths --json '[..., "${DIST_DIR}"]'`, 'cyan');
logWarn(`Config failed: ${err.message}`);
}
// Check pass_mgr init
if (existsSync(join(homedir(), '.pass_mgr', 'config.json'))) {
logOk('pass_mgr already initialized');
} else {
logWarn(`pass_mgr not initialized — run: ${passMgrPath} admin init`);
}
}
// ============================================================================
// Step 6: Print Summary
// ============================================================================
function printSummary(env, passMgrPath) {
logStep(6, 'Installation Summary');
// ── Step 6: Summary ─────────────────────────────────────────────────────
function summary(result) {
logStep(6, 6, 'Done!');
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Installation Complete ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
console.log('');
log('╔══════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell v0.2.0 Install Complete ║', 'cyan');
log('╚══════════════════════════════════════════════╝', 'cyan');
if (options.buildOnly) {
log('Build-only mode - binaries built but not installed', 'yellow');
console.log('');
log('Built artifacts:', 'blue');
log(` • pass_mgr: ${join(__dirname, 'pass_mgr', 'dist', 'pass_mgr')}`, 'reset');
log(` • pcexec: ${join(__dirname, 'pcexec', 'dist')}`, 'reset');
log(` • safe-restart: ${join(__dirname, 'safe-restart', 'dist')}`, 'reset');
} else {
log('Installed components:', 'blue');
log(` • pass_mgr binary: ${passMgrPath}`, 'reset');
log(` • Plugin files: ${DIST_DIR}`, 'reset');
console.log('');
log('Next steps:', 'blue');
console.log('');
log('1. Initialize pass_mgr (required before first use):', 'yellow');
log(` ${passMgrPath} admin init`, 'cyan');
console.log('');
log('2. Test pass_mgr:', 'yellow');
log(` ${passMgrPath} set test_key mypass`, 'cyan');
log(` ${passMgrPath} get test_key`, 'cyan');
console.log('');
log('3. Restart OpenClaw gateway:', 'yellow');
log(' openclaw gateway restart', 'cyan');
log('\nBuild-only binaries not installed.', 'yellow');
return;
}
console.log('');
log('Next steps:', 'blue');
log(' 1. openclaw gateway restart', 'cyan');
const openclawPath = resolveOpenclawPath();
const pmPath = join(openclawPath, 'bin', 'pass_mgr');
if (!existsSync(join(homedir(), '.pass_mgr', 'config.json'))) {
log(` 2. ${pmPath} admin init`, 'cyan');
}
console.log('');
}
// ============================================================================
// Uninstall
// ============================================================================
// ── Uninstall ───────────────────────────────────────────────────────────
async function uninstall(env) {
logStep(1, 'Uninstalling PaddedCell...');
async function uninstall() {
log('Uninstalling PaddedCell...', 'cyan');
const openclawPath = resolveOpenclawPath();
const installDir = options.prefix || env.openclawDir || join(homedir(), '.openclaw');
const passMgrBinary = join(installDir, 'bin', 'pass_mgr');
// Remove pass_mgr binary
if (existsSync(passMgrBinary)) {
try {
execSync(`rm -f "${passMgrBinary}"`, { silent: true });
logSuccess(`Removed ${passMgrBinary}`);
} catch (err) {
logError(`Failed to remove ${passMgrBinary}`);
}
// Remove binaries
for (const name of ['pass_mgr', 'pcguard']) {
const p = join(openclawPath, 'bin', name);
if (existsSync(p)) { rmSync(p); logOk(`Removed ${p}`); }
}
// Remove dist/padded-cell directory
if (existsSync(DIST_DIR)) {
try {
execSync(`rm -rf "${DIST_DIR}"`, { silent: true });
logSuccess(`Removed ${DIST_DIR}`);
} catch (err) {
logError(`Failed to remove ${DIST_DIR}`);
}
}
// Remove plugin dir
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
if (existsSync(destDir)) { rmSync(destDir, { recursive: true }); logOk(`Removed ${destDir}`); }
// Remove OpenClaw configuration
log('\n Removing OpenClaw configuration...', 'blue');
// Remove config
try {
// Remove from plugins.allow
const allowList = getOpenclawConfig('plugins.allow', []);
const idx = allowList.indexOf(PLUGIN_NAME);
if (idx !== -1) {
allowList.splice(idx, 1);
setOpenclawConfig('plugins.allow', allowList);
logSuccess(`Removed '${PLUGIN_NAME}' from plugins.allow`);
}
// Remove plugin entry
const allow = getOpenclawConfig('plugins.allow', []);
const idx = allow.indexOf(PLUGIN_NAME);
if (idx !== -1) { allow.splice(idx, 1); setOpenclawConfig('plugins.allow', allow); logOk('Removed from allow list'); }
unsetOpenclawConfig(`plugins.entries.${PLUGIN_NAME}`);
logSuccess(`Removed ${PLUGIN_NAME} plugin entry`);
logOk('Removed plugin entry');
const paths = getOpenclawConfig('plugins.load.paths', []);
const pidx = paths.indexOf(destDir);
if (pidx !== -1) { paths.splice(pidx, 1); setOpenclawConfig('plugins.load.paths', paths); logOk('Removed from load paths'); }
} catch (err) { logWarn(`Config cleanup: ${err.message}`); }
// Remove from plugins.load.paths
const currentPaths = getOpenclawConfig('plugins.load.paths', []);
const pathIdx = currentPaths.indexOf(DIST_DIR);
if (pathIdx !== -1) {
currentPaths.splice(pathIdx, 1);
setOpenclawConfig('plugins.load.paths', currentPaths);
logSuccess(`Removed plugin path from plugins.load.paths`);
}
} catch (err) {
logWarning(`Failed to update OpenClaw config: ${err.message}`);
}
// Check for admin key directory
const adminKeyDir = join(homedir(), '.pass_mgr');
if (existsSync(adminKeyDir)) {
log('\n⚠ Admin key directory found:', 'yellow');
log(` ${adminKeyDir}`, 'cyan');
log(' This contains your encryption keys. Remove manually if desired.', 'yellow');
}
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Uninstall Complete ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
console.log('');
log('Restart OpenClaw gateway:', 'yellow');
log(' openclaw gateway restart', 'cyan');
log('\nRun: openclaw gateway restart', 'yellow');
}
// ============================================================================
// Main
// ============================================================================
// ── Main ────────────────────────────────────────────────────────────────
async function main() {
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Plugin Installer v0.1.0 ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
log('╔══════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Plugin Installer v0.2.0 ║', 'cyan');
log('╚══════════════════════════════════════════════╝', 'cyan');
console.log('');
try {
const env = detectEnvironment();
// Handle uninstall
if (options.uninstall) {
await uninstall(env);
process.exit(0);
}
checkDependencies(env);
await buildComponents(env);
const result = await installComponents(env);
await configure(env);
printSummary(env, result?.passMgrPath);
process.exit(0);
if (options.uninstall) { await uninstall(); process.exit(0); }
checkDeps(env);
await build();
const result = await install();
await configure();
summary(result);
} catch (err) {
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'red');
log('║ Installation Failed ║', 'red');
log('╚════════════════════════════════════════════════════════╝', 'red');
console.log('');
log(`Error: ${err.message}`, 'red');
log(`\nInstallation failed: ${err.message}`, 'red');
process.exit(1);
}
}

File diff suppressed because it is too large Load Diff

3846
pcexec/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
{
"name": "pcexec",
"version": "0.1.0",
"description": "Safe exec wrapper for OpenClaw with password sanitization",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "jest"
},
"dependencies": {
"@types/node": "^20.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0",
"ts-jest": "^29.0.0"
}
}

View File

@@ -1,366 +0,0 @@
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 }> = [];
// Pattern for $(pass_mgr get key) or `pass_mgr get key`
const patterns = [
/\$\(\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', 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;

3
pcguard/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module pcguard
go 1.24.0

36
pcguard/src/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"fmt"
"os"
)
const (
// Must match the sentinel value injected by pcexec
expectedAgentVerify = "IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE"
errorMessage = "PLEASE USE TOOL PCEXEC TO RUN THIS SCRIPT"
)
func main() {
agentVerify := os.Getenv("AGENT_VERIFY")
agentID := os.Getenv("AGENT_ID")
agentWorkspace := os.Getenv("AGENT_WORKSPACE")
if agentVerify != expectedAgentVerify {
fmt.Fprintln(os.Stderr, errorMessage)
os.Exit(1)
}
if agentID == "" {
fmt.Fprintln(os.Stderr, errorMessage)
os.Exit(1)
}
if agentWorkspace == "" {
fmt.Fprintln(os.Stderr, errorMessage)
os.Exit(1)
}
// All checks passed — output nothing, exit 0
os.Exit(0)
}

View File

@@ -1,4 +1,4 @@
import { StatusManager } from './status-manager';
import { StatusManager } from '../core/status-manager';
export interface SlashCommandOptions {
statusManager: StatusManager;

View File

@@ -1,4 +1,4 @@
export { StatusManager, type AgentStatus, type GlobalStatus, type AgentState } from './status-manager';
export { createApiServer, startApiServer } from './api';
export { safeRestart, createSafeRestartTool, type SafeRestartOptions, type SafeRestartResult } from './safe-restart';
export { SlashCommandHandler, type SlashCommandOptions } from './slash-commands';
export { SlashCommandHandler, type SlashCommandOptions } from '../commands/slash-commands';

1
plugin/hooks/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# placeholder

View File

@@ -1,25 +1,41 @@
// PaddedCell Plugin for OpenClaw
// Registers pcexec and safe_restart tools
const { pcexec, pcexecSync } = require('./pcexec/dist/index.js');
const {
import { pcexec, pcexecSync } from './tools/pcexec';
import {
safeRestart,
createSafeRestartTool,
StatusManager,
createApiServer,
startApiServer,
SlashCommandHandler
} = require('./safe-restart/dist/index.js');
} from './core/index';
import { SlashCommandHandler } from './commands/slash-commands';
/** Sentinel value injected into every pcexec subprocess */
const AGENT_VERIFY = 'IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE';
/**
* Resolve the openclaw base path.
* Priority: explicit config $OPENCLAW_PATH ~/.openclaw
*/
function resolveOpenclawPath(config?: { openclawProfilePath?: string }): string {
if (config?.openclawProfilePath) return config.openclawProfilePath;
if (process.env.OPENCLAW_PATH) return process.env.OPENCLAW_PATH;
const home = process.env.HOME || require('os').homedir();
return require('path').join(home, '.openclaw');
}
// Plugin registration function
function register(api, config) {
function register(api: any, config?: any) {
const logger = api.logger || { info: console.log, error: console.error };
logger.info('PaddedCell plugin initializing...');
// Register pcexec tool - pass a FACTORY function that receives context
api.registerTool((ctx) => {
console.log(`[PaddedCell] pcexec factory called with ctx:`, JSON.stringify(ctx, null, 2));
const openclawPath = resolveOpenclawPath(config);
const binDir = require('path').join(openclawPath, 'bin');
// Register pcexec tool — pass a FACTORY function that receives context
api.registerTool((ctx: any) => {
const agentId = ctx.agentId;
const workspaceDir = ctx.workspaceDir;
@@ -35,20 +51,29 @@ function register(api, config) {
},
required: ['command'],
},
async execute(_id, params) {
async execute(_id: string, params: any) {
const command = params.command;
if (!command) {
throw new Error('Missing required parameter: command');
}
console.log(`[PaddedCell] pcexec execute: agentId=${agentId}, workspaceDir=${workspaceDir}`);
// Build PATH with openclaw bin dir appended
const currentPath = process.env.PATH || '';
const newPath = currentPath.includes(binDir)
? currentPath
: `${currentPath}:${binDir}`;
const result = await pcexec(command, {
cwd: params.cwd || workspaceDir,
timeout: params.timeout,
env: {
AGENT_ID: agentId || '',
AGENT_WORKSPACE: workspaceDir || '',
AGENT_VERIFY,
PATH: newPath,
},
});
// Format output for OpenClaw tool response
let output = result.stdout;
if (result.stderr) {
@@ -59,8 +84,8 @@ function register(api, config) {
};
});
// Register safe_restart tool - pass a FACTORY function that receives context
api.registerTool((ctx) => {
// Register safe_restart tool
api.registerTool((ctx: any) => {
const agentId = ctx.agentId;
const sessionKey = ctx.sessionKey;
@@ -74,10 +99,10 @@ function register(api, config) {
log: { type: 'string', description: 'Log file path' },
},
},
async execute(_id, params) {
async execute(_id: string, params: any) {
return await safeRestart({
agentId: agentId,
sessionKey: sessionKey,
agentId,
sessionKey,
rollback: params.rollback,
log: params.log,
});
@@ -88,7 +113,7 @@ function register(api, config) {
logger.info('PaddedCell plugin initialized');
}
// Export for OpenClaw
// CommonJS export for OpenClaw
module.exports = { register };
// Also export individual modules for direct use
@@ -100,3 +125,4 @@ module.exports.StatusManager = StatusManager;
module.exports.createApiServer = createApiServer;
module.exports.startApiServer = startApiServer;
module.exports.SlashCommandHandler = SlashCommandHandler;
module.exports.AGENT_VERIFY = AGENT_VERIFY;

View File

@@ -1,14 +1,15 @@
{
"id": "padded-cell",
"name": "PaddedCell",
"version": "0.1.0",
"description": "Secure password management, safe execution, and coordinated restart",
"version": "0.2.0",
"description": "Secure password management, safe execution, and coordinated agent restart",
"entry": "./index.js",
"configSchema": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true },
"passMgrPath": { "type": "string", "default": "/root/.openclaw/bin/pass_mgr" }
"passMgrPath": { "type": "string", "default": "" },
"openclawProfilePath": { "type": "string", "default": "" }
}
}
}

20
plugin/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "padded-cell-plugin",
"version": "0.2.0",
"description": "PaddedCell plugin for OpenClaw - secure exec, password management, coordinated restart",
"main": "index.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@types/node": "^20.0.0",
"express": "^4.18.0",
"ws": "^8.14.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/express": "^4.17.0",
"@types/ws": "^8.5.0"
}
}

313
plugin/tools/pcexec.ts Normal file
View File

@@ -0,0 +1,313 @@
import { spawn, SpawnOptions } from '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 pass_mgr invocations from a command string.
*
* Supports both legacy and new formats:
* Legacy: $(pass_mgr get <key>) / `pass_mgr get <key>`
* 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; subcommand: string }> {
const results: Array<{ key: string; fullMatch: string; subcommand: string }> = [];
const seen = new Set<string>();
// New format: 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 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;
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',
});
}
}
}
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',
});
}
}
}
return results;
}
/**
* Execute 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>,
): Promise<string> {
return new Promise((resolve, reject) => {
const passMgrPath = env.PASS_MGR_PATH || process.env.PASS_MGR_PATH || 'pass_mgr';
const args =
subcommand === 'get-secret'
? ['get-secret', '--key', key]
: ['get', key];
const child = spawn(passMgrPath, 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(`pass_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 pass_mgr invocations, replace them inline, and collect passwords.
*/
async function replacePassMgrGets(
command: string,
env: Record<string, string>,
): Promise<{ command: string; passwords: string[] }> {
const matches = extractPassMgrGets(command);
const passwords: string[] = [];
let replaced = command;
for (const { key, fullMatch, subcommand } of matches) {
const pw = await fetchPassword(subcommand, key, env);
passwords.push(pw);
replaced = replaced.split(fullMatch).join(pw);
}
return { command: replaced, passwords };
}
/**
* Safe exec wrapper that handles pass_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 replacePassMgrGets(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}`) 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 pass_mgr integration).
*/
export function pcexecSync(
command: string,
options: PcExecOptions = {},
): PcExecResult {
const { execSync } = require('child_process');
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;

View File

@@ -3,8 +3,8 @@
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"outDir": "../dist/padded-cell",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -14,6 +14,6 @@
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
"include": ["./**/*.ts"],
"exclude": ["node_modules", "../dist", "**/*.test.ts"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"name": "safe-restart",
"version": "0.1.0",
"description": "Safe restart module for OpenClaw agents",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "jest"
},
"dependencies": {
"@types/node": "^20.0.0",
"express": "^4.18.0",
"ws": "^8.14.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/express": "^4.17.0",
"@types/ws": "^8.5.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0",
"ts-jest": "^29.0.0"
}
}

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}