Compare commits

..

7 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
4 changed files with 448 additions and 24 deletions

View File

@@ -17,13 +17,14 @@ const (
// Exit codes per spec // Exit codes per spec
const ( const (
ExitSuccess = 0 ExitSuccess = 0
ExitUsageError = 1 ExitUsageError = 1
ExitColumnNotFound = 2 ExitColumnNotFound = 2
ExitColumnExists = 3 ExitColumnExists = 3
ExitPermission = 4 ExitPermission = 4
ExitLockFailed = 5 ExitLockFailed = 5
ExitJSONError = 6 ExitJSONError = 6
ExitNotFound = 7
) )
// EgoData is the on-disk JSON structure // EgoData is the on-disk JSON structure
@@ -185,7 +186,7 @@ Examples:
ego-mgr delete name`, 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 { if err := rootCmd.Execute(); err != nil {
os.Exit(ExitUsageError) os.Exit(ExitUsageError)
@@ -480,3 +481,37 @@ func listColumnsCmd() *cobra.Command {
} }
return cmd 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); chmodSync(join(pgDir, 'dist', 'pcguard'), 0o755);
logOk('pcguard'); 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'); log(' Building plugin...', 'blue');
const pluginDir = join(__dirname, 'plugin'); const pluginDir = join(__dirname, 'plugin');
exec('npm install', { cwd: pluginDir, silent: !options.verbose }); exec('npm install', { cwd: pluginDir, silent: !options.verbose });
@@ -192,7 +199,7 @@ function handoffSecretIfPossible(openclawPath) {
function clearInstallTargets(openclawPath) { function clearInstallTargets(openclawPath) {
const binDir = join(openclawPath, 'bin'); 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); const p = join(binDir, name);
if (existsSync(p)) { rmSync(p, { force: true }); logOk(`Removed ${p}`); } 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: 'secret-mgr', src: join(__dirname, 'secret-mgr', 'dist', 'secret-mgr') },
{ name: 'ego-mgr', src: join(__dirname, 'ego-mgr', 'dist', 'ego-mgr') }, { name: 'ego-mgr', src: join(__dirname, 'ego-mgr', 'dist', 'ego-mgr') },
{ name: 'pcguard', src: join(__dirname, 'pcguard', 'dist', 'pcguard') }, { name: 'pcguard', src: join(__dirname, 'pcguard', 'dist', 'pcguard') },
{ name: 'lock-mgr', src: join(__dirname, 'lock-mgr', 'dist', 'lock-mgr') },
]; ];
for (const b of bins) { for (const b of bins) {
const dest = join(binDir, b.name); const dest = join(binDir, b.name);
@@ -341,23 +349,21 @@ async function configure() {
if (!allow.includes(PLUGIN_NAME)) { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); } if (!allow.includes(PLUGIN_NAME)) { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); }
logOk(`plugins.allow includes ${PLUGIN_NAME}`); logOk(`plugins.allow includes ${PLUGIN_NAME}`);
const plugins = getOpenclawConfig('plugins', {}); const entryPath = `plugins.entries.${PLUGIN_NAME}`;
plugins.entries = plugins.entries || {}; const existingEnabled = getOpenclawConfig(`${entryPath}.enabled`, undefined);
if (existingEnabled === undefined) setOpenclawConfig(`${entryPath}.enabled`, true);
const existingEntry = plugins.entries[PLUGIN_NAME] || {}; const cfgPath = `${entryPath}.config`;
const existingConfig = existingEntry.config || {}; const existingCfgEnabled = getOpenclawConfig(`${cfgPath}.enabled`, undefined);
const defaultConfig = { enabled: true, secretMgrPath, openclawProfilePath: openclawPath }; if (existingCfgEnabled === undefined) setOpenclawConfig(`${cfgPath}.enabled`, true);
plugins.entries[PLUGIN_NAME] = { const existingSecretMgr = getOpenclawConfig(`${cfgPath}.secretMgrPath`, undefined);
...existingEntry, if (existingSecretMgr === undefined) setOpenclawConfig(`${cfgPath}.secretMgrPath`, secretMgrPath);
enabled: existingEntry.enabled ?? true,
config: { const existingProfile = getOpenclawConfig(`${cfgPath}.openclawProfilePath`, undefined);
...defaultConfig, if (existingProfile === undefined) setOpenclawConfig(`${cfgPath}.openclawProfilePath`, openclawPath);
...existingConfig,
}, logOk('Plugin entry configured (set missing defaults only)');
};
setOpenclawConfig('plugins', plugins);
logOk('Plugin entry configured (preserved existing config, added missing defaults)');
} catch (err) { } catch (err) {
logWarn(`Config failed: ${err.message}`); 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)
}
}