Compare commits
19 Commits
7fd2819a04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cb683c43bb | |||
| 392eafccf2 | |||
| 39856a3060 | |||
| c6f0393c65 | |||
| 1a202986e8 | |||
| dcc91ead9b | |||
| 311d9f4d9f | |||
| 525436d64b | |||
| 36f3c93484 | |||
| 1ac75f429c | |||
| a2b965094d | |||
| 98a75a50d3 | |||
| 4a8a4b01cb | |||
| 95fb9ba820 | |||
| 764ada7c60 | |||
| 81c0a4c289 | |||
| 4e8e264390 | |||
| 21d7a85ba1 | |||
| 7346c80c88 |
@@ -24,6 +24,7 @@ const (
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
41
install.mjs
41
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);
|
||||||
@@ -296,13 +304,13 @@ async function install() {
|
|||||||
// Initialize ego.json if it doesn't exist
|
// Initialize ego.json if it doesn't exist
|
||||||
const egoJsonPath = join(openclawPath, 'ego.json');
|
const egoJsonPath = join(openclawPath, 'ego.json');
|
||||||
if (!existsSync(egoJsonPath)) {
|
if (!existsSync(egoJsonPath)) {
|
||||||
const emptyEgo = {
|
const defaultEgo = {
|
||||||
columns: [],
|
columns: ['default-username', 'name', 'discord-id', 'email', 'role', 'position', 'date-of-birth', 'agent-id', 'gender'],
|
||||||
'public-columns': [],
|
'public-columns': ['git-host', 'keycloak-host'],
|
||||||
'public-scope': {},
|
'public-scope': {},
|
||||||
'agent-scope': {},
|
'agent-scope': {},
|
||||||
};
|
};
|
||||||
writeFileSync(egoJsonPath, JSON.stringify(emptyEgo, null, 2) + '\n', { mode: 0o644 });
|
writeFileSync(egoJsonPath, JSON.stringify(defaultEgo, null, 2) + '\n', { mode: 0o644 });
|
||||||
logOk('Created ego.json');
|
logOk('Created ego.json');
|
||||||
} else {
|
} else {
|
||||||
logOk('ego.json already exists');
|
logOk('ego.json already exists');
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
plans/PROXY_PC_EXEC.md
Normal file
83
plans/PROXY_PC_EXEC.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# PROXY_PC_EXEC
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
新增一个可代理执行的安全命令工具,整体行为尽量保持与现有 `pcexec` 一致,但允许在受控条件下以指定的代理身份注入 `AGENT_ID`。
|
||||||
|
|
||||||
|
## 开发想法整理
|
||||||
|
|
||||||
|
### 1. 新建工具 `proxy-pcexec`
|
||||||
|
- 新增一个工具:`proxy-pcexec`
|
||||||
|
- 目标是复用或对齐现有 `pcexec` 的能力与行为
|
||||||
|
- 预期执行语义与安全边界尽量和 `pcexec` 保持一致,避免出现两套不同标准
|
||||||
|
|
||||||
|
### 2. 扩展 `openclaw.plugin.json`
|
||||||
|
需要在 `openclaw.plugin.json` 中新增配置字段:
|
||||||
|
- `config.proxyAllowlist`
|
||||||
|
|
||||||
|
兼容性说明:
|
||||||
|
- 如有需要,也可兼容读取 `proxy-allowlist` 作为别名
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 用于声明哪些 agent 允许调用 `proxy-pcexec`
|
||||||
|
- 只有在该 allowlist 中的 agent,才具备调用该工具的权限
|
||||||
|
|
||||||
|
建议约束:
|
||||||
|
- `config.proxyAllowlist` 应为 agent 标识列表
|
||||||
|
- `allowlist` 仅支持精确匹配,不支持通配、分组或模糊匹配
|
||||||
|
- 若调用方不在 allowlist 中,应直接拒绝调用
|
||||||
|
- 默认配置应偏保守;未配置时建议视为不允许任何 agent 调用
|
||||||
|
- 一旦调用方 agent 在 allowlist 中,则允许其代理任意 `proxy-for` 值
|
||||||
|
|
||||||
|
### 3. `proxy-pcexec` 与 `pcexec` 的关键区别
|
||||||
|
`proxy-pcexec` 的功能与 `pcexec` 基本一致,核心差异如下:
|
||||||
|
|
||||||
|
#### `pcexec`
|
||||||
|
- 直接将调用者的 `agent-id` 注入环境变量 `AGENT_ID`
|
||||||
|
|
||||||
|
#### `proxy-pcexec`
|
||||||
|
- 不直接使用调用者的 `agent-id` 作为 `AGENT_ID`
|
||||||
|
- 增加一个**必填**工具参数:`proxy-for`
|
||||||
|
- 实际注入到环境变量 `AGENT_ID` 中的值,取自 `proxy-for`
|
||||||
|
|
||||||
|
## 建议的行为规则
|
||||||
|
|
||||||
|
### 调用参数
|
||||||
|
`proxy-pcexec` 至少包含:
|
||||||
|
- `command`
|
||||||
|
- `proxy-for`(必填)
|
||||||
|
- 其他参数可尽量与 `pcexec` 保持一致
|
||||||
|
|
||||||
|
### 权限校验
|
||||||
|
调用 `proxy-pcexec` 时应至少进行以下校验:
|
||||||
|
1. 校验调用方 agent 是否在 `config.proxyAllowlist` 中(精确匹配)
|
||||||
|
2. 校验 `proxy-for` 是否存在且非空
|
||||||
|
3. 不要求 `proxy-for` 必须是已注册或已知 agent-id,可自由填写
|
||||||
|
4. 通过校验后,再执行与 `pcexec` 等价的命令执行流程
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- allowlist 控制的是“谁可以发起代理执行”
|
||||||
|
- 只要调用方 agent 在 allowlist 中,就允许其代理任意 agent
|
||||||
|
|
||||||
|
### 环境变量注入
|
||||||
|
- `AGENT_ID` = `proxy-for`
|
||||||
|
- `PROXY_PCEXEC_EXECUTOR` = 调用方真实 `agent-id`
|
||||||
|
- `PCEXEC_PROXIED` = `true`
|
||||||
|
- 不应再把原始调用者的 `agent-id` 直接写入 `AGENT_ID`
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
- 保持与 `pcexec` 尽可能一致,降低维护成本
|
||||||
|
- 通过 allowlist 控制谁可以发起代理执行
|
||||||
|
- 通过显式 `proxy-for` 参数,避免隐式身份继承
|
||||||
|
- 让代理身份切换是显式、可审计、可配置的
|
||||||
|
|
||||||
|
## 日志与审计
|
||||||
|
建议日志至少记录:
|
||||||
|
- `executor`(调用方真实 agent-id)
|
||||||
|
- `proxy-for`
|
||||||
|
- 最终执行命令
|
||||||
|
|
||||||
|
## 已明确的设计结论
|
||||||
|
- `proxy-for` 可以随意填写,不要求必须是已注册 agent
|
||||||
|
- 日志需要记录 `executor` 和 `proxy-for`
|
||||||
|
- `config.proxyAllowlist` 仅支持精确匹配
|
||||||
|
- allowlist 中的 agent 可以代理任意 agent,不需要额外的 `proxy-for` 限制
|
||||||
214
plugin/commands/ego-mgr-slash.ts
Normal file
214
plugin/commands/ego-mgr-slash.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { pcexec } from '../tools/pcexec';
|
||||||
|
|
||||||
|
export interface EgoMgrSlashCommandOptions {
|
||||||
|
/** OpenClaw base path */
|
||||||
|
openclawPath: string;
|
||||||
|
/** Current agent ID */
|
||||||
|
agentId: string;
|
||||||
|
/** Current workspace directory */
|
||||||
|
workspaceDir: string;
|
||||||
|
/** Callback for replies */
|
||||||
|
onReply: (message: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentinel value injected into every pcexec subprocess */
|
||||||
|
const AGENT_VERIFY = 'IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE';
|
||||||
|
|
||||||
|
export class EgoMgrSlashCommand {
|
||||||
|
private openclawPath: string;
|
||||||
|
private agentId: string;
|
||||||
|
private workspaceDir: string;
|
||||||
|
private onReply: (message: string) => Promise<void>;
|
||||||
|
private binDir: string;
|
||||||
|
|
||||||
|
constructor(options: EgoMgrSlashCommandOptions) {
|
||||||
|
this.openclawPath = options.openclawPath;
|
||||||
|
this.agentId = options.agentId;
|
||||||
|
this.workspaceDir = options.workspaceDir;
|
||||||
|
this.onReply = options.onReply;
|
||||||
|
this.binDir = require('path').join(this.openclawPath, 'bin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /ego-mgr slash command
|
||||||
|
* @param command Full command string (e.g., "/ego-mgr get name")
|
||||||
|
*/
|
||||||
|
async handle(command: string): Promise<void> {
|
||||||
|
const parts = command.trim().split(/\s+/);
|
||||||
|
// Remove the "/ego-mgr" prefix
|
||||||
|
const args = parts.slice(1);
|
||||||
|
const subcommand = args[0];
|
||||||
|
|
||||||
|
if (!subcommand) {
|
||||||
|
await this.showUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'get':
|
||||||
|
await this.handleGet(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'set':
|
||||||
|
await this.handleSet(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
await this.handleList(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await this.handleDelete(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'add-column':
|
||||||
|
await this.handleAddColumn(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'add-public-column':
|
||||||
|
await this.handleAddPublicColumn(args.slice(1));
|
||||||
|
break;
|
||||||
|
case 'show':
|
||||||
|
await this.handleShow();
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
default:
|
||||||
|
await this.showUsage();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
await this.onReply(`Error: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showUsage(): Promise<void> {
|
||||||
|
const usage = [
|
||||||
|
'**ego-mgr Commands**',
|
||||||
|
'',
|
||||||
|
'`/ego-mgr get <column-name>` - Get field value',
|
||||||
|
'`/ego-mgr set <column-name> <value>` - Set field value',
|
||||||
|
'`/ego-mgr list` - List all field names',
|
||||||
|
'`/ego-mgr delete <column-name>` - Delete a field',
|
||||||
|
'`/ego-mgr add-column <column-name>` - Add an Agent Scope field',
|
||||||
|
'`/ego-mgr add-public-column <column-name>` - Add a Public Scope field',
|
||||||
|
'`/ego-mgr show` - Show all fields and values',
|
||||||
|
'',
|
||||||
|
'Examples:',
|
||||||
|
'`/ego-mgr get name`',
|
||||||
|
'`/ego-mgr set timezone Asia/Shanghai`',
|
||||||
|
].join('\n');
|
||||||
|
await this.onReply(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGet(args: string[]): Promise<void> {
|
||||||
|
if (args.length < 1) {
|
||||||
|
await this.onReply('Usage: `/ego-mgr get <column-name>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = args[0];
|
||||||
|
const result = await this.execEgoMgr(['get', columnName]);
|
||||||
|
await this.onReply(`**${columnName}**: ${result || '(empty)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSet(args: string[]): Promise<void> {
|
||||||
|
if (args.length < 2) {
|
||||||
|
await this.onReply('Usage: `/ego-mgr set <column-name> <value>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = args[0];
|
||||||
|
const value = args.slice(1).join(' '); // Support values with spaces
|
||||||
|
await this.execEgoMgr(['set', columnName, value]);
|
||||||
|
await this.onReply(`Set **${columnName}** = \`${value}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleList(args: string[]): Promise<void> {
|
||||||
|
const result = await this.execEgoMgr(['list', 'columns']);
|
||||||
|
if (!result.trim()) {
|
||||||
|
await this.onReply('No fields defined');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columns = result.split('\n').filter(Boolean);
|
||||||
|
const lines = ['**Fields**:', ''];
|
||||||
|
for (const col of columns) {
|
||||||
|
lines.push(`• ${col}`);
|
||||||
|
}
|
||||||
|
await this.onReply(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDelete(args: string[]): Promise<void> {
|
||||||
|
if (args.length < 1) {
|
||||||
|
await this.onReply('Usage: `/ego-mgr delete <column-name>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = args[0];
|
||||||
|
await this.execEgoMgr(['delete', columnName]);
|
||||||
|
await this.onReply(`Deleted field **${columnName}**`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAddColumn(args: string[]): Promise<void> {
|
||||||
|
if (args.length < 1) {
|
||||||
|
await this.onReply('Usage: `/ego-mgr add-column <column-name>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = args[0];
|
||||||
|
await this.execEgoMgr(['add', 'column', columnName]);
|
||||||
|
await this.onReply(`Added Agent Scope field **${columnName}**`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAddPublicColumn(args: string[]): Promise<void> {
|
||||||
|
if (args.length < 1) {
|
||||||
|
await this.onReply('Usage: `/ego-mgr add-public-column <column-name>`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = args[0];
|
||||||
|
await this.execEgoMgr(['add', 'public-column', columnName]);
|
||||||
|
await this.onReply(`Added Public Scope field **${columnName}**`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleShow(): Promise<void> {
|
||||||
|
const result = await this.execEgoMgr(['show']);
|
||||||
|
if (!result.trim()) {
|
||||||
|
await this.onReply('No field data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = ['**Field Data**:', ''];
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(result);
|
||||||
|
lines.push('```');
|
||||||
|
await this.onReply(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute ego-mgr binary via pc-exec
|
||||||
|
*/
|
||||||
|
private async execEgoMgr(args: string[]): Promise<string> {
|
||||||
|
const currentPath = process.env.PATH || '';
|
||||||
|
const newPath = currentPath.includes(this.binDir)
|
||||||
|
? currentPath
|
||||||
|
: `${currentPath}:${this.binDir}`;
|
||||||
|
|
||||||
|
const command = `ego-mgr ${args.map(a => this.shellEscape(a)).join(' ')}`;
|
||||||
|
|
||||||
|
const result = await pcexec(command, {
|
||||||
|
cwd: this.workspaceDir,
|
||||||
|
env: {
|
||||||
|
AGENT_ID: this.agentId,
|
||||||
|
AGENT_WORKSPACE: this.workspaceDir,
|
||||||
|
AGENT_VERIFY,
|
||||||
|
PATH: newPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0 && result.stderr) {
|
||||||
|
throw new Error(result.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for shell usage
|
||||||
|
*/
|
||||||
|
private shellEscape(str: string): string {
|
||||||
|
// Simple escaping for common cases
|
||||||
|
if (/^[a-zA-Z0-9._-]+$/.test(str)) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
return `'${str.replace(/'/g, "'\"'\"'")}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export class SlashCommandHandler {
|
|||||||
async handle(command: string, userId: string): Promise<void> {
|
async handle(command: string, userId: string): Promise<void> {
|
||||||
// Check authorization
|
// Check authorization
|
||||||
if (!this.authorizedUsers.includes(userId)) {
|
if (!this.authorizedUsers.includes(userId)) {
|
||||||
await this.onReply('❌ 无权执行此命令');
|
await this.onReply('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ export class SlashCommandHandler {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
await this.onReply(
|
await this.onReply(
|
||||||
'用法:\n' +
|
'Usage:\n' +
|
||||||
'`/padded-cell-ctrl status` - 查看状态\n' +
|
'`/padded-cell-ctrl status` - Show status\n' +
|
||||||
'`/padded-cell-ctrl enable pass-mgr|safe-restart` - 启用功能\n' +
|
'`/padded-cell-ctrl enable pass-mgr|safe-restart` - Enable feature\n' +
|
||||||
'`/padded-cell-ctrl disable pass-mgr|safe-restart` - 禁用功能'
|
'`/padded-cell-ctrl disable pass-mgr|safe-restart` - Disable feature'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,12 +81,12 @@ export class SlashCommandHandler {
|
|||||||
const agents = this.statusManager.getAllAgents();
|
const agents = this.statusManager.getAllAgents();
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'**PaddedCell 状态**',
|
'**PaddedCell Status**',
|
||||||
'',
|
'',
|
||||||
`🔐 密码管理: ${this.state.passMgrEnabled ? '✅ 启用' : '❌ 禁用'}`,
|
`Secret Manager: ${this.state.passMgrEnabled ? 'Enabled' : 'Disabled'}`,
|
||||||
`🔄 安全重启: ${this.state.safeRestartEnabled ? '✅ 启用' : '❌ 禁用'}`,
|
`Safe Restart: ${this.state.safeRestartEnabled ? 'Enabled' : 'Disabled'}`,
|
||||||
'',
|
'',
|
||||||
'**Agent 状态:**',
|
'**Agent Status:**',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
@@ -95,14 +95,14 @@ export class SlashCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
lines.push('(暂无 agent 注册)');
|
lines.push('(No agents registered)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (global.restartStatus !== 'idle') {
|
if (global.restartStatus !== 'idle') {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push(`⚠️ 重启状态: ${global.restartStatus}`);
|
lines.push(`Restart Status: ${global.restartStatus}`);
|
||||||
if (global.restartScheduledBy) {
|
if (global.restartScheduledBy) {
|
||||||
lines.push(` 由 ${global.restartScheduledBy} 发起`);
|
lines.push(` Initiated by ${global.restartScheduledBy}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +111,12 @@ export class SlashCommandHandler {
|
|||||||
|
|
||||||
private async handleEnable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
private async handleEnable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
||||||
if (!this.isValidFeature(feature)) {
|
if (!this.isValidFeature(feature)) {
|
||||||
await this.onReply('❌ 未知功能。可用选项: pass-mgr, safe-restart');
|
await this.onReply('Unknown feature. Available: pass-mgr, safe-restart');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isOnCooldown(feature)) {
|
if (this.isOnCooldown(feature)) {
|
||||||
await this.onReply('⏳ 该功能最近刚被修改过,请稍后再试');
|
await this.onReply('This feature was recently modified. Please try again later.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,17 +127,17 @@ export class SlashCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.state.lastToggle[feature] = Date.now();
|
this.state.lastToggle[feature] = Date.now();
|
||||||
await this.onReply(`✅ 已启用 ${feature}`);
|
await this.onReply(`Enabled ${feature}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDisable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
private async handleDisable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
||||||
if (!this.isValidFeature(feature)) {
|
if (!this.isValidFeature(feature)) {
|
||||||
await this.onReply('❌ 未知功能。可用选项: pass-mgr, safe-restart');
|
await this.onReply('Unknown feature. Available: pass-mgr, safe-restart');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isOnCooldown(feature)) {
|
if (this.isOnCooldown(feature)) {
|
||||||
await this.onReply('⏳ 该功能最近刚被修改过,请稍后再试');
|
await this.onReply('This feature was recently modified. Please try again later.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export class SlashCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.state.lastToggle[feature] = Date.now();
|
this.state.lastToggle[feature] = Date.now();
|
||||||
await this.onReply(`✅ 已禁用 ${feature}`);
|
await this.onReply(`Disabled ${feature}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isValidFeature(feature: string): feature is 'pass-mgr' | 'safe-restart' {
|
private isValidFeature(feature: string): feature is 'pass-mgr' | 'safe-restart' {
|
||||||
|
|||||||
103
plugin/index.ts
103
plugin/index.ts
@@ -10,6 +10,7 @@ import {
|
|||||||
startApiServer,
|
startApiServer,
|
||||||
} from './core/index';
|
} from './core/index';
|
||||||
import { SlashCommandHandler } from './commands/slash-commands';
|
import { SlashCommandHandler } from './commands/slash-commands';
|
||||||
|
import { EgoMgrSlashCommand } from './commands/ego-mgr-slash';
|
||||||
|
|
||||||
/** Sentinel value injected into every pcexec subprocess */
|
/** Sentinel value injected into every pcexec subprocess */
|
||||||
const AGENT_VERIFY = 'IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE';
|
const AGENT_VERIFY = 'IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE';
|
||||||
@@ -25,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
|
||||||
@@ -84,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;
|
||||||
@@ -110,6 +186,28 @@ function register(api: any, config?: any) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register /ego-mgr slash command
|
||||||
|
if (api.registerSlashCommand) {
|
||||||
|
api.registerSlashCommand({
|
||||||
|
name: 'ego-mgr',
|
||||||
|
description: 'Manage agent identity/profile fields',
|
||||||
|
handler: async (ctx: any, command: string) => {
|
||||||
|
const egoMgrSlash = new EgoMgrSlashCommand({
|
||||||
|
openclawPath,
|
||||||
|
agentId: ctx.agentId || '',
|
||||||
|
workspaceDir: ctx.workspaceDir || '',
|
||||||
|
onReply: async (message: string) => {
|
||||||
|
if (ctx.reply) {
|
||||||
|
await ctx.reply(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await egoMgrSlash.handle(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info('Registered /ego-mgr slash command');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('PaddedCell plugin initialized');
|
logger.info('PaddedCell plugin initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +223,5 @@ module.exports.StatusManager = StatusManager;
|
|||||||
module.exports.createApiServer = createApiServer;
|
module.exports.createApiServer = createApiServer;
|
||||||
module.exports.startApiServer = startApiServer;
|
module.exports.startApiServer = startApiServer;
|
||||||
module.exports.SlashCommandHandler = SlashCommandHandler;
|
module.exports.SlashCommandHandler = SlashCommandHandler;
|
||||||
|
module.exports.EgoMgrSlashCommand = EgoMgrSlashCommand;
|
||||||
module.exports.AGENT_VERIFY = AGENT_VERIFY;
|
module.exports.AGENT_VERIFY = AGENT_VERIFY;
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
"id": "padded-cell",
|
"id": "padded-cell",
|
||||||
"name": "PaddedCell",
|
"name": "PaddedCell",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"description": "Secure password management, safe execution, and coordinated agent restart",
|
"description": "Secure secret management, agent identity management, safe execution, and coordinated agent restart",
|
||||||
"entry": "./index.js",
|
"entry": "./index.js",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"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": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ async function replaceSecretMgrGets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safe exec wrapper that handles pass_mgr get commands and sanitizes output.
|
* Safe exec wrapper that handles secret-mgr get commands and sanitizes output.
|
||||||
*/
|
*/
|
||||||
export async function pcexec(
|
export async function pcexec(
|
||||||
command: string,
|
command: string,
|
||||||
@@ -297,7 +297,7 @@ export async function pcexec(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous version — password substitution is NOT supported here
|
* Synchronous version — password substitution is NOT supported here
|
||||||
* (use async pcexec for pass_mgr integration).
|
* (use async pcexec for secret-mgr integration).
|
||||||
*/
|
*/
|
||||||
export function pcexecSync(
|
export function pcexecSync(
|
||||||
command: string,
|
command: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user