docs: 沉淀 account-manager 多账号与 Profile 工业标准
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-07-24 16:50:57 +08:00
parent 0ea924958f
commit c15e23e0d4
20 changed files with 604 additions and 41 deletions

View File

@@ -9,6 +9,14 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.58
- 新增 account-manager 接入标准,说明一账号一 Profile、多账号矩阵、登录状态持久化与完整运行生命周期
- 明确账号创建后的查看位置:匠厂数据管理、account-manager CLI、本地用户数据目录及 Profile 目录
- 强制所有账号选择路径取得租约并在异常出口释放,修复模板中指定数字账号 ID 时缺少租约的问题
- 增加关闭重开保持登录、多账号隔离、租约冲突、异常释放和自助排障验收项
- 新增机器门禁,防止后续模板退回到裸浏览器、空租约或只做单账号冒烟测试
## 1.0.57
- 新增 AI + RPA 技能开发总纲,明确 Agent 负责理解与编排、RPA 负责浏览器 / 桌面 / 手机的确定性执行

View File

@@ -41,6 +41,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
说明用户在使用前要准备的东西,例如:
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
- 相关业务权限(查询、导出、提交等)已具备
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
@@ -100,3 +101,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
### 已经登记的账号在哪里查看?
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.57
version: 1.0.58
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -27,7 +27,7 @@ allowed-tools:
- **Agent 是入口与编排者**:理解用户意图、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
- **RPA 是确定性执行器**:只按已验证步骤操作授权范围内的浏览器、桌面软件或手机;不得破解、逆向、调用未授权私有协议或绕过验证码、安全验证和平台限制。
- 遇到验证码、人脸、U盾、频控、账号风险或权限不足时,转人工或安全停止,禁止自动硬闯、换号或无限重试。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`,三端执行细则再读 `development/RPA.md`。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`;浏览器账号、Profile、多账号和租约再读 `development/ACCOUNT_MANAGER.md`;三端执行细则读 `development/RPA.md`。
### 架构铁律(先读)

View File

@@ -18,7 +18,8 @@
2. 已在技能市场安装本技能(或更新到最新版)
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
---
@@ -58,6 +59,7 @@
| 现象 | 你可以先试 |
|------|------------|
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |

View File

