diff --git a/install.mjs b/install.mjs index 78eab90..850ce5e 100755 --- a/install.mjs +++ b/install.mjs @@ -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); diff --git a/lock-mgr/go.mod b/lock-mgr/go.mod new file mode 100644 index 0000000..8024680 --- /dev/null +++ b/lock-mgr/go.mod @@ -0,0 +1,3 @@ +module lock-mgr + +go 1.24.0 diff --git a/lock-mgr/main.go b/lock-mgr/main.go new file mode 100644 index 0000000..0d67ad9 --- /dev/null +++ b/lock-mgr/main.go @@ -0,0 +1,314 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type LockEntry struct { + Locked bool `json:"locked"` + Key string `json:"key,omitempty"` + Time string `json:"time,omitempty"` +} + +type LockFile map[string]*LockEntry + +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 { + 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) + return writeLockFile(mgrPath, lf) +} + +func cmdAcquire(mgrPath, filePath 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 + } + + envKey := os.Getenv("LOCK_MGR_KEY") + + // Step 11: if locked by someone else, wait and retry + if xLocked && xKey != envKey { + 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 us) — acquire + var newKey string + if envKey != "" { + newKey = envKey + } else { + newKey, err = generateUUID() + if err != nil { + _ = releaseMgrLock(mgrPath) + return err + } + } + + if lf[filePath] == nil { + lf[filePath] = &LockEntry{} + } + lf[filePath].Locked = true + lf[filePath].Key = newKey + 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 + } + + fmt.Println(newKey) + return nil + } +} + +func cmdRelease(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 + } + + entry := lf[filePath] + xLocked := entry != nil && entry.Locked + xKey := "" + if entry != nil { + xKey = entry.Key + } + + envKey := os.Getenv("LOCK_MGR_KEY") + + // Step 14: validate preconditions + if envKey == "" { + _ = releaseMgrLock(mgrPath) + return fmt.Errorf("LOCK_MGR_KEY environment variable not set") + } + if !xLocked { + _ = releaseMgrLock(mgrPath) + return fmt.Errorf("file %s is not locked", filePath) + } + if xKey != envKey { + _ = releaseMgrLock(mgrPath) + return fmt.Errorf("LOCK_MGR_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) + return writeLockFile(mgrPath, lf) +} + +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) + return writeLockFile(mgrPath, lf) +} + +func main() { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: lock-mgr ") + os.Exit(1) + } + + action := os.Args[1] + fileArg := os.Args[2] + + // Step 1: resolve tool directory and locate (or create) the lock file + execPath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot determine executable path: %v\n", err) + os.Exit(1) + } + + toolDir := filepath.Dir(execPath) + rawMgrPath := filepath.Join(toolDir, "..", ".lock-mgr.json") + + // Step 2: get absolute paths + mgrPath, err := filepath.Abs(rawMgrPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot resolve lock file path: %v\n", err) + os.Exit(1) + } + + filePath, err := filepath.Abs(fileArg) + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot resolve file path: %v\n", err) + os.Exit(1) + } + + // Ensure lock file exists + if _, statErr := os.Stat(mgrPath); os.IsNotExist(statErr) { + if writeErr := os.WriteFile(mgrPath, []byte("{}"), 0644); writeErr != nil { + fmt.Fprintf(os.Stderr, "error: cannot create lock file: %v\n", writeErr) + os.Exit(1) + } + } + + switch action { + case "acquire": + err = cmdAcquire(mgrPath, filePath) + case "release": + err = cmdRelease(mgrPath, filePath) + case "force-unlock": + err = cmdForceUnlock(mgrPath, filePath) + default: + fmt.Fprintf(os.Stderr, "error: unknown action %q\n", action) + fmt.Fprintln(os.Stderr, "Usage: lock-mgr ") + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +}