Merge pull request 'dev/zhi' (#1) from dev/zhi into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.test
|
||||||
|
vendor/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# PaddedCell — Agent Tasks
|
# PaddedCell - Agent Tasks
|
||||||
|
|
||||||
> 任务拆分 + 依赖关系(基于 PROJECT_PLAN.md)
|
> 任务拆分 + 依赖关系(基于 PROJECT_PLAN.md)
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## 0. 项目准备
|
## 0. 项目准备
|
||||||
- **T-0001**:确认配置项清单(路径模板、rotate 语义、admin 泄露检测范围)
|
- **T-0001**:确认配置项清单(路径模板、rotate 语义、admin 泄露检测范围)
|
||||||
- Deps: —
|
- Deps: -
|
||||||
- **T-0002**:确定加密库与存储格式(选型与接口约束)
|
- **T-0002**:确定加密库与存储格式(选型与接口约束)
|
||||||
- Deps: T-0001
|
- Deps: T-0001
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
## 2. pcexec 工具(TS)
|
## 2. pcexec 工具(TS)
|
||||||
- **T-2001**:exec 参数/行为兼容设计(与原生 exec 对齐)
|
- **T-2001**:exec 参数/行为兼容设计(与原生 exec 对齐)
|
||||||
- Deps: —
|
- Deps: -
|
||||||
- **T-2002**:pass_mgr get 检测与预执行(不限 `$(...)`)
|
- **T-2002**:pass_mgr get 检测与预执行(不限 `$(...)`)
|
||||||
- Deps: T-2001, T-1001
|
- Deps: T-2001, T-1001
|
||||||
- **T-2003**:多密码脱敏替换(stdout/stderr)
|
- **T-2003**:多密码脱敏替换(stdout/stderr)
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
|
|
||||||
## 3. 安全重启(CalmGate 功能并入)
|
## 3. 安全重启(CalmGate 功能并入)
|
||||||
- **T-3001**:状态机与 session tracker(idle/busy/focus/freeze…)
|
- **T-3001**:状态机与 session tracker(idle/busy/focus/freeze…)
|
||||||
- Deps: —
|
- Deps: -
|
||||||
- **T-3002**:消息生命周期 hooks(start/end)与状态迁移
|
- **T-3002**:消息生命周期 hooks(start/end)与状态迁移
|
||||||
- Deps: T-3001
|
- Deps: T-3001
|
||||||
- **T-3003**:workflow/focus 机制与“忙碌回复”
|
- **T-3003**:workflow/focus 机制与"忙碌回复"
|
||||||
- Deps: T-3001
|
- Deps: T-3001
|
||||||
- **T-3004**:query-restart API(OK/NOT_READY/ALREADY_SCHEDULED)
|
- **T-3004**:query-restart API(OK/NOT_READY/ALREADY_SCHEDULED)
|
||||||
- Deps: T-3001, T-3002
|
- Deps: T-3001, T-3002
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
## 4.1 功能开关(Slash Commands)
|
## 4.1 功能开关(Slash Commands)
|
||||||
- **T-4101**:实现 `/padded-cell-ctrl` 命令(status/enable/disable)
|
- **T-4101**:实现 `/padded-cell-ctrl` 命令(status/enable/disable)
|
||||||
- Deps: —
|
- Deps: -
|
||||||
- **T-4102**:开关状态持久化、权限限制(授权用户)与 10 秒冷却
|
- **T-4102**:开关状态持久化、权限限制(授权用户)与 10 秒冷却
|
||||||
- Deps: T-4101
|
- Deps: T-4101
|
||||||
|
|
||||||
@@ -98,9 +98,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7. 安装脚本
|
||||||
|
- **T-7001**:依赖检测(Node.js, Go, openclaw CLI, 平台检测)
|
||||||
|
- Deps: —
|
||||||
|
- **T-7002**:自动构建逻辑(Go + TypeScript)
|
||||||
|
- Deps: T-1001, T-2001, T-3001
|
||||||
|
- **T-7003**:安装逻辑(二进制/模块复制、PATH 配置)
|
||||||
|
- Deps: T-7002
|
||||||
|
- **T-7004**:初始化向导(admin 密码设置、配置生成)
|
||||||
|
- Deps: T-1002, T-7003
|
||||||
|
- **T-7005**:安装验证与摘要输出
|
||||||
|
- Deps: T-7004
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 依赖关系示意(简化)
|
## 依赖关系示意(简化)
|
||||||
- 0.* → 1.* / 2.* / 3.*
|
- 0.* → 1.* / 2.* / 3.*
|
||||||
- 1.* → 4.* → 6.*
|
- 1.* → 4.* → 6.*
|
||||||
- 2.* → 6.*
|
- 2.* → 6.*
|
||||||
- 3.* → 6.*
|
- 3.* → 6.*
|
||||||
- 5.* 可与 1.* / 2.* 并行,但需接口稳定
|
- 5.* 可与 1.* / 2.* 并行,但需接口稳定
|
||||||
|
- 7.* 依赖 1.* / 2.* / 3.* 完成(可在构建后执行)
|
||||||
|
|||||||
@@ -244,6 +244,56 @@ Request:
|
|||||||
- 任务分配清单
|
- 任务分配清单
|
||||||
- 测试用例列表
|
- 测试用例列表
|
||||||
|
|
||||||
|
## 6. 安装脚本
|
||||||
|
|
||||||
|
### 6.1 需求
|
||||||
|
提供统一的插件安装脚本 `install.mjs`,实现以下功能:
|
||||||
|
|
||||||
|
**检测与依赖**
|
||||||
|
- 检测系统平台 (Linux/macOS)
|
||||||
|
- 检测必要依赖 (Node.js, Go, openclaw CLI)
|
||||||
|
- 检测 openclaw 配置目录
|
||||||
|
|
||||||
|
**构建**
|
||||||
|
- 自动构建 pass_mgr Go 二进制
|
||||||
|
- 自动构建 pcexec TypeScript 模块
|
||||||
|
- 自动构建 safe-restart TypeScript 模块
|
||||||
|
|
||||||
|
**安装**
|
||||||
|
- 安装 pass_mgr 到系统 PATH (或 openclaw bin 目录)
|
||||||
|
- 安装 TypeScript 模块到 openclaw skills 目录
|
||||||
|
- 创建必要的配置文件和目录
|
||||||
|
|
||||||
|
**配置**
|
||||||
|
- 初始化 pass_mgr admin (引导用户设置 admin 密码)
|
||||||
|
- 配置环境变量
|
||||||
|
- 注册 safe-restart API 服务
|
||||||
|
|
||||||
|
**验证**
|
||||||
|
- 验证所有组件安装成功
|
||||||
|
- 输出安装摘要和下一步操作提示
|
||||||
|
|
||||||
|
### 6.2 使用方式
|
||||||
|
```bash
|
||||||
|
# 默认安装
|
||||||
|
node install.mjs
|
||||||
|
|
||||||
|
# 指定安装路径
|
||||||
|
node install.mjs --prefix /usr/local
|
||||||
|
|
||||||
|
# 仅构建不安装
|
||||||
|
node install.mjs --build-only
|
||||||
|
|
||||||
|
# 跳过依赖检测
|
||||||
|
node install.mjs --skip-check
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
node install.mjs --uninstall
|
||||||
|
|
||||||
|
# 从指定路径卸载
|
||||||
|
node install.mjs --uninstall --prefix /usr/local
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> 更新日志:v0.1(基于当前需求整理)
|
> 更新日志:v0.1(基于当前需求整理)
|
||||||
|
|||||||
136
README.md
Normal file
136
README.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# PaddedCell
|
||||||
|
|
||||||
|
OpenClaw plugin for secure password management, safe command execution, and coordinated agent restart.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. pass_mgr - Password Manager Binary (Go)
|
||||||
|
|
||||||
|
AES-256-GCM encryption, per-agent key-based encryption/decryption.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize
|
||||||
|
pass_mgr admin init [--key-path <path>]
|
||||||
|
|
||||||
|
# Get password
|
||||||
|
pass_mgr get <key> [--username]
|
||||||
|
|
||||||
|
# Generate password (agent can use)
|
||||||
|
pass_mgr generate <key> [--username <user>]
|
||||||
|
|
||||||
|
# Set password (human only)
|
||||||
|
pass_mgr set <key> <password> [--username <user>]
|
||||||
|
|
||||||
|
# Delete password
|
||||||
|
pass_mgr unset <key>
|
||||||
|
|
||||||
|
# Rotate password
|
||||||
|
pass_mgr rotate <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Features:**
|
||||||
|
- Agents cannot execute `set` (detected via environment variables)
|
||||||
|
- All operations fail before initialization
|
||||||
|
- Admin password leak detection (monitors messages/tool calls)
|
||||||
|
|
||||||
|
### 2. pcexec - Safe Execution Tool (TypeScript)
|
||||||
|
|
||||||
|
Compatible with OpenClaw native exec interface, automatically handles `pass_mgr get` and sanitizes output.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pcexec } from 'pcexec';
|
||||||
|
|
||||||
|
const result = await pcexec('echo $(pass_mgr get mypassword)', {
|
||||||
|
cwd: '/workspace',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// Passwords in result.stdout will be replaced with ######
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. safe-restart - Safe Restart Module (TypeScript)
|
||||||
|
|
||||||
|
Provides agent state management and coordinated restart.
|
||||||
|
|
||||||
|
**Agent States:**
|
||||||
|
- `idle` - Idle
|
||||||
|
- `busy` - Processing messages
|
||||||
|
- `focus` - Focus mode (workflow)
|
||||||
|
- `freeze` - Frozen (not accepting new messages)
|
||||||
|
- `pre-freeze` - Preparing to freeze
|
||||||
|
- `pre-freeze-focus` - Preparing to freeze (focus mode)
|
||||||
|
|
||||||
|
**APIs:**
|
||||||
|
- `POST /query-restart` - Query restart readiness
|
||||||
|
- `POST /restart-result` - Report restart result
|
||||||
|
- `GET /status` - Get all statuses
|
||||||
|
|
||||||
|
**Slash Commands:**
|
||||||
|
```
|
||||||
|
/padded-cell-ctrl status
|
||||||
|
/padded-cell-ctrl enable pass-mgr|safe-restart
|
||||||
|
/padded-cell-ctrl disable pass-mgr|safe-restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
PaddedCell/
|
||||||
|
├── pass_mgr/ # Go password manager binary
|
||||||
|
│ ├── src/
|
||||||
|
│ │ └── main.go
|
||||||
|
│ └── go.mod
|
||||||
|
├── pcexec/ # TypeScript safe execution tool
|
||||||
|
│ ├── src/
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── safe-restart/ # TypeScript safe restart module
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── status-manager.ts
|
||||||
|
│ │ ├── api.ts
|
||||||
|
│ │ ├── safe-restart.ts
|
||||||
|
│ │ └── slash-commands.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── PROJECT_PLAN.md # Project plan
|
||||||
|
├── AGENT_TASKS.md # Task list
|
||||||
|
├── README.md # This file (English)
|
||||||
|
└── README.zh-CN.md # Chinese version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
node install.mjs --install
|
||||||
|
|
||||||
|
# Uninstall
|
||||||
|
node install.mjs --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### pass_mgr
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize (required before first use)
|
||||||
|
~/.openclaw/bin/pass_mgr admin init
|
||||||
|
|
||||||
|
# Set password
|
||||||
|
~/.openclaw/bin/pass_mgr set mykey mypassword
|
||||||
|
|
||||||
|
# Get password
|
||||||
|
~/.openclaw/bin/pass_mgr get mykey
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
136
README.zh-CN.md
Normal file
136
README.zh-CN.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# PaddedCell
|
||||||
|
|
||||||
|
OpenClaw 插件:安全密码管理 + 安全执行 + 安全重启
|
||||||
|
|
||||||
|
## 功能模块
|
||||||
|
|
||||||
|
### 1. pass_mgr - 密码管理二进制 (Go)
|
||||||
|
|
||||||
|
使用 AES-256-GCM 加密,每个 agent 基于公私钥进行加/解密。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 初始化
|
||||||
|
pass_mgr admin init [--key-path <path>]
|
||||||
|
|
||||||
|
# 获取密码
|
||||||
|
pass_mgr get <key> [--username]
|
||||||
|
|
||||||
|
# 生成密码 (agent 可用)
|
||||||
|
pass_mgr generate <key> [--username <user>]
|
||||||
|
|
||||||
|
# 设置密码 (仅人类)
|
||||||
|
pass_mgr set <key> <password> [--username <user>]
|
||||||
|
|
||||||
|
# 删除密码
|
||||||
|
pass_mgr unset <key>
|
||||||
|
|
||||||
|
# 轮换密码
|
||||||
|
pass_mgr rotate <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
**安全特性:**
|
||||||
|
- Agent 无法执行 `set` 操作(通过环境变量检测)
|
||||||
|
- 未初始化前所有操作报错
|
||||||
|
- Admin 密码泄露检测(监控 message/tool calling)
|
||||||
|
|
||||||
|
### 2. pcexec - 安全执行工具 (TypeScript)
|
||||||
|
|
||||||
|
与 OpenClaw 原生 exec 接口一致,自动处理 pass_mgr get 并脱敏输出。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pcexec } from 'pcexec';
|
||||||
|
|
||||||
|
const result = await pcexec('echo $(pass_mgr get mypassword)', {
|
||||||
|
cwd: '/workspace',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// result.stdout 中密码会被替换为 ######
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. safe-restart - 安全重启模块 (TypeScript)
|
||||||
|
|
||||||
|
提供 agent 状态管理和协调重启。
|
||||||
|
|
||||||
|
**Agent 状态:**
|
||||||
|
- `idle` - 空闲
|
||||||
|
- `busy` - 处理消息中
|
||||||
|
- `focus` - 专注模式(工作流)
|
||||||
|
- `freeze` - 冻结(不接受新消息)
|
||||||
|
- `pre-freeze` - 准备冻结
|
||||||
|
- `pre-freeze-focus` - 准备冻结(专注模式)
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- `POST /query-restart` - 查询重启就绪状态
|
||||||
|
- `POST /restart-result` - 报告重启结果
|
||||||
|
- `GET /status` - 获取所有状态
|
||||||
|
|
||||||
|
**Slash 命令:**
|
||||||
|
```
|
||||||
|
/padded-cell-ctrl status
|
||||||
|
/padded-cell-ctrl enable pass-mgr|safe-restart
|
||||||
|
/padded-cell-ctrl disable pass-mgr|safe-restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
PaddedCell/
|
||||||
|
├── pass_mgr/ # Go 密码管理二进制
|
||||||
|
│ ├── src/
|
||||||
|
│ │ └── main.go
|
||||||
|
│ └── go.mod
|
||||||
|
├── pcexec/ # TypeScript 安全执行工具
|
||||||
|
│ ├── src/
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── safe-restart/ # TypeScript 安全重启模块
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── status-manager.ts
|
||||||
|
│ │ ├── api.ts
|
||||||
|
│ │ ├── safe-restart.ts
|
||||||
|
│ │ └── slash-commands.ts
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── docs/ # 文档
|
||||||
|
├── PROJECT_PLAN.md # 项目计划
|
||||||
|
├── AGENT_TASKS.md # 任务清单
|
||||||
|
├── README.md # 英文版本
|
||||||
|
└── README.zh-CN.md # 本文档 (简体中文)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装
|
||||||
|
node install.mjs --install
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
node install.mjs --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
### pass_mgr
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 初始化(首次使用前必须执行)
|
||||||
|
~/.openclaw/bin/pass_mgr admin init
|
||||||
|
|
||||||
|
# 设置密码
|
||||||
|
~/.openclaw/bin/pass_mgr set mykey mypassword
|
||||||
|
|
||||||
|
# 获取密码
|
||||||
|
~/.openclaw/bin/pass_mgr get mykey
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
62
TEST_PLAN.md
Normal file
62
TEST_PLAN.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# PaddedCell 测试计划 - 2026-03-05
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
- 代码已推送到 `dev/zhi` 分支 (commit: 28af11c)
|
||||||
|
- 已修复构建错误
|
||||||
|
- 安装脚本已更新
|
||||||
|
|
||||||
|
## 测试步骤及结果
|
||||||
|
|
||||||
|
### 1. 安装脚本测试 ✅
|
||||||
|
```bash
|
||||||
|
cd /root/.openclaw/workspace-developer/PaddedCell
|
||||||
|
node install.mjs --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: 构建成功,但安装未完成(等待用户确认安装路径)
|
||||||
|
|
||||||
|
### 2. 依赖安装 ✅
|
||||||
|
- Go v1.22.2 已安装
|
||||||
|
- Node.js v22.x 可用
|
||||||
|
|
||||||
|
### 3. 构建测试 ✅
|
||||||
|
- pass_mgr Go 二进制编译成功
|
||||||
|
- pcexec TypeScript 构建成功
|
||||||
|
- safe-restart TypeScript 构建成功
|
||||||
|
|
||||||
|
### 4. 修复的构建错误
|
||||||
|
|
||||||
|
#### pass_mgr (Go)
|
||||||
|
- **问题**: `username` string 变量用作 boolean 判断
|
||||||
|
- **修复**: 使用 `BoolVar` 定义 `--username` flag
|
||||||
|
|
||||||
|
#### pcexec (TypeScript)
|
||||||
|
- **问题**: `process.env` 类型不匹配 `Record<string, string>`
|
||||||
|
- **修复**: 循环复制并过滤 undefined 值
|
||||||
|
|
||||||
|
#### safe-restart (TypeScript)
|
||||||
|
- **问题**: `fetch` 返回 `unknown` 类型
|
||||||
|
- **修复**: 添加类型断言 `as { status: string }`
|
||||||
|
|
||||||
|
## 下一步测试
|
||||||
|
|
||||||
|
需要完成的测试:
|
||||||
|
1. 完整安装流程测试(需要确认安装路径)
|
||||||
|
2. pass_mgr 功能测试(init/get/set/generate/rotate)
|
||||||
|
3. pcexec 密码脱敏测试
|
||||||
|
4. safe-restart API 测试
|
||||||
|
|
||||||
|
## 重启后计划
|
||||||
|
|
||||||
|
如果测试过程中需要重启 OpenClaw gateway,重启后我需要:
|
||||||
|
|
||||||
|
1. **验证环境变量** - 检查 PATH 和 PADDEDCELL_SKILLS_DIR 是否正确设置
|
||||||
|
2. **继续安装测试** - 重新运行 install.mjs 或验证已安装组件
|
||||||
|
3. **功能测试** - 测试 pass_mgr/pcexec/safe-restart 是否正常工作
|
||||||
|
4. **记录结果** - 更新此文件,记录测试通过/失败项
|
||||||
|
|
||||||
|
## 当前阻塞点
|
||||||
|
|
||||||
|
**无阻塞** - 构建已通过,可以开始完整安装测试。
|
||||||
|
|
||||||
|
**建议**: 运行 `node install.mjs` 完成安装,然后进行功能测试。
|
||||||
76
index.js
Normal file
76
index.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// PaddedCell Plugin for OpenClaw
|
||||||
|
// Registers pcexec and safe_restart tools
|
||||||
|
|
||||||
|
const { pcexec, pcexecSync } = require('./pcexec/dist/index.js');
|
||||||
|
const {
|
||||||
|
safeRestart,
|
||||||
|
createSafeRestartTool,
|
||||||
|
StatusManager,
|
||||||
|
createApiServer,
|
||||||
|
startApiServer,
|
||||||
|
SlashCommandHandler
|
||||||
|
} = require('./safe-restart/dist/index.js');
|
||||||
|
|
||||||
|
// Plugin registration function (OpenClaw expects 'register' or 'activate')
|
||||||
|
function register(api, config) {
|
||||||
|
const logger = api.logger || { info: console.log, error: console.error };
|
||||||
|
|
||||||
|
logger.info('PaddedCell plugin initializing...');
|
||||||
|
|
||||||
|
// Register pcexec tool
|
||||||
|
api.registerTool({
|
||||||
|
name: 'pcexec',
|
||||||
|
description: 'Safe exec with password sanitization',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
command: { type: 'string', description: 'Command to execute' },
|
||||||
|
cwd: { type: 'string', description: 'Working directory' },
|
||||||
|
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||||
|
},
|
||||||
|
required: ['command'],
|
||||||
|
},
|
||||||
|
async handler(params) {
|
||||||
|
return await pcexec(params.command, {
|
||||||
|
cwd: params.cwd,
|
||||||
|
timeout: params.timeout,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register safe_restart tool
|
||||||
|
api.registerTool({
|
||||||
|
name: 'safe_restart',
|
||||||
|
description: 'Safe coordinated restart of OpenClaw gateway',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
rollback: { type: 'string', description: 'Rollback script path' },
|
||||||
|
log: { type: 'string', description: 'Log file path' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(params, context) {
|
||||||
|
return await safeRestart({
|
||||||
|
agentId: context.agentId,
|
||||||
|
sessionKey: context.sessionKey,
|
||||||
|
rollback: params.rollback,
|
||||||
|
log: params.log,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('PaddedCell plugin initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for OpenClaw
|
||||||
|
module.exports = { register };
|
||||||
|
|
||||||
|
// Also export individual modules for direct use
|
||||||
|
module.exports.pcexec = pcexec;
|
||||||
|
module.exports.pcexecSync = pcexecSync;
|
||||||
|
module.exports.safeRestart = safeRestart;
|
||||||
|
module.exports.createSafeRestartTool = createSafeRestartTool;
|
||||||
|
module.exports.StatusManager = StatusManager;
|
||||||
|
module.exports.createApiServer = createApiServer;
|
||||||
|
module.exports.startApiServer = startApiServer;
|
||||||
|
module.exports.SlashCommandHandler = SlashCommandHandler;
|
||||||
556
install.mjs
Executable file
556
install.mjs
Executable file
@@ -0,0 +1,556 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaddedCell Plugin Installer
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node install.mjs
|
||||||
|
* node install.mjs --prefix /usr/local
|
||||||
|
* node install.mjs --build-only
|
||||||
|
* node install.mjs --skip-check
|
||||||
|
* node install.mjs --uninstall
|
||||||
|
* node install.mjs --uninstall --prefix /usr/local
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, mkdirSync, copyFileSync, writeFileSync, chmodSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { dirname, join, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { homedir, platform } from 'os';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = resolve(dirname(__filename));
|
||||||
|
|
||||||
|
// Plugin configuration - matches directory name in dist/
|
||||||
|
const PLUGIN_NAME = 'padded-cell';
|
||||||
|
const DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME);
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options = {
|
||||||
|
prefix: null,
|
||||||
|
buildOnly: args.includes('--build-only'),
|
||||||
|
skipCheck: args.includes('--skip-check'),
|
||||||
|
verbose: args.includes('--verbose') || args.includes('-v'),
|
||||||
|
uninstall: args.includes('--uninstall'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse --prefix value
|
||||||
|
const prefixIndex = args.indexOf('--prefix');
|
||||||
|
if (prefixIndex !== -1 && args[prefixIndex + 1]) {
|
||||||
|
options.prefix = resolve(args[prefixIndex + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors for output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message, color = 'reset') {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(step, message) {
|
||||||
|
log(`[${step}/6] ${message}`, 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSuccess(message) {
|
||||||
|
log(` ✓ ${message}`, 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWarning(message) {
|
||||||
|
log(` ⚠ ${message}`, 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(message) {
|
||||||
|
log(` ✗ ${message}`, 'red');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(command, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
cwd: __dirname,
|
||||||
|
stdio: options.silent ? 'pipe' : 'inherit',
|
||||||
|
encoding: 'utf8',
|
||||||
|
};
|
||||||
|
return execSync(command, { ...defaultOptions, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClaw config helpers
|
||||||
|
function getOpenclawConfig(pathKey, defaultValue = undefined) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`openclaw config get ${pathKey} --json 2>/dev/null || echo "undefined"`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: __dirname
|
||||||
|
}).trim();
|
||||||
|
if (out === 'undefined' || out === '') return defaultValue;
|
||||||
|
return JSON.parse(out);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpenclawConfig(pathKey, value) {
|
||||||
|
const cmd = `openclaw config set ${pathKey} '${JSON.stringify(value)}' --json`;
|
||||||
|
execSync(cmd, { cwd: __dirname, encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsetOpenclawConfig(pathKey) {
|
||||||
|
try {
|
||||||
|
execSync(`openclaw config unset ${pathKey}`, { cwd: __dirname, encoding: 'utf8' });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy directory recursively
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
mkdirSync(dest, { recursive: true });
|
||||||
|
const entries = readdirSync(src, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = join(src, entry.name);
|
||||||
|
const destPath = join(dest, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDir(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 1: Environment Detection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function detectEnvironment() {
|
||||||
|
logStep(1, 'Detecting environment...');
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
platform: platform(),
|
||||||
|
nodeVersion: null,
|
||||||
|
goVersion: null,
|
||||||
|
openclawDir: join(homedir(), '.openclaw'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check Node.js
|
||||||
|
try {
|
||||||
|
env.nodeVersion = exec('node --version', { silent: true }).trim();
|
||||||
|
logSuccess(`Node.js ${env.nodeVersion}`);
|
||||||
|
} catch {
|
||||||
|
logError('Node.js not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Go
|
||||||
|
try {
|
||||||
|
env.goVersion = exec('go version', { silent: true }).trim();
|
||||||
|
logSuccess(`Go ${env.goVersion}`);
|
||||||
|
} catch {
|
||||||
|
logError('Go not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check openclaw
|
||||||
|
try {
|
||||||
|
const path = exec('which openclaw', { silent: true }).trim();
|
||||||
|
logSuccess(`openclaw at ${path}`);
|
||||||
|
|
||||||
|
// Try to find openclaw config dir
|
||||||
|
const home = homedir();
|
||||||
|
const possibleDirs = [
|
||||||
|
join(home, '.openclaw'),
|
||||||
|
join(home, '.config', 'openclaw'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dir of possibleDirs) {
|
||||||
|
if (existsSync(dir)) {
|
||||||
|
env.openclawDir = dir;
|
||||||
|
logSuccess(`openclaw config dir: ${dir}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logWarning('openclaw CLI not found in PATH');
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDependencies(env) {
|
||||||
|
if (options.skipCheck) {
|
||||||
|
logWarning('Skipping dependency checks');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(2, 'Checking dependencies...');
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
if (!env.nodeVersion) {
|
||||||
|
logError('Node.js is required. Please install Node.js 18+');
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
const majorVersion = parseInt(env.nodeVersion.slice(1).split('.')[0]);
|
||||||
|
if (majorVersion < 18) {
|
||||||
|
logError(`Node.js 18+ required, found ${env.nodeVersion}`);
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
logSuccess(`Node.js version OK`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.goVersion) {
|
||||||
|
logError('Go is required. Please install Go 1.22+');
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
logSuccess(`Go version OK`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
log('\nPlease install missing dependencies and try again.', 'red');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 3: Build Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function buildComponents(env) {
|
||||||
|
logStep(3, 'Building components...');
|
||||||
|
|
||||||
|
// Build pass_mgr
|
||||||
|
log(' Building pass_mgr (Go)...', 'blue');
|
||||||
|
try {
|
||||||
|
const passMgrDir = join(__dirname, 'pass_mgr');
|
||||||
|
exec('go mod tidy', { cwd: passMgrDir, silent: !options.verbose });
|
||||||
|
exec('go build -o dist/pass_mgr src/main.go', { cwd: passMgrDir, silent: !options.verbose });
|
||||||
|
|
||||||
|
const binaryPath = join(passMgrDir, 'dist', 'pass_mgr');
|
||||||
|
if (!existsSync(binaryPath)) {
|
||||||
|
throw new Error('pass_mgr binary not found after build');
|
||||||
|
}
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
logSuccess('pass_mgr built successfully');
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to build pass_mgr: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pcexec
|
||||||
|
log(' Building pcexec (TypeScript)...', 'blue');
|
||||||
|
try {
|
||||||
|
const pcexecDir = join(__dirname, 'pcexec');
|
||||||
|
exec('npm install', { cwd: pcexecDir, silent: !options.verbose });
|
||||||
|
exec('npm run build', { cwd: pcexecDir, silent: !options.verbose });
|
||||||
|
logSuccess('pcexec built successfully');
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to build pcexec: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build safe-restart
|
||||||
|
log(' Building safe-restart (TypeScript)...', 'blue');
|
||||||
|
try {
|
||||||
|
const safeRestartDir = join(__dirname, 'safe-restart');
|
||||||
|
exec('npm install', { cwd: safeRestartDir, silent: !options.verbose });
|
||||||
|
exec('npm run build', { cwd: safeRestartDir, silent: !options.verbose });
|
||||||
|
logSuccess('safe-restart built successfully');
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to build safe-restart: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 4: Install Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function installComponents(env) {
|
||||||
|
if (options.buildOnly) {
|
||||||
|
logStep(4, 'Skipping installation (--build-only)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(4, 'Installing components...');
|
||||||
|
|
||||||
|
const installDir = options.prefix || env.openclawDir;
|
||||||
|
const binDir = join(installDir, 'bin');
|
||||||
|
|
||||||
|
log(` Install directory: ${installDir}`, 'blue');
|
||||||
|
log(` Binary directory: ${binDir}`, 'blue');
|
||||||
|
log(` Dist directory: ${DIST_DIR}`, 'blue');
|
||||||
|
|
||||||
|
// Create dist/padded-cell directory and copy plugin files
|
||||||
|
log(' Copying plugin files to dist/padded-cell...', 'blue');
|
||||||
|
mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Copy pcexec
|
||||||
|
copyDir(join(__dirname, 'pcexec'), join(DIST_DIR, 'pcexec'));
|
||||||
|
logSuccess('Copied pcexec to dist/padded-cell/');
|
||||||
|
|
||||||
|
// Copy safe-restart
|
||||||
|
copyDir(join(__dirname, 'safe-restart'), join(DIST_DIR, 'safe-restart'));
|
||||||
|
logSuccess('Copied safe-restart to dist/padded-cell/');
|
||||||
|
|
||||||
|
// Create root index.js entry point (copy from source)
|
||||||
|
copyFileSync(join(__dirname, 'index.js'), join(DIST_DIR, 'index.js'));
|
||||||
|
logSuccess('Copied index.js entry point');
|
||||||
|
|
||||||
|
// Copy openclaw.plugin.json from source
|
||||||
|
copyFileSync(join(__dirname, 'openclaw.plugin.json'), join(DIST_DIR, 'openclaw.plugin.json'));
|
||||||
|
logSuccess('Copied openclaw.plugin.json');
|
||||||
|
|
||||||
|
// Create bin directory and install pass_mgr binary
|
||||||
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
log(' Installing pass_mgr binary...', 'blue');
|
||||||
|
const passMgrSource = join(__dirname, 'pass_mgr', 'dist', 'pass_mgr');
|
||||||
|
const passMgrDest = join(binDir, 'pass_mgr');
|
||||||
|
copyFileSync(passMgrSource, passMgrDest);
|
||||||
|
chmodSync(passMgrDest, 0o755);
|
||||||
|
logSuccess(`pass_mgr installed to ${passMgrDest}`);
|
||||||
|
|
||||||
|
return { passMgrPath: passMgrDest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 5: Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function configure(env) {
|
||||||
|
if (options.buildOnly) {
|
||||||
|
logStep(5, 'Skipping configuration (--build-only)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(5, 'Configuration...');
|
||||||
|
|
||||||
|
const installDir = options.prefix || env.openclawDir;
|
||||||
|
const passMgrPath = join(installDir, 'bin', 'pass_mgr');
|
||||||
|
|
||||||
|
// Check if already initialized
|
||||||
|
const adminKeyDir = join(homedir(), '.pass_mgr');
|
||||||
|
const configPath = join(adminKeyDir, 'config.json');
|
||||||
|
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
logSuccess('pass_mgr already initialized');
|
||||||
|
} else {
|
||||||
|
log(' pass_mgr not initialized yet.', 'yellow');
|
||||||
|
log(` Run "${passMgrPath} admin init" manually after installation.`, 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure OpenClaw
|
||||||
|
log('\n Configuring OpenClaw plugin...', 'blue');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Add plugin path to plugins.load.paths FIRST (required for validation)
|
||||||
|
const currentPaths = getOpenclawConfig('plugins.load.paths', []);
|
||||||
|
log(` Current paths: ${JSON.stringify(currentPaths)}`, 'blue');
|
||||||
|
log(` DIST_DIR: ${DIST_DIR}`, 'blue');
|
||||||
|
if (!currentPaths.includes(DIST_DIR)) {
|
||||||
|
currentPaths.push(DIST_DIR);
|
||||||
|
log(` Adding plugin path...`, 'blue');
|
||||||
|
try {
|
||||||
|
setOpenclawConfig('plugins.load.paths', currentPaths);
|
||||||
|
logSuccess(`Added ${DIST_DIR} to plugins.load.paths`);
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to set paths: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(' Plugin path already in plugins.load.paths', 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add to plugins.allow (after path is set)
|
||||||
|
const allowList = getOpenclawConfig('plugins.allow', []);
|
||||||
|
if (!allowList.includes(PLUGIN_NAME)) {
|
||||||
|
allowList.push(PLUGIN_NAME);
|
||||||
|
setOpenclawConfig('plugins.allow', allowList);
|
||||||
|
logSuccess(`Added '${PLUGIN_NAME}' to plugins.allow`);
|
||||||
|
} else {
|
||||||
|
log(' Already in plugins.allow', 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add plugin entry
|
||||||
|
const plugins = getOpenclawConfig('plugins', {});
|
||||||
|
plugins.entries = plugins.entries || {};
|
||||||
|
plugins.entries[PLUGIN_NAME] = {
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
passMgrPath: passMgrPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setOpenclawConfig('plugins', plugins);
|
||||||
|
logSuccess(`Configured ${PLUGIN_NAME} plugin entry`);
|
||||||
|
} catch (err) {
|
||||||
|
logWarning(`Failed to configure OpenClaw: ${err.message}`);
|
||||||
|
log(' Please manually configure:', 'yellow');
|
||||||
|
log(` openclaw config set plugins.allow --json '[..., "${PLUGIN_NAME}"]'`, 'cyan');
|
||||||
|
log(` openclaw config set plugins.load.paths --json '[..., "${DIST_DIR}"]'`, 'cyan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 6: Print Summary
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function printSummary(env, passMgrPath) {
|
||||||
|
logStep(6, 'Installation Summary');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
log('╔════════════════════════════════════════════════════════╗', 'cyan');
|
||||||
|
log('║ PaddedCell Installation Complete ║', 'cyan');
|
||||||
|
log('╚════════════════════════════════════════════════════════╝', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (options.buildOnly) {
|
||||||
|
log('Build-only mode - binaries built but not installed', 'yellow');
|
||||||
|
console.log('');
|
||||||
|
log('Built artifacts:', 'blue');
|
||||||
|
log(` • pass_mgr: ${join(__dirname, 'pass_mgr', 'dist', 'pass_mgr')}`, 'reset');
|
||||||
|
log(` • pcexec: ${join(__dirname, 'pcexec', 'dist')}`, 'reset');
|
||||||
|
log(` • safe-restart: ${join(__dirname, 'safe-restart', 'dist')}`, 'reset');
|
||||||
|
} else {
|
||||||
|
log('Installed components:', 'blue');
|
||||||
|
log(` • pass_mgr binary: ${passMgrPath}`, 'reset');
|
||||||
|
log(` • Plugin files: ${DIST_DIR}`, 'reset');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
log('Next steps:', 'blue');
|
||||||
|
console.log('');
|
||||||
|
log('1. Initialize pass_mgr (required before first use):', 'yellow');
|
||||||
|
log(` ${passMgrPath} admin init`, 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('2. Test pass_mgr:', 'yellow');
|
||||||
|
log(` ${passMgrPath} set test_key mypass`, 'cyan');
|
||||||
|
log(` ${passMgrPath} get test_key`, 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('3. Restart OpenClaw gateway:', 'yellow');
|
||||||
|
log(' openclaw gateway restart', 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uninstall
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function uninstall(env) {
|
||||||
|
logStep(1, 'Uninstalling PaddedCell...');
|
||||||
|
|
||||||
|
const installDir = options.prefix || env.openclawDir || join(homedir(), '.openclaw');
|
||||||
|
const passMgrBinary = join(installDir, 'bin', 'pass_mgr');
|
||||||
|
|
||||||
|
// Remove pass_mgr binary
|
||||||
|
if (existsSync(passMgrBinary)) {
|
||||||
|
try {
|
||||||
|
execSync(`rm -f "${passMgrBinary}"`, { silent: true });
|
||||||
|
logSuccess(`Removed ${passMgrBinary}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to remove ${passMgrBinary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove dist/padded-cell directory
|
||||||
|
if (existsSync(DIST_DIR)) {
|
||||||
|
try {
|
||||||
|
execSync(`rm -rf "${DIST_DIR}"`, { silent: true });
|
||||||
|
logSuccess(`Removed ${DIST_DIR}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError(`Failed to remove ${DIST_DIR}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove OpenClaw configuration
|
||||||
|
log('\n Removing OpenClaw configuration...', 'blue');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove from plugins.allow
|
||||||
|
const allowList = getOpenclawConfig('plugins.allow', []);
|
||||||
|
const idx = allowList.indexOf(PLUGIN_NAME);
|
||||||
|
if (idx !== -1) {
|
||||||
|
allowList.splice(idx, 1);
|
||||||
|
setOpenclawConfig('plugins.allow', allowList);
|
||||||
|
logSuccess(`Removed '${PLUGIN_NAME}' from plugins.allow`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove plugin entry
|
||||||
|
unsetOpenclawConfig(`plugins.entries.${PLUGIN_NAME}`);
|
||||||
|
logSuccess(`Removed ${PLUGIN_NAME} plugin entry`);
|
||||||
|
|
||||||
|
// Remove from plugins.load.paths
|
||||||
|
const currentPaths = getOpenclawConfig('plugins.load.paths', []);
|
||||||
|
const pathIdx = currentPaths.indexOf(DIST_DIR);
|
||||||
|
if (pathIdx !== -1) {
|
||||||
|
currentPaths.splice(pathIdx, 1);
|
||||||
|
setOpenclawConfig('plugins.load.paths', currentPaths);
|
||||||
|
logSuccess(`Removed plugin path from plugins.load.paths`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarning(`Failed to update OpenClaw config: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin key directory
|
||||||
|
const adminKeyDir = join(homedir(), '.pass_mgr');
|
||||||
|
if (existsSync(adminKeyDir)) {
|
||||||
|
log('\n⚠️ Admin key directory found:', 'yellow');
|
||||||
|
log(` ${adminKeyDir}`, 'cyan');
|
||||||
|
log(' This contains your encryption keys. Remove manually if desired.', 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
log('╔════════════════════════════════════════════════════════╗', 'cyan');
|
||||||
|
log('║ PaddedCell Uninstall Complete ║', 'cyan');
|
||||||
|
log('╚════════════════════════════════════════════════════════╝', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
log('Restart OpenClaw gateway:', 'yellow');
|
||||||
|
log(' openclaw gateway restart', 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('');
|
||||||
|
log('╔════════════════════════════════════════════════════════╗', 'cyan');
|
||||||
|
log('║ PaddedCell Plugin Installer v0.1.0 ║', 'cyan');
|
||||||
|
log('╚════════════════════════════════════════════════════════╝', 'cyan');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = detectEnvironment();
|
||||||
|
|
||||||
|
// Handle uninstall
|
||||||
|
if (options.uninstall) {
|
||||||
|
await uninstall(env);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDependencies(env);
|
||||||
|
await buildComponents(env);
|
||||||
|
const result = await installComponents(env);
|
||||||
|
await configure(env);
|
||||||
|
printSummary(env, result?.passMgrPath);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('');
|
||||||
|
log('╔════════════════════════════════════════════════════════╗', 'red');
|
||||||
|
log('║ Installation Failed ║', 'red');
|
||||||
|
log('╚════════════════════════════════════════════════════════╝', 'red');
|
||||||
|
console.log('');
|
||||||
|
log(`Error: ${err.message}`, 'red');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
14
openclaw.plugin.json
Normal file
14
openclaw.plugin.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "padded-cell",
|
||||||
|
"name": "PaddedCell",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Secure password management, safe execution, and coordinated restart",
|
||||||
|
"entry": "./index.js",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean", "default": true },
|
||||||
|
"passMgrPath": { "type": "string", "default": "/root/.openclaw/bin/pass_mgr" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
pass_mgr/go.mod
Normal file
14
pass_mgr/go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module pass_mgr
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
golang.org/x/term v0.40.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
)
|
||||||
14
pass_mgr/go.sum
Normal file
14
pass_mgr/go.sum
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
533
pass_mgr/src/main.go
Normal file
533
pass_mgr/src/main.go
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultAlgorithm = "AES-256-GCM"
|
||||||
|
AdminKeyDir = ".pass_mgr"
|
||||||
|
AdminKeyFile = ".priv"
|
||||||
|
SecretsDirName = ".secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncryptedData represents the structure of encrypted password file
|
||||||
|
type EncryptedData struct {
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds admin key configuration
|
||||||
|
type Config struct {
|
||||||
|
KeyHash string `json:"key_hash"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
workspaceDir string
|
||||||
|
agentID string
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "pass_mgr",
|
||||||
|
Short: "Password manager for OpenClaw agents",
|
||||||
|
Long: `A secure password management tool using AES-256-GCM encryption.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get environment variables
|
||||||
|
workspaceDir = os.Getenv("AGENT_WORKSPACE")
|
||||||
|
agentID = os.Getenv("AGENT_ID")
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
rootCmd.AddCommand(getCmd())
|
||||||
|
rootCmd.AddCommand(generateCmd())
|
||||||
|
rootCmd.AddCommand(unsetCmd())
|
||||||
|
rootCmd.AddCommand(rotateCmd())
|
||||||
|
rootCmd.AddCommand(adminInitCmd())
|
||||||
|
rootCmd.AddCommand(setCmd())
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCmd() *cobra.Command {
|
||||||
|
var showUsername bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "get [key]",
|
||||||
|
Short: "Get password for a key",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
key := args[0]
|
||||||
|
password, user, err := getPassword(key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if showUsername {
|
||||||
|
fmt.Println(user)
|
||||||
|
} else {
|
||||||
|
fmt.Println(password)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&showUsername, "username", false, "Show username instead of password")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateCmd() *cobra.Command {
|
||||||
|
var user string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "generate [key]",
|
||||||
|
Short: "Generate a new password",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
key := args[0]
|
||||||
|
// Check if agent is trying to set password
|
||||||
|
if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Use generate instead.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
password, err := generatePassword(32)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := setPassword(key, user, password); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(password)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&user, "username", "", "Username associated with the password")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsetCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "unset [key]",
|
||||||
|
Short: "Remove a password",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
key := args[0]
|
||||||
|
if err := removePassword(key); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "rotate [key]",
|
||||||
|
Short: "Rotate password for a key",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
key := args[0]
|
||||||
|
// Check if initialized
|
||||||
|
if !isInitialized() {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: pass_mgr not initialized. Run 'pass_mgr admin init' first.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user if exists
|
||||||
|
_, user, err := getPassword(key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new password
|
||||||
|
newPassword, err := generatePassword(32)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setPassword(key, user, newPassword); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(newPassword)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminInitCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "admin init",
|
||||||
|
Short: "Initialize pass_mgr with admin key",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := initAdminInteractive(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("pass_mgr initialized successfully")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCmd() *cobra.Command {
|
||||||
|
var user string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "set [key] [password]",
|
||||||
|
Short: "Set password (admin only)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Check if agent is trying to set password
|
||||||
|
if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Only humans can use 'set'.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := args[0]
|
||||||
|
password := args[1]
|
||||||
|
|
||||||
|
if err := setPassword(key, user, password); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&user, "username", "", "Username associated with the password")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func getHomeDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAdminKeyPath() string {
|
||||||
|
return filepath.Join(getHomeDir(), AdminKeyDir, AdminKeyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigPath() string {
|
||||||
|
return filepath.Join(getHomeDir(), AdminKeyDir, "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInitialized() bool {
|
||||||
|
_, err := os.Stat(getConfigPath())
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdminKey() ([]byte, error) {
|
||||||
|
keyPath := getAdminKeyPath()
|
||||||
|
key, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load admin key: %w", err)
|
||||||
|
}
|
||||||
|
// Hash the key to get 32 bytes for AES-256
|
||||||
|
hash := sha256.Sum256(key)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAdminInteractive() error {
|
||||||
|
fmt.Print("Enter admin password: ")
|
||||||
|
password1, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Trim whitespace/newlines
|
||||||
|
password1 = []byte(strings.TrimSpace(string(password1)))
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if len(password1) < 6 {
|
||||||
|
return fmt.Errorf("password must be at least 6 characters long (got %d)", len(password1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Confirm admin password: ")
|
||||||
|
password2, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password confirmation: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Trim whitespace/newlines
|
||||||
|
password2 = []byte(strings.TrimSpace(string(password2)))
|
||||||
|
|
||||||
|
// Check passwords match
|
||||||
|
if string(password1) != string(password2) {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the key
|
||||||
|
return saveAdminKey(password1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAdminKey(key []byte) error {
|
||||||
|
homeDir := getHomeDir()
|
||||||
|
adminDir := filepath.Join(homeDir, AdminKeyDir)
|
||||||
|
|
||||||
|
// Create admin directory
|
||||||
|
if err := os.MkdirAll(adminDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create admin directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save key
|
||||||
|
keyFile := filepath.Join(adminDir, AdminKeyFile)
|
||||||
|
if err := os.WriteFile(keyFile, key, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to save key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
config := Config{
|
||||||
|
KeyHash: fmt.Sprintf("%x", sha256.Sum256(key)),
|
||||||
|
Algorithm: DefaultAlgorithm,
|
||||||
|
}
|
||||||
|
configData, _ := json.MarshalIndent(config, "", " ")
|
||||||
|
configPath := filepath.Join(adminDir, "config.json")
|
||||||
|
if err := os.WriteFile(configPath, configData, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAdmin(keyPath string) error {
|
||||||
|
// Read provided key
|
||||||
|
key, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length (must be >= 6 characters)
|
||||||
|
if len(key) < 6 {
|
||||||
|
return fmt.Errorf("password must be at least 6 characters long (got %d)", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveAdminKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSecretsDir() string {
|
||||||
|
if workspaceDir != "" && agentID != "" {
|
||||||
|
return filepath.Join(workspaceDir, SecretsDirName, agentID)
|
||||||
|
}
|
||||||
|
// Fallback to home directory
|
||||||
|
return filepath.Join(getHomeDir(), SecretsDirName, "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPasswordFilePath(key string) string {
|
||||||
|
return filepath.Join(getSecretsDir(), key+".gpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encrypt(plaintext []byte, key []byte) (*EncryptedData, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
|
||||||
|
return &EncryptedData{
|
||||||
|
Algorithm: DefaultAlgorithm,
|
||||||
|
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||||
|
Data: base64.StdEncoding.EncodeToString(ciphertext[gcm.NonceSize():]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(data *EncryptedData, key []byte) ([]byte, error) {
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(data.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := base64.StdEncoding.DecodeString(data.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPassword(key, user, password string) error {
|
||||||
|
if !isInitialized() {
|
||||||
|
return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminKey, err := loadAdminKey()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create secrets directory
|
||||||
|
secretsDir := getSecretsDir()
|
||||||
|
if err := os.MkdirAll(secretsDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt password
|
||||||
|
data := map[string]string{
|
||||||
|
"password": password,
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
plaintext, _ := json.Marshal(data)
|
||||||
|
|
||||||
|
encrypted, err := encrypt(plaintext, adminKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
encrypted.User = user
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
filePath := getPasswordFilePath(key)
|
||||||
|
fileData, _ := json.MarshalIndent(encrypted, "", " ")
|
||||||
|
if err := os.WriteFile(filePath, fileData, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to save password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPassword(key string) (string, string, error) {
|
||||||
|
if !isInitialized() {
|
||||||
|
return "", "", fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminKey, err := loadAdminKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := getPasswordFilePath(key)
|
||||||
|
fileData, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("password not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var encrypted EncryptedData
|
||||||
|
if err := json.Unmarshal(fileData, &encrypted); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse password file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := decrypt(&encrypted, adminKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]string
|
||||||
|
if err := json.Unmarshal(plaintext, &data); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse decrypted data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data["password"], data["user"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePassword(key string) error {
|
||||||
|
if !isInitialized() {
|
||||||
|
return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := getPasswordFilePath(key)
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePassword(length int) (string, error) {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||||
|
password := make([]byte, length)
|
||||||
|
for i := range password {
|
||||||
|
randomByte := make([]byte, 1)
|
||||||
|
if _, err := rand.Read(randomByte); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
password[i] = charset[int(randomByte[0])%len(charset)]
|
||||||
|
}
|
||||||
|
return string(password), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckForAdminLeak checks if admin password appears in message/tool calling
|
||||||
|
func CheckForAdminLeak(content string) bool {
|
||||||
|
// This is a placeholder - actual implementation should check against actual admin password
|
||||||
|
// This function should be called by the plugin to monitor messages
|
||||||
|
configPath := getConfigPath()
|
||||||
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual leak detection
|
||||||
|
// For now, just check if content contains common patterns
|
||||||
|
return strings.Contains(content, "admin") && strings.Contains(content, "password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetOnLeak resets pass_mgr to uninitialized state and logs security breach
|
||||||
|
func ResetOnLeak() error {
|
||||||
|
configPath := getConfigPath()
|
||||||
|
|
||||||
|
// Remove config (but keep key file for potential recovery)
|
||||||
|
if err := os.Remove(configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log security breach
|
||||||
|
logPath := filepath.Join(getHomeDir(), AdminKeyDir, "security_breach.log")
|
||||||
|
logEntry := fmt.Sprintf("[%s] CRITICAL: Admin password leaked! pass_mgr reset to uninitialized state.\n",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
|
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.WriteString(logEntry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
3846
pcexec/package-lock.json
generated
Normal file
3846
pcexec/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
pcexec/package.json
Normal file
21
pcexec/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "pcexec",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Safe exec wrapper for OpenClaw with password sanitization",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^20.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"jest": "^29.0.0",
|
||||||
|
"@types/jest": "^29.0.0",
|
||||||
|
"ts-jest": "^29.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
364
pcexec/src/index.ts
Normal file
364
pcexec/src/index.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { spawn, SpawnOptions } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execAsync = promisify(require('child_process').exec);
|
||||||
|
|
||||||
|
export interface PcExecOptions {
|
||||||
|
/** Current working directory */
|
||||||
|
cwd?: string;
|
||||||
|
/** Environment variables */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** Timeout in milliseconds */
|
||||||
|
timeout?: number;
|
||||||
|
/** Maximum buffer size for stdout/stderr */
|
||||||
|
maxBuffer?: number;
|
||||||
|
/** Kill signal */
|
||||||
|
killSignal?: NodeJS.Signals;
|
||||||
|
/** Shell to use */
|
||||||
|
shell?: string | boolean;
|
||||||
|
/** UID to run as */
|
||||||
|
uid?: number;
|
||||||
|
/** GID to run as */
|
||||||
|
gid?: number;
|
||||||
|
/** Window style (Windows only) */
|
||||||
|
windowsHide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PcExecResult {
|
||||||
|
/** Standard output */
|
||||||
|
stdout: string;
|
||||||
|
/** Standard error */
|
||||||
|
stderr: string;
|
||||||
|
/** Exit code */
|
||||||
|
exitCode: number;
|
||||||
|
/** Command that was executed */
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PcExecError extends Error {
|
||||||
|
/** Exit code */
|
||||||
|
code?: number;
|
||||||
|
/** Signal that terminated the process */
|
||||||
|
signal?: string;
|
||||||
|
/** Standard output */
|
||||||
|
stdout: string;
|
||||||
|
/** Standard error */
|
||||||
|
stderr: string;
|
||||||
|
/** Killed by timeout */
|
||||||
|
killed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pass_mgr get commands from a command string
|
||||||
|
* Supports formats like:
|
||||||
|
* - $(pass_mgr get key)
|
||||||
|
* - `pass_mgr get key`
|
||||||
|
* - pass_mgr get key (direct invocation)
|
||||||
|
*/
|
||||||
|
function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: string }> {
|
||||||
|
const results: Array<{ key: string; fullMatch: string }> = [];
|
||||||
|
|
||||||
|
// Pattern for $(pass_mgr get key) or `pass_mgr get key`
|
||||||
|
const patterns = [
|
||||||
|
/\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g,
|
||||||
|
/`\s*pass_mgr\s+get\s+(\S+)\s*`/g,
|
||||||
|
/pass_mgr\s+get\s+(\S+)/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(command)) !== null) {
|
||||||
|
results.push({
|
||||||
|
key: match[1],
|
||||||
|
fullMatch: match[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute pass_mgr get and return the password
|
||||||
|
*/
|
||||||
|
async function getPassword(key: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const passMgrPath = process.env.PASS_MGR_PATH || 'pass_mgr';
|
||||||
|
const child = spawn(passMgrPath, ['get', key], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AGENT_WORKSPACE: process.env.AGENT_WORKSPACE || '',
|
||||||
|
AGENT_ID: process.env.AGENT_ID || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`pass_mgr get failed: ${stderr || stdout}`));
|
||||||
|
} else {
|
||||||
|
resolve(stdout.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize output by replacing passwords with ######
|
||||||
|
*/
|
||||||
|
function sanitizeOutput(output: string, passwords: string[]): string {
|
||||||
|
let sanitized = output;
|
||||||
|
for (const password of passwords) {
|
||||||
|
if (password) {
|
||||||
|
// Escape special regex characters
|
||||||
|
const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escaped, 'g');
|
||||||
|
sanitized = sanitized.replace(regex, '######');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace pass_mgr get commands with actual passwords in command
|
||||||
|
*/
|
||||||
|
async function replacePassMgrGets(command: string): Promise<{ command: string; passwords: string[] }> {
|
||||||
|
const passMgrGets = extractPassMgrGets(command);
|
||||||
|
const passwords: string[] = [];
|
||||||
|
let replacedCommand = command;
|
||||||
|
|
||||||
|
for (const { key, fullMatch } of passMgrGets) {
|
||||||
|
try {
|
||||||
|
const password = await getPassword(key);
|
||||||
|
passwords.push(password);
|
||||||
|
replacedCommand = replacedCommand.replace(fullMatch, password);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to get password for key '${key}': ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: replacedCommand, passwords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe exec wrapper that handles pass_mgr get commands and sanitizes output
|
||||||
|
*
|
||||||
|
* @param command - Command to execute
|
||||||
|
* @param options - Execution options
|
||||||
|
* @returns Promise resolving to execution result
|
||||||
|
*/
|
||||||
|
export async function pcexec(command: string, options: PcExecOptions = {}): Promise<PcExecResult> {
|
||||||
|
// Set up environment with workspace/agent info
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Copy process.env, filtering out undefined values
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options.env
|
||||||
|
if (options.env) {
|
||||||
|
Object.assign(env, options.env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.AGENT_WORKSPACE) {
|
||||||
|
env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE;
|
||||||
|
}
|
||||||
|
if (process.env.AGENT_ID) {
|
||||||
|
env.AGENT_ID = process.env.AGENT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and replace pass_mgr get commands
|
||||||
|
let finalCommand = command;
|
||||||
|
let passwords: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await replacePassMgrGets(command);
|
||||||
|
finalCommand = result.command;
|
||||||
|
passwords = result.passwords;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const spawnOptions: SpawnOptions = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env,
|
||||||
|
shell: options.shell ?? true,
|
||||||
|
windowsHide: options.windowsHide,
|
||||||
|
uid: options.uid,
|
||||||
|
gid: options.gid,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use bash for better compatibility
|
||||||
|
const child = spawn('bash', ['-c', finalCommand], spawnOptions);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let killed = false;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Set up timeout
|
||||||
|
if (options.timeout && options.timeout > 0) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
killed = true;
|
||||||
|
child.kill(options.killSignal || 'SIGTERM');
|
||||||
|
}, options.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stdout
|
||||||
|
child.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
|
||||||
|
// Check maxBuffer
|
||||||
|
if (options.maxBuffer && stdout.length > options.maxBuffer) {
|
||||||
|
child.kill(options.killSignal || 'SIGTERM');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stderr
|
||||||
|
child.stderr?.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
|
||||||
|
// Check maxBuffer
|
||||||
|
if (options.maxBuffer && stderr.length > options.maxBuffer) {
|
||||||
|
child.kill(options.killSignal || 'SIGTERM');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process close
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize output
|
||||||
|
const sanitizedStdout = sanitizeOutput(stdout, passwords);
|
||||||
|
const sanitizedStderr = sanitizeOutput(stderr, passwords);
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({
|
||||||
|
stdout: sanitizedStdout,
|
||||||
|
stderr: sanitizedStderr,
|
||||||
|
exitCode: 0,
|
||||||
|
command: finalCommand,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const error = new Error(`Command failed: ${command}`) as PcExecError;
|
||||||
|
error.code = code ?? undefined;
|
||||||
|
error.signal = signal ?? undefined;
|
||||||
|
error.stdout = sanitizedStdout;
|
||||||
|
error.stderr = sanitizedStderr;
|
||||||
|
error.killed = killed;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process error
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError;
|
||||||
|
error.stdout = sanitizeOutput(stdout, passwords);
|
||||||
|
error.stderr = sanitizeOutput(stderr, passwords);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version of pcexec
|
||||||
|
* Note: Password sanitization is still applied
|
||||||
|
*/
|
||||||
|
export function pcexecSync(command: string, options: PcExecOptions = {}): PcExecResult {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Copy process.env, filtering out undefined values
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options.env
|
||||||
|
if (options.env) {
|
||||||
|
Object.assign(env, options.env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.AGENT_WORKSPACE) {
|
||||||
|
env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE;
|
||||||
|
}
|
||||||
|
if (process.env.AGENT_ID) {
|
||||||
|
env.AGENT_ID = process.env.AGENT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For sync version, we need to pre-resolve passwords
|
||||||
|
// This is a limitation - passwords will be in command
|
||||||
|
const passMgrGets = extractPassMgrGets(command);
|
||||||
|
let finalCommand = command;
|
||||||
|
const passwords: string[] = [];
|
||||||
|
|
||||||
|
// Note: In sync version, we can't async fetch passwords
|
||||||
|
// So we use the original command and rely on the user to not use pass_mgr gets in sync mode
|
||||||
|
// Or they need to resolve passwords beforehand
|
||||||
|
|
||||||
|
const execOptions: any = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env,
|
||||||
|
shell: options.shell ?? true,
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: options.windowsHide,
|
||||||
|
uid: options.uid,
|
||||||
|
gid: options.gid,
|
||||||
|
maxBuffer: options.maxBuffer,
|
||||||
|
timeout: options.timeout,
|
||||||
|
killSignal: options.killSignal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdout = execSync(finalCommand, execOptions);
|
||||||
|
const sanitizedStdout = sanitizeOutput(stdout.toString(), passwords);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: sanitizedStdout,
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
command: finalCommand,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const sanitizedStdout = sanitizeOutput(err.stdout?.toString() || '', passwords);
|
||||||
|
const sanitizedStderr = sanitizeOutput(err.stderr?.toString() || '', passwords);
|
||||||
|
|
||||||
|
const error = new Error(`Command failed: ${command}`) as PcExecError;
|
||||||
|
error.code = err.status;
|
||||||
|
error.signal = err.signal;
|
||||||
|
error.stdout = sanitizedStdout;
|
||||||
|
error.stderr = sanitizedStderr;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default export
|
||||||
|
export default pcexec;
|
||||||
19
pcexec/tsconfig.json
Normal file
19
pcexec/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
4821
safe-restart/package-lock.json
generated
Normal file
4821
safe-restart/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
safe-restart/package.json
Normal file
25
safe-restart/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "safe-restart",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Safe restart module for OpenClaw agents",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"ws": "^8.14.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@types/express": "^4.17.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"jest": "^29.0.0",
|
||||||
|
"@types/jest": "^29.0.0",
|
||||||
|
"ts-jest": "^29.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
100
safe-restart/src/api.ts
Normal file
100
safe-restart/src/api.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { StatusManager } from './status-manager';
|
||||||
|
|
||||||
|
export interface ApiOptions {
|
||||||
|
port?: number;
|
||||||
|
statusManager: StatusManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and starts the REST API server for query-restart
|
||||||
|
*/
|
||||||
|
export function createApiServer(options: ApiOptions): express.Application {
|
||||||
|
const { port = 8765, statusManager } = options;
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// POST /query-restart
|
||||||
|
app.post('/query-restart', (req, res) => {
|
||||||
|
const { requesterAgentId, requesterSessionKey } = req.body;
|
||||||
|
|
||||||
|
if (!requesterAgentId || !requesterSessionKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: requesterAgentId, requesterSessionKey',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = statusManager.queryRestart(requesterAgentId, requesterSessionKey);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /restart-result
|
||||||
|
app.post('/restart-result', (req, res) => {
|
||||||
|
const { status, log } = req.body;
|
||||||
|
|
||||||
|
if (!status || !['ok', 'failed'].includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid status. Must be "ok" or "failed"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
statusManager.completeRestart(status === 'ok', log);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status
|
||||||
|
app.get('/status', (req, res) => {
|
||||||
|
const agents = statusManager.getAllAgents();
|
||||||
|
const global = statusManager.getGlobalStatus();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
agents,
|
||||||
|
global,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /agent/:agentId
|
||||||
|
app.get('/agent/:agentId', (req, res) => {
|
||||||
|
const { agentId } = req.params;
|
||||||
|
const agent = statusManager.getAgent(agentId);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Agent ${agentId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
agent,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startApiServer(options: ApiOptions): Promise<void> {
|
||||||
|
const { port = 8765 } = options;
|
||||||
|
const app = createApiServer(options);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`Safe-restart API server listening on port ${port}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
4
safe-restart/src/index.ts
Normal file
4
safe-restart/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { StatusManager, type AgentStatus, type GlobalStatus, type AgentState } from './status-manager';
|
||||||
|
export { createApiServer, startApiServer } from './api';
|
||||||
|
export { safeRestart, createSafeRestartTool, type SafeRestartOptions, type SafeRestartResult } from './safe-restart';
|
||||||
|
export { SlashCommandHandler, type SlashCommandOptions } from './slash-commands';
|
||||||
288
safe-restart/src/safe-restart.ts
Normal file
288
safe-restart/src/safe-restart.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { StatusManager } from './status-manager';
|
||||||
|
|
||||||
|
const sleep = promisify(setTimeout);
|
||||||
|
|
||||||
|
export interface SafeRestartOptions {
|
||||||
|
/** Agent ID performing the restart */
|
||||||
|
agentId: string;
|
||||||
|
/** Session key for notifications */
|
||||||
|
sessionKey: string;
|
||||||
|
/** API endpoint for query-restart */
|
||||||
|
apiEndpoint?: string;
|
||||||
|
/** Rollback script path */
|
||||||
|
rollback?: string;
|
||||||
|
/** Log file path */
|
||||||
|
log?: string;
|
||||||
|
/** Polling interval in ms (default: 5000) */
|
||||||
|
pollInterval?: number;
|
||||||
|
/** Maximum wait time in ms (default: 300000 = 5min) */
|
||||||
|
maxWaitTime?: number;
|
||||||
|
/** Restart script/command */
|
||||||
|
restartScript?: string;
|
||||||
|
/** Callback for notifications */
|
||||||
|
onNotify?: (sessionKey: string, message: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeRestartResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
log?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a safe restart with polling and rollback support
|
||||||
|
*/
|
||||||
|
export async function safeRestart(options: SafeRestartOptions): Promise<SafeRestartResult> {
|
||||||
|
const {
|
||||||
|
agentId,
|
||||||
|
sessionKey,
|
||||||
|
apiEndpoint = 'http://localhost:8765',
|
||||||
|
rollback,
|
||||||
|
log: logPath,
|
||||||
|
pollInterval = 5000,
|
||||||
|
maxWaitTime = 300000,
|
||||||
|
restartScript = 'openclaw gateway restart',
|
||||||
|
onNotify,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
const log = (msg: string) => {
|
||||||
|
const entry = `[${new Date().toISOString()}] ${msg}`;
|
||||||
|
logs.push(entry);
|
||||||
|
console.log(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`Starting safe restart. Agent: ${agentId}, Session: ${sessionKey}`);
|
||||||
|
|
||||||
|
// Step 1: Poll query-restart until OK or timeout
|
||||||
|
const startTime = Date.now();
|
||||||
|
let restartApproved = false;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiEndpoint}/query-restart`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
requesterAgentId: agentId,
|
||||||
|
requesterSessionKey: sessionKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { status: string };
|
||||||
|
|
||||||
|
if (data.status === 'OK') {
|
||||||
|
log('All agents ready for restart');
|
||||||
|
restartApproved = true;
|
||||||
|
break;
|
||||||
|
} else if (data.status === 'ALREADY_SCHEDULED') {
|
||||||
|
log('Restart already scheduled by another agent');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'ALREADY_SCHEDULED',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
log(`Waiting for agents to be ready... (${data.status})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error polling query-restart: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!restartApproved) {
|
||||||
|
const msg = 'Timeout waiting for agents to be ready';
|
||||||
|
log(msg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: msg,
|
||||||
|
log: logs.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Report restart starting
|
||||||
|
log('Executing restart...');
|
||||||
|
|
||||||
|
// Step 3: Start restart in background process
|
||||||
|
const restartProcess = startBackgroundRestart(restartScript, logPath);
|
||||||
|
|
||||||
|
// Wait a moment for restart to initiate
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
// Step 4: Check if gateway comes back
|
||||||
|
log('Waiting for gateway to restart...');
|
||||||
|
await sleep(60000); // Wait 60s as specified
|
||||||
|
|
||||||
|
// Check gateway status
|
||||||
|
const gatewayOk = await checkGatewayStatus();
|
||||||
|
|
||||||
|
if (gatewayOk) {
|
||||||
|
log('Gateway restarted successfully');
|
||||||
|
|
||||||
|
// Report success
|
||||||
|
await fetch(`${apiEndpoint}/restart-result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'ok',
|
||||||
|
log: logPath || logs.join('\n'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify resumption
|
||||||
|
if (onNotify) {
|
||||||
|
await onNotify(sessionKey, 'restart 结束了,我们继续');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Restart completed successfully',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
log('Gateway restart failed');
|
||||||
|
|
||||||
|
// Execute rollback if provided
|
||||||
|
if (rollback) {
|
||||||
|
log(`Executing rollback: ${rollback}`);
|
||||||
|
try {
|
||||||
|
await executeRollback(rollback);
|
||||||
|
log('Rollback completed');
|
||||||
|
} catch (err) {
|
||||||
|
log(`Rollback failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report failure
|
||||||
|
await fetch(`${apiEndpoint}/restart-result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'failed',
|
||||||
|
log: logPath || logs.join('\n'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify failure
|
||||||
|
if (onNotify) {
|
||||||
|
await onNotify(sessionKey, 'restart 失败,已经 rollback,请参考 log 调查。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Restart failed',
|
||||||
|
log: logs.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = `Unexpected error: ${err}`;
|
||||||
|
log(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: errorMsg,
|
||||||
|
log: logs.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBackgroundRestart(restartScript: string, logPath?: string): void {
|
||||||
|
const script = `
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
sleep 60
|
||||||
|
${restartScript}
|
||||||
|
openclaw gateway status
|
||||||
|
`;
|
||||||
|
|
||||||
|
const child = spawn('bash', ['-c', script], {
|
||||||
|
detached: true,
|
||||||
|
stdio: logPath ? ['ignore', fs.openSync(logPath, 'w'), fs.openSync(logPath, 'w+')] : 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkGatewayStatus(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('openclaw', ['gateway', 'status'], {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
child.stdout?.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve(code === 0 && output.includes('running'));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRollback(rollbackScript: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('bash', ['-c', rollbackScript], {
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Rollback script exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe restart tool that can be registered with OpenClaw
|
||||||
|
*/
|
||||||
|
export function createSafeRestartTool(statusManager: StatusManager) {
|
||||||
|
return {
|
||||||
|
name: 'safe_restart',
|
||||||
|
description: 'Perform a safe restart of OpenClaw gateway with agent coordination',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
rollback: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Path to rollback script',
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Path to log file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (params: { rollback?: string; log?: string }, context: { agentId: string; sessionKey: string }) => {
|
||||||
|
const result = await safeRestart({
|
||||||
|
agentId: context.agentId,
|
||||||
|
sessionKey: context.sessionKey,
|
||||||
|
rollback: params.rollback,
|
||||||
|
log: params.log,
|
||||||
|
async onNotify(sessionKey, message) {
|
||||||
|
// This would be connected to the messaging system
|
||||||
|
console.log(`[${sessionKey}] ${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
182
safe-restart/src/slash-commands.ts
Normal file
182
safe-restart/src/slash-commands.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { StatusManager } from './status-manager';
|
||||||
|
|
||||||
|
export interface SlashCommandOptions {
|
||||||
|
statusManager: StatusManager;
|
||||||
|
/** List of authorized user IDs */
|
||||||
|
authorizedUsers: string[];
|
||||||
|
/** Cooldown duration in seconds */
|
||||||
|
cooldownSeconds?: number;
|
||||||
|
/** Callback for replies */
|
||||||
|
onReply: (message: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandState {
|
||||||
|
passMgrEnabled: boolean;
|
||||||
|
safeRestartEnabled: boolean;
|
||||||
|
lastToggle: {
|
||||||
|
'pass-mgr': number;
|
||||||
|
'safe-restart': number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SlashCommandHandler {
|
||||||
|
private statusManager: StatusManager;
|
||||||
|
private authorizedUsers: string[];
|
||||||
|
private cooldownMs: number;
|
||||||
|
private onReply: (message: string) => Promise<void>;
|
||||||
|
private state: CommandState;
|
||||||
|
|
||||||
|
constructor(options: SlashCommandOptions) {
|
||||||
|
this.statusManager = options.statusManager;
|
||||||
|
this.authorizedUsers = options.authorizedUsers;
|
||||||
|
this.cooldownMs = (options.cooldownSeconds || 10) * 1000;
|
||||||
|
this.onReply = options.onReply;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
passMgrEnabled: true,
|
||||||
|
safeRestartEnabled: true,
|
||||||
|
lastToggle: {
|
||||||
|
'pass-mgr': 0,
|
||||||
|
'safe-restart': 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a slash command
|
||||||
|
*/
|
||||||
|
async handle(command: string, userId: string): Promise<void> {
|
||||||
|
// Check authorization
|
||||||
|
if (!this.authorizedUsers.includes(userId)) {
|
||||||
|
await this.onReply('❌ 无权执行此命令');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = command.trim().split(/\s+/);
|
||||||
|
const subcommand = parts[1];
|
||||||
|
const feature = parts[2] as 'pass-mgr' | 'safe-restart';
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'status':
|
||||||
|
await this.handleStatus();
|
||||||
|
break;
|
||||||
|
case 'enable':
|
||||||
|
await this.handleEnable(feature);
|
||||||
|
break;
|
||||||
|
case 'disable':
|
||||||
|
await this.handleDisable(feature);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.onReply(
|
||||||
|
'用法:\n' +
|
||||||
|
'`/padded-cell-ctrl status` - 查看状态\n' +
|
||||||
|
'`/padded-cell-ctrl enable pass-mgr|safe-restart` - 启用功能\n' +
|
||||||
|
'`/padded-cell-ctrl disable pass-mgr|safe-restart` - 禁用功能'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStatus(): Promise<void> {
|
||||||
|
const global = this.statusManager.getGlobalStatus();
|
||||||
|
const agents = this.statusManager.getAllAgents();
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'**PaddedCell 状态**',
|
||||||
|
'',
|
||||||
|
`🔐 密码管理: ${this.state.passMgrEnabled ? '✅ 启用' : '❌ 禁用'}`,
|
||||||
|
`🔄 安全重启: ${this.state.safeRestartEnabled ? '✅ 启用' : '❌ 禁用'}`,
|
||||||
|
'',
|
||||||
|
'**Agent 状态:**',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const emoji = this.getStateEmoji(agent.state);
|
||||||
|
lines.push(`${emoji} ${agent.agentId}: ${agent.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
lines.push('(暂无 agent 注册)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.restartStatus !== 'idle') {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`⚠️ 重启状态: ${global.restartStatus}`);
|
||||||
|
if (global.restartScheduledBy) {
|
||||||
|
lines.push(` 由 ${global.restartScheduledBy} 发起`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.onReply(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleEnable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
||||||
|
if (!this.isValidFeature(feature)) {
|
||||||
|
await this.onReply('❌ 未知功能。可用选项: pass-mgr, safe-restart');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isOnCooldown(feature)) {
|
||||||
|
await this.onReply('⏳ 该功能最近刚被修改过,请稍后再试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature === 'pass-mgr') {
|
||||||
|
this.state.passMgrEnabled = true;
|
||||||
|
} else {
|
||||||
|
this.state.safeRestartEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.lastToggle[feature] = Date.now();
|
||||||
|
await this.onReply(`✅ 已启用 ${feature}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDisable(feature: 'pass-mgr' | 'safe-restart'): Promise<void> {
|
||||||
|
if (!this.isValidFeature(feature)) {
|
||||||
|
await this.onReply('❌ 未知功能。可用选项: pass-mgr, safe-restart');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isOnCooldown(feature)) {
|
||||||
|
await this.onReply('⏳ 该功能最近刚被修改过,请稍后再试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature === 'pass-mgr') {
|
||||||
|
this.state.passMgrEnabled = false;
|
||||||
|
} else {
|
||||||
|
this.state.safeRestartEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.lastToggle[feature] = Date.now();
|
||||||
|
await this.onReply(`✅ 已禁用 ${feature}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidFeature(feature: string): feature is 'pass-mgr' | 'safe-restart' {
|
||||||
|
return feature === 'pass-mgr' || feature === 'safe-restart';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOnCooldown(feature: 'pass-mgr' | 'safe-restart'): boolean {
|
||||||
|
const lastToggle = this.state.lastToggle[feature];
|
||||||
|
return Date.now() - lastToggle < this.cooldownMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStateEmoji(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle': return '💤';
|
||||||
|
case 'busy': return '⚡';
|
||||||
|
case 'focus': return '🎯';
|
||||||
|
case 'freeze': return '🧊';
|
||||||
|
case 'pre-freeze': return '⏳';
|
||||||
|
case 'pre-freeze-focus': return '📝';
|
||||||
|
default: return '❓';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPassMgrEnabled(): boolean {
|
||||||
|
return this.state.passMgrEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSafeRestartEnabled(): boolean {
|
||||||
|
return this.state.safeRestartEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
396
safe-restart/src/status-manager.ts
Normal file
396
safe-restart/src/status-manager.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export type AgentState =
|
||||||
|
'idle' |
|
||||||
|
'busy' |
|
||||||
|
'focus' |
|
||||||
|
'freeze' |
|
||||||
|
'pre-freeze' |
|
||||||
|
'pre-freeze-focus';
|
||||||
|
|
||||||
|
export interface AgentStatus {
|
||||||
|
agentId: string;
|
||||||
|
state: AgentState;
|
||||||
|
workflow: string | null;
|
||||||
|
activeSessions: string[];
|
||||||
|
lastSessions: string[];
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalStatus {
|
||||||
|
restartScheduledBy: string | null;
|
||||||
|
restartSession: string | null;
|
||||||
|
restartStatus: 'idle' | 'waiting' | 'restarting' | 'rollback';
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusManagerOptions {
|
||||||
|
dataDir?: string;
|
||||||
|
persistenceInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages agent states and global restart status
|
||||||
|
*/
|
||||||
|
export class StatusManager extends EventEmitter {
|
||||||
|
private agents: Map<string, AgentStatus> = new Map();
|
||||||
|
private global: GlobalStatus;
|
||||||
|
private dataDir: string;
|
||||||
|
private persistenceInterval: number;
|
||||||
|
private persistenceTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(options: StatusManagerOptions = {}) {
|
||||||
|
super();
|
||||||
|
this.dataDir = options.dataDir || path.join(process.env.HOME || '.', '.paddedcell');
|
||||||
|
this.persistenceInterval = options.persistenceInterval || 5000;
|
||||||
|
|
||||||
|
this.global = {
|
||||||
|
restartScheduledBy: null,
|
||||||
|
restartSession: null,
|
||||||
|
restartStatus: 'idle',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ensureDataDir();
|
||||||
|
this.loadFromDisk();
|
||||||
|
this.startPersistence();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDataDir(): void {
|
||||||
|
if (!fs.existsSync(this.dataDir)) {
|
||||||
|
fs.mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAgentFilePath(agentId: string): string {
|
||||||
|
return path.join(this.dataDir, `agent_${agentId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGlobalFilePath(): string {
|
||||||
|
return path.join(this.dataDir, 'global.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFromDisk(): void {
|
||||||
|
// Load global status
|
||||||
|
const globalPath = this.getGlobalFilePath();
|
||||||
|
if (fs.existsSync(globalPath)) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(globalPath, 'utf8');
|
||||||
|
this.global = JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load global status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load agent statuses
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.dataDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('agent_') && file.endsWith('.json')) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(path.join(this.dataDir, file), 'utf8');
|
||||||
|
const agent = JSON.parse(data) as AgentStatus;
|
||||||
|
this.agents.set(agent.agentId, agent);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load agent status from ${file}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read data directory:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveToDisk(): void {
|
||||||
|
// Save global status
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
this.getGlobalFilePath(),
|
||||||
|
JSON.stringify(this.global, null, 2),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save global status:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save agent statuses
|
||||||
|
for (const [agentId, agent] of this.agents) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
this.getAgentFilePath(agentId),
|
||||||
|
JSON.stringify(agent, null, 2),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to save agent status for ${agentId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPersistence(): void {
|
||||||
|
this.persistenceTimer = setInterval(() => {
|
||||||
|
this.saveToDisk();
|
||||||
|
}, this.persistenceInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPersistence(): void {
|
||||||
|
if (this.persistenceTimer) {
|
||||||
|
clearInterval(this.persistenceTimer);
|
||||||
|
this.persistenceTimer = null;
|
||||||
|
}
|
||||||
|
this.saveToDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent state management
|
||||||
|
|
||||||
|
getOrCreateAgent(agentId: string): AgentStatus {
|
||||||
|
if (!this.agents.has(agentId)) {
|
||||||
|
const agent: AgentStatus = {
|
||||||
|
agentId,
|
||||||
|
state: 'idle',
|
||||||
|
workflow: null,
|
||||||
|
activeSessions: [],
|
||||||
|
lastSessions: [],
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.agents.set(agentId, agent);
|
||||||
|
this.emit('agentCreated', agent);
|
||||||
|
}
|
||||||
|
return this.agents.get(agentId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgent(agentId: string): AgentStatus | undefined {
|
||||||
|
return this.agents.get(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllAgents(): AgentStatus[] {
|
||||||
|
return Array.from(this.agents.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessageStart(session: string, agentId: string): void {
|
||||||
|
const agent = this.getOrCreateAgent(agentId);
|
||||||
|
|
||||||
|
// Don't update state for heartbeat sessions
|
||||||
|
if (this.isHeartbeatSession(session)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.state === 'idle') {
|
||||||
|
agent.state = 'busy';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.activeSessions.includes(session)) {
|
||||||
|
agent.activeSessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updatedAt = Date.now();
|
||||||
|
this.emit('stateChanged', agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessageEnd(session: string, agentId: string): void {
|
||||||
|
const agent = this.getOrCreateAgent(agentId);
|
||||||
|
|
||||||
|
// Remove from active sessions
|
||||||
|
agent.activeSessions = agent.activeSessions.filter(s => s !== session);
|
||||||
|
|
||||||
|
// Add to last sessions if not already there
|
||||||
|
if (!agent.lastSessions.includes(session)) {
|
||||||
|
agent.lastSessions.unshift(session);
|
||||||
|
if (agent.lastSessions.length > 10) {
|
||||||
|
agent.lastSessions = agent.lastSessions.slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State transitions
|
||||||
|
if (agent.activeSessions.length === 0) {
|
||||||
|
if (agent.state === 'busy') {
|
||||||
|
agent.state = 'idle';
|
||||||
|
} else if (agent.state === 'pre-freeze' || agent.state === 'pre-freeze-focus') {
|
||||||
|
agent.state = 'freeze';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updatedAt = Date.now();
|
||||||
|
this.emit('stateChanged', agent);
|
||||||
|
|
||||||
|
// Check if all agents are frozen (for restart completion)
|
||||||
|
if (agent.state === 'freeze') {
|
||||||
|
this.checkAllFrozen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkflow(agentId: string, workflow: string | null): void {
|
||||||
|
const agent = this.getOrCreateAgent(agentId);
|
||||||
|
agent.workflow = workflow;
|
||||||
|
|
||||||
|
if (workflow) {
|
||||||
|
agent.state = 'focus';
|
||||||
|
} else {
|
||||||
|
// Transition from focus to idle or busy
|
||||||
|
if (agent.activeSessions.length === 0) {
|
||||||
|
agent.state = 'idle';
|
||||||
|
} else {
|
||||||
|
agent.state = 'busy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updatedAt = Date.now();
|
||||||
|
this.emit('stateChanged', agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
isHeartbeatSession(session: string): boolean {
|
||||||
|
// Check if session is a heartbeat session
|
||||||
|
// This can be customized based on naming convention or metadata
|
||||||
|
return session.includes('heartbeat') || session.includes('poll');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query restart logic
|
||||||
|
|
||||||
|
queryRestart(requesterAgentId: string, requesterSessionKey: string): 'OK' | 'NOT_READY' | 'ALREADY_SCHEDULED' {
|
||||||
|
// Check if restart is already scheduled
|
||||||
|
if (this.global.restartStatus !== 'idle') {
|
||||||
|
// If same agent is requesting, allow continuation
|
||||||
|
if (this.global.restartScheduledBy === requesterAgentId) {
|
||||||
|
return this.allAgentsFrozen() ? 'OK' : 'NOT_READY';
|
||||||
|
}
|
||||||
|
return 'ALREADY_SCHEDULED';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule restart
|
||||||
|
this.global.restartScheduledBy = requesterAgentId;
|
||||||
|
this.global.restartSession = requesterSessionKey;
|
||||||
|
this.global.restartStatus = 'waiting';
|
||||||
|
this.global.updatedAt = Date.now();
|
||||||
|
|
||||||
|
// Transition agents to freeze/pre-freeze states
|
||||||
|
for (const [agentId, agent] of this.agents) {
|
||||||
|
if (agentId === requesterAgentId) {
|
||||||
|
// Don't freeze the requester agent
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (agent.state) {
|
||||||
|
case 'idle':
|
||||||
|
agent.state = 'freeze';
|
||||||
|
break;
|
||||||
|
case 'busy':
|
||||||
|
agent.state = 'pre-freeze';
|
||||||
|
break;
|
||||||
|
case 'focus':
|
||||||
|
agent.state = 'pre-freeze-focus';
|
||||||
|
// Notify agent to prepare for restart
|
||||||
|
this.emit('preparingRestart', agent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updatedAt = Date.now();
|
||||||
|
this.emit('stateChanged', agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToDisk();
|
||||||
|
|
||||||
|
// Check if all are frozen immediately
|
||||||
|
if (this.allAgentsFrozen()) {
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'NOT_READY';
|
||||||
|
}
|
||||||
|
|
||||||
|
allAgentsFrozen(): boolean {
|
||||||
|
for (const [agentId, agent] of this.agents) {
|
||||||
|
// Skip the agent that scheduled the restart
|
||||||
|
if (agentId === this.global.restartScheduledBy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (agent.state !== 'freeze') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkAllFrozen(): void {
|
||||||
|
if (this.allAgentsFrozen() && this.global.restartStatus === 'waiting') {
|
||||||
|
this.emit('allFrozen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart completion
|
||||||
|
|
||||||
|
completeRestart(success: boolean, log?: string): void {
|
||||||
|
if (success) {
|
||||||
|
this.global.restartStatus = 'idle';
|
||||||
|
|
||||||
|
// Unfreeze all agents
|
||||||
|
for (const agent of this.agents.values()) {
|
||||||
|
if (agent.state === 'freeze') {
|
||||||
|
// Restore previous state from lastSessions
|
||||||
|
agent.state = agent.activeSessions.length > 0 ? 'busy' : 'idle';
|
||||||
|
agent.updatedAt = Date.now();
|
||||||
|
this.emit('stateChanged', agent);
|
||||||
|
this.emit('unfrozen', agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('restartCompleted');
|
||||||
|
} else {
|
||||||
|
this.global.restartStatus = 'rollback';
|
||||||
|
this.emit('restartFailed', log);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.global.restartScheduledBy = null;
|
||||||
|
this.global.restartSession = null;
|
||||||
|
this.global.updatedAt = Date.now();
|
||||||
|
this.saveToDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global status getters
|
||||||
|
|
||||||
|
getGlobalStatus(): GlobalStatus {
|
||||||
|
return { ...this.global };
|
||||||
|
}
|
||||||
|
|
||||||
|
isRestartScheduled(): boolean {
|
||||||
|
return this.global.restartStatus !== 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For focus mode: check if agent should respond
|
||||||
|
|
||||||
|
shouldRespond(agentId: string, session: string): boolean {
|
||||||
|
const agent = this.getAgent(agentId);
|
||||||
|
if (!agent) return true;
|
||||||
|
|
||||||
|
// In focus mode, only respond to workflow sessions
|
||||||
|
if (agent.state === 'focus' || agent.state === 'pre-freeze-focus') {
|
||||||
|
return agent.workflow !== null && session.includes(agent.workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In freeze/pre-freeze states, don't accept new messages
|
||||||
|
if (agent.state === 'freeze' || agent.state === 'pre-freeze') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBusyMessage(agentId: string): string {
|
||||||
|
const agent = this.getAgent(agentId);
|
||||||
|
if (!agent) return '在忙,无法应答';
|
||||||
|
|
||||||
|
switch (agent.state) {
|
||||||
|
case 'focus':
|
||||||
|
case 'pre-freeze-focus':
|
||||||
|
return '当前处于专注模式,无法应答非工作流消息';
|
||||||
|
case 'freeze':
|
||||||
|
case 'pre-freeze':
|
||||||
|
return '系统正在准备重启,请稍后再试';
|
||||||
|
case 'busy':
|
||||||
|
return '正在处理其他消息,请稍后再试';
|
||||||
|
default:
|
||||||
|
return '在忙,无法应答';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
safe-restart/tsconfig.json
Normal file
19
safe-restart/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user