Compare commits

...

12 Commits

Author SHA1 Message Date
cb683c43bb fix: avoid clobbering sensitive config fields during configure
Reading the entire plugins tree via openclaw config get returns redacted
values for sensitive fields. Writing it back overwrites real secrets with
the __OPENCLAW_REDACTED__ sentinel. Changed to set individual leaf paths
only when missing.
2026-04-16 14:34:07 +00:00
h z
392eafccf2 Merge pull request 'feat(ego-mgr): add lookup subcommand' (#15) from feat/ego-mgr-lookup into main
Reviewed-on: #15
2026-04-14 21:35:37 +00:00
39856a3060 feat(ego-mgr): add lookup subcommand
ego-mgr lookup <username>
  - Finds agent whose default-username == username
  - Echoes the agent-id (map key in agent-scope)
  - Exits 7 if not found or column missing
2026-04-14 21:33:09 +00:00
c6f0393c65 fix: lock-mgr lock identifier if file does not exist 2026-04-13 16:00:40 +01:00
1a202986e8 fix: lock-mgr release meta-lock before exiting 2026-04-13 15:46:49 +01:00
dcc91ead9b fix: lock-mgr get key from arg instead of env 2026-04-13 15:37:13 +01:00
311d9f4d9f feat: add lock-mgr CLI tool and wire into install script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:41:51 +01:00
h z
525436d64b Merge pull request 'feat/proxy-pcexec' (#14) from feat/proxy-pcexec into main
Reviewed-on: #14
2026-03-30 11:57:56 +00:00
nav
36f3c93484 fix: preserve existing plugin config on install 2026-03-30 11:56:52 +00:00
nav
1ac75f429c fix: load plugin config from api.pluginConfig 2026-03-30 11:47:42 +00:00
nav
a2b965094d chore: log proxy pcexec auth context 2026-03-30 11:38:05 +00:00
nav
98a75a50d3 feat: add proxy pcexec tool 2026-03-30 11:22:26 +00:00
7 changed files with 540 additions and 24 deletions

View File

@@ -17,13 +17,14 @@ const (
// Exit codes per spec
const (
ExitSuccess = 0
ExitUsageError = 1
ExitColumnNotFound = 2
ExitColumnExists = 3
ExitPermission = 4
ExitLockFailed = 5
ExitJSONError = 6
ExitSuccess = 0
ExitUsageError = 1
ExitColumnNotFound = 2
ExitColumnExists = 3
ExitPermission = 4
ExitLockFailed = 5
ExitJSONError = 6
ExitNotFound = 7
)
// EgoData is the on-disk JSON structure
@@ -185,7 +186,7 @@ Examples:
ego-mgr delete name`,
}
rootCmd.AddCommand(addCmd(), deleteCmd(), setCmd(), getCmd(), showCmd(), listCmd())
rootCmd.AddCommand(addCmd(), deleteCmd(), setCmd(), getCmd(), showCmd(), listCmd(), lookupCmd())
if err := rootCmd.Execute(); err != nil {
os.Exit(ExitUsageError)
@@ -480,3 +481,37 @@ func listColumnsCmd() *cobra.Command {
}
return cmd
}
func lookupCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "lookup <username>",
Short: "Look up an agent ID by default-username",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
username := args[0]
data, err := readEgoData()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(ExitJSONError)
}
// Verify default-username column exists
if !isAgentColumn(data, "default-username") {
fmt.Fprintf(os.Stderr, "Error: column 'default-username' does not exist\n")
os.Exit(ExitColumnNotFound)
}
for agentID, agentData := range data.AgentScope {
if agentData["default-username"] == username {
fmt.Print(agentID)
return
}
}
fmt.Fprintf(os.Stderr, "Error: no agent found with default-username '%s'\n", username)
os.Exit(ExitNotFound)
},
}
return cmd
}

View File

@@ -153,6 +153,13 @@ async function build() {
chmodSync(join(pgDir, 'dist', 'pcguard'), 0o755);
logOk('pcguard');
log(' Building lock-mgr...', 'blue');
const lmDir = join(__dirname, 'lock-mgr');
exec('go mod tidy', { cwd: lmDir, silent: !options.verbose });
exec('go build -o dist/lock-mgr .', { cwd: lmDir, silent: !options.verbose });
chmodSync(join(lmDir, 'dist', 'lock-mgr'), 0o755);
logOk('lock-mgr');
log(' Building plugin...', 'blue');
const pluginDir = join(__dirname, 'plugin');
exec('npm install', { cwd: pluginDir, silent: !options.verbose });
@@ -192,7 +199,7 @@ function handoffSecretIfPossible(openclawPath) {
function clearInstallTargets(openclawPath) {
const binDir = join(openclawPath, 'bin');
for (const name of ['pass_mgr', 'secret-mgr', 'ego-mgr', 'pcguard']) {
for (const name of ['pass_mgr', 'secret-mgr', 'ego-mgr', 'pcguard', 'lock-mgr']) {
const p = join(binDir, name);
if (existsSync(p)) { rmSync(p, { force: true }); logOk(`Removed ${p}`); }
}
@@ -273,6 +280,7 @@ async function install() {
{ name: 'secret-mgr', src: join(__dirname, 'secret-mgr', 'dist', 'secret-mgr') },
{ name: 'ego-mgr', src: join(__dirname, 'ego-mgr', 'dist', 'ego-mgr') },
{ name: 'pcguard', src: join(__dirname, 'pcguard', 'dist', 'pcguard') },
{ name: 'lock-mgr', src: join(__dirname, 'lock-mgr', 'dist', 'lock-mgr') },
];
for (const b of bins) {
const dest = join(binDir, b.name);
@@ -341,14 +349,21 @@ async function configure() {
if (!allow.includes(PLUGIN_NAME)) { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); }
logOk(`plugins.allow includes ${PLUGIN_NAME}`);
const plugins = getOpenclawConfig('plugins', {});
plugins.entries = plugins.entries || {};
plugins.entries[PLUGIN_NAME] = {
enabled: true,
config: { enabled: true, secretMgrPath, openclawProfilePath: openclawPath },
};
setOpenclawConfig('plugins', plugins);
logOk('Plugin entry configured');
const entryPath = `plugins.entries.${PLUGIN_NAME}`;
const existingEnabled = getOpenclawConfig(`${entryPath}.enabled`, undefined);
if (existingEnabled === undefined) setOpenclawConfig(`${entryPath}.enabled`, true);
const cfgPath = `${entryPath}.config`;
const existingCfgEnabled = getOpenclawConfig(`${cfgPath}.enabled`, undefined);
if (existingCfgEnabled === undefined) setOpenclawConfig(`${cfgPath}.enabled`, true);
const existingSecretMgr = getOpenclawConfig(`${cfgPath}.secretMgrPath`, undefined);
if (existingSecretMgr === undefined) setOpenclawConfig(`${cfgPath}.secretMgrPath`, secretMgrPath);
const existingProfile = getOpenclawConfig(`${cfgPath}.openclawProfilePath`, undefined);
if (existingProfile === undefined) setOpenclawConfig(`${cfgPath}.openclawProfilePath`, openclawPath);
logOk('Plugin entry configured (set missing defaults only)');
} catch (err) {
logWarn(`Config failed: ${err.message}`);
}

3
lock-mgr/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module lock-mgr
go 1.24.0

380
lock-mgr/main.go Normal file
View File

@@ -0,0 +1,380 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
type LockEntry struct {
Locked bool `json:"locked"`
Key string `json:"key,omitempty"`
Time string `json:"time,omitempty"`
}
type LockFile map[string]*LockEntry
// heldLock tracks the meta-lock currently held by this process so it can be
// released on panic, signal, or any other unexpected exit.
var (
heldMu sync.Mutex
heldMgrPath string
heldMgrKey string
)
func setHeldLock(path, key string) {
heldMu.Lock()
heldMgrPath = path
heldMgrKey = key
heldMu.Unlock()
}
func clearHeldLock() {
heldMu.Lock()
heldMgrPath = ""
heldMgrKey = ""
heldMu.Unlock()
}
// cleanupMgrLock releases the meta-lock if this process still holds it.
// Safe to call multiple times.
func cleanupMgrLock() {
heldMu.Lock()
path := heldMgrPath
key := heldMgrKey
heldMu.Unlock()
if path == "" || key == "" {
return
}
lf, err := readLockFile(path)
if err != nil {
return
}
entry := lf[path]
if entry != nil && entry.Key == key {
delete(lf, path)
_ = writeLockFile(path, lf)
}
clearHeldLock()
}
func generateUUID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant bits
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func readLockFile(path string) (LockFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(LockFile), nil
}
return nil, fmt.Errorf("read lock file: %w", err)
}
if len(data) == 0 {
return make(LockFile), nil
}
var lf LockFile
if err := json.Unmarshal(data, &lf); err != nil {
return make(LockFile), nil
}
if lf == nil {
return make(LockFile), nil
}
return lf, nil
}
func writeLockFile(path string, lf LockFile) error {
data, err := json.MarshalIndent(lf, "", " ")
if err != nil {
return fmt.Errorf("marshal lock file: %w", err)
}
return os.WriteFile(path, data, 0644)
}
// acquireMgrLock implements steps 3-8: acquire the meta-lock on the JSON file itself.
// Returns the mgr-key on success.
func acquireMgrLock(mgrPath string) (string, error) {
// Step 3: generate mgr-key once
mgrKey, err := generateUUID()
if err != nil {
return "", err
}
for {
// Step 4: record current time
startTime := time.Now()
// Steps 5-6: spin until the meta-lock is free or owned by us
for {
lf, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
entry := lf[mgrPath]
if entry == nil || !entry.Locked || entry.Key == mgrKey {
break
}
if time.Since(startTime) > 5*time.Second {
return "", fmt.Errorf("timeout waiting to acquire manager lock")
}
time.Sleep(100 * time.Millisecond)
}
// Step 7: write locked=true, key=mgrKey
lf, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
if lf[mgrPath] == nil {
lf[mgrPath] = &LockEntry{}
}
lf[mgrPath].Locked = true
lf[mgrPath].Key = mgrKey
if err := writeLockFile(mgrPath, lf); err != nil {
return "", err
}
// Step 8: verify we actually won the race
lf2, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
if lf2[mgrPath] != nil && lf2[mgrPath].Key == mgrKey {
setHeldLock(mgrPath, mgrKey)
return mgrKey, nil
}
// Lost the race; go back to step 4 (keep same mgrKey, reset timer)
}
}
// releaseMgrLock removes the meta-lock entry and writes the file.
func releaseMgrLock(mgrPath string) error {
lf, err := readLockFile(mgrPath)
if err != nil {
return err
}
delete(lf, mgrPath)
err = writeLockFile(mgrPath, lf)
if err == nil {
clearHeldLock()
}
return err
}
func cmdAcquire(mgrPath, filePath, key string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
// Step 9: record start time for file-lock wait timeout
startTime := time.Now()
for {
// Step 10: read current file lock state
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
entry := lf[filePath]
xLocked := entry != nil && entry.Locked
xKey := ""
if entry != nil {
xKey = entry.Key
}
// Step 11: if locked by someone else, wait and retry
if xLocked && xKey != key {
if time.Since(startTime) > 30*time.Second {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("timeout waiting to acquire lock on %s", filePath)
}
// Release meta-lock while sleeping so others can proceed
if err := releaseMgrLock(mgrPath); err != nil {
return err
}
time.Sleep(1 * time.Second)
// Re-acquire meta-lock before next read
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
continue
}
// Step 12: not locked (or already owned by caller's key) — acquire
if lf[filePath] == nil {
lf[filePath] = &LockEntry{}
}
lf[filePath].Locked = true
lf[filePath].Key = key
lf[filePath].Time = time.Now().UTC().Format(time.RFC3339)
// Step 13: delete meta-lock entry and write atomically
delete(lf, mgrPath)
if err := writeLockFile(mgrPath, lf); err != nil {
return err
}
clearHeldLock()
return nil
}
}
func cmdRelease(mgrPath, filePath, key string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
entry := lf[filePath]
xLocked := entry != nil && entry.Locked
xKey := ""
if entry != nil {
xKey = entry.Key
}
// Step 14: validate preconditions
if !xLocked {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("file %s is not locked", filePath)
}
if xKey != key {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("key does not match the lock key for %s", filePath)
}
// Step 15: delete file lock and meta-lock, write
delete(lf, filePath)
delete(lf, mgrPath)
err = writeLockFile(mgrPath, lf)
if err == nil {
clearHeldLock()
}
return err
}
func cmdForceUnlock(mgrPath, filePath string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
// Remove file lock and meta-lock unconditionally
delete(lf, filePath)
delete(lf, mgrPath)
err = writeLockFile(mgrPath, lf)
if err == nil {
clearHeldLock()
}
return err
}
func run() error {
// cleanupMgrLock runs on normal return AND on panic (defer unwinds through panics).
// os.Exit bypasses defer, so we keep os.Exit only in main() after run() returns.
defer cleanupMgrLock()
action := ""
if len(os.Args) >= 2 {
action = os.Args[1]
}
if action == "force-unlock" && len(os.Args) < 3 {
return fmt.Errorf("usage: lock-mgr force-unlock <file>")
}
if (action == "acquire" || action == "release") && len(os.Args) < 4 {
return fmt.Errorf("usage: lock-mgr %s <file> <key>", action)
}
if action == "" || (action != "acquire" && action != "release" && action != "force-unlock") {
return fmt.Errorf("usage: lock-mgr <acquire|release> <file> <key>\n lock-mgr force-unlock <file>")
}
fileArg := os.Args[2]
key := ""
if action != "force-unlock" {
key = os.Args[3]
}
// Step 1: resolve tool directory and locate (or create) the lock file
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot determine executable path: %w", err)
}
toolDir := filepath.Dir(execPath)
rawMgrPath := filepath.Join(toolDir, "..", ".lock-mgr.json")
// Step 2: get absolute paths
mgrPath, err := filepath.Abs(rawMgrPath)
if err != nil {
return fmt.Errorf("cannot resolve lock file path: %w", err)
}
filePath, err := filepath.Abs(fileArg)
if err != nil {
return fmt.Errorf("cannot resolve file path: %w", err)
}
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
filePath = fileArg
}
// Ensure lock file exists
if _, statErr := os.Stat(mgrPath); os.IsNotExist(statErr) {
if writeErr := os.WriteFile(mgrPath, []byte("{}"), 0644); writeErr != nil {
return fmt.Errorf("cannot create lock file: %w", writeErr)
}
}
switch action {
case "acquire":
return cmdAcquire(mgrPath, filePath, key)
case "release":
return cmdRelease(mgrPath, filePath, key)
case "force-unlock":
return cmdForceUnlock(mgrPath, filePath)
}
return nil
}
func main() {
// Signal handler: release meta-lock on SIGINT / SIGTERM before exiting.
// This covers Ctrl+C and process termination while the lock is held.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cleanupMgrLock()
os.Exit(130)
}()
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}

View File

@@ -12,14 +12,17 @@
### 2. 扩展 `openclaw.plugin.json`
需要在 `openclaw.plugin.json` 中新增配置字段:
- `config.proxy-allowlist`
- `config.proxyAllowlist`
兼容性说明:
- 如有需要,也可兼容读取 `proxy-allowlist` 作为别名
用途:
- 用于声明哪些 agent 允许调用 `proxy-pcexec`
- 只有在该 allowlist 中的 agent才具备调用该工具的权限
建议约束:
- `config.proxy-allowlist` 应为 agent 标识列表
- `config.proxyAllowlist` 应为 agent 标识列表
- `allowlist` 仅支持精确匹配,不支持通配、分组或模糊匹配
- 若调用方不在 allowlist 中,应直接拒绝调用
- 默认配置应偏保守;未配置时建议视为不允许任何 agent 调用
@@ -46,7 +49,7 @@
### 权限校验
调用 `proxy-pcexec` 时应至少进行以下校验:
1. 校验调用方 agent 是否在 `config.proxy-allowlist` 中(精确匹配)
1. 校验调用方 agent 是否在 `config.proxyAllowlist` 中(精确匹配)
2. 校验 `proxy-for` 是否存在且非空
3. 不要求 `proxy-for` 必须是已注册或已知 agent-id可自由填写
4. 通过校验后,再执行与 `pcexec` 等价的命令执行流程
@@ -76,5 +79,5 @@
## 已明确的设计结论
- `proxy-for` 可以随意填写,不要求必须是已注册 agent
- 日志需要记录 `executor``proxy-for`
- `config.proxy-allowlist` 仅支持精确匹配
- `config.proxyAllowlist` 仅支持精确匹配
- allowlist 中的 agent 可以代理任意 agent不需要额外的 `proxy-for` 限制

View File

@@ -26,13 +26,25 @@ function resolveOpenclawPath(config?: { openclawProfilePath?: string }): string
return require('path').join(home, '.openclaw');
}
function getPluginConfig(api: any): Record<string, unknown> {
return ((api?.pluginConfig as Record<string, unknown> | undefined) || {});
}
function resolveProxyAllowlist(config?: { proxyAllowlist?: unknown; 'proxy-allowlist'?: unknown }): string[] {
const value = config?.proxyAllowlist ?? config?.['proxy-allowlist'];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string');
}
// Plugin registration function
function register(api: any, config?: any) {
function register(api: any) {
const logger = api.logger || { info: console.log, error: console.error };
logger.info('PaddedCell plugin initializing...');
const openclawPath = resolveOpenclawPath(config);
const pluginConfig = getPluginConfig(api);
const openclawPath = resolveOpenclawPath(pluginConfig as { openclawProfilePath?: string });
const proxyAllowlist = resolveProxyAllowlist(pluginConfig as { proxyAllowlist?: unknown; 'proxy-allowlist'?: unknown });
const binDir = require('path').join(openclawPath, 'bin');
// Register pcexec tool — pass a FACTORY function that receives context
@@ -85,6 +97,69 @@ function register(api: any, config?: any) {
};
});
api.registerTool((ctx: any) => {
const agentId = ctx.agentId;
const workspaceDir = ctx.workspaceDir;
return {
name: 'proxy-pcexec',
description: 'Safe exec with password sanitization using a proxied AGENT_ID',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Command to execute' },
cwd: { type: 'string', description: 'Working directory' },
timeout: { type: 'number', description: 'Timeout in milliseconds' },
'proxy-for': { type: 'string', description: 'AGENT_ID value to inject for the subprocess' },
},
required: ['command', 'proxy-for'],
},
async execute(_id: string, params: any) {
const command = params.command;
const proxyFor = params['proxy-for'];
if (!command) {
throw new Error('Missing required parameter: command');
}
if (!proxyFor) {
throw new Error('Missing required parameter: proxy-for');
}
if (!agentId || !proxyAllowlist.includes(agentId)) {
throw new Error('Current agent is not allowed to call proxy-pcexec');
}
logger.info('proxy-pcexec invoked', {
executor: agentId,
proxyFor,
command,
});
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: String(proxyFor),
AGENT_WORKSPACE: workspaceDir || '',
AGENT_VERIFY,
PROXY_PCEXEC_EXECUTOR: agentId || '',
PCEXEC_PROXIED: 'true',
PATH: newPath,
},
});
let output = result.stdout;
if (result.stderr) {
output += result.stderr;
}
return { content: [{ type: 'text', text: output }] };
},
};
});
// Register safe_restart tool
api.registerTool((ctx: any) => {
const agentId = ctx.agentId;

View File

@@ -9,7 +9,12 @@
"properties": {
"enabled": { "type": "boolean", "default": true },
"secretMgrPath": { "type": "string", "default": "" },
"openclawProfilePath": { "type": "string", "default": "" }
"openclawProfilePath": { "type": "string", "default": "" },
"proxyAllowlist": {
"type": "array",
"items": { "type": "string" },
"default": []
}
}
}
}