diff --git a/CHANGELOG.md b/CHANGELOG.md index 523d5f0..74c59a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.58 + +- 新增 account-manager 接入标准,说明一账号一 Profile、多账号矩阵、登录状态持久化与完整运行生命周期 +- 明确账号创建后的查看位置:匠厂数据管理、account-manager CLI、本地用户数据目录及 Profile 目录 +- 强制所有账号选择路径取得租约并在异常出口释放,修复模板中指定数字账号 ID 时缺少租约的问题 +- 增加关闭重开保持登录、多账号隔离、租约冲突、异常释放和自助排障验收项 +- 新增机器门禁,防止后续模板退回到裸浏览器、空租约或只做单账号冒烟测试 + ## 1.0.57 - 新增 AI + RPA 技能开发总纲,明确 Agent 负责理解与编排、RPA 负责浏览器 / 桌面 / 手机的确定性执行 diff --git a/README.md b/README.md index cca8610..b2b457a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合 说明用户在使用前要准备的东西,例如: - **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站 +- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态 - **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事 - 相关业务权限(查询、导出、提交等)已具备 - 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息 @@ -100,3 +101,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合 ### 结果文件在哪里查看? > 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。 + +### 已经登记的账号在哪里查看? + +> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。 diff --git a/SKILL.md b/SKILL.md index 742a220..b7d9845 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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`。 ### 架构铁律(先读) diff --git a/TUTORIAL.md b/TUTORIAL.md index c680a83..ae90bbb 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -18,7 +18,8 @@ 2. 已在技能市场安装本技能(或更新到最新版) 3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站 4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录 -5. 【如需】需要的文件 / 时间范围 / 单号等已准备好 +5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话 +6. 【如需】需要的文件 / 时间范围 / 单号等已准备好 --- @@ -58,6 +59,7 @@ | 现象 | 你可以先试 | |------|------------| | 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 | +| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 | | 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 | | 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 | | 结果不对 | 用更明确的条件再说一次,或缩小范围重试 | diff --git a/development/ACCOUNT_MANAGER.md b/development/ACCOUNT_MANAGER.md new file mode 100644 index 0000000..45be4bf --- /dev/null +++ b/development/ACCOUNT_MANAGER.md @@ -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 ` 不能代替租约。 +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 --limit 50 +python scripts/main.py account get +python scripts/main.py lease list --active +python scripts/main.py account open +``` + +`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 ` 返回的 `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)。 diff --git a/development/ADAPTER.md b/development/ADAPTER.md index 7b7152b..3d6b019 100644 --- a/development/ADAPTER.md +++ b/development/ADAPTER.md @@ -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 ` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 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 `;`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 与隔离体系 diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index 1e1e9fb..3154e1d 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -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 或自动换号处理 --- diff --git a/development/CONFIG.md b/development/CONFIG.md index abd7d91..d7bf086 100644 --- a/development/CONFIG.md +++ b/development/CONFIG.md @@ -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` 规范 diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 30d9707..ff913ae 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -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//`,见各 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 / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。 diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index 657b8cb..3a5f45f 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -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` | diff --git a/development/README.md b/development/README.md index a46f956..7dcbe06 100644 --- a/development/README.md +++ b/development/README.md @@ -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)。 diff --git a/development/RPA.md b/development/RPA.md index a60d994..729c4dc 100644 --- a/development/RPA.md +++ b/development/RPA.md @@ -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 工业级风控、等待与收尾 diff --git a/development/SHARED_REPOS.md b/development/SHARED_REPOS.md index d0fd6bc..bfbce29 100644 --- a/development/SHARED_REPOS.md +++ b/development/SHARED_REPOS.md @@ -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/` | | 「公共东西到底在哪」 | **本页登记表** | diff --git a/examples/real_browser_rpa/scripts/service/account_client.py b/examples/real_browser_rpa/scripts/service/account_client.py index 3c05cff..b068608 100644 --- a/examples/real_browser_rpa/scripts/service/account_client.py +++ b/examples/real_browser_rpa/scripts/service/account_client.py @@ -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): diff --git a/examples/real_browser_rpa/tests/test_account_client.py b/examples/real_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_account_client.py @@ -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() diff --git a/examples/simulator_browser_rpa/scripts/service/account_client.py b/examples/simulator_browser_rpa/scripts/service/account_client.py index 3c05cff..b068608 100644 --- a/examples/simulator_browser_rpa/scripts/service/account_client.py +++ b/examples/simulator_browser_rpa/scripts/service/account_client.py @@ -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): diff --git a/examples/simulator_browser_rpa/tests/test_account_client.py b/examples/simulator_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_account_client.py @@ -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() diff --git a/scripts/util/constants.py b/scripts/util/constants.py index ecb177a..f00da0a 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -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" diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index 34bb6bf..e195197 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -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"): diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index 5fc19f8..1aebfaf 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -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())