@@ -0,0 +1,216 @@
# account-manager 接入标准(账号 / Profile / 租约)
> 本文是浏览器 RPA 技能接入账号体系的统一标准。开发者和 AI 编程工具在编写 `account_client.py`、处理多账号、排查登录状态或验收浏览器任务前,必须阅读本文。
## 目录
- [1. 为什么必须依赖 account-manager](#1-为什么必须依赖-account-manager)
- [2. 核心模型](#2-核心模型)
- [3. 强制规则](#3-强制规则)
- [4. 标准生命周期](#4-标准生命周期)
- [5. 账号选择与多账号](#5-账号选择与多账号)
- [6. 创建后去哪里查看](#6-创建后去哪里查看)
- [7. 登录状态与 Profile 验收](#7-登录状态与-profile-验收)
- [8. 租约与异常退出](#8-租约与异常退出)
- [9. 工业级测试](#9-工业级测试)
- [10. 排障顺序与提问证据](#10-排障顺序与提问证据)
- [11. 安全边界](#11-安全边界)
## 1. 为什么必须依赖 account-manager
真实用户会长期使用同一个账号,也会在批量发布、矩阵运营、多店铺、多租户等场景中使用多个账号。一次打开临时浏览器、成功操作一两条数据,不能证明技能可交付。
account-manager 统一负责:
- 账号身份及平台归属;
- 一账号一份持久化浏览器 Profile;
- Cookie、会话和登录状态的持续复用;
- 多任务之间的账号租约与并发互斥;
- 账号状态、最近错误和运维排查入口。
业务技能只负责编排业务动作,不得再造一套账号库,也不得随意找一个浏览器目录直接运行。
## 2. 核心模型
| 对象 | 含义 | 业务技能应如何使用 |
|------|------|--------------------|
| `account` | 某个平台上的一个账号身份 | 通过 account-manager CLI 获取,不自行落库 |
| `profile_dir` | 该账号独占的浏览器用户数据目录 | 只传给 `launch_persistent_context`,长期复用 |
| 登录状态 | 目标站 Cookie / Session 等实际会话 | 由业务技能打开同一 Profile 后检查;账号已登记不代表已登录 |
| `lease_token` | 当前任务对账号的临时独占权 | 打开浏览器前取得,所有退出路径释放 |
| `session_status` / error | 会话健康和最近故障线索 | 登录失效或账号异常时按约定回写 |
关系必须保持为:
```text
一个账号记录 ──绑定──► 一个稳定 profile_dir
│
└──任务运行前取得 lease_token ──► 同一时刻只允许一个任务使用
```
## 3. 强制规则
1. 所有浏览器 RPA,包括公开页和无需登录的页面,都必须先经 account-manager 取得 `profile_dir`。
2. 一个账号只能绑定一个稳定 Profile;不同账号禁止共用 Profile。
3. 禁止使用本机日常 Chrome / Edge 用户目录,禁止每次运行创建无归属的临时 Profile。
4. 获取账号时必须同时取得租约;仅 `account get <id>` 不能代替租约。
5. 业务代码只通过单一 `scripts/service/account_client.py` 集中调用 account-manager CLI。
6. 禁止 import account-manager 的 `service/`、`db/`、`util/`、`rpa_helpers` 等内部模块。
7. 未取得非空 `profile_dir` 和 `lease_token` 时禁止打开浏览器。
8. 账号均被占用时返回 `ERROR:LEASE_CONFLICT`,不得自动创建重复账号或换号绕过。
9. 所有成功、失败、取消和异常路径都必须在 `finally` 中释放租约。
10. 密码、Cookie、Token、短信验证码等敏感信息不得写入业务技能配置、数据库、日志或错误信息。
对应机器门禁:`POLICY-RPA-004`、`POLICY-RPA-006`。
## 4. 标准生命周期
```text
解析用户指定账号
→ ensure / pick 账号并取得租约
→ 校验账号平台、profile_dir、lease_token
→ 用 profile_dir 启动持久浏览器
→ 打开目标站并检查实际登录状态
→ 执行业务并记录结果
→ 回写会话状态 / 最近错误
→ 关闭浏览器
→ finally 释放租约
```
参考结构:
```python
account = pick_web_account(platform=TARGET_PLATFORM, account_id=account_id, login_id=login_id)
lease_token = account["lease_token"]
try:
profile_dir = account["profile_dir"]
# 只用 profile_dir 启动 persistent context,然后检查登录并执行任务
finally:
release_lease(lease_token)
```
浏览器启动、登录门禁、业务执行和关闭顺序见 [`RPA.md`](RPA.md) §0.2;CLI 封装边界见 [`ADAPTER.md`](ADAPTER.md)。
## 5. 账号选择与多账号
选择优先级:
1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。
2. 用户指定 `DEFAULT_LOGIN_ID`:使用 `ensure-web --login-id` 获取或登记,并取得租约。
3. 用户未指定账号:按平台 `ensure-web`,有则选择可用账号,无则登记一条。
4. 多条记录无法唯一确定:返回 `ERROR:AMBIGUOUS_ACCOUNT`,要求用户明确选择。
5. 匹配账号均被占用:返回 `ERROR:LEASE_CONFLICT`,等待或由运维确认租约状态。
批量和矩阵任务必须以账号为隔离单元:
- 每个账号独立 Profile、独立租约、独立任务结果和失败原因;
- 同一账号默认串行,不因追求吞吐提高同账号并发;
- 一个账号失败不得污染其他账号的 Profile 或结果;
- 设置单账号批量上限、单位时间操作上限和任务总时长;
- 风控、封禁或权限错误不得自动切换下一个账号继续试探。
## 6. 创建后去哪里查看
账号创建成功后必须可见、可查,不得只相信命令没有报错。
### 6.1 宿主数据管理
优先在匠厂宿主的**数据管理页面**查看 account-manager 数据:
- 账号记录:确认账号 ID、平台、账号名称 / 登录标识、状态、`profile_dir`、会话状态;
- 租约记录:确认当前持有者、用途、到期时间和是否已释放;
- 凭据记录:只能查看类型、引用和脱敏值,不应出现明文密码或 Token。
宿主版本若直接展示数据库表,对应核心表为 `accounts`、`account_leases`、`credentials`、`platforms`。不得在数据管理页面或 SQLite 工具中随意修改记录。
### 6.2 account-manager CLI
在 account-manager 仓库或宿主提供的入口中检查:
```powershell
python scripts/main.py account list --platform <platform_key> --limit 50
python scripts/main.py account get <account_id>
python scripts/main.py lease list --active
python scripts/main.py account open <account_id>
```
`account open` 仅用于查看指定 Profile。业务运行仍须走带租约的 pick / ensure 流程。
### 6.3 本地用户数据目录
实际数据按宿主注入的用户隔离:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/
├── account-manager.db
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
- `account-manager.db` 保存账号、平台、凭据元数据和租约。
- `profiles/...` 是真实浏览器用户数据目录,保存对应账号的 Cookie / Session 等浏览器状态。
- 实际路径以 `account get <id>` 返回的 `profile_dir` 为准,不得在代码里拼写固定盘符或用户 ID。
- Profile 正在使用时禁止复制、移动、删除或同时打开;需要迁移或清理时先停止任务并释放租约。
## 7. 登录状态与 Profile 验收
“账号已创建”只表示账号记录和 Profile 已准备,不代表目标站已经登录。
最小验收必须完成:
1. 数据管理页面或 `account list` 能看到账号记录。
2. `profile_dir` 非空、真实存在,并位于当前用户的 account-manager 数据目录。
3. 两个不同账号的 `profile_dir` 不相同。
4. 第一次用该 Profile 打开目标站,按平台要求人工登录或扫码。
5. 正常关闭浏览器并释放租约。
6. 再次取得同一账号租约,用同一 `profile_dir` 重开浏览器。
7. 验证登录状态仍有效;失效时返回 `ERROR:REQUIRE_LOGIN`,不得假成功。
## 8. 租约与异常退出
- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。
- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。
- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。
- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。
## 9. 工业级测试
除单账号冒烟测试外,至少覆盖:
- 指定账号 ID、指定登录标识和自动选择三条路径;
- 账号不存在、平台不匹配、Profile 缺失、返回格式异常;
- 同一账号并发抢占和 `LEASE_CONFLICT`;
- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放;
- 登录过期、验证码 / 二次验证、账号风险和权限不足;
- 两个以上账号轮换且 Profile、登录态、结果互不污染;
- 关闭重开后登录保持;
- 批量长时间运行、限流、有限重试和断点续跑。
不得以“单账号成功操作一两次”作为多账号或矩阵能力已经交付的证明。
## 10. 排障顺序与提问证据
开发者和 AI 在群里提问前按顺序自查:
1. 阅读本文以及 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)。
2. 在数据管理页面确认账号记录是否存在、平台和状态是否正确。
3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。
4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。
5. 执行 `lease list --active`,确认是否被其他任务占用或残留。
6. 查看任务日志、错误码、失败截图和浏览器当前页面。
7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。
仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。
## 11. 安全边界
- 账号和 Profile 只能用于用户或企业明确授权的正常业务操作。
- 多账号管理用于账号隔离、矩阵业务和稳定调度,不得用于绕过频控、封禁或平台限制。
- 禁止破解协议、逆向客户端、伪造设备指纹、验证码破解、养号、撞库、代理池轮换或自动换号继续试探。
- 风控、封禁、安全警告和权限不足必须停止或转人工。
- 不手工编辑 `account-manager.db`,不在账号使用期间操作 Profile 文件。
完整合规与风控边界见 [`AI_RPA.md`](AI_RPA.md)。

View File

@@ -102,9 +102,10 @@ def get_adapter():
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。
5. **pick/ensure lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease,需在运维文档说明排查方式(查 account-manager lease 列表 / 手动释放)。
5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。
6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → `get <id>`;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id`;皆空 → 按平台 `ensure-web`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配 → `ERROR:AMBIGUOUS_ACCOUNT`。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。
8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。
```python
# 普通兄弟技能 — 走 sibling_bridge
@@ -127,5 +128,6 @@ finally:
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准
- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

View File

@@ -107,6 +107,12 @@ checklist_version: 1.0
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放
- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录
- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染
- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理
---

View File

@@ -169,9 +169,10 @@ config.get_float("STEP_DELAY_MIN", 1.0)
- **留空即未配置**:`KEY=` 或 `KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑;**禁止**把示例说明、`# 可填…` 当成真实配置值。
- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明 `platform_kit_min_version: "1.2.2"`(或更高)。
- **网页账号配置语义**(有则用):
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;非数字非空可当 login_id(不推荐,优先用 `DEFAULT_LOGIN_ID`)
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get`
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐
- 占位串(`#…`、`none`、`n/a`、`default-account`)一律当未配置
- **用户隔离必须一致**:调用 account-manager 时继承宿主注入的 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`;不得覆盖为开发者自己的固定路径,否则账号会写到另一位用户的数据目录。
- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。
## health / config-path
@@ -189,6 +190,7 @@ config.get_float("STEP_DELAY_MIN", 1.0)
## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定
- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范

View File

@@ -133,7 +133,7 @@ scripts/
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读总纲与三份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。
1. **先读总纲与四份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`ACCOUNT_MANAGER.md`(账号 / Profile / 租约 / 多账号 / 查看排障)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。
2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map):
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`:
- `examples/real_browser_rpa/scripts/service/browser_session.py`
@@ -148,14 +148,14 @@ scripts/
- `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排)
- `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill
- **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
- **网页硬规则**:未拿到 `profile_dir` 禁止开浏览器;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
- **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
3. **只用共享库,不在 skill 里重写反反爬**:
```python
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`,**不要在新 skill 里重复造包**(尚待实战验证)。
**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。

View File

@@ -21,6 +21,7 @@
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |

View File

@@ -9,7 +9,7 @@
| 步骤 | 做什么 | 详见 |
|------|--------|------|
| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 |
| 2 | **先读 AI + RPA 总纲**,理解 Agent / RPA 职责、授权边界、禁止路线、风控和操作节奏 | [`AI_RPA.md`](AI_RPA.md) |
| 2 | **先读 AI + RPA 总纲**;浏览器技能再读账号 / Profile / 租约标准,理解真实多账号场景 | [`AI_RPA.md`](AI_RPA.md)、[`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) |
| 3 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
| 4 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
| 5 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
@@ -26,18 +26,19 @@
开始定制 skill 前,建议按以下顺序阅读:
1. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环
2. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
3. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
4. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
5. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
6. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
7. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
8. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
9. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
10. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
11. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
12. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
13. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
2. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障
3. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
4. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
5. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
6. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
7. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
8. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
9. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
10. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
11. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
12. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
13. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
14. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
14. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md)。

View File

@@ -4,7 +4,7 @@
先读 [`AI_RPA.md`](AI_RPA.md):Agent 负责理解、确认与编排,RPA 只负责授权范围内的确定性界面执行。RPA 是代替人完成正常业务操作的工具,**不是**破解、逆向、私有协议调用或对抗第三方安全机制的手段。
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。账号设计、查看、租约、多账号和验收标准先读 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md);二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:可控会话(Profile)、有头运行、拟人操作、失败存证、人工兜底。
@@ -70,6 +70,9 @@ async def open_login(page):
| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 |
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 |
| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 |
| **账号必须可见可查** | 创建后须能在宿主数据管理、`account list/get` 和实际 `profile_dir` 中核验;不得把“命令没报错”当作创建与登录验收。 |
| **一账号一 Profile** | 不同账号必须使用不同且稳定的 `profile_dir`;不得共用、临时创建或指向本机日常浏览器目录。 |
| **租约贯穿任务** | 所有账号选择路径(包括指定数字 ID)都必须取得非空 `lease_token`;成功、失败、取消和异常均在 `finally` 释放。 |
**反模式(禁止):**
@@ -82,6 +85,8 @@ async def open_login(page):
**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。
账号完整生命周期、数据目录、数据管理查看方式、矩阵场景和排障证据见 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)。不得只测试单账号一两次就宣称支持批量或多账号。
---
### 0.3 工业级风控、等待与收尾

View File

@@ -56,9 +56,11 @@ git clone https://git.jc2009.com/client-commons/skill-template.git
| 项 | 说明 |
|----|------|
| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 |
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir`(`ensure-web` / `pick-web`) |
| **为什么必须用** | 支撑真实业务的一账号一 Profile、登录状态持久化、多账号矩阵和并发互斥;禁止浏览器“裸奔” |
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir + lease_token`(`ensure-web` / `pick-web`) |
| **红线** | **禁止**业务技能 `import` 其内部 `rpa_helpers` / 散落存密码(见 `POLICY-RPA-001`);对方文档若写「同进程 rpa_helpers」,**以本模板约束为准** |
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`;本模板 [`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
| **创建后查看** | 宿主数据管理;CLI `account list/get`、`lease list`;本地用户数据目录中的 `account-manager.db` 与 `profiles/` |
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`、`SCHEMA.md`;本模板 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)、[`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
```powershell
git clone https://git.jc2009.com/client-commons/account-manager.git
@@ -104,6 +106,7 @@ git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git
|------|----------|
| 不知道新技能目录/发布/developer_ids 怎么做 | **skill-template** `development/` |
| 定 `platform_key`、ensure-web、租约、凭证存储 | **account-manager** + 本模板 RPA/ADAPTER |
| 账号创建后不知道去哪看、登录态不保持、多账号互相污染 | 本模板 **ACCOUNT_MANAGER.md** + account-manager 数据管理 / CLI |
| 看 `emit` / `finish` / `@rpa_step` / `launch_persistent_browser` 实现 | **jiangchang-platform-kit** |
| 技能发布 CI / reusable workflow 从哪来 | **jiangchang-platform-kit** `.github/workflows/` |
| 「公共东西到底在哪」 | **本页登记表** |

View File

@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
return _validate_pick_payload(payload)
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
@@ -271,7 +329,7 @@ def pick_web_account(
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id(platform_key, int(aid_raw))
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

View File

@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
return _validate_pick_payload(payload)
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
@@ -271,7 +329,7 @@ def pick_web_account(
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id(platform_key, int(aid_raw))
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.57"
SKILL_VERSION = "1.0.58"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.2"

View File

@@ -37,6 +37,7 @@ POLICY_IDS = (
"POLICY-RPA-003",
"POLICY-RPA-004",
"POLICY-RPA-005",
"POLICY-RPA-006",
"POLICY-PACKAGING-001",
"POLICY-PACKAGING-002",
"POLICY-DOCS-001",
@@ -175,6 +176,26 @@ RPA_005_STEP_DELAY_KEY_RE = re.compile(
r"(?m)^\s*(STEP_DELAY_MIN|STEP_DELAY_MAX)\s*=\s*([^\s#]+)"
)
RPA_006_SOURCE = (
"development/ACCOUNT_MANAGER.md; development/ADAPTER.md §兄弟依赖; "
"development/RPA.md §0.2; development/POLICY_MATRIX.md"
)
RPA_006_REQUIRED_DOC_MARKERS = (
"一账号一",
"profile_dir",
"lease_token",
"数据管理页面",
"account-manager.db",
"account list",
"lease list",
"关闭浏览器并释放租约",
"两个以上账号",
)
RPA_006_EXAMPLE_CLIENTS = (
"examples/real_browser_rpa/scripts/service/account_client.py",
"examples/simulator_browser_rpa/scripts/service/account_client.py",
)
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md"
@@ -736,6 +757,40 @@ class TestPolicyRpa005(unittest.TestCase):
)
class TestPolicyRpa006(unittest.TestCase):
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "development", "ACCOUNT_MANAGER.md")
self.assertTrue(
os.path.isfile(path),
msg=_policy_msg("POLICY-RPA-006", RPA_006_SOURCE, "development/ACCOUNT_MANAGER.md missing"),
)
text = _read_text(path)
missing = [marker for marker in RPA_006_REQUIRED_DOC_MARKERS if marker not in text]
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-RPA-006",
RPA_006_SOURCE,
"ACCOUNT_MANAGER.md missing markers: " + ", ".join(missing),
),
)
def test_example_account_id_selection_keeps_lease(self) -> None:
skill_root = get_skill_root()
for rel in RPA_006_EXAMPLE_CLIENTS:
text = _read_text(os.path.join(skill_root, *rel.split("/")))
self.assertIn("_pick_by_id_with_lease", text, msg=f"{rel} must lease explicit account ids")
self.assertIn('"account",\n "pick-web"', text, msg=f"{rel} must use pick-web for lease")
self.assertIn("lease_token", text, msg=f"{rel} must validate lease_token")
self.assertNotIn(
'data["lease_token"] = ""',
text,
msg=f"{rel} must not return an explicit account without a lease",
)
def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]:
offenders: list[str] = []
for path in _walk_files(skill_root, "scripts", suffix=".py"):

View File

@@ -249,6 +249,21 @@ class TestDocsStandards(unittest.TestCase):
msg="RPA.md §0.2 must separate site-login from Profile requirement",
)
def test_account_manager_standard_is_discoverable(self) -> None:
account_text = self._read("development/ACCOUNT_MANAGER.md")
for marker in (
"一账号一",
"profile_dir",
"lease_token",
"数据管理",
"account-manager.db",
"关闭浏览器并释放租约",
"两个以上账号",
):
self.assertIn(marker, account_text)
for rel in ("SKILL.md", "development/README.md", "development/RPA.md"):
self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard")
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower())