From 99787e6ded71235315cbd25c7f742fd774af315e Mon Sep 17 00:00:00 2001 From: orion Date: Sun, 8 Mar 2026 23:09:16 +0000 Subject: [PATCH] fix: resolve issues #4 #6 #7 #8 for install and pass_mgr --- README.md | 11 +- install.mjs | 226 +++++++++++++++++----------- pass_mgr/go.mod | 6 +- pass_mgr/go.sum | 4 - pass_mgr/src/main.go | 341 +++++++++++++------------------------------ 5 files changed, 248 insertions(+), 340 deletions(-) diff --git a/README.md b/README.md index 71c8422..988f761 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ pass_mgr get # 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 ``` @@ -131,14 +130,16 @@ openclaw gateway restart ## Usage ```bash -# Initialize admin password -~/.openclaw/bin/pass_mgr admin init - -# Agent sets and gets passwords (via pcexec) +# Agent sets and gets private passwords (via pcexec) pass_mgr set --key myservice --secret s3cret --username admin pass_mgr get-secret --key myservice pass_mgr get-username --key myservice +# Shared scope (.public) +pass_mgr set --public --key shared-api --secret s3cret +pass_mgr list --public +pass_mgr get-secret --public --key shared-api + # 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 ``` diff --git a/install.mjs b/install.mjs index b2bf3a2..134622a 100755 --- a/install.mjs +++ b/install.mjs @@ -1,18 +1,20 @@ #!/usr/bin/env node /** - * PaddedCell Plugin Installer v0.2.0 - * - * Usage: - * node install.mjs - * node install.mjs --openclaw-profile-path /path/to/.openclaw - * node install.mjs --build-only - * node install.mjs --skip-check - * node install.mjs --uninstall + * PaddedCell Plugin Installer v0.3.0 */ import { execSync } from 'child_process'; -import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync, readFileSync, writeFileSync } 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'; @@ -24,7 +26,6 @@ const __dirname = resolve(dirname(__filename)); const PLUGIN_NAME = 'padded-cell'; const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME); -// Parse arguments const args = process.argv.slice(2); const options = { openclawProfilePath: null, @@ -32,22 +33,20 @@ const options = { skipCheck: args.includes('--skip-check'), verbose: args.includes('--verbose') || args.includes('-v'), uninstall: args.includes('--uninstall'), + installOnly: args.includes('--install'), }; -// Parse --openclaw-profile-path value const profileIdx = args.indexOf('--openclaw-profile-path'); if (profileIdx !== -1 && args[profileIdx + 1]) { options.openclawProfilePath = resolve(args[profileIdx + 1]); } -// 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', @@ -86,26 +85,20 @@ function copyDir(src, dest) { 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 + if (entry.name === 'node_modules') continue; entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); } } -// ── Step 1: Detect ────────────────────────────────────────────────────── - function detectEnvironment() { logStep(1, 6, 'Detecting environment...'); const env = { platform: platform(), nodeVersion: null, goVersion: null }; - 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; } -// ── Step 2: Check deps ────────────────────────────────────────────────── - function checkDeps(env) { if (options.skipCheck) { logStep(2, 6, 'Skipping dep checks'); return; } logStep(2, 6, 'Checking dependencies...'); @@ -116,8 +109,6 @@ function checkDeps(env) { logOk('All deps OK'); } -// ── Step 3: Build ─────────────────────────────────────────────────────── - function ensureBuildSecret() { const secretFile = join(__dirname, '.build-secret'); if (existsSync(secretFile)) { @@ -136,10 +127,10 @@ function ensureBuildSecret() { async function build() { logStep(3, 6, 'Building components...'); - // Generate / load build secret for pass_mgr const buildSecret = ensureBuildSecret(); - // pass_mgr (Go) + rmSync(SRC_DIST_DIR, { recursive: true, force: true }); + log(' Building pass_mgr...', 'blue'); const pmDir = join(__dirname, 'pass_mgr'); exec('go mod tidy', { cwd: pmDir, silent: !options.verbose }); @@ -148,7 +139,6 @@ async function build() { chmodSync(join(pmDir, 'dist', 'pass_mgr'), 0o755); logOk('pass_mgr'); - // pcguard (Go) log(' Building pcguard...', 'blue'); const pgDir = join(__dirname, 'pcguard'); exec('go mod tidy', { cwd: pgDir, silent: !options.verbose }); @@ -156,15 +146,85 @@ async function build() { chmodSync(join(pgDir, 'dist', 'pcguard'), 0o755); logOk('pcguard'); - // 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'); + + const skillsSrc = join(__dirname, 'skills'); + const skillsDist = join(SRC_DIST_DIR, 'skills'); + if (existsSync(skillsSrc)) { + copyDir(skillsSrc, skillsDist); + logOk('skills copied to dist/padded-cell/skills'); + } } -// ── Step 4: Install ───────────────────────────────────────────────────── +function handoffSecretIfPossible(openclawPath) { + const passMgrPath = join(openclawPath, 'bin', 'pass_mgr'); + if (!existsSync(passMgrPath)) return null; + + const storeA = join(openclawPath, 'pc-pass-store'); + const storeB = join(openclawPath, 'pc-secret-store'); + if (!existsSync(storeA) && !existsSync(storeB)) return null; + + const secretFile = join(openclawPath, 'pc-pass-store.secret'); + try { + exec(`${passMgrPath} admin handoff ${secretFile}`, { silent: !options.verbose }); + logOk(`handoff secret → ${secretFile}`); + return secretFile; + } catch (err) { + logWarn(`handoff failed: ${err.message}`); + return null; + } +} + +function clearInstallTargets(openclawPath) { + const binDir = join(openclawPath, 'bin'); + for (const name of ['pass_mgr', 'pcguard']) { + const p = join(binDir, name); + if (existsSync(p)) { rmSync(p, { force: true }); logOk(`Removed ${p}`); } + } + + const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); + if (existsSync(destDir)) { rmSync(destDir, { recursive: true, force: true }); logOk(`Removed ${destDir}`); } +} + +function cleanupConfig(openclawPath) { + const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); + const skillsDir = join(openclawPath, 'skills'); + try { + 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}`); + 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'); + } + + const skillEntries = ['pcexec', 'safe-restart', 'safe_restart', 'pass-mgr']; + for (const sk of skillEntries) { + const p = join(skillsDir, sk); + if (existsSync(p)) { + rmSync(p, { recursive: true, force: true }); + logOk(`Removed skill ${p}`); + } + } + } catch (err) { + logWarn(`Config cleanup: ${err.message}`); + } +} async function install() { if (options.buildOnly) { logStep(4, 6, 'Skipping install (--build-only)'); return null; } @@ -174,23 +234,29 @@ async function install() { const binDir = join(openclawPath, 'bin'); const pluginsDir = join(openclawPath, 'plugins'); const destDir = join(pluginsDir, PLUGIN_NAME); + const skillsDir = join(openclawPath, 'skills'); + const distSkillsDir = join(SRC_DIST_DIR, 'skills'); log(` OpenClaw path: ${openclawPath}`, 'blue'); - // Copy dist/padded-cell → plugins/padded-cell + // update/reinstall path: remove old install first + if (existsSync(destDir) || existsSync(join(binDir, 'pass_mgr')) || existsSync(join(binDir, 'pcguard'))) { + logWarn('Existing install detected, uninstalling before install...'); + handoffSecretIfPossible(openclawPath); + clearInstallTargets(openclawPath); + cleanupConfig(openclawPath); + } + if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true }); copyDir(SRC_DIST_DIR, destDir); - // 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 }); const bins = [ { name: 'pass_mgr', src: join(__dirname, 'pass_mgr', 'dist', 'pass_mgr') }, @@ -203,11 +269,34 @@ async function install() { logOk(`${b.name} → ${dest}`); } + // Only copy dist/padded-cell/skills to ~/.openclaw/skills + mkdirSync(skillsDir, { recursive: true }); + if (existsSync(distSkillsDir)) { + for (const entry of readdirSync(distSkillsDir, { withFileTypes: true })) { + const s = join(distSkillsDir, entry.name); + const d = join(skillsDir, entry.name); + rmSync(d, { recursive: true, force: true }); + entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); + logOk(`skill synced → ${d}`); + } + } + + // if prior encrypted store exists, run init-from once new binary is installed + const hasStore = existsSync(join(openclawPath, 'pc-pass-store')) || existsSync(join(openclawPath, 'pc-secret-store')); + const secretFile = join(openclawPath, 'pc-pass-store.secret'); + if (hasStore && existsSync(secretFile)) { + const passMgrPath = join(binDir, 'pass_mgr'); + try { + exec(`${passMgrPath} admin init-from ${secretFile}`, { silent: !options.verbose }); + logOk('init-from completed from handoff secret'); + } catch (err) { + logWarn(`init-from failed: ${err.message}`); + } + } + return { binDir, destDir }; } -// ── Step 5: Configure ─────────────────────────────────────────────────── - async function configure() { if (options.buildOnly) { logStep(5, 6, 'Skipping config'); return; } logStep(5, 6, 'Configuring OpenClaw...'); @@ -217,17 +306,14 @@ async function configure() { const passMgrPath = join(openclawPath, 'bin', 'pass_mgr'); try { - // 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}`); - // 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}`); - // plugins.entries const plugins = getOpenclawConfig('plugins', {}); plugins.entries = plugins.entries || {}; plugins.entries[PLUGIN_NAME] = { @@ -239,22 +325,13 @@ async function configure() { } catch (err) { 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: Summary ───────────────────────────────────────────────────── - -function summary(result) { +function summary() { logStep(6, 6, 'Done!'); console.log(''); log('╔══════════════════════════════════════════════╗', 'cyan'); - log('║ PaddedCell v0.2.0 Install Complete ║', 'cyan'); + log('║ PaddedCell v0.3.0 Install Complete ║', 'cyan'); log('╚══════════════════════════════════════════════╝', 'cyan'); if (options.buildOnly) { @@ -265,63 +342,42 @@ function summary(result) { 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 ─────────────────────────────────────────────────────────── - async function uninstall() { log('Uninstalling PaddedCell...', 'cyan'); const openclawPath = resolveOpenclawPath(); - - // Remove binaries - for (const name of ['pass_mgr', 'pcguard']) { - const p = join(openclawPath, 'bin', name); - if (existsSync(p)) { rmSync(p); logOk(`Removed ${p}`); } - } - - // Remove plugin dir - const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); - if (existsSync(destDir)) { rmSync(destDir, { recursive: true }); logOk(`Removed ${destDir}`); } - - // Remove config - try { - 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}`); - 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}`); } - + handoffSecretIfPossible(openclawPath); + clearInstallTargets(openclawPath); + cleanupConfig(openclawPath); log('\nRun: openclaw gateway restart', 'yellow'); } -// ── Main ──────────────────────────────────────────────────────────────── - async function main() { console.log(''); log('╔══════════════════════════════════════════════╗', 'cyan'); - log('║ PaddedCell Plugin Installer v0.2.0 ║', 'cyan'); + log('║ PaddedCell Plugin Installer v0.3.0 ║', 'cyan'); log('╚══════════════════════════════════════════════╝', 'cyan'); console.log(''); try { const env = detectEnvironment(); - if (options.uninstall) { await uninstall(); process.exit(0); } + + if (options.uninstall) { + await uninstall(); + process.exit(0); + } + checkDeps(env); await build(); - const result = await install(); - await configure(); - summary(result); + + if (!options.buildOnly) { + await install(); + await configure(); + } + + summary(); } catch (err) { log(`\nInstallation failed: ${err.message}`, 'red'); process.exit(1); diff --git a/pass_mgr/go.mod b/pass_mgr/go.mod index c2a757a..278e504 100644 --- a/pass_mgr/go.mod +++ b/pass_mgr/go.mod @@ -2,13 +2,9 @@ module pass_mgr go 1.24.0 -require ( - github.com/spf13/cobra v1.8.0 - golang.org/x/term v0.40.0 -) +require github.com/spf13/cobra v1.8.0 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.41.0 // indirect ) diff --git a/pass_mgr/go.sum b/pass_mgr/go.sum index 9dfa6e5..d0e8c2c 100644 --- a/pass_mgr/go.sum +++ b/pass_mgr/go.sum @@ -6,9 +6,5 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pass_mgr/src/main.go b/pass_mgr/src/main.go index c0f0852..58ad820 100644 --- a/pass_mgr/src/main.go +++ b/pass_mgr/src/main.go @@ -12,10 +12,8 @@ import ( "os" "path/filepath" "strings" - "syscall" "github.com/spf13/cobra" - "golang.org/x/term" ) // buildSecret is injected at compile time via -ldflags "-X main.buildSecret=" @@ -23,16 +21,14 @@ var buildSecret string const ( PassStoreDirName = "pc-pass-store" - AdminDirName = ".pass_mgr" - AdminFile = "admin.json" + PublicDirName = ".public" // Must match pcguard sentinel expectedAgentVerify = "IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE" ) -// ── Data types ────────────────────────────────────────────────────────── - // EncryptedFile is the on-disk format for .gpg files +// (kept for compatibility with existing files) type EncryptedFile struct { Nonce string `json:"nonce"` Data string `json:"data"` @@ -44,13 +40,6 @@ type Entry struct { Secret string `json:"secret"` } -// AdminConfig stores hashed admin password -type AdminConfig struct { - PasswordHash string `json:"password_hash"` -} - -// ── Helpers ───────────────────────────────────────────────────────────── - func resolveOpenclawPath() string { if p := os.Getenv("OPENCLAW_PATH"); p != "" { return p @@ -59,29 +48,28 @@ func resolveOpenclawPath() string { return filepath.Join(home, ".openclaw") } -func passStoreBase() string { - return filepath.Join(resolveOpenclawPath(), PassStoreDirName) +func passStoreBase() string { return filepath.Join(resolveOpenclawPath(), PassStoreDirName) } +func publicStoreDir() string { return filepath.Join(passStoreBase(), PublicDirName) } +func agentStoreDir(agentID string) string { return filepath.Join(passStoreBase(), agentID) } +func currentAgentID() string { return os.Getenv("AGENT_ID") } +func resolveStoreDir(public bool) string { + if public { + return publicStoreDir() + } + return agentStoreDir(currentAgentID()) +} +func ensurePublicStoreDir() error { return os.MkdirAll(publicStoreDir(), 0700) } +func deriveKey(secret string) []byte { h := sha256.Sum256([]byte(secret)); return h[:] } +func anyAgentEnvSet() bool { + return os.Getenv("AGENT_ID") != "" || os.Getenv("AGENT_WORKSPACE") != "" || os.Getenv("AGENT_VERIFY") != "" +} +func rejectIfAgent(cmdName string) { + if anyAgentEnvSet() { + fmt.Fprintf(os.Stderr, "Error: '%s' can only be run by a human (AGENT_* env vars detected)\n", cmdName) + os.Exit(1) + } } -func agentStoreDir(agentID string) string { - return filepath.Join(passStoreBase(), agentID) -} - -func adminDir() string { - return filepath.Join(resolveOpenclawPath(), AdminDirName) -} - -func adminConfigPath() string { - return filepath.Join(adminDir(), AdminFile) -} - -// deriveKey returns AES-256 key from SHA-256 of the given secret string -func deriveKey(secret string) []byte { - h := sha256.Sum256([]byte(secret)) - return h[:] -} - -// currentKey returns the AES key derived from the compiled-in buildSecret func currentKey() ([]byte, error) { if buildSecret == "" { return nil, fmt.Errorf("pass_mgr was built without a build secret; re-run install.mjs") @@ -89,8 +77,6 @@ func currentKey() ([]byte, error) { return deriveKey(buildSecret), nil } -// ── Encryption ────────────────────────────────────────────────────────── - func encrypt(plaintext, key []byte) (*EncryptedFile, error) { block, err := aes.NewCipher(key) if err != nil { @@ -105,10 +91,7 @@ func encrypt(plaintext, key []byte) (*EncryptedFile, error) { return nil, err } ciphertext := gcm.Seal(nil, nonce, plaintext, nil) - return &EncryptedFile{ - Nonce: base64.StdEncoding.EncodeToString(nonce), - Data: base64.StdEncoding.EncodeToString(ciphertext), - }, nil + return &EncryptedFile{Nonce: base64.StdEncoding.EncodeToString(nonce), Data: base64.StdEncoding.EncodeToString(ciphertext)}, nil } func decrypt(ef *EncryptedFile, key []byte) ([]byte, error) { @@ -131,8 +114,6 @@ func decrypt(ef *EncryptedFile, key []byte) ([]byte, error) { return gcm.Open(nil, nonce, ciphertext, nil) } -// ── Entry I/O ─────────────────────────────────────────────────────────── - func readEntry(filePath string, key []byte) (*Entry, error) { raw, err := os.ReadFile(filePath) if err != nil { @@ -163,8 +144,6 @@ func writeEntry(filePath string, entry *Entry, key []byte) error { return os.WriteFile(filePath, data, 0600) } -// ── pcguard inline check ──────────────────────────────────────────────── - func requirePcguard() { if os.Getenv("AGENT_VERIFY") != expectedAgentVerify { fmt.Fprintln(os.Stderr, "Error: must be invoked via pcexec (AGENT_VERIFY mismatch)") @@ -180,77 +159,6 @@ func requirePcguard() { } } -func currentAgentID() string { - return os.Getenv("AGENT_ID") -} - -// ── Admin helpers ─────────────────────────────────────────────────────── - -func anyAgentEnvSet() bool { - return os.Getenv("AGENT_ID") != "" || - os.Getenv("AGENT_WORKSPACE") != "" || - os.Getenv("AGENT_VERIFY") != "" -} - -func rejectIfAgent(cmdName string) { - if anyAgentEnvSet() { - fmt.Fprintf(os.Stderr, "Error: '%s' can only be run by a human (AGENT_* env vars detected)\n", cmdName) - os.Exit(1) - } -} - -func hashPassword(pw string) string { - h := sha256.Sum256([]byte(pw)) - return fmt.Sprintf("%x", h) -} - -func loadAdminConfig() (*AdminConfig, error) { - raw, err := os.ReadFile(adminConfigPath()) - if err != nil { - return nil, err - } - var cfg AdminConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, err - } - return &cfg, nil -} - -func saveAdminConfig(cfg *AdminConfig) error { - if err := os.MkdirAll(adminDir(), 0700); err != nil { - return err - } - data, _ := json.MarshalIndent(cfg, "", " ") - return os.WriteFile(adminConfigPath(), data, 0600) -} - -func verifyAdminPassword() error { - cfg, err := loadAdminConfig() - if err != nil { - return fmt.Errorf("admin not initialized — run 'pass_mgr admin init' first") - } - - var password string - if envPw := os.Getenv("PC_ADMIN_PASS"); envPw != "" { - password = envPw - } else { - fmt.Fprint(os.Stderr, "Admin password: ") - raw, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - fmt.Fprintln(os.Stderr) - password = strings.TrimSpace(string(raw)) - } - - if hashPassword(password) != cfg.PasswordHash { - return fmt.Errorf("invalid admin password") - } - return nil -} - -// ── Password generation ───────────────────────────────────────────────── - func generatePassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+" buf := make([]byte, length) @@ -264,64 +172,67 @@ func generatePassword(length int) (string, error) { return string(buf), nil } -// ── Commands ──────────────────────────────────────────────────────────── - func main() { - rootCmd := &cobra.Command{ - Use: "pass_mgr", - Short: "Password manager for OpenClaw agents", - } - - rootCmd.AddCommand( - listCmd(), - getSecretCmd(), - getUsernameCmd(), - getLegacyCmd(), // backward compat: pass_mgr get - setCmd(), - generateCmd(), - unsetCmd(), - adminCmd(), - ) - + rootCmd := &cobra.Command{Use: "pass_mgr", Short: "Password manager for OpenClaw agents"} + rootCmd.AddCommand(listCmd(), getSecretCmd(), getUsernameCmd(), getLegacyCmd(), setCmd(), generateCmd(), unsetCmd(), adminCmd()) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } -// ── list ───────────────────────────────────────────────────────────────── +func listKeys(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + out := []string{} + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".gpg") { + out = append(out, strings.TrimSuffix(name, ".gpg")) + } + } + return out +} func listCmd() *cobra.Command { - return &cobra.Command{ + var public bool + cmd := &cobra.Command{ Use: "list", Short: "List keys for current agent", Run: func(cmd *cobra.Command, args []string) { requirePcguard() - dir := agentStoreDir(currentAgentID()) - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return // no keys yet + if public { + fmt.Println("----------public------------") + for _, k := range listKeys(publicStoreDir()) { + fmt.Println(k) } - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + fmt.Println("----------private-----------") + for _, k := range listKeys(agentStoreDir(currentAgentID())) { + fmt.Println(k) + } + return } - for _, e := range entries { - name := e.Name() - if strings.HasSuffix(name, ".gpg") { - fmt.Println(strings.TrimSuffix(name, ".gpg")) - } + for _, k := range listKeys(agentStoreDir(currentAgentID())) { + fmt.Println(k) } }, } + cmd.Flags().BoolVar(&public, "public", false, "Include shared public scope") + return cmd } -// ── get-secret ────────────────────────────────────────────────────────── - func getSecretCmd() *cobra.Command { var keyFlag string + var public bool cmd := &cobra.Command{ - Use: "get-secret", - Short: "Get secret for a key", + Use: "get-secret", + Aliases: []string{"get_secret"}, + Short: "Get secret for a key", Run: func(cmd *cobra.Command, args []string) { requirePcguard() if keyFlag == "" { @@ -333,7 +244,7 @@ func getSecretCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + fp := filepath.Join(resolveStoreDir(public), keyFlag+".gpg") entry, err := readEntry(fp, key) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -343,16 +254,17 @@ func getSecretCmd() *cobra.Command { }, } cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + cmd.Flags().BoolVar(&public, "public", false, "Use shared public scope only") return cmd } -// ── get-username ──────────────────────────────────────────────────────── - func getUsernameCmd() *cobra.Command { var keyFlag string + var public bool cmd := &cobra.Command{ - Use: "get-username", - Short: "Get username for a key", + Use: "get-username", + Aliases: []string{"get_username"}, + Short: "Get username for a key", Run: func(cmd *cobra.Command, args []string) { requirePcguard() if keyFlag == "" { @@ -364,7 +276,7 @@ func getUsernameCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + fp := filepath.Join(resolveStoreDir(public), keyFlag+".gpg") entry, err := readEntry(fp, key) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -374,11 +286,10 @@ func getUsernameCmd() *cobra.Command { }, } cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + cmd.Flags().BoolVar(&public, "public", false, "Use shared public scope only") return cmd } -// ── get (legacy) ──────────────────────────────────────────────────────── - func getLegacyCmd() *cobra.Command { var showUsername bool cmd := &cobra.Command{ @@ -411,10 +322,9 @@ func getLegacyCmd() *cobra.Command { return cmd } -// ── set ───────────────────────────────────────────────────────────────── - func setCmd() *cobra.Command { var keyFlag, username, secret string + var public bool cmd := &cobra.Command{ Use: "set", Short: "Set a key entry", @@ -429,11 +339,15 @@ func setCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - dir := agentStoreDir(currentAgentID()) + dir := resolveStoreDir(public) if err := os.MkdirAll(dir, 0700); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + if err := ensurePublicStoreDir(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } entry := &Entry{Username: username, Secret: secret} fp := filepath.Join(dir, keyFlag+".gpg") if err := writeEntry(fp, entry, aesKey); err != nil { @@ -445,13 +359,13 @@ func setCmd() *cobra.Command { cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") cmd.Flags().StringVar(&username, "username", "", "Username") cmd.Flags().StringVar(&secret, "secret", "", "Secret value") + cmd.Flags().BoolVar(&public, "public", false, "Use shared public scope only") return cmd } -// ── generate ──────────────────────────────────────────────────────────── - func generateCmd() *cobra.Command { var keyFlag, username string + var public bool cmd := &cobra.Command{ Use: "generate", Short: "Generate a random secret for a key", @@ -471,11 +385,15 @@ func generateCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - dir := agentStoreDir(currentAgentID()) + dir := resolveStoreDir(public) if err := os.MkdirAll(dir, 0700); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + if err := ensurePublicStoreDir(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } entry := &Entry{Username: username, Secret: pw} fp := filepath.Join(dir, keyFlag+".gpg") if err := writeEntry(fp, entry, aesKey); err != nil { @@ -487,13 +405,13 @@ func generateCmd() *cobra.Command { } cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") cmd.Flags().StringVar(&username, "username", "", "Username") + cmd.Flags().BoolVar(&public, "public", false, "Use shared public scope only") return cmd } -// ── unset ─────────────────────────────────────────────────────────────── - func unsetCmd() *cobra.Command { var keyFlag string + var public bool cmd := &cobra.Command{ Use: "unset", Short: "Remove a key entry", @@ -503,7 +421,7 @@ func unsetCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Error: --key is required") os.Exit(1) } - fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + fp := filepath.Join(resolveStoreDir(public), keyFlag+".gpg") if err := os.Remove(fp); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -511,67 +429,16 @@ func unsetCmd() *cobra.Command { }, } cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + cmd.Flags().BoolVar(&public, "public", false, "Use shared public scope only") return cmd } -// ── admin ─────────────────────────────────────────────────────────────── - func adminCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "admin", - Short: "Admin commands (human only)", - } - cmd.AddCommand(adminInitCmd(), adminHandoffCmd(), adminInitFromCmd()) + cmd := &cobra.Command{Use: "admin", Short: "Admin commands (human only)"} + cmd.AddCommand(adminHandoffCmd(), adminInitFromCmd()) return cmd } -func adminInitCmd() *cobra.Command { - return &cobra.Command{ - Use: "init", - Short: "Set admin password", - Run: func(cmd *cobra.Command, args []string) { - rejectIfAgent("admin init") - - var password string - if envPw := os.Getenv("PC_ADMIN_PASS"); envPw != "" { - password = envPw - } else { - fmt.Fprint(os.Stderr, "Enter admin password: ") - pw1, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - fmt.Fprintf(os.Stderr, "\nError: %v\n", err) - os.Exit(1) - } - fmt.Fprintln(os.Stderr) - p1 := strings.TrimSpace(string(pw1)) - if len(p1) < 6 { - fmt.Fprintln(os.Stderr, "Error: password must be at least 6 characters") - os.Exit(1) - } - fmt.Fprint(os.Stderr, "Confirm admin password: ") - pw2, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - fmt.Fprintf(os.Stderr, "\nError: %v\n", err) - os.Exit(1) - } - fmt.Fprintln(os.Stderr) - if p1 != strings.TrimSpace(string(pw2)) { - fmt.Fprintln(os.Stderr, "Error: passwords do not match") - os.Exit(1) - } - password = p1 - } - - cfg := &AdminConfig{PasswordHash: hashPassword(password)} - if err := saveAdminConfig(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println("Admin password set successfully") - }, - } -} - func adminHandoffCmd() *cobra.Command { return &cobra.Command{ Use: "handoff [secret_file_path]", @@ -579,10 +446,6 @@ func adminHandoffCmd() *cobra.Command { Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { rejectIfAgent("admin handoff") - if err := verifyAdminPassword(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } if buildSecret == "" { fmt.Fprintln(os.Stderr, "Error: no build secret compiled in") os.Exit(1) @@ -603,15 +466,12 @@ func adminHandoffCmd() *cobra.Command { func adminInitFromCmd() *cobra.Command { return &cobra.Command{ - Use: "init-from [secret_file_path]", - Short: "Re-encrypt all data from old build secret to current", - Args: cobra.MaximumNArgs(1), + Use: "init-from [secret_file_path]", + Aliases: []string{"init_from"}, + Short: "Re-encrypt all data from old build secret to current", + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { rejectIfAgent("admin init-from") - if err := verifyAdminPassword(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } inPath := "pc-pass-store.secret" if len(args) > 0 { @@ -632,8 +492,9 @@ func adminInitFromCmd() *cobra.Command { os.Exit(1) } + _ = ensurePublicStoreDir() base := passStoreBase() - agentDirs, err := os.ReadDir(base) + dirs, err := os.ReadDir(base) if err != nil { if os.IsNotExist(err) { fmt.Fprintln(os.Stderr, "No pass store found — nothing to migrate") @@ -644,12 +505,12 @@ func adminInitFromCmd() *cobra.Command { } count := 0 - for _, ad := range agentDirs { - if !ad.IsDir() { + for _, d := range dirs { + if !d.IsDir() { continue } - agentDir := filepath.Join(base, ad.Name()) - files, err := os.ReadDir(agentDir) + scopeDir := filepath.Join(base, d.Name()) + files, err := os.ReadDir(scopeDir) if err != nil { continue } @@ -657,7 +518,7 @@ func adminInitFromCmd() *cobra.Command { if !strings.HasSuffix(f.Name(), ".gpg") { continue } - fp := filepath.Join(agentDir, f.Name()) + fp := filepath.Join(scopeDir, f.Name()) entry, err := readEntry(fp, oldKey) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to decrypt %s: %v\n", fp, err) @@ -671,11 +532,9 @@ func adminInitFromCmd() *cobra.Command { } } - // Delete the old secret file if err := os.Remove(inPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not remove secret file: %v\n", err) } - fmt.Fprintf(os.Stderr, "Re-encrypted %d entries. Secret file removed.\n", count) }, }