Compare commits
12 Commits
4a8a4b01cb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cb683c43bb | |||
| 392eafccf2 | |||
| 39856a3060 | |||
| c6f0393c65 | |||
| 1a202986e8 | |||
| dcc91ead9b | |||
| 311d9f4d9f | |||
| 525436d64b | |||
| 36f3c93484 | |||
| 1ac75f429c | |||
| a2b965094d | |||
| 98a75a50d3 |
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
33
install.mjs
33
install.mjs
@@ -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,14 +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);
|
||||||
plugins.entries[PLUGIN_NAME] = {
|
if (existingEnabled === undefined) setOpenclawConfig(`${entryPath}.enabled`, true);
|
||||||
enabled: true,
|
|
||||||
config: { enabled: true, secretMgrPath, openclawProfilePath: openclawPath },
|
const cfgPath = `${entryPath}.config`;
|
||||||
};
|
const existingCfgEnabled = getOpenclawConfig(`${cfgPath}.enabled`, undefined);
|
||||||
setOpenclawConfig('plugins', plugins);
|
if (existingCfgEnabled === undefined) setOpenclawConfig(`${cfgPath}.enabled`, true);
|
||||||
logOk('Plugin entry configured');
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
logWarn(`Config failed: ${err.message}`);
|
logWarn(`Config failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
3
lock-mgr/go.mod
Normal file
3
lock-mgr/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module lock-mgr
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
380
lock-mgr/main.go
Normal file
380
lock-mgr/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,14 +12,17 @@
|
|||||||
|
|
||||||
### 2. 扩展 `openclaw.plugin.json`
|
### 2. 扩展 `openclaw.plugin.json`
|
||||||
需要在 `openclaw.plugin.json` 中新增配置字段:
|
需要在 `openclaw.plugin.json` 中新增配置字段:
|
||||||
- `config.proxy-allowlist`
|
- `config.proxyAllowlist`
|
||||||
|
|
||||||
|
兼容性说明:
|
||||||
|
- 如有需要,也可兼容读取 `proxy-allowlist` 作为别名
|
||||||
|
|
||||||
用途:
|
用途:
|
||||||
- 用于声明哪些 agent 允许调用 `proxy-pcexec`
|
- 用于声明哪些 agent 允许调用 `proxy-pcexec`
|
||||||
- 只有在该 allowlist 中的 agent,才具备调用该工具的权限
|
- 只有在该 allowlist 中的 agent,才具备调用该工具的权限
|
||||||
|
|
||||||
建议约束:
|
建议约束:
|
||||||
- `config.proxy-allowlist` 应为 agent 标识列表
|
- `config.proxyAllowlist` 应为 agent 标识列表
|
||||||
- `allowlist` 仅支持精确匹配,不支持通配、分组或模糊匹配
|
- `allowlist` 仅支持精确匹配,不支持通配、分组或模糊匹配
|
||||||
- 若调用方不在 allowlist 中,应直接拒绝调用
|
- 若调用方不在 allowlist 中,应直接拒绝调用
|
||||||
- 默认配置应偏保守;未配置时建议视为不允许任何 agent 调用
|
- 默认配置应偏保守;未配置时建议视为不允许任何 agent 调用
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
|
|
||||||
### 权限校验
|
### 权限校验
|
||||||
调用 `proxy-pcexec` 时应至少进行以下校验:
|
调用 `proxy-pcexec` 时应至少进行以下校验:
|
||||||
1. 校验调用方 agent 是否在 `config.proxy-allowlist` 中(精确匹配)
|
1. 校验调用方 agent 是否在 `config.proxyAllowlist` 中(精确匹配)
|
||||||
2. 校验 `proxy-for` 是否存在且非空
|
2. 校验 `proxy-for` 是否存在且非空
|
||||||
3. 不要求 `proxy-for` 必须是已注册或已知 agent-id,可自由填写
|
3. 不要求 `proxy-for` 必须是已注册或已知 agent-id,可自由填写
|
||||||
4. 通过校验后,再执行与 `pcexec` 等价的命令执行流程
|
4. 通过校验后,再执行与 `pcexec` 等价的命令执行流程
|
||||||
@@ -76,5 +79,5 @@
|
|||||||
## 已明确的设计结论
|
## 已明确的设计结论
|
||||||
- `proxy-for` 可以随意填写,不要求必须是已注册 agent
|
- `proxy-for` 可以随意填写,不要求必须是已注册 agent
|
||||||
- 日志需要记录 `executor` 和 `proxy-for`
|
- 日志需要记录 `executor` 和 `proxy-for`
|
||||||
- `config.proxy-allowlist` 仅支持精确匹配
|
- `config.proxyAllowlist` 仅支持精确匹配
|
||||||
- allowlist 中的 agent 可以代理任意 agent,不需要额外的 `proxy-for` 限制
|
- allowlist 中的 agent 可以代理任意 agent,不需要额外的 `proxy-for` 限制
|
||||||
|
|||||||
@@ -26,13 +26,25 @@ function resolveOpenclawPath(config?: { openclawProfilePath?: string }): string
|
|||||||
return require('path').join(home, '.openclaw');
|
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
|
// Plugin registration function
|
||||||
function register(api: any, config?: any) {
|
function register(api: any) {
|
||||||
const logger = api.logger || { info: console.log, error: console.error };
|
const logger = api.logger || { info: console.log, error: console.error };
|
||||||
|
|
||||||
logger.info('PaddedCell plugin initializing...');
|
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');
|
const binDir = require('path').join(openclawPath, 'bin');
|
||||||
|
|
||||||
// Register pcexec tool — pass a FACTORY function that receives context
|
// 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
|
// Register safe_restart tool
|
||||||
api.registerTool((ctx: any) => {
|
api.registerTool((ctx: any) => {
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": true },
|
"enabled": { "type": "boolean", "default": true },
|
||||||
"secretMgrPath": { "type": "string", "default": "" },
|
"secretMgrPath": { "type": "string", "default": "" },
|
||||||
"openclawProfilePath": { "type": "string", "default": "" }
|
"openclawProfilePath": { "type": "string", "default": "" },
|
||||||
|
"proxyAllowlist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user