Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb980704bf | |||
| a7bd6ff9d8 | |||
| 4fe1351e1e | |||
| fd5c432735 | |||
| 2c7e648a72 | |||
| 6fc4f1af1d | |||
| dadf89af3d | |||
| 1792ba2a0d | |||
| dbf5604090 | |||
| 769ccec601 | |||
| cfca82ed0c | |||
| 104aa91364 | |||
| c23240d0ea | |||
| 5d4425c060 | |||
| ef80d64f30 | |||
| 65d939dc5b | |||
| 04cafae3d7 | |||
| a74157ff4a | |||
| 4c7e4f9769 | |||
| e7f41d6e27 | |||
| 07adc98e8c | |||
| 5f8c93dee9 | |||
| 127e9b6019 | |||
| ee851f719b | |||
| 06aed39661 | |||
| 00e3116339 | |||
| 27d687becd |
41
SKILL.md
41
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 账号与凭据管理
|
||||
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流平台(Maersk、COSCO、MSC 等)、LLM 平台(DeepSeek、豆包等)、内容平台。业务 skill 通过 CLI 获取账号,不直接读 SQLite。"
|
||||
version: 1.0.56
|
||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
|
||||
version: 2.0.0
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -14,24 +14,30 @@ allowed-tools:
|
||||
|
||||
# 账号与凭据管理(account-manager)
|
||||
|
||||
通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据(API Key/Token 不存明文)、RPA 并发锁(lease),供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息。
|
||||
通用账号、凭据与浏览器 Profile 管理器。在本地 SQLite 管理平台注册、账号身份、凭据元数据;可选 Fernet 加密密码入库;提供租约(lease)防并发;供其它技能通过 CLI 调用 `scripts/main.py` 或通过 **同进程** [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md) 完成登录流程。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。
|
||||
2. **SQLite 只保存元数据** — `secret_ref`、`secret_mask`,不保存原始 secret 明文。
|
||||
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session,不保存网页登录密码。
|
||||
4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。
|
||||
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己散落存储账号、密码、API Key、Token。
|
||||
2. **SQLite 主要保存元数据** — `secret_ref`、`secret_mask`;非 `local_encrypted` 路径不在库中存明文。
|
||||
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session。
|
||||
4. **业务 skill 优先通过 CLI 获取结构化账号 JSON** — 需要时再组合同进程 rpa_helpers(见集成文档)。
|
||||
5. **9 种登录策略统一接口**:`auth_strategy` 标识账号登录方式,业务 skill 可调用 `ensure_logged_in` 自动分派。
|
||||
6. **加密 secret 双钥分离**:master key 文件默认与数据库同目录,也可用环境变量覆盖;**数据库密文 + 合法 master key** 才能解密。
|
||||
|
||||
## 何时使用本技能
|
||||
|
||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir。
|
||||
2. **登记 API Key/Token 账号**:`account add-secret` — 平台 + 凭据类型 + secret 存储方式。
|
||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。
|
||||
2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
||||
3. **查看/列出账号**:`account get/list` — JSON 输出。
|
||||
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。
|
||||
5. **获取 API Key**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做登录检测。
|
||||
5. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
|
||||
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||
8. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||
9. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||
10. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||
11. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
|
||||
## CLI 调用
|
||||
|
||||
@@ -39,20 +45,25 @@ allowed-tools:
|
||||
|
||||
## 支持的平台
|
||||
|
||||
见 `references/PLATFORMS.md`;支持物流、LLM、内容三大业务域。
|
||||
见 [`references/PLATFORMS.md`](references/PLATFORMS.md);代码注册表按业务域分为 logistics / llm / content / dev / sim(实际数量与别名以 `scripts/util/platforms.py` 为准)。
|
||||
|
||||
## 数据与环境
|
||||
|
||||
库路径、profile 布局、环境变量见 `references/RUNTIME.md`;表结构见 `references/SCHEMA.md`。
|
||||
库路径、profile 布局、环境变量见 [`references/RUNTIME.md`](references/RUNTIME.md);表结构见 [`references/SCHEMA.md`](references/SCHEMA.md)。
|
||||
|
||||
## 跨技能集成
|
||||
|
||||
`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`。
|
||||
stdout 协议与同进程 import 见 [`references/INTEGRATION.md`](references/INTEGRATION.md);登录 helper 细节见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
|
||||
## Secret 存储
|
||||
|
||||
- `none`:无 secret(浏览器 profile 登录态)
|
||||
- `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取
|
||||
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
|
||||
- `local_encrypted`:Fernet 加密后存 SQLite(`secret_ciphertext`),密钥从 `ACCOUNT_MANAGER_MASTER_KEY` 或 `master.key` 读取(须先 `account init-master-key`)
|
||||
|
||||
详见 `references/RUNTIME.md`。
|
||||
详见 [`references/RUNTIME.md`](references/RUNTIME.md)。
|
||||
|
||||
## v2 升级说明
|
||||
|
||||
v1→v2 为**平滑迁移**:启动时 `ensure_schema` 幂等执行 `migrate_v1_to_v2` 与 `migrate_v2_to_v2_1`,自动补列与 `_schema_meta.schema_version`(最终为 `"2.1"`)。旧有三种 `secret_storage`(`none` / `env` / `windows_credential`)仍可用;CLI 旧别名(`get` / `pick-web` / `add` 等)保留。
|
||||
|
||||
@@ -1,160 +1,208 @@
|
||||
# account-manager CLI 速查
|
||||
|
||||
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py` 表示在 `scripts/` 下执行。
|
||||
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行。
|
||||
|
||||
## 子命令与实现(与代码一致)
|
||||
> **说明**:内置 `_USAGE`(无参数启动 CLI)已列出加密三类子命令;更完整的 JSON 约定仍以本文与源码为准。
|
||||
|
||||
### 平台管理
|
||||
|
||||
## stdout / JSON 约定(集成必读)
|
||||
|
||||
- 若干命令输出 **单层 JSON 对象**(例如 `account pick-web`、`account get`、`account get-credential` 成功时),**没有**统一的 `{"success":true}` 外层包裹。
|
||||
- `account add-web`、`account add-secret`(等多数字命令)成功时为 **`{"success": true, "data": {...}}`**。
|
||||
- `credential pick` / `credential resolve` 成功时为 **`{"success": true, ...字段}`**。
|
||||
- `platform list` / `platform get` 成功时为 **`{"success": true, "data": ...}`**。
|
||||
- 失败行多为 **`{"success": false, "error": {"code": "...", "message": "..."}}`**(个别老旧路径仍可能打印 `ERROR:` 前缀文本,首行解析逻辑见 [INTEGRATION.md](INTEGRATION.md))。
|
||||
|
||||
---
|
||||
|
||||
## 子命令速查(按业务流程)
|
||||
|
||||
### A. 平台管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 |
|
||||
| `platform get <platform>` | 获取平台详情 |
|
||||
|---|---|
|
||||
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
|
||||
| `platform get <platform>` | 获取数据库中的平台详情 |
|
||||
|
||||
### 账号管理
|
||||
### B. 加密初始化(v2)
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `account add-web --platform <p> --login-id <id> [...]` | 创建网页/RPA 账号 |
|
||||
| `account add-secret --platform <p> --credential-type <type> [...]` | 创建 API Key/Token 凭据 |
|
||||
| `account get <id> [--with-credentials]` | 获取单条账号 JSON |
|
||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>]` | 列出账号 JSON 数组 |
|
||||
| `account pick-web --platform <p> [--lease] [--ttl-sec 900]` | 挑选网页自动化候选 |
|
||||
| `account mark-session <id> --session-status <status>` | 标记登录状态 |
|
||||
|---|---|
|
||||
| `account init-master-key` | 生成 master.key(写入 `{数据根}/{用户}/account-manager/master.key`) |
|
||||
| `account init-master-key --print` | 生成密钥并 **单行 JSON** 打印完整 key(备份用,不落盘) |
|
||||
| `account init-master-key --from-env` | 从 `ACCOUNT_MANAGER_MASTER_KEY` 读取合法 Fernet key 并写入文件 |
|
||||
| `account init-master-key --rotate` | 覆盖现有 master.key(stderr 会先打印警告;应先有 export 备份) |
|
||||
|
||||
### C. 账号管理(v2 扩展)
|
||||
|
||||
| CLI | 说明 |
|
||||
|---|---|
|
||||
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
|
||||
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
|
||||
| `account get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
|
||||
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
|
||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
|
||||
| `account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] [--role <role>] [--lease] [--ttl-sec 900] [--holder <h>] [--purpose <purpose>]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` |
|
||||
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
|
||||
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
||||
| `account mark-used <id>` | 标记已使用 |
|
||||
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
|
||||
|
||||
### 凭据管理
|
||||
### D. 加密备份与迁移(v2)
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `credential pick --platform <p> [--type <type>] [--reveal]` | 挑选凭据(默认隐藏 secret) |
|
||||
| `credential resolve --id <credential_id> [--reveal]` | 按 ID 解析凭据 |
|
||||
|---|---|
|
||||
| `account export-credentials --plaintext` | 将所有凭据行导出为 JSON **数组** 打到 stdout(stderr 有人类可读警告);**必须**显式 `--plaintext` |
|
||||
| `account import-credentials [--replace-all]` | 从 stdin 读 JSON 数组,仅对带 `plaintext` 的行调用加密插入;结果摘要打到 **stderr** |
|
||||
|
||||
### 租约管理
|
||||
### E. 凭据 / 租约 / 浏览器(v1 兼容入口保留)
|
||||
|
||||
以下命令仍推荐使用,行为与 v1 时期一致,适合「只拿 API Key」或脚本兼容:
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
|---|---|
|
||||
| `credential pick --platform <p> [--type <type>] [--environment <env>] [--role <role>] [--reveal]` | v1 兼容:挑选凭据 |
|
||||
| `credential resolve --id <credential_id> [--reveal]` | v1 兼容:按凭据 id 解析 |
|
||||
| `lease release <lease_token>` | 释放租约 |
|
||||
| `lease list [--active]` | 列出租约 |
|
||||
| `lease cleanup` | 清理过期租约 |
|
||||
|
||||
### 浏览器
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `account open <id>` | 打开浏览器查看(仅查看) |
|
||||
| `open <id>` | 同上(兼容别名) |
|
||||
| `account open <id>` / `open <id>` | 浏览器查看 |
|
||||
|
||||
### 兼容入口(旧版别名)
|
||||
|
||||
| CLI | 等价于 |
|
||||
|-----|--------|
|
||||
| CLI | 等价 / 说明 |
|
||||
|---|---|
|
||||
| `get <id>` | `account get <id>` |
|
||||
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
|
||||
| `pick-web <platform>` | `account pick-web --platform <platform>` |
|
||||
| `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
|
||||
| `list [platform\|all]` | 人类可读列表 |
|
||||
| `add <platform> <phone>` | `account add-web`(phone 作为 login_id) |
|
||||
| `add <platform> <phone>` | 映射到 `account add-web`(`login_id_type=phone`),不传 v2 扩展参数 |
|
||||
| `delete id\|platform <...>` | 删除账号 |
|
||||
|
||||
---
|
||||
|
||||
## account add-web
|
||||
## account add-web(扩参示例)
|
||||
|
||||
```bash
|
||||
# 扫码登录的抖音账号
|
||||
python main.py account add-web \
|
||||
--platform maersk \
|
||||
--login-id demo@maersk.com \
|
||||
--login-id-type email \
|
||||
--label "Maersk simulator booking" \
|
||||
--tenant-id tenant-demo \
|
||||
--environment simulator \
|
||||
--role booking \
|
||||
--provider-code maersk \
|
||||
--url http://localhost:5180/industries/logistics/maersk/login
|
||||
--platform douyin \
|
||||
--label "douyin-creator-1" \
|
||||
--auth-strategy qr_code_manual \
|
||||
--session-persistent 1
|
||||
```
|
||||
|
||||
- `--platform`(必填):平台键/别名
|
||||
- `--login-id`:登录标识(email/username/phone/customer_code)
|
||||
- `--login-id-type`:默认 `unknown`
|
||||
- `--label`:人可读名称,不填则自动拼接
|
||||
- `--tenant-id`:租户/项目标识
|
||||
- `--environment`:默认 `production`
|
||||
- `--role`:默认 `default`
|
||||
- `--provider-code`:默认取平台 provider_code
|
||||
- `--url`:默认取平台 default_url
|
||||
- `--extra-json`:扩展 JSON
|
||||
- `--profile-dir`:高级调试用,不推荐
|
||||
- `--auth-strategy`:`password_auto` / `password_with_captcha` / `password_plus_2fa` / `qr_code_manual` / `per_session_manual` / `device_bound` / `client_certificate` / `api_token` / `sso_redirect`;省略时按平台默认(`seed_platforms` + `platforms.default_auth_strategy`,回填规则见 `db/platform_defaults.py`);非法值返回 **`ERR_AUTH_STRATEGY_NOT_SUPPORTED`**。
|
||||
- `--session-persistent`:`0` 或 `1`;省略时由代码推导:`qr_code_manual`、`password_plus_2fa`、`device_bound`、`sso_redirect` → `1`;`per_session_manual`、`client_certificate` → `0`;其余默认 `1`。
|
||||
- `--device-fingerprint`:**仅当** `--auth-strategy=device_bound` 时允许设置;否则 **`ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE`**。
|
||||
- 成功 stdout:`{"success": true, "data": { ... }}`,`data` 内含 `id`、`platform_key`、`profile_dir`、`auth_strategy`、`session_persistent`、`device_fingerprint` 等。
|
||||
|
||||
输出 JSON,包含 `id`、`profile_dir`、`url` 等。
|
||||
---
|
||||
|
||||
## account add-secret
|
||||
## account add-secret(`local_encrypted` 示例)
|
||||
|
||||
```bash
|
||||
# env 方式
|
||||
python main.py account add-secret \
|
||||
--platform deepseek \
|
||||
--credential-type api_key \
|
||||
--label "DeepSeek env key" \
|
||||
--secret-storage env \
|
||||
--secret-ref DEEPSEEK_API_KEY \
|
||||
--environment production \
|
||||
--role api
|
||||
|
||||
# windows_credential 方式
|
||||
echo "sk-test-1234" | python main.py account add-secret \
|
||||
--platform deepseek \
|
||||
--credential-type api_key \
|
||||
--label "DeepSeek production key" \
|
||||
--secret-storage windows_credential \
|
||||
echo "my-icbc-password" | python main.py account add-secret \
|
||||
--platform icbc_sim \
|
||||
--credential-type password \
|
||||
--secret-storage local_encrypted \
|
||||
--secret-stdin \
|
||||
--environment production \
|
||||
--role api
|
||||
--label "工行仿真主账号"
|
||||
```
|
||||
|
||||
- `--secret-storage`:`env` / `windows_credential` / `none`
|
||||
- `--secret-stdin`:从 stdin 读取 secret(仅 windows_credential)
|
||||
- `--secret-ref`:环境变量名(仅 env)
|
||||
- `none` 仅允许 `credential_type=browser_profile` 或 `note`
|
||||
- `local_encrypted` **必须** `--secret-stdin`(否则 **`ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN`**)。
|
||||
- 允许的 `credential_type`:`password`、`api_key`、`api_token`、`bearer_token`、`refresh_token`、`oauth_client`(`browser_profile` / `note` 会被 **`ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED`** 拒绝)。
|
||||
- stdin 为空:**`ERR_LOCAL_ENCRYPTED_EMPTY_STDIN`**。
|
||||
- master key 未就绪:**`ERR_MASTER_KEY_MISSING`**。
|
||||
- 成功:`{"success": true, "data": {"credential_id", "account_id", "platform_key", "credential_type", "secret_storage", "secret_mask"}}`。
|
||||
|
||||
输出 JSON,包含 `account_id`(未指定 `--account-id` 时会自动创建一条用于挂载凭据的账号记录)、`credential_id`、`secret_mask`、`secret_ref`,**不包含 secret 明文**。
|
||||
---
|
||||
|
||||
## account pick-web
|
||||
## account get-credential(示例)
|
||||
|
||||
```bash
|
||||
python main.py account pick-web \
|
||||
--platform maersk \
|
||||
--environment simulator \
|
||||
--tenant-id tenant-demo \
|
||||
--role booking \
|
||||
--lease \
|
||||
--ttl-sec 900 \
|
||||
--holder monitor-competitor-rates \
|
||||
--purpose maersk_sim_rpa
|
||||
# 不 reveal:仅返回 metadata + mask
|
||||
python main.py account get-credential 42
|
||||
|
||||
# reveal:必须带合法 lease-token(通常来自 pick-web --lease)
|
||||
LEASE=$(python main.py account pick-web --platform icbc_sim --lease --holder my-rpa | jq -r .lease_token)
|
||||
python main.py account get-credential 42 --reveal --lease-token "$LEASE"
|
||||
```
|
||||
|
||||
- 筛选 status=active 且未被 active lease 占用的账号
|
||||
- 优先 session_status != expired
|
||||
- 优先 last_used_at 为空的账号
|
||||
- `--lease`:创建 lease,返回 `lease_token` 和 `lease_expires_at`
|
||||
- `--ttl-sec`:租约时长,默认 900 秒
|
||||
- 无 `--reveal`:单行 JSON,`success`/`account_id`/`credential_id`/`secret_storage`/`secret_mask` 等。
|
||||
- `--reveal`:`secret_storage=local_encrypted` 等在 lease 校验通过后追加 **`plaintext`**;若底层存储为 `none`,返回 **`plaintext: null`** 及说明字段(并非抛错)。
|
||||
- 常见错误:`ERR_LEASE_INVALID`、`ERR_CREDENTIAL_MISSING`、`ERR_MASTER_KEY_MISSING`、`ERR_CREDENTIAL_DECRYPT_FAILED`(见 [ERRORS.md](ERRORS.md))。
|
||||
|
||||
## credential pick
|
||||
---
|
||||
|
||||
成功时输出单行 JSON:`{"success": true, ...fields }`;失败时为 [ERRORS.md](ERRORS.md) 的机器 JSON。
|
||||
## account export-credentials / import-credentials(示例)
|
||||
|
||||
```bash
|
||||
# 默认不返回 secret
|
||||
python main.py credential pick --platform deepseek --type api_key --environment production --role api
|
||||
# 备份(stdout 为 JSON 数组,含解密后的 plaintext;务必妥善销毁)
|
||||
python main.py account export-credentials --plaintext > backup-2026-05-11.json
|
||||
|
||||
# --reveal 才返回 secret 明文
|
||||
python main.py credential pick --platform deepseek --type api_key --environment production --role api --reveal
|
||||
# 在新机器恢复(需先 init-master-key,使 master key 可用)
|
||||
python main.py account import-credentials --replace-all < backup-2026-05-11.json
|
||||
```
|
||||
|
||||
- 未带 `--plaintext`:**`ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG`**(单行 JSON)。
|
||||
- `import-credentials` 成功时 **stdout 通常为空**,进度与计数在 **stderr**。
|
||||
|
||||
---
|
||||
|
||||
## account init-master-key(示例)
|
||||
|
||||
```bash
|
||||
# 首次初始化(写入 master.key)
|
||||
python main.py account init-master-key
|
||||
|
||||
# 打印 key 备份到密码管理器(不落盘)
|
||||
python main.py account init-master-key --print
|
||||
|
||||
# 从环境变量恢复写入文件
|
||||
ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-master-key --from-env
|
||||
```
|
||||
|
||||
- 文件已存在且未 `--rotate`:**`ERR_MASTER_KEY_EXISTS`**(JSON)。
|
||||
- `--from-env` 字符串非法:**`ERR_MASTER_KEY_INVALID`**。
|
||||
|
||||
---
|
||||
|
||||
## account pick-web(字段)
|
||||
|
||||
成功时为 **单行 JSON 对象**(非 `_ok_json` 包裹)。核心字段:
|
||||
|
||||
- `id`, `platform_key`, `account_label`, `login_id`, `profile_dir`, `url`, `tenant_id`, `environment`, `role`, `provider_code`, `session_status`
|
||||
- **v2**:`auth_strategy`, `session_persistent`, `device_fingerprint`
|
||||
- 带 `--lease`:`lease_token`, `lease_expires_at`(ISO8601 **本地时间**字符串,由内部 Unix 秒字段转换而来)
|
||||
|
||||
---
|
||||
|
||||
## 9 种 `auth_strategy` 简表(参考)
|
||||
|
||||
| 策略键 | 适用场景 | 是否要 plaintext(典型) | 是否要人介入 |
|
||||
|---|---|---|---|
|
||||
| `password_auto` | 标准账密表单 | 是 | 否 |
|
||||
| `password_with_captcha` | 带图形验证码 | 是 | 是(填码阶段) |
|
||||
| `password_plus_2fa` | 短信/邮件/OTP 2FA | 是 | 是(OTP 阶段) |
|
||||
| `qr_code_manual` | 扫码登录 | 否 | 是(扫码阶段) |
|
||||
| `per_session_manual` | 银行/政务等每次手动 | 否 | 是 |
|
||||
| `device_bound` | 绑机器 / U-Key 等 | 可选(有密码则自动填) | 视情况 |
|
||||
| `client_certificate` | 客户端证书 + PIN | — | 不可自动化(rpa_helpers 抛错) |
|
||||
| `api_token` | 无浏览器登录 | 否 | 否 |
|
||||
| `sso_redirect` | OAuth / SSO 跳转 | 否 | 是(SSO 页阶段) |
|
||||
|
||||
业务 skill 同进程调用详见 [RPA_HELPERS.md](RPA_HELPERS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 通用说明
|
||||
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**展示名、别名或英文键**(如 `sohu`、`搜狐号`、`maersk`、`马士基`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile 目录由环境变量决定。详见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读命令**:成功时输出单行 JSON;失败时首行 `ERROR:`。详见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中的英文键、`display_name`、任一 alias(不区分大小写)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
||||
|
||||
## 内置 `_USAGE`(阶段 F)
|
||||
|
||||
无参数运行 CLI(例如 `python scripts/main.py` / `python -m cli.app`,cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
# 终端错误码(account-manager)
|
||||
|
||||
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
|
||||
约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON,`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定。
|
||||
|
||||
## 新增机器 JSON 失败格式
|
||||
## 机器 JSON 失败格式(常见)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR:...",
|
||||
"message": "..."
|
||||
"code": "……",
|
||||
"message": "……"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
部分 CLI(例如遗留 `ERROR:INVALID_PLATFORM`)仍可能在成功行前额外打印 hint——请以 [`INTEGRATION.md`](INTEGRATION.md) 的建议解析顺序为准。
|
||||
|
||||
---
|
||||
|
||||
## ERROR:CLI — CLI 参数 / 路由
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
|
||||
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
|
||||
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
|
||||
@@ -28,34 +32,42 @@
|
||||
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
|
||||
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
|
||||
|
||||
## ERROR: — 数据库
|
||||
---
|
||||
|
||||
## ERROR: — 数据库 / 通用
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 平台与账号
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
||||
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含:均被 active 未过期 lease 占用时) |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 租约
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
|
||||
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
|
||||
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 凭据与 Secret
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
|
||||
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
|
||||
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
|
||||
@@ -65,15 +77,74 @@
|
||||
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
|
||||
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
|
||||
|
||||
---
|
||||
|
||||
## ERROR: — 浏览器 / 环境
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
|
||||
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
||||
|
||||
## 非前缀输出
|
||||
---
|
||||
|
||||
- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**。
|
||||
- 失败时首行 `ERROR:`,可能有后续说明行。
|
||||
- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `ERROR:` 文本前缀。
|
||||
## v2 新增错误码(机器可读 JSON,`code` 字段通常 **无** `ERROR:` 前缀)
|
||||
|
||||
### 加密 / Master Key
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_MASTER_KEY_MISSING` | 环境变量与 `master.key` 均未解析到合法密钥 |
|
||||
| `ERR_MASTER_KEY_EXISTS` | `init-master-key` 时文件已存在且未 `--rotate` |
|
||||
| `ERR_MASTER_KEY_INVALID` | `--from-env` / 环境变量提供字符串无法归一成 Fernet key |
|
||||
| `ERR_CREDENTIAL_DECRYPT_FAILED` | Fernet 解密失败(`CredentialDecryptError`,典型为密文损坏或 key 不匹配) |
|
||||
| `ERR_CREDENTIAL_NOT_ENCRYPTED` | `db.credentials_repo.read_credential_plaintext` 对 `secret_storage='none'` 抛出 **`CredentialNotEncryptedError`**;CLI `get-credential --reveal` **会捕获并返回 `plaintext: null`**(非致命 JSON 错误) |
|
||||
|
||||
### 凭据导入导出
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG` | `export-credentials` 未带 `--plaintext` |
|
||||
|
||||
### `add-web` 扩参
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `--auth-strategy` 不在允许集合 / 账号缺少策略(同进程 dispatcher 亦使用) |
|
||||
| `ERR_INVALID_SESSION_PERSISTENT` | `--session-persistent` 不是 `0` 或 `1` |
|
||||
| `ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE` | 非 `device_bound` 策略却传入 `--device-fingerprint` |
|
||||
|
||||
### `add-secret` + `local_encrypted`
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN` | `local_encrypted` 未配合 `--secret-stdin` |
|
||||
| `ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED` | `credential_type` 不允许加密(如 `browser_profile` / `note`) |
|
||||
| `ERR_LOCAL_ENCRYPTED_EMPTY_STDIN` | stdin 无内容 |
|
||||
|
||||
### `get-credential` / 访问控制
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_LEASE_INVALID` | `--reveal` 时 lease 缺失、过期或不匹配账号 |
|
||||
| `ERR_CREDENTIAL_MISSING` | 账号下无 `status=active` 的凭据 |
|
||||
|
||||
---
|
||||
|
||||
## rpa_helpers(同进程异常,非 CLI)
|
||||
|
||||
详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。`AuthStrategyError.code` 取值:
|
||||
|
||||
| 错误码 | 触发场景 |
|
||||
|---|---|
|
||||
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `auth_strategy` 为空或 dispatcher 不识别的字符串 |
|
||||
| `ERR_CREDENTIAL_MISSING` | 策略需要 `credentials["plaintext"]` 但未提供 |
|
||||
| `ERR_AUTH_STRATEGY_NOT_AUTOMATABLE` | `client_certificate` |
|
||||
| `ERR_DEVICE_FINGERPRINT_MISMATCH` | `device_bound` 机器指纹与账号不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 非前缀输出提示
|
||||
|
||||
- **`pick-web` / `account get` / `account list`** 成功:stdout 多为单行 JSON(不一定含 `success` 字段)。
|
||||
- **失败**:优先解析 JSON;否则兼容 `ERROR:` 文本。
|
||||
|
||||
@@ -2,31 +2,53 @@
|
||||
|
||||
## 核心约束
|
||||
|
||||
业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。
|
||||
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
|
||||
|
||||
## 两种集成方式
|
||||
|
||||
| 方式 | 适用场景 | 调用形态 |
|
||||
|---|---|---|
|
||||
| **A. CLI subprocess(v1 起)** | 任意业务 skill;一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
|
||||
| **B. rpa_helpers 同进程 import(v2)** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
|
||||
|
||||
常见组合:**CLI** 负责 pick / lease / `get-credential --reveal`;**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。
|
||||
使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`)必须与最终用户会话一致——否则会写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)。
|
||||
|
||||
## 机器可读命令摘要
|
||||
|
||||
| 目的 | 命令 | 成功时 stdout |
|
||||
|------|------|----------------|
|
||||
| 取单条账号 JSON | `account get <id>` | 单行 JSON 对象 |
|
||||
| 批量账号 JSON | `account list --platform <p> [--environment <e>]` | 单行 JSON 数组 |
|
||||
| 网页自动化候选 | `account pick-web --platform <p> [--lease]` | 单行 JSON 对象 |
|
||||
| 获取 API Key | `credential pick --platform <p> --type api_key [--reveal]` | 单行 JSON(`success: true` + 字段) |
|
||||
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上 |
|
||||
| 目的 | 命令 | 成功时 stdout(摘要) |
|
||||
|---|---|---|
|
||||
| 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
|
||||
| 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
|
||||
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
||||
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
||||
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
|
||||
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
|
||||
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) |
|
||||
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
|
||||
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
|
||||
| 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` |
|
||||
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
|
||||
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
|
||||
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
|
||||
|
||||
## 解析协议
|
||||
更完整的命令表见 [`CLI.md`](CLI.md)。
|
||||
|
||||
1. 读取子进程 **stdout** 首行。
|
||||
2. 若首行以 **`ERROR:`** 开头 → 失败。
|
||||
3. 否则对首行做 `json.loads`。
|
||||
## 解析协议(稳妥做法)
|
||||
|
||||
1. 读取子进程 **stdout**;若有多行,**集成应以首行为准**(export/import 例外:`export` 整块 stdout 即 JSON;`import` 读 stderr)。
|
||||
2. **优先尝试 `json.loads`**:
|
||||
- 若为 dict 且 `success is False` → 读取 `error.code` / `error.message`。
|
||||
- 若 dict 且 `success is True` 且存在 `data` → 业务字段通常在 `data` 内(`add-web` / `add-secret` / `platform`)。
|
||||
3. 若 JSON 首字段不是上述统一格式(例如 `pick-web`)→ 直接按字段读取。
|
||||
4. **遗留文本失败**:首行以 **`ERROR:`** 开头 → 视为失败(旧别名路径仍可能出现)。
|
||||
|
||||
## JSON 对象字段
|
||||
|
||||
### account get / pick-web
|
||||
### `account pick-web`(典型)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -34,26 +56,28 @@
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"last_used_at": "2026-05-01T10:00:00",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
"auth_strategy": "qr_code_manual",
|
||||
"session_persistent": 1,
|
||||
"device_fingerprint": null,
|
||||
"lease_token": "…………",
|
||||
"lease_expires_at": "2026-05-11T12:34:56"
|
||||
}
|
||||
```
|
||||
|
||||
pick-web 额外字段(带 lease 时):
|
||||
- `lease_token`: 租约 token
|
||||
- `lease_expires_at`: 过期时间
|
||||
`lease_*` 仅在传入 `--lease` 时出现;时间与仓库 `_unix_to_iso_local` 一致。
|
||||
|
||||
### credential pick
|
||||
### `account get`
|
||||
|
||||
输出即数据库映射字典(字段与 [`SCHEMA.md`](SCHEMA.md) `accounts` 一致,`extra_json` 等为结构化 JSON)。
|
||||
|
||||
### `credential pick`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -69,13 +93,48 @@ pick-web 额外字段(带 lease 时):
|
||||
}
|
||||
```
|
||||
|
||||
**只有 `--reveal` 时才返回 `secret` 字段。业务 skill 不要向最终用户展示 reveal 输出。**
|
||||
仅 `--reveal` 时附加 **`secret`**。请勿向终端用户打印 reveal 结果。
|
||||
|
||||
失败时使用统一 JSON:`{"success": false, "error": {"code": "ERROR:...", "message": "..."}}`(见 [ERRORS.md](ERRORS.md))。
|
||||
### `account get-credential`
|
||||
|
||||
成功示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"account_id": 42,
|
||||
"credential_id": 7,
|
||||
"credential_type": "password",
|
||||
"secret_storage": "local_encrypted",
|
||||
"secret_mask": "****abcd",
|
||||
"plaintext": "……"
|
||||
}
|
||||
```
|
||||
|
||||
失败时使用统一 JSON:`{"success": false, "error": {"code": "...", "message": "..."}}`(亦可能夹杂历史 `ERROR:` 文本命令——见 [`ERRORS.md`](ERRORS.md))。
|
||||
|
||||
## rpa_helpers 同进程使用(摘要)
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
|
||||
sys.path.insert(0, f"{AM_ROOT}/scripts")
|
||||
|
||||
from service.rpa_helpers import (
|
||||
launch_browser_with_profile,
|
||||
close_browser,
|
||||
ensure_logged_in,
|
||||
)
|
||||
```
|
||||
|
||||
**务必**:`sys.path` 指向 **`account-manager/scripts`**,因此 import 形态为 **`from service.rpa_helpers ...`**(而不是 `from scripts.service.rpa_helpers ...`)。
|
||||
|
||||
完整 API、登录判定 hook、`LoginResult` / `AuthStrategyError` 说明见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
## 业务流程示例
|
||||
|
||||
### 物流 RPA
|
||||
### 物流 RPA(CLI)
|
||||
|
||||
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
|
||||
2. 解析 JSON → 取 `id`、`profile_dir`、`url`、`lease_token`
|
||||
@@ -83,12 +142,71 @@ pick-web 额外字段(带 lease 时):
|
||||
4. 业务完成后:`lease release <lease_token>`
|
||||
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
|
||||
|
||||
### LLM API 调用
|
||||
### LLM API 调用(CLI)
|
||||
|
||||
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
|
||||
2. 解析 JSON → 取 `secret` 字段
|
||||
3. 在业务进程内存中使用,不写文件、不打日志
|
||||
3. 仅在内存中使用,不写磁盘、不打日志
|
||||
|
||||
### 加密密码 + 自动登录(CLI + rpa_helpers)
|
||||
|
||||
```python
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AM = r"D:\OpenClaw\client-commons\account-manager"
|
||||
PY = sys.executable
|
||||
MAIN = f"{AM}/scripts/main.py"
|
||||
|
||||
sys.path.insert(0, f"{AM}/scripts")
|
||||
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
|
||||
|
||||
|
||||
def am_json(argv: list[str]) -> dict:
|
||||
r = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
|
||||
line = (r.stdout or "").splitlines()[0] if r.stdout else ""
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
# 1) pick 账号 + lease
|
||||
acc = am_json(
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"icbc_sim",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"my-rpa",
|
||||
]
|
||||
)
|
||||
|
||||
# 2) 取明文密码(必须 reveal + lease)
|
||||
cred = am_json(
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(acc["id"]),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
acc["lease_token"],
|
||||
]
|
||||
)
|
||||
|
||||
# 3) 浏览器 + 登录
|
||||
ctx, page = launch_browser_with_profile(acc["profile_dir"])
|
||||
try:
|
||||
result = ensure_logged_in(page, acc, credentials={"plaintext": cred["plaintext"]})
|
||||
if not result.ok:
|
||||
print(result.message, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# …业务逻辑…
|
||||
finally:
|
||||
close_browser(ctx)
|
||||
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
|
||||
```
|
||||
|
||||
## 退出码
|
||||
|
||||
请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准;不要仅依赖退出码。
|
||||
请以 **stdout JSON / `ERROR:` 前缀** 为准;不要仅依赖退出码。
|
||||
|
||||
@@ -1,50 +1,101 @@
|
||||
# 平台与别名(account-manager)
|
||||
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
|
||||
|
||||
## 物流平台
|
||||
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias(大小写不敏感)。
|
||||
|
||||
| 英文键 | 展示名 | 供应商代码 | 别名 |
|
||||
|--------|--------|-----------|------|
|
||||
| `maersk` | Maersk | maersk | 马士基 |
|
||||
| `cosco` | COSCO | cosco | 中远海运 |
|
||||
| `msc` | MSC | msc | 地中海航运 |
|
||||
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 |
|
||||
| `evergreen` | Evergreen | evergreen | 长荣 |
|
||||
| `cargo_wise` | CargoWise | cargo_wise | Cargo Wise |
|
||||
| `freightos` | Freightos | freightos | — |
|
||||
| `webcargo` | WebCargo | webcargo | Web Cargo |
|
||||
| `sinotrans` | Sinotrans | sinotrans | 中外运 |
|
||||
---
|
||||
|
||||
## LLM / AI 平台
|
||||
## 物流平台(`domain = logistics`,9)
|
||||
|
||||
| 英文键 | 展示名 | 别名 | 默认 URL |
|
||||
|--------|--------|------|----------|
|
||||
| `deepseek` | DeepSeek | — | https://chat.deepseek.com |
|
||||
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|---|
|
||||
| `maersk` | Maersk | maersk | 马士基 | https://www.maersk.com |
|
||||
| `cosco` | COSCO | cosco | 中远海运 | (空) |
|
||||
| `msc` | MSC | msc | 地中海航运 | (空) |
|
||||
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 | (空) |
|
||||
| `evergreen` | Evergreen | evergreen | 长荣 | (空) |
|
||||
| `cargo_wise` | CargoWise | cargo_wise | CargoWise, Cargo Wise | (空) |
|
||||
| `freightos` | Freightos | freightos | Freightos | (空) |
|
||||
| `webcargo` | WebCargo | webcargo | WebCargo, web cargo | (空) |
|
||||
| `sinotrans` | Sinotrans | sinotrans | 中外运 | (空) |
|
||||
|
||||
---
|
||||
|
||||
## LLM / AI 平台(`domain = llm`,10)
|
||||
|
||||
其中 `openai`、`anthropic` 在注册表中 **`capabilities.web = false`**,以 API Key 场景为主;其余条目多为 Web + API。
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `deepseek` | DeepSeek | DeepSeek | https://chat.deepseek.com |
|
||||
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
|
||||
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
|
||||
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
|
||||
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
|
||||
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
|
||||
| `gemini` | Gemini | 谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
|
||||
| `minimax` | MiniMax | — | — |
|
||||
| `gemini` | Gemini | Gemini、谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
|
||||
| `minimax` | MiniMax | MiniMax | (空) |
|
||||
| `openai` | OpenAI API | OpenAI、GPT、ChatGPT API | https://platform.openai.com |
|
||||
| `anthropic` | Anthropic | Anthropic、Claude API | https://console.anthropic.com |
|
||||
|
||||
## 内容平台
|
||||
---
|
||||
|
||||
| 英文键 | 展示名 | 别名 | 默认 URL |
|
||||
|--------|--------|------|----------|
|
||||
| `sohu` | 搜狐号 | 搜狐 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | 知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 公众号、微信 | https://mp.weixin.qq.com |
|
||||
## 内容平台(`domain = content`,8)
|
||||
|
||||
## 解析规则
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `sohu` | 搜狐号 | 搜狐、搜狐号 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条、头条号 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | 知乎、知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 公众号、微信、微信公众号 | https://mp.weixin.qq.com |
|
||||
| `douyin` | 抖音 | 抖音、Douyin | https://www.douyin.com |
|
||||
| `wechat_mp` | 微信公众号 | 微信公众号、公众号、WeChat MP | https://mp.weixin.qq.com |
|
||||
| `xiaohongshu` | 小红书 | 小红书、Xiaohongshu、RED | https://www.xiaohongshu.com |
|
||||
| `bilibili` | 哔哩哔哩 | B站、哔哩哔哩、Bilibili | https://www.bilibili.com |
|
||||
|
||||
- 英文键本身、各 `display_name`、各 `aliases` 条目(及小写形式)均可解析到对应键。
|
||||
- 不区分大小写。
|
||||
- 无法识别时输出 `ERROR:INVALID_PLATFORM`,并附带支持的平台列表。
|
||||
- 解析函数:`resolve_platform_key(name)` — 支持 key、display_name、aliases。
|
||||
---
|
||||
|
||||
## 开发控制台(`domain = dev`,3)
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `github` | GitHub | GitHub、github | https://github.com/login |
|
||||
| `aws` | AWS | AWS、Amazon Web Services、亚马逊云 | https://console.aws.amazon.com |
|
||||
| `gcp` | GCP | GCP、Google Cloud、谷歌云 | https://console.cloud.google.com |
|
||||
|
||||
---
|
||||
|
||||
## 仿真平台(`domain = sim`,1)
|
||||
|
||||
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|
||||
|---|---|---|---|
|
||||
| `icbc_sim` | 工行仿真 | 工行仿真、ICBC Simulator、ICBC SIM | http://localhost:5180 |
|
||||
|
||||
---
|
||||
|
||||
## `platform list --domain` 说明
|
||||
|
||||
CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cmd_platform_list`)。`dev` / `sim` 域平台请 **省略 `--domain`** 获取完整列表后在 JSON 中筛选,或直接 `platform get <key>`。
|
||||
|
||||
---
|
||||
|
||||
## 内置默认 `auth_strategy`
|
||||
|
||||
`db/platform_defaults.py` 中的 `PLATFORM_DEFAULT_AUTH_STRATEGY` 提供 **17** 个平台的初始映射(迁移时写入 `platforms.default_auth_strategy`,可被数据库手工覆盖):
|
||||
|
||||
| 平台键 | 默认 `auth_strategy` |
|
||||
|---|---|
|
||||
| `maersk`, `cosco`, `msc` | `qr_code_manual` |
|
||||
| `douyin`, `wechat_mp`, `xiaohongshu`, `bilibili`, `toutiao`, `sohu` | `qr_code_manual` |
|
||||
| `github`, `aws`, `gcp` | `password_plus_2fa` |
|
||||
| `icbc_sim` | `password_auto` |
|
||||
| `openai`, `anthropic`, `deepseek`, `doubao` | `api_token` |
|
||||
|
||||
其余注册平台若 DB 中 `default_auth_strategy` 为空,则在创建账号时回落到 `resolve_auth_strategy_for_platform`(内置映射 → 否则 `password_auto`)。业务可在 `add-web` 显式传入 `--auth-strategy`。
|
||||
|
||||
---
|
||||
|
||||
## 平台注册到数据库
|
||||
|
||||
`seed_platforms(conn)` 在每次 `init_db()` 后自动执行,将 `PLATFORMS` 字典 upsert 到 `platforms` 表。
|
||||
`seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
---
|
||||
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。"
|
||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
|
||||
---
|
||||
|
||||
# 🔐 账号与凭据管理
|
||||
|
||||
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
|
||||
|
||||
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key,而是通过 CLI 调用获取。
|
||||
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己散落密码、API Key,而是通过 CLI(或与 CLI 组合的同进程 [`RPA_HELPERS.md`](RPA_HELPERS.md))获取。
|
||||
|
||||
## 它能帮你做什么
|
||||
|
||||
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
|
||||
- **添加 API Key/Token** — 安全存储:API Key 明文存入 Windows Credential Manager 或环境变量引用,SQLite 只保存元数据和打码值。
|
||||
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。
|
||||
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
|
||||
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发。
|
||||
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发;输出含 `auth_strategy` / `session_persistent` / `device_fingerprint`。
|
||||
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
|
||||
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
|
||||
- **加密存储密码** — Fernet 加密,密码在 SQLite 中以密文形式存在;只有持有 master key 才能解密。
|
||||
- **9 种登录策略** — 账密自动、账密+图形验证码、账密+2FA、扫码、每次手动登录、设备绑定、客户端证书、API Token、SSO 跳转;业务 skill 可调用统一入口 `ensure_logged_in`(见 [`RPA_HELPERS.md`](RPA_HELPERS.md))。
|
||||
- **加密备份与迁移** — `export-credentials --plaintext` 导出 JSON,`import-credentials` 在新机器重新加密写入(需先 `init-master-key`)。
|
||||
|
||||
## 你可以这样对它说
|
||||
|
||||
@@ -24,21 +27,23 @@ description: "通用 Credential & Browser Profile Manager:管理网页账号
|
||||
- 「列出所有物流平台的账号。」
|
||||
- 「用浏览器打开 3 号账号看看页面。」
|
||||
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
|
||||
- 「我要把工行仿真账号的密码加密保存,下次 RPA 自动登录用。」
|
||||
- 「给我导出所有加密凭据,明天换台电脑要用。」
|
||||
|
||||
## 你需要提供什么
|
||||
|
||||
| 场景 | 你需要给的 |
|
||||
|---|---|
|
||||
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
|
||||
| 添加 API Key | **平台名称** + **凭据类型**(api_key 等)+ **secret**(通过 stdin 或环境变量) |
|
||||
| 添加 API Key / 加密密码 | **平台名称** + **凭据类型**(api_key 等)+ **secret**(stdin / 环境变量引用等) |
|
||||
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
|
||||
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
|
||||
|
||||
## 它不做什么
|
||||
|
||||
- 不在此处判定各网站「是否已登录」(由业务技能自行处理)
|
||||
- 不在此处包办所有网站的 DOM 自动化细节(具体抓取逻辑在各业务 skill)
|
||||
- 不代替内容管理、发布、大模型调用等业务技能
|
||||
- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值)
|
||||
- `windows_credential` / `env` 路径下不把明文写入 SQLite;`local_encrypted` 仅存 Fernet 密文
|
||||
|
||||
## Secret 存储方式
|
||||
|
||||
@@ -47,7 +52,10 @@ description: "通用 Credential & Browser Profile Manager:管理网页账号
|
||||
| `none` | 无 secret(浏览器 profile 登录态) |
|
||||
| `env` | 引用环境变量名,运行时读取 |
|
||||
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
|
||||
| `local_encrypted` | 本地 Fernet 加密存 DB;需先 `account init-master-key` |
|
||||
|
||||
命令细节见 [`CLI.md`](CLI.md);加密与环境变量见 [`RUNTIME.md`](RUNTIME.md)。
|
||||
|
||||
---
|
||||
|
||||
**作者**:深圳匠厂科技有限公司 · **版本**:1.0.55
|
||||
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0
|
||||
|
||||
218
references/RPA_HELPERS.md
Normal file
218
references/RPA_HELPERS.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# rpa_helpers 库 — 业务 skill 同进程登录
|
||||
|
||||
`scripts/service/rpa_helpers/` 是 account-manager v2 提供给业务 skill 的 **同进程 Python 库**。业务代码调用 **`ensure_logged_in`**,库按账号记录的 **`auth_strategy`** 分发到对应登录流程(底层基于 Playwright)。
|
||||
|
||||
## 与 CLI 的关系
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| **CLI subprocess** | 获取结构化账号 JSON、租约、`get-credential --reveal` 明文密码;进程隔离、易于跨语言 |
|
||||
| **rpa_helpers** | 在已有 `BrowserContext`/`Page` 上执行登录编排与人类化输入 |
|
||||
|
||||
推荐组合:**CLI → pick / lease → get-credential → `launch_browser_with_profile` + `ensure_logged_in`**。
|
||||
|
||||
## 集成方式
|
||||
|
||||
### Import 路径
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
|
||||
sys.path.insert(0, f"{AM_ROOT}/scripts")
|
||||
|
||||
from service.rpa_helpers import (
|
||||
AuthStrategyError,
|
||||
LoginResult,
|
||||
close_browser,
|
||||
ensure_logged_in,
|
||||
launch_browser_with_profile,
|
||||
)
|
||||
```
|
||||
|
||||
**务必**:`sys.path` 追加的是 **`…/account-manager/scripts`**,因此模块命名空间为 `service.rpa_helpers`(**不要**写成 `from scripts.service.rpa_helpers …`)。
|
||||
|
||||
### 运行时依赖
|
||||
|
||||
- Python **3.10+**
|
||||
- 已安装 **`playwright`** Python 包(宿主 runtime 提供)
|
||||
- 本机已安装 **Chrome 或 Edge**(启动时使用 channel 模式,优先 Chrome,失败回落 Edge——见 `service.browser_service.resolve_chromium_channel`)
|
||||
|
||||
文档不提供 `pip install …` 指令;版本由匠厂宿主统一管理。
|
||||
|
||||
---
|
||||
|
||||
## 公开 API 一览
|
||||
|
||||
下列符号由 `service/rpa_helpers/__init__.py` **导出(共 19 个名字)**:
|
||||
|
||||
| 分类 | 符号 |
|
||||
|---|---|
|
||||
| **dispatcher + 策略函数** | `ensure_logged_in`,以及 `ensure_logged_in_password_auto`、`ensure_logged_in_password_with_captcha`、`ensure_logged_in_password_plus_2fa`、`ensure_logged_in_qr_code_manual`、`ensure_logged_in_per_session_manual`、`ensure_logged_in_device_bound`、`ensure_logged_in_client_certificate`、`ensure_logged_in_api_token`、`ensure_logged_in_sso_redirect` |
|
||||
| **结果 / 异常** | `LoginResult`,`AuthStrategyError` |
|
||||
| **浏览器** | `launch_browser_with_profile`,`close_browser` |
|
||||
| **人类化输入** | `pause`,`human_type`,`human_click`,`human_select`,`human_scroll_to` |
|
||||
|
||||
---
|
||||
|
||||
## `browser` 模块
|
||||
|
||||
### `launch_browser_with_profile(profile_dir, *, channel=None, headless=False, extra_args=None, locale="zh-CN") -> tuple[BrowserContext, Page]`
|
||||
|
||||
- 创建持久化上下文(`user_data_dir=profile_dir`),注入可选 stealth 脚本。
|
||||
- `channel` 默认为宿主检测结果(Chrome 优先)。
|
||||
- **必须在 `finally` 中调用 `close_browser(context)`**,否则 playwright driver 可能泄漏。
|
||||
|
||||
### `close_browser(ctx) -> None`
|
||||
|
||||
关闭上下文并停止内部的 Playwright session manager。
|
||||
|
||||
---
|
||||
|
||||
## `human` 模块(人类化操作)
|
||||
|
||||
| 函数 | 行为 |
|
||||
|---|---|
|
||||
| `pause(min_ms=200, max_ms=800)` | 随机等待 |
|
||||
| `human_type(locator, text, *, per_char_ms_range=(50, 200), pre_pause=True)` | 滚动进入视图 → 停顿 → `click` → 逐字符 `type` |
|
||||
| `human_click(locator)` | 滚动 → 停顿 → `click` |
|
||||
| `human_select(locator, value_or_label)` | 滚动 → 停顿 → `select_option`(依次尝试 value / label) |
|
||||
| `human_scroll_to(locator)` | 平滑滚动到元素 |
|
||||
|
||||
_locator_ 均为 Playwright `Locator`。
|
||||
|
||||
---
|
||||
|
||||
## `auth_flows` 模块
|
||||
|
||||
### `ensure_logged_in(page, account, *, credentials=None, **kwargs) -> LoginResult`
|
||||
|
||||
1. 读取 `account["auth_strategy"]`。为空字符串 → 抛出 `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`。
|
||||
2. 若策略需要密码则要求 `credentials["plaintext"]`;缺失 → `ERR_CREDENTIAL_MISSING`。
|
||||
3. 分派到对应 `ensure_logged_in_*`。
|
||||
4. 未知策略 → `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`。
|
||||
|
||||
附加 kwargs 会透传给具体策略(如 `captcha_value`、`wait_human_sec`)。
|
||||
|
||||
### `LoginResult`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | `bool` | 流程是否达到判定条件 |
|
||||
| `needs_human` | `bool` | 是否需要人工协助(扫码 / OTP / SSO 等) |
|
||||
| `message` | `str` | 描述信息(便于日志) |
|
||||
|
||||
### `AuthStrategyError`
|
||||
|
||||
属性:`code: str`,`message: str`。错误码与 CLI / [`ERRORS.md`](ERRORS.md) 对齐。
|
||||
|
||||
### 九个 `ensure_logged_in_*` 入口
|
||||
|
||||
业务代码通常只调用 `ensure_logged_in`。如需针对性 reuse,可直接 import `auth_flows` 中的同名函数;签名见源码 `scripts/service/rpa_helpers/auth_flows.py`。
|
||||
|
||||
---
|
||||
|
||||
## 已登录检测(`_is_already_logged_in`)
|
||||
|
||||
核心逻辑:
|
||||
|
||||
1. 读取当前 `page.url`;空白页 → **False**。
|
||||
2. 仅在账号 `extra_json`(可为 dict 或 JSON 字符串)中出现以下 hook 之一才可能返回 True:
|
||||
- **`logged_in_url_pattern`**:正则,匹配当前 URL。
|
||||
- **`logged_in_selector`**:CSS/XPath selector,`locator.first.is_visible(timeout=500)`。
|
||||
3. 若无任一 hook → **默认 False** → **除非已有持久化 session**,否则会完整跑登录流程。
|
||||
|
||||
### 接入建议
|
||||
|
||||
1. 首次上线:`auth_strategy=qr_code_manual` + 无 hook → 用户扫码登录。
|
||||
2. 登录成功后记录典型 URL 片段或 DOM selector。
|
||||
3. 写入账号 `extra_json`,例如:
|
||||
|
||||
```python
|
||||
{
|
||||
"logged_in_url_pattern": r"/user/",
|
||||
"logged_in_selector": "div.avatar-wrapper"
|
||||
}
|
||||
```
|
||||
|
||||
4. 下次启动 → `_is_already_logged_in` 命中 → `needs_human` 可能降低。
|
||||
|
||||
### `per_session_manual` 的差异
|
||||
|
||||
该策略 **开场不会调用 `_is_already_logged_in`**,总是导航到业务 URL 并等待人工完成登录(适合强制会话)。
|
||||
|
||||
---
|
||||
|
||||
## 设备绑定策略(`device_bound`)
|
||||
|
||||
如账号设置了 `device_fingerprint`,库会使用 `_current_machine_fingerprint()`(hostname + MAC 派生)比对;不一致抛出 **`ERR_DEVICE_FINGERPRINT_MISMATCH`**。
|
||||
|
||||
---
|
||||
|
||||
## 客户端证书策略(`client_certificate`)
|
||||
|
||||
直接抛出 **`ERR_AUTH_STRATEGY_NOT_AUTOMATABLE`**;请在宿主机手动装载证书后使用其它路径衔接。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(CLI + rpa_helpers)
|
||||
|
||||
```python
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AM = r"D:\OpenClaw\client-commons\account-manager"
|
||||
PY = sys.executable
|
||||
MAIN = f"{AM}/scripts/main.py"
|
||||
|
||||
sys.path.insert(0, f"{AM}/scripts")
|
||||
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
|
||||
|
||||
|
||||
def am_json(argv: list[str]) -> dict:
|
||||
proc = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
|
||||
line = (proc.stdout or "").splitlines()[0]
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
acc = am_json(
|
||||
["account", "pick-web", "--platform", "icbc_sim", "--lease", "--holder", "monitor-rates"]
|
||||
)
|
||||
|
||||
cred = am_json(
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(acc["id"]),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
acc["lease_token"],
|
||||
]
|
||||
)
|
||||
|
||||
plaintext = cred.get("plaintext")
|
||||
if plaintext in (None, ""):
|
||||
raise RuntimeError("no plaintext returned; check secret_storage / lease")
|
||||
|
||||
ctx, page = launch_browser_with_profile(acc["profile_dir"])
|
||||
try:
|
||||
result = ensure_logged_in(page, acc, credentials={"plaintext": plaintext})
|
||||
if not result.ok:
|
||||
raise RuntimeError(result.message)
|
||||
# …业务代码…
|
||||
finally:
|
||||
close_browser(ctx)
|
||||
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
- account-manager 仓库内置 **107** 个 pytest(截至 v2.0.0 文档修订时),覆盖 CLI / schema / rpa_helpers mock 路径。
|
||||
- 业务 skill:
|
||||
- **单元测试**:patch `ensure_logged_in` → 返回 `LoginResult(ok=True, …)`。
|
||||
- **端到端**:在 Windows + 真浏览器环境标记 `@pytest.mark.integration`,禁止在 CI 泄露明文密码。
|
||||
|
||||
更多 CLI 细节参见 [`CLI.md`](CLI.md)、[`INTEGRATION.md`](INTEGRATION.md)。
|
||||
@@ -2,58 +2,80 @@
|
||||
|
||||
## 数据落盘位置
|
||||
|
||||
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**;`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。
|
||||
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(兼容 **`JIANGCHANG_*` / `CLAW_*` 别名**);`scripts/util/runtime_paths.py` 在此基础上拼接技能子目录。
|
||||
|
||||
- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**;未设置且未走本地注入时,Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`。
|
||||
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
|
||||
- **数据根目录**:默认读取宿主注入变量;开发模式下未注入时可能落到 Windows `D:\jiangchang-data` 等(详见 runtime_env 实现)。
|
||||
- **用户 / 工作空间 id**:默认 `_anon`(以实际注入为准)。
|
||||
- **技能数据目录**:`{数据根}/{用户id}/account-manager/`
|
||||
- **SQLite**:`account-manager.db`
|
||||
- **Master Key**:`master.key`(仅当执行过 `account init-master-key` 且未仅用环境变量场景)
|
||||
- **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
|
||||
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
|
||||
|
||||
## 调试:为什么「list 没数据」
|
||||
|
||||
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径。
|
||||
- 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(或宿主等价开关)后,CLI 会在 **stderr** 打印实际路径。
|
||||
|
||||
## 本地 CLI 默认值
|
||||
|
||||
各技能 `main.py` 调用 `apply_cli_local_defaults()`:
|
||||
|
||||
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
|
||||
- 发版前改为 `False` 或依赖宿主注入。
|
||||
|
||||
## Secret 存储
|
||||
|
||||
| 方式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
|---|---|---|
|
||||
| `none` | 不存 secret | 浏览器 profile 登录态 |
|
||||
| `env` | SQLite 只存环境变量名,运行时从 `os.environ` 读取 | 轻量测试 |
|
||||
| `windows_credential` | 真实 secret 存 Windows Credential Manager | Windows 本地正式部署 |
|
||||
| `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
|
||||
| `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
|
||||
| `local_encrypted` | Fernet 密文存 SQLite(`secret_ciphertext`),密钥来自 master key | RPA 密码落地;需先 `account init-master-key` |
|
||||
|
||||
- **不把 secret 明文写入 SQLite**
|
||||
- **不在日志中记录 secret 明文**
|
||||
- 只有 `credential pick --reveal` 或 `credential resolve --reveal` 才返回 secret
|
||||
- **禁止**把明文写入日志。
|
||||
- `credential pick --reveal` / `credential resolve --reveal` 仍会输出明文 secret——仅限受控环境。
|
||||
|
||||
## Master Key
|
||||
|
||||
| 变量 | 作用 |
|
||||
|---|---|
|
||||
| `ACCOUNT_MANAGER_MASTER_KEY` | Fernet key(典型为 url-safe Base64 **44** 字符);**优先级高于** `master.key` 文件 |
|
||||
|
||||
**加载优先级(解密路径)**:环境变量 → `master.key` 文件 → `ERR_MASTER_KEY_MISSING`。
|
||||
|
||||
**安全建议**
|
||||
|
||||
- 生产环境优先 **环境变量 / Secret Manager** 注入,不在磁盘留 `master.key`。
|
||||
- 桌面环境可使用文件(默认尽力写入私有权限)。
|
||||
- 备份 master key 至密码管理器;**丢失将导致所有 `local_encrypted` 凭据不可解密**。
|
||||
|
||||
详见 [`CLI.md`](CLI.md)(`init-master-key`)、[`ERRORS.md`](ERRORS.md)。
|
||||
|
||||
## 日志
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
|---|---|
|
||||
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
|
||||
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr |
|
||||
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
|
||||
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
|
||||
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
|
||||
|
||||
## 浏览器(open)
|
||||
## 浏览器(`account open` / rpa_helpers)
|
||||
|
||||
- 本机已安装 Chrome 或 Edge
|
||||
- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含)
|
||||
- 使用 **channel 模式**(`channel=chrome|msedge`),**不下载 Chromium**
|
||||
- **不要执行 `playwright install chromium`**
|
||||
- 本机需安装 Chrome 或 Edge。
|
||||
- Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
|
||||
- 使用 **channel 模式**(`chrome` / `msedge`),**不下载 Chromium**。
|
||||
- **不要执行** `playwright install chromium`。
|
||||
|
||||
### 关键浏览器参数
|
||||
### 关键浏览器参数(摘录)
|
||||
|
||||
- `headless=False`
|
||||
- `no_viewport=True`
|
||||
- `args` 包含 `--start-maximized`
|
||||
- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制)
|
||||
- stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`)
|
||||
|
||||
### rpa_helpers
|
||||
|
||||
登录编排封装见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||
|
||||
@@ -2,121 +2,143 @@
|
||||
|
||||
## 数据库路径
|
||||
|
||||
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
|
||||
|
||||
路径解析细节见 [RUNTIME.md](RUNTIME.md)。
|
||||
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
|
||||
(或宿主映射的 `CLAW_DATA_ROOT` / `CLAW_USER_ID`,解析细节见 [`RUNTIME.md`](RUNTIME.md)。)
|
||||
|
||||
## Schema 版本
|
||||
|
||||
`PRAGMA user_version = 2`
|
||||
- `PRAGMA user_version = 2`
|
||||
- `_schema_meta.schema_version = "2.1"`(字符串;迁移完成后取值)
|
||||
|
||||
## 1. platforms — 平台注册表
|
||||
启动时 `ensure_schema` 自动执行 `migrate_v1_to_v2` 与 `migrate_v2_to_v2_1`,幂等。
|
||||
|
||||
---
|
||||
|
||||
## 1. `platforms` — 平台注册表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `platform_key` | TEXT PK | 平台唯一键(maersk / deepseek / sohu 等) |
|
||||
| `display_name` | TEXT NOT NULL | 展示名(Maersk / DeepSeek / 搜狐号) |
|
||||
| `domain` | TEXT NOT NULL | 业务域:logistics / llm / content / ecommerce / generic |
|
||||
| `provider_code` | TEXT | 供应商代码,物流 profile 可复用 |
|
||||
|---|---|---|
|
||||
| `platform_key` | TEXT PK | 平台唯一键(`maersk` / `deepseek` / `sohu` …) |
|
||||
| `display_name` | TEXT NOT NULL | 展示名 |
|
||||
| `domain` | TEXT NOT NULL | `logistics` / `llm` / `content` / `dev` / `sim` / …(以代码注册为准) |
|
||||
| `provider_code` | TEXT | 供应商代码 |
|
||||
| `default_url` | TEXT | 默认入口 URL |
|
||||
| `aliases_json` | TEXT | 别名 JSON 数组 |
|
||||
| `capabilities_json` | TEXT | 能力声明 JSON(`{"web":true,"api_key":true,"rpa":true}`) |
|
||||
| `enabled` | INTEGER | 是否启用(1=yes 0=no) |
|
||||
| `capabilities_json` | TEXT | `{"web": bool, "api_key": bool, "rpa": bool}` |
|
||||
| `enabled` | INTEGER | 是否启用(1/0) |
|
||||
| `default_auth_strategy` | TEXT | 新账号默认 `auth_strategy`;可由迁移回填 |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_platforms_domain`, `idx_platforms_provider_code`
|
||||
**索引:** `idx_platforms_domain`,`idx_platforms_provider_code`
|
||||
|
||||
## 2. accounts — 账号身份
|
||||
---
|
||||
|
||||
## 2. `accounts` — 账号身份
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `platform_key` | TEXT NOT NULL | 对应 platforms.platform_key |
|
||||
| `platform_key` | TEXT NOT NULL | 对应 `platforms.platform_key` |
|
||||
| `account_label` | TEXT NOT NULL | 人可读名称 |
|
||||
| `login_id` | TEXT | 登录标识(email/phone/username/customer_code),可空 |
|
||||
| `login_id_type` | TEXT | email / username / phone / customer_code / api_account / unknown |
|
||||
| `tenant_id` | TEXT | 租户/项目标识,可空 |
|
||||
| `environment` | TEXT | simulator / staging / production / test |
|
||||
| `role` | TEXT | booking / tracking / admin / sales / api / default |
|
||||
| `login_id` | TEXT | 登录标识(可空) |
|
||||
| `login_id_type` | TEXT | `email` / `username` / `phone` / … |
|
||||
| `tenant_id` | TEXT | 租户,可空 |
|
||||
| `environment` | TEXT | `simulator` / `staging` / `production` / … |
|
||||
| `role` | TEXT | `booking` / `tracking` / `api` / … |
|
||||
| `provider_code` | TEXT | 冗余供应商码 |
|
||||
| `profile_dir` | TEXT | Playwright 持久化浏览器 profile 目录 |
|
||||
| `url` | TEXT | 账号默认入口 URL |
|
||||
| `status` | TEXT | active / disabled / needs_review |
|
||||
| `session_status` | TEXT | unknown / likely_valid / needs_login / expired |
|
||||
| `last_used_at` | INTEGER | 最近 pick/使用时间 |
|
||||
| `last_login_check_at` | INTEGER | 最近登录检查时间 |
|
||||
| `last_error_code` | TEXT | 最近错误代码 |
|
||||
| `last_error_message` | TEXT | 最近错误消息 |
|
||||
| `extra_json` | TEXT | 扩展字段 JSON |
|
||||
| `profile_dir` | TEXT | Playwright profile 目录 |
|
||||
| `url` | TEXT | 账号入口 URL |
|
||||
| `status` | TEXT | `active` / `disabled` / … |
|
||||
| `session_status` | TEXT | `unknown` / `likely_valid` / … |
|
||||
| `last_used_at` | INTEGER | Unix 秒 |
|
||||
| `last_login_check_at` | INTEGER | Unix 秒 |
|
||||
| `last_error_code` | TEXT | 文本错误码 |
|
||||
| `last_error_message` | TEXT | 错误说明 |
|
||||
| `extra_json` | TEXT | 扩展 JSON(可含 `logged_in_url_pattern` / `logged_in_selector` 等 hook) |
|
||||
| `auth_strategy` | TEXT NOT NULL | 九种策略之一(见 [`PLATFORMS.md`](PLATFORMS.md) / `db/auth_strategy.py`) |
|
||||
| `session_persistent` | INTEGER NOT NULL DEFAULT 1 | `0`/`1` |
|
||||
| `device_fingerprint` | TEXT | 仅 `device_bound` |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_accounts_platform`, `idx_accounts_pick_web`, `idx_accounts_provider`, `idx_accounts_status`
|
||||
**索引:** `idx_accounts_platform`,`idx_accounts_pick_web`,`idx_accounts_provider`,`idx_accounts_status`
|
||||
|
||||
**唯一约束说明:** `(platform_key, login_id, tenant_id, environment, role)` 通过应用层检查,因为 SQLite 允许多条 NULL login_id。
|
||||
---
|
||||
|
||||
## 3. credentials — 凭据(API Key / Token / Password)
|
||||
|
||||
**核心原则:SQLite 只保存元数据,不保存 secret 明文。**
|
||||
## 3. `credentials` — 凭据(API Key / Token / Password …)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `account_id` | INTEGER | 关联 accounts.id,可空(平台级 API Key) |
|
||||
| `platform_key` | TEXT NOT NULL | 平台唯一键 |
|
||||
| `credential_label` | TEXT NOT NULL | 人可读名称 |
|
||||
| `credential_type` | TEXT | api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note |
|
||||
| `secret_storage` | TEXT | none / env / windows_credential |
|
||||
| `secret_ref` | TEXT | env 时为环境变量名;windows_credential 时为 target name |
|
||||
| `secret_mask` | TEXT | 打码展示(sk-****abcd),不可反推 |
|
||||
| `expires_at` | INTEGER | 过期时间 |
|
||||
| `status` | TEXT | active / disabled / expired / needs_rotation |
|
||||
| `last_used_at` | INTEGER | 最近使用时间 |
|
||||
| `extra_json` | TEXT | 扩展字段 JSON |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
| `account_id` | INTEGER | 关联 `accounts.id`,可空(历史平台级 Key) |
|
||||
| `platform_key` | TEXT NOT NULL | 平台键 |
|
||||
| `credential_label` | TEXT NOT NULL | 展示名称 |
|
||||
| `credential_type` | TEXT NOT NULL | `api_key` / `password` / … |
|
||||
| `secret_storage` | TEXT NOT NULL | `none` / `env` / `windows_credential` / `local_encrypted` |
|
||||
| `secret_ref` | TEXT | `env` 变量名 / `windows_credential` target;其它后端可为空 |
|
||||
| `secret_mask` | TEXT | 打码展示 |
|
||||
| `secret_ciphertext` | TEXT | `local_encrypted` 专用 Fernet token(v2.1 增加) |
|
||||
| `expires_at` | INTEGER | Unix 秒 |
|
||||
| `status` | TEXT | `active` / `disabled` / … |
|
||||
| `last_used_at` | INTEGER | Unix 秒 |
|
||||
| `extra_json` | TEXT | JSON |
|
||||
| `created_at` | INTEGER | Unix 秒 |
|
||||
| `updated_at` | INTEGER | Unix 秒 |
|
||||
|
||||
**索引:** `idx_credentials_platform_type`, `idx_credentials_account`, `idx_credentials_status`
|
||||
**原则**:不把 **明文** secret 写入 SQLite;`local_encrypted` 仅存 **密文**。外部后端(env / Windows CM)仍在运行时解析。
|
||||
|
||||
## 4. account_leases — RPA 并发锁
|
||||
**索引:** `idx_credentials_platform_type`,`idx_credentials_status`,**`idx_credentials_account`(`migrate_v1_to_v2` 中 `CREATE INDEX IF NOT EXISTS`)**
|
||||
|
||||
防止多个任务同时打开同一个 profile_dir。
|
||||
---
|
||||
|
||||
## 4. `account_leases` — RPA 并发锁
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `account_id` | INTEGER NOT NULL | 关联 accounts.id |
|
||||
| `lease_token` | TEXT UNIQUE | 随机 token,用于 release/heartbeat |
|
||||
| `account_id` | INTEGER NOT NULL | `accounts.id` |
|
||||
| `lease_token` | TEXT UNIQUE | 随机 token |
|
||||
| `holder` | TEXT NOT NULL | 持有者标识 |
|
||||
| `purpose` | TEXT | 用途说明 |
|
||||
| `expires_at` | INTEGER NOT NULL | 过期时间 |
|
||||
| `heartbeat_at` | INTEGER | 最近心跳时间 |
|
||||
| `released_at` | INTEGER | 主动释放时间 |
|
||||
| `status` | TEXT | active / released / expired |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
| `purpose` | TEXT | 用途 |
|
||||
| `expires_at` | INTEGER NOT NULL | Unix 秒 UTC |
|
||||
| `heartbeat_at` | INTEGER | Unix 秒 |
|
||||
| `released_at` | INTEGER | Unix 秒 |
|
||||
| `status` | TEXT | `active` / `released` / `expired` |
|
||||
| `created_at` | INTEGER | Unix 秒 |
|
||||
| `updated_at` | INTEGER | Unix 秒 |
|
||||
|
||||
**索引:** `idx_leases_account_status`, `idx_leases_token`, `idx_leases_expires`
|
||||
**索引:** `idx_leases_account_status`,`idx_leases_token`,`idx_leases_expires`
|
||||
|
||||
**约束:**
|
||||
- acquire 前先把过期 active lease 标记为 expired
|
||||
- 同一 account_id 同时只能有一个 active 未过期 lease
|
||||
- release 必须校验 lease_token
|
||||
---
|
||||
|
||||
## 5. `_schema_meta` — Schema 元数据
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `key` | TEXT PRIMARY KEY | 键名 |
|
||||
| `value` | TEXT NOT NULL | 值 |
|
||||
|
||||
**已知键**
|
||||
|
||||
- `schema_version` — 逻辑 schema 版本字符串:`migrate_v1_to_v2` 完成后写入 `"2"`,`migrate_v2_to_v2_1` 完成后更新为 **`"2.1"`**。
|
||||
|
||||
---
|
||||
|
||||
## Profile 文件布局
|
||||
|
||||
```
|
||||
{DATA_ROOT}/{USER_ID}/account-manager/
|
||||
├── account-manager.db
|
||||
├── master.key # 若执行过 init-master-key(也可用环境变量代替)
|
||||
└── profiles/
|
||||
└── {domain}/
|
||||
└── {platform_key}/
|
||||
└── {safe_label}[_{login_id_suffix}]/
|
||||
```
|
||||
|
||||
删除账号时实现会尽量同时删除对应目录。
|
||||
删除账号时实现会尽量同步删除对应目录。
|
||||
|
||||
## 与 JSON 输出的对应关系
|
||||
|
||||
`account get/list/pick-web` 和 `credential pick` 的 JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`。
|
||||
CLI JSON 字段与上表对应;若有封装层(`_ok_json`),详见 [`CLI.md`](CLI.md) / [`INTEGRATION.md`](INTEGRATION.md)。
|
||||
|
||||
43
references/V1_COMPATIBILITY.md
Normal file
43
references/V1_COMPATIBILITY.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# v1 调用方兼容承诺(account-manager v2)
|
||||
|
||||
account-manager v2 **不破坏**下列面向脚本 / 其它仓库(通过 CLI subprocess 集成)的契约。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
下列命令的**子命令名与既有必填参数**保持不变(v2 仅 **扩展** 可选参数,不移除):
|
||||
|
||||
- `account pick-web` / `lease release` / `account mark-used` / `account mark-session` / `account mark-error`
|
||||
- `account get` / `account list` / `account add-web` / `account add-secret`
|
||||
- 兼容入口:`get`、`list-json`、`pick-web`、`add`、`delete`、`open`
|
||||
|
||||
加密相关:`account init-master-key` / `account export-credentials` / `account import-credentials`(**仅新增**,不影响旧调用方)。
|
||||
|
||||
内置 `--help` / 无参数打印的 `_USAGE` 文案见仓库 `scripts/cli/app.py`(阶段 F 已与路由对齐)。
|
||||
|
||||
## JSON 字段
|
||||
|
||||
- **`account pick-web`**:成功时为 **单层 JSON 对象**(**无** `success`/`data` 外层)。保留字段包括但不限于:`id`、`platform_key`、`environment`、`role`、`profile_dir`、`lease_token`、`lease_expires_at`(带 `--lease` 时)。v2 **允许追加**字段(如 `auth_strategy`、`session_persistent`、`device_fingerprint`),**不得改名或删除**上述 v1 字段。
|
||||
- **`account get`**:仍为单行账号 JSON(字段集合见 [SCHEMA.md](SCHEMA.md))。
|
||||
- **`lease release` / `account mark-*`**:成功时使用 `_ok_json`(`{"success": true, "data": {...}}`);失败为统一错误 JSON。
|
||||
|
||||
## 错误码
|
||||
|
||||
下列 **`error.code` 字面值**保持不变:
|
||||
|
||||
- `ERROR:NO_ACCOUNT`
|
||||
- `ERROR:LEASE_CONFLICT`
|
||||
- `ERROR:ACCOUNT_NOT_FOUND`
|
||||
- `ERROR:INVALID_PLATFORM`
|
||||
- `ERROR:ACCOUNT_DISABLED`
|
||||
- `ERROR:DB_ERROR`
|
||||
|
||||
v2 新增的 `ERR_*` 代码(如 `ERR_LEASE_INVALID`、`ERR_AUTH_STRATEGY_NOT_SUPPORTED`)仅出现在 **v2 新命令**路径,不应干扰上述 monitor 命令的失败解析。
|
||||
|
||||
## stderr
|
||||
|
||||
v2 可能在 stderr 打印迁移摘要、`[export] WARNING` 等。**调用方应只解析 stdout**,并与 monitor 一样优先取 **最后一行非空行** 做 `json.loads`(见集成测试 `tests/test_v1_compatibility.py`)。
|
||||
|
||||
## 集成验证
|
||||
|
||||
- 仓库内 **`tests/test_v1_compatibility.py`** 以 subprocess 覆盖典型调用序列与 stdout 形态。
|
||||
- 已知调用方:**`monitor-competitor-rates`**(物流仓库)—— 仅 CLI,无 Python import。
|
||||
274
release.ps1
274
release.ps1
@@ -1,23 +1,269 @@
|
||||
# Vendored from jiangchang-platform-kit `tools/release.ps1`.
|
||||
# Source: https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit/-/blob/main/tools/release.ps1
|
||||
# The PyPI wheel does not ship this script; sync periodically when upstream workflow changes.
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-command release script for skill repos.
|
||||
|
||||
.DESCRIPTION
|
||||
- Optional auto-commit
|
||||
- Push current branch
|
||||
- Auto-increment semantic tag (vX.Y.Z)
|
||||
- Create & push tag
|
||||
- Fail fast on unsafe states
|
||||
|
||||
.EXAMPLES
|
||||
# Safe mode (recommended): requires clean working tree
|
||||
.\release.ps1
|
||||
|
||||
# Auto commit tracked/untracked changes then release
|
||||
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
|
||||
|
||||
# Dry run (show what would happen)
|
||||
.\release.ps1 -DryRun
|
||||
|
||||
# Custom tag prefix
|
||||
.\release.ps1 -Prefix "v" -Message "正式发布"
|
||||
|
||||
.NOTES
|
||||
Requires: git, PowerShell 5+
|
||||
|
||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
||||
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
||||
- 复制 SKILL.md
|
||||
- 复制 references/(排除 REQUIREMENTS.md)
|
||||
- 复制 assets/
|
||||
- 复制 tests/
|
||||
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
|
||||
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
[switch]$DryRun
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
|
||||
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
|
||||
|
||||
if (-not (Test-Path $sharedScript)) {
|
||||
throw "Shared release script not found: $sharedScript"
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
Write-Host ">> git $Args" -ForegroundColor DarkGray
|
||||
# 将 git 的 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop 下
|
||||
# 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
|
||||
& cmd /c "git $Args 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
}
|
||||
|
||||
& $sharedScript @PSBoundParameters
|
||||
exit $LASTEXITCODE
|
||||
function Get-GitOutput {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
$output = & cmd /c "git $Args" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
return @($output)
|
||||
}
|
||||
|
||||
function Test-Repo {
|
||||
& git rev-parse --is-inside-work-tree *> $null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
|
||||
return $b
|
||||
}
|
||||
|
||||
function Get-StatusPorcelain {
|
||||
$lines = @(Get-GitOutput "status --porcelain")
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Parse-SemVerTag {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$TagPrefix
|
||||
)
|
||||
$escaped = [regex]::Escape($TagPrefix)
|
||||
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
|
||||
if (-not $m.Success) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Raw = $Tag
|
||||
Major = [int]$m.Groups[1].Value
|
||||
Minor = [int]$m.Groups[2].Value
|
||||
Patch = [int]$m.Groups[3].Value
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NextTag {
|
||||
param([string]$TagPrefix)
|
||||
|
||||
$tags = Get-GitOutput "tag --list"
|
||||
$parsed = @()
|
||||
|
||||
foreach ($t in $tags) {
|
||||
$t = $t.Trim()
|
||||
if (-not $t) { continue }
|
||||
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
|
||||
if ($null -ne $obj) { $parsed += $obj }
|
||||
}
|
||||
|
||||
if ($parsed.Count -eq 0) {
|
||||
return "${TagPrefix}1.0.1"
|
||||
}
|
||||
|
||||
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
|
||||
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
|
||||
}
|
||||
|
||||
function Assert-SkillReleasePackagingSources {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
$scriptsDir = Join-Path $SkillRoot "scripts"
|
||||
$mainPy = Join-Path $scriptsDir "main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
return
|
||||
}
|
||||
|
||||
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return
|
||||
}
|
||||
|
||||
$need = @()
|
||||
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
|
||||
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
|
||||
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
|
||||
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
|
||||
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
|
||||
|
||||
foreach ($p in ($need | Select-Object -Unique)) {
|
||||
$folder = Join-Path $scriptsDir $p
|
||||
if (-not (Test-Path -LiteralPath $folder)) {
|
||||
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
|
||||
}
|
||||
}
|
||||
|
||||
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
|
||||
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Ensure-CleanOrAutoCommit {
|
||||
param(
|
||||
[switch]$DoAutoCommit,
|
||||
[switch]$NeedClean,
|
||||
[switch]$IsDryRun,
|
||||
[string]$Msg
|
||||
)
|
||||
|
||||
$status = @(Get-StatusPorcelain)
|
||||
if ($status.Length -eq 0) { return }
|
||||
|
||||
if ($NeedClean) {
|
||||
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
|
||||
& git status --short
|
||||
throw "Abort: dirty working tree."
|
||||
}
|
||||
|
||||
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
|
||||
$commitMsg = $Msg
|
||||
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
|
||||
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
|
||||
}
|
||||
|
||||
if (-not $DoAutoCommit) {
|
||||
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($IsDryRun) {
|
||||
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-Git "add -A"
|
||||
Invoke-Git "commit -m `"$commitMsg`""
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-Repo)) {
|
||||
throw "Current directory is not a git repository."
|
||||
}
|
||||
|
||||
$branch = Get-CurrentBranch
|
||||
if ([string]::IsNullOrWhiteSpace($branch)) {
|
||||
throw "Unable to determine current branch."
|
||||
}
|
||||
|
||||
if ($branch -notin @("main", "master")) {
|
||||
throw "Current branch is '$branch'. Release is only allowed from main/master."
|
||||
}
|
||||
|
||||
Invoke-Git "fetch --tags --prune origin"
|
||||
|
||||
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
|
||||
|
||||
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
|
||||
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
|
||||
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
|
||||
}
|
||||
|
||||
# 用 cmd /c + 2>&1 将 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop
|
||||
# 下把首次发布时 "fatal: ambiguous argument '@{u}'" 之类的 stderr 误判为异常。
|
||||
$upstream = & cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1'
|
||||
$hasUpstream = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($DryRun) {
|
||||
if ($hasUpstream) {
|
||||
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
if ($hasUpstream) {
|
||||
Invoke-Git "push"
|
||||
} else {
|
||||
Invoke-Git "push -u origin $branch"
|
||||
}
|
||||
}
|
||||
|
||||
$nextTag = Get-NextTag -TagPrefix $Prefix
|
||||
Write-Host "Next tag: $nextTag" -ForegroundColor Green
|
||||
|
||||
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
|
||||
if ($existing.Length -gt 0) {
|
||||
throw "Tag already exists: $nextTag"
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
|
||||
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Git "tag -a $nextTag -m `"$Message`""
|
||||
Invoke-Git "push origin $nextTag"
|
||||
|
||||
Write-Host "Release success: $nextTag" -ForegroundColor Green
|
||||
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
7
requirements-platform-kit.txt
Normal file
7
requirements-platform-kit.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# Optional: install shared SDK from Gitea Package Registry (matches vendored scripts/jiangchang_skill_core at release time).
|
||||
#
|
||||
# pip install -r requirements-platform-kit.txt ^
|
||||
# --index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/ ^
|
||||
# --extra-index-url https://pypi.org/simple
|
||||
#
|
||||
jiangchang-platform-kit>=0.1.0
|
||||
@@ -3,8 +3,9 @@
|
||||
CLI entry point: argv dispatch for account-manager v2.
|
||||
|
||||
Subcommand tree:
|
||||
platform list/get
|
||||
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used
|
||||
platform list/get/ensure
|
||||
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
|
||||
init-master-key|export-credentials|import-credentials
|
||||
credential pick|resolve
|
||||
lease release|list|cleanup
|
||||
(legacy) add|list|get|list-json|pick-web|open|delete
|
||||
@@ -12,11 +13,18 @@ Subcommand tree:
|
||||
Machine commands output single-line JSON where appropriate.
|
||||
"""
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from service.account_crypto_commands import (
|
||||
cmd_account_export_credentials,
|
||||
cmd_account_import_credentials,
|
||||
cmd_account_init_master_key,
|
||||
)
|
||||
from service.account_service import (
|
||||
cmd_account_add_secret,
|
||||
cmd_account_add_web,
|
||||
cmd_account_get,
|
||||
cmd_account_get_credential,
|
||||
cmd_account_list,
|
||||
cmd_account_mark_error,
|
||||
cmd_account_mark_session,
|
||||
@@ -34,6 +42,7 @@ from service.account_service import (
|
||||
cmd_list,
|
||||
cmd_list_json,
|
||||
cmd_pick_web,
|
||||
cmd_platform_ensure,
|
||||
cmd_platform_get,
|
||||
cmd_platform_list,
|
||||
)
|
||||
@@ -52,18 +61,22 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
平台管理:
|
||||
platform list [--domain logistics|llm|content]
|
||||
platform get <platform>
|
||||
platform ensure --key <key> --display-name <name> [--domain <d>]
|
||||
[--url <url>] [--auth-strategy <s>]
|
||||
|
||||
账号管理:
|
||||
account add-web --platform <p> --login-id <id> --login-id-type <type>
|
||||
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
||||
[--role <role>] [--provider-code <code>] [--url <url>]
|
||||
[--extra-json <json>] [--profile-dir <path>]
|
||||
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
|
||||
account add-secret --platform <p> --credential-type <type>
|
||||
--secret-storage env|windows_credential|none
|
||||
--secret-storage env|windows_credential|none|local_encrypted
|
||||
[--secret-ref <env_var>] [--secret-stdin]
|
||||
[--label <label>] [--account-id <id>]
|
||||
[--environment <env>] [--role <role>]
|
||||
account get <id> [--with-credentials]
|
||||
account get-credential <account_id> [--reveal --lease-token <token>]
|
||||
account list [--platform <p>] [--environment <env>]
|
||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||
@@ -73,6 +86,9 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
account mark-error <id> --code <code> --message <msg>
|
||||
account mark-used <id>
|
||||
account open <id>
|
||||
account init-master-key [--print|--from-env|--rotate]
|
||||
account export-credentials --plaintext
|
||||
account import-credentials [--replace-all] < backup.json
|
||||
|
||||
凭据管理:
|
||||
credential pick --platform <p> [--type <type>] [--environment <env>]
|
||||
@@ -148,7 +164,7 @@ def main() -> None:
|
||||
# ---- platform subcommand ----
|
||||
if cmd == "platform":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get")
|
||||
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get | ensure")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "list":
|
||||
@@ -159,8 +175,30 @@ def main() -> None:
|
||||
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
|
||||
sys.exit(1)
|
||||
cmd_platform_get(av[3])
|
||||
elif sub == "ensure":
|
||||
import argparse as _ap
|
||||
p = _ap.ArgumentParser(prog="platform ensure")
|
||||
p.add_argument("--key", required=True)
|
||||
p.add_argument("--display-name", default="")
|
||||
p.add_argument("--domain", default="generic")
|
||||
p.add_argument("--url", default="")
|
||||
p.add_argument("--auth-strategy", default=None)
|
||||
p.add_argument("--aliases", default=None,
|
||||
help="JSON 数组字符串,如 '[\"1688\",\"阿里巴巴1688\"]'")
|
||||
p.add_argument("--capabilities", default=None,
|
||||
help='JSON 对象,如 \'{"web":true,"api_key":false,"rpa":true}\'')
|
||||
args = p.parse_args(av[3:])
|
||||
sys.exit(cmd_platform_ensure(
|
||||
key=args.key,
|
||||
display_name=args.display_name or args.key,
|
||||
domain=args.domain,
|
||||
url=args.url,
|
||||
auth_strategy=args.auth_strategy,
|
||||
aliases=args.aliases,
|
||||
capabilities=args.capabilities,
|
||||
))
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
|
||||
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list、get 或 ensure。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- account subcommand ----
|
||||
@@ -174,6 +212,11 @@ def main() -> None:
|
||||
_cmd_add_web(av)
|
||||
elif sub == "add-secret":
|
||||
_cmd_add_secret(av)
|
||||
elif sub == "get-credential":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get-credential 需要 <account_id>。")
|
||||
sys.exit(1)
|
||||
_cmd_get_credential(av)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
|
||||
@@ -211,6 +254,12 @@ def main() -> None:
|
||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||
sys.exit(1)
|
||||
cmd_open(av[3])
|
||||
elif sub == "init-master-key":
|
||||
cmd_account_init_master_key(av)
|
||||
elif sub == "export-credentials":
|
||||
cmd_account_export_credentials(av)
|
||||
elif sub == "import-credentials":
|
||||
cmd_account_import_credentials(av)
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
||||
print(_USAGE)
|
||||
@@ -352,6 +401,17 @@ def _cmd_add_web(argv: list[str]) -> None:
|
||||
url = _get_opt(argv, "--url")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
profile_dir_override = _get_opt(argv, "--profile-dir")
|
||||
auth_strategy = _get_opt(argv, "--auth-strategy")
|
||||
session_persistent_raw = _get_opt(argv, "--session-persistent")
|
||||
device_fingerprint = _get_opt(argv, "--device-fingerprint")
|
||||
|
||||
session_persistent: Optional[int] = None
|
||||
if session_persistent_raw is not None:
|
||||
try:
|
||||
session_persistent = int(session_persistent_raw)
|
||||
except ValueError:
|
||||
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数(0 或 1)。")
|
||||
return
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
|
||||
@@ -368,9 +428,24 @@ def _cmd_add_web(argv: list[str]) -> None:
|
||||
url=url,
|
||||
extra_json_str=extra_json_str,
|
||||
profile_dir_override=profile_dir_override,
|
||||
auth_strategy=auth_strategy,
|
||||
session_persistent=session_persistent,
|
||||
device_fingerprint=device_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_get_credential(argv: list[str]) -> None:
|
||||
aid_raw = argv[3]
|
||||
try:
|
||||
aid = int(aid_raw)
|
||||
except ValueError:
|
||||
print(f"ERROR:CLI_BAD_ARGS account_id 必须是整数:{aid_raw}")
|
||||
return
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
lease_token = _get_opt(argv, "--lease-token")
|
||||
cmd_account_get_credential(aid, reveal=reveal, lease_token=lease_token)
|
||||
|
||||
|
||||
def _cmd_add_secret(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--credential-type", "api_key")
|
||||
|
||||
@@ -10,7 +10,9 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.auth_strategy import ALL_AUTH_STRATEGIES, AUTH_STRATEGY_PASSWORD_AUTO
|
||||
from db.connection import get_conn, init_db
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
|
||||
@@ -32,8 +34,30 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str:
|
||||
"""Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
(platform_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
s = str(row[0])
|
||||
if s in ALL_AUTH_STRATEGIES:
|
||||
return s
|
||||
mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key)
|
||||
if mapped and mapped in ALL_AUTH_STRATEGIES:
|
||||
return mapped
|
||||
return AUTH_STRATEGY_PASSWORD_AUTO
|
||||
|
||||
|
||||
def _acc_row_to_dict(row) -> dict[str, Any]:
|
||||
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
|
||||
dev_fp = row[20]
|
||||
return {
|
||||
"id": row[0],
|
||||
"platform_key": row[1],
|
||||
@@ -53,8 +77,11 @@ def _acc_row_to_dict(row) -> dict[str, Any]:
|
||||
"last_error_code": row[15] or "",
|
||||
"last_error_message": row[16] or "",
|
||||
"extra_json": _safe_json_loads(row[17]),
|
||||
"created_at": _unix_to_iso_local(row[18]),
|
||||
"updated_at": _unix_to_iso_local(row[19]),
|
||||
"auth_strategy": row[18],
|
||||
"session_persistent": int(row[19]) if row[19] is not None else 1,
|
||||
"device_fingerprint": dev_fp if dev_fp is not None else None,
|
||||
"created_at": _unix_to_iso_local(row[21]),
|
||||
"updated_at": _unix_to_iso_local(row[22]),
|
||||
}
|
||||
|
||||
|
||||
@@ -74,14 +101,14 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
|
||||
"default_auth_strategy, created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
|
||||
(domain,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms ORDER BY platform_key"
|
||||
"default_auth_strategy, created_at, updated_at FROM platforms ORDER BY platform_key"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
result = []
|
||||
@@ -95,8 +122,9 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
|
||||
"aliases": _safe_json_loads(r[5], []),
|
||||
"capabilities": _safe_json_loads(r[6], {}),
|
||||
"enabled": bool(r[7]),
|
||||
"created_at": _unix_to_iso_local(r[8]),
|
||||
"updated_at": _unix_to_iso_local(r[9]),
|
||||
"default_auth_strategy": r[8],
|
||||
"created_at": _unix_to_iso_local(r[9]),
|
||||
"updated_at": _unix_to_iso_local(r[10]),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -106,7 +134,7 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms WHERE platform_key = ?",
|
||||
"default_auth_strategy, created_at, updated_at FROM platforms WHERE platform_key = ?",
|
||||
(platform_key,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
@@ -121,8 +149,9 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
|
||||
"aliases": _safe_json_loads(r[5], []),
|
||||
"capabilities": _safe_json_loads(r[6], {}),
|
||||
"enabled": bool(r[7]),
|
||||
"created_at": _unix_to_iso_local(r[8]),
|
||||
"updated_at": _unix_to_iso_local(r[9]),
|
||||
"default_auth_strategy": r[8],
|
||||
"created_at": _unix_to_iso_local(r[9]),
|
||||
"updated_at": _unix_to_iso_local(r[10]),
|
||||
}
|
||||
|
||||
|
||||
@@ -144,20 +173,30 @@ def insert_account(
|
||||
url: Optional[str],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
auth_strategy: str,
|
||||
session_persistent: int = 1,
|
||||
device_fingerprint: Optional[str] = None,
|
||||
) -> int:
|
||||
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||||
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
|
||||
if session_persistent not in (0, 1):
|
||||
raise ValueError("session_persistent must be 0 or 1")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO accounts
|
||||
(platform_key, account_label, login_id, login_id_type, tenant_id,
|
||||
environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?)
|
||||
status, session_status, extra_json,
|
||||
auth_strategy, session_persistent, device_fingerprint,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
platform_key, account_label, login_id, login_id_type, tenant_id,
|
||||
environment, role, provider_code, profile_dir, url,
|
||||
extra_json or "{}", now, now,
|
||||
extra_json or "{}", auth_strategy, session_persistent, device_fingerprint,
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
@@ -176,6 +215,7 @@ def get_account_by_id(account_id) -> Optional[dict]:
|
||||
profile_dir, url, status, session_status,
|
||||
last_used_at, last_login_check_at,
|
||||
last_error_code, last_error_message, extra_json,
|
||||
auth_strategy, session_persistent, device_fingerprint,
|
||||
created_at, updated_at
|
||||
FROM accounts WHERE id = ?
|
||||
""",
|
||||
@@ -232,7 +272,9 @@ def find_accounts(
|
||||
"SELECT id, platform_key, account_label, login_id, login_id_type, "
|
||||
"tenant_id, environment, role, provider_code, profile_dir, url, "
|
||||
"status, session_status, last_used_at, last_login_check_at, "
|
||||
"last_error_code, last_error_message, extra_json, created_at, updated_at "
|
||||
"last_error_code, last_error_message, extra_json, "
|
||||
"auth_strategy, session_persistent, device_fingerprint, "
|
||||
"created_at, updated_at "
|
||||
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
|
||||
)
|
||||
params.append(int(limit))
|
||||
@@ -328,6 +370,42 @@ def update_account_error(
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_account(
|
||||
conn,
|
||||
account_id: int,
|
||||
*,
|
||||
auth_strategy: Optional[str] = None,
|
||||
session_persistent: Optional[int] = None,
|
||||
device_fingerprint: Any = _UNSET,
|
||||
) -> None:
|
||||
"""Optional field updates for accounts v2 columns."""
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
if auth_strategy is not None:
|
||||
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||||
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
|
||||
sets.append("auth_strategy = ?")
|
||||
params.append(auth_strategy)
|
||||
if session_persistent is not None:
|
||||
if session_persistent not in (0, 1):
|
||||
raise ValueError("session_persistent must be 0 or 1")
|
||||
sets.append("session_persistent = ?")
|
||||
params.append(session_persistent)
|
||||
if device_fingerprint is not _UNSET:
|
||||
sets.append("device_fingerprint = ?")
|
||||
params.append(device_fingerprint)
|
||||
if not sets:
|
||||
return
|
||||
sets.append("updated_at = ?")
|
||||
params.append(_now_unix())
|
||||
params.append(int(account_id))
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
@@ -353,19 +431,20 @@ def insert_credential(
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
secret_ciphertext: Optional[str] = None,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO credentials
|
||||
(account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at, status,
|
||||
secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, status,
|
||||
extra_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at,
|
||||
secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at,
|
||||
extra_json or "{}", now, now,
|
||||
),
|
||||
)
|
||||
@@ -654,6 +733,79 @@ def cleanup_expired_leases() -> int:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_active_lease_by_token(conn, lease_token: str) -> Optional[dict[str, Any]]:
|
||||
"""Look up a lease by token: active and not expired.
|
||||
|
||||
Returns dict with id, account_id, lease_token, holder, purpose, expires_at (Unix sec),
|
||||
status; or None if not found / inactive / expired.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
now = _now_unix()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account_id, lease_token, holder, purpose, expires_at, status
|
||||
FROM account_leases
|
||||
WHERE lease_token = ? AND status = 'active' AND expires_at > ?
|
||||
""",
|
||||
(lease_token, now),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"account_id": row[1],
|
||||
"lease_token": row[2],
|
||||
"holder": row[3],
|
||||
"purpose": row[4],
|
||||
"expires_at": int(row[5]),
|
||||
"status": row[6],
|
||||
}
|
||||
|
||||
|
||||
def upsert_platform(
|
||||
conn,
|
||||
key: str,
|
||||
display_name: str,
|
||||
domain: str = "generic",
|
||||
provider_code: str | None = None,
|
||||
default_url: str = "",
|
||||
aliases_json: str = "[]",
|
||||
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
|
||||
default_auth_strategy: str | None = None,
|
||||
) -> None:
|
||||
"""幂等地注册或更新平台到 DB。"""
|
||||
now = int(time.time())
|
||||
if provider_code is None:
|
||||
provider_code = key
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO platforms (
|
||||
platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
enabled, default_auth_strategy, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(platform_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
domain = excluded.domain,
|
||||
provider_code = excluded.provider_code,
|
||||
default_url = excluded.default_url,
|
||||
aliases_json = excluded.aliases_json,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
enabled = 1,
|
||||
default_auth_strategy = COALESCE(excluded.default_auth_strategy,
|
||||
platforms.default_auth_strategy),
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
default_auth_strategy, now, now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Internal helpers
|
||||
# ============================================================================
|
||||
|
||||
29
scripts/db/auth_strategy.py
Normal file
29
scripts/db/auth_strategy.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth strategy identifiers for accounts (v2 schema).
|
||||
|
||||
Imported by migration, repositories, and later CLI/service layers.
|
||||
"""
|
||||
|
||||
AUTH_STRATEGY_PASSWORD_AUTO = "password_auto"
|
||||
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA = "password_with_captcha"
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA = "password_plus_2fa"
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL = "qr_code_manual"
|
||||
AUTH_STRATEGY_PER_SESSION_MANUAL = "per_session_manual"
|
||||
AUTH_STRATEGY_DEVICE_BOUND = "device_bound"
|
||||
AUTH_STRATEGY_CLIENT_CERTIFICATE = "client_certificate"
|
||||
AUTH_STRATEGY_API_TOKEN = "api_token"
|
||||
AUTH_STRATEGY_SSO_REDIRECT = "sso_redirect"
|
||||
|
||||
ALL_AUTH_STRATEGIES = frozenset(
|
||||
{
|
||||
AUTH_STRATEGY_PASSWORD_AUTO,
|
||||
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA,
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
AUTH_STRATEGY_PER_SESSION_MANUAL,
|
||||
AUTH_STRATEGY_DEVICE_BOUND,
|
||||
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||||
AUTH_STRATEGY_API_TOKEN,
|
||||
AUTH_STRATEGY_SSO_REDIRECT,
|
||||
}
|
||||
)
|
||||
@@ -14,7 +14,7 @@ import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from db.schema import ALL_DDL, SCHEMA_VERSION
|
||||
from db.schema import SCHEMA_VERSION, ensure_schema
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger
|
||||
from util.runtime_paths import get_db_path
|
||||
@@ -67,13 +67,6 @@ def _check_schema_version(conn: sqlite3.Connection) -> int:
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _create_new_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Execute ALL_DDL (all CREATE TABLE/INDEX IF NOT EXISTS)."""
|
||||
cur = conn.cursor()
|
||||
cur.executescript(ALL_DDL)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Idempotent: migrate if needed, create tables, set schema version."""
|
||||
log = get_skill_logger()
|
||||
@@ -88,10 +81,14 @@ def init_db() -> None:
|
||||
backed_up = _backup_old_accounts(conn)
|
||||
if backed_up:
|
||||
log.info("schema_init_backup_done old_version=%s", old_version)
|
||||
_create_new_tables(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
|
||||
ensure_schema(conn)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
|
||||
if old_version < SCHEMA_VERSION:
|
||||
log.info(
|
||||
"schema_init_done version=%s migrated_from=%s",
|
||||
SCHEMA_VERSION,
|
||||
@@ -99,12 +96,6 @@ def init_db() -> None:
|
||||
)
|
||||
else:
|
||||
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
|
||||
|
||||
# Ensure all tables exist even if schema version matches
|
||||
_create_new_tables(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
log.exception("schema_init_failure")
|
||||
raise
|
||||
|
||||
206
scripts/db/credentials_repo.py
Normal file
206
scripts/db/credentials_repo.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Credentials repository.
|
||||
|
||||
包含加密路径(secret_storage='local_encrypted')与现存非加密路径
|
||||
(none/env/windows_credential)的统一访问。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.accounts_repo import _unix_to_iso_local, insert_credential as _insert_credential_raw
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret
|
||||
from service.secret_store import delete_secret, mask_secret, read_secret
|
||||
|
||||
CREDENTIAL_TYPES = frozenset(
|
||||
{
|
||||
"password",
|
||||
"api_key",
|
||||
"api_token",
|
||||
"bearer_token",
|
||||
"oauth_client",
|
||||
"refresh_token",
|
||||
"browser_profile",
|
||||
"note",
|
||||
}
|
||||
)
|
||||
ALLOWED_SECRET_STORAGE = frozenset({"none", "env", "windows_credential", "local_encrypted"})
|
||||
|
||||
|
||||
class CredentialNotEncryptedError(Exception):
|
||||
"""Credential has no decryptable plaintext via this API (e.g. storage=none)."""
|
||||
|
||||
code = "ERR_CREDENTIAL_NOT_ENCRYPTED"
|
||||
|
||||
|
||||
def _validate_credential_type(credential_type: str) -> None:
|
||||
if credential_type not in CREDENTIAL_TYPES:
|
||||
raise ValueError(f"invalid credential_type: {credential_type!r}")
|
||||
|
||||
|
||||
def _validate_storage(secret_storage: str) -> None:
|
||||
if secret_storage not in ALLOWED_SECRET_STORAGE:
|
||||
raise ValueError(f"invalid secret_storage: {secret_storage!r}")
|
||||
|
||||
|
||||
def insert_credential_encrypted(
|
||||
conn,
|
||||
*,
|
||||
account_id: Optional[int],
|
||||
platform_key: str,
|
||||
credential_label: str,
|
||||
credential_type: str,
|
||||
plaintext: str,
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
_validate_credential_type(credential_type)
|
||||
if plaintext is None or str(plaintext).strip() == "":
|
||||
raise ValueError("plaintext required for encrypted credential insert")
|
||||
from service.crypto import encrypt_secret
|
||||
|
||||
ciphertext = encrypt_secret(str(plaintext))
|
||||
mask = mask_secret(str(plaintext))
|
||||
return _insert_credential_raw(
|
||||
conn,
|
||||
account_id,
|
||||
platform_key,
|
||||
credential_label,
|
||||
credential_type,
|
||||
"local_encrypted",
|
||||
None,
|
||||
mask,
|
||||
expires_at,
|
||||
extra_json,
|
||||
now,
|
||||
secret_ciphertext=ciphertext,
|
||||
)
|
||||
|
||||
|
||||
def insert_credential_external(
|
||||
conn,
|
||||
*,
|
||||
account_id: Optional[int],
|
||||
platform_key: str,
|
||||
credential_label: str,
|
||||
credential_type: str,
|
||||
secret_storage: str,
|
||||
secret_ref: Optional[str],
|
||||
secret_mask: Optional[str],
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
_validate_credential_type(credential_type)
|
||||
_validate_storage(secret_storage)
|
||||
if secret_storage == "local_encrypted":
|
||||
raise ValueError("use insert_credential_encrypted for local_encrypted")
|
||||
return _insert_credential_raw(
|
||||
conn,
|
||||
account_id,
|
||||
platform_key,
|
||||
credential_label,
|
||||
credential_type,
|
||||
secret_storage,
|
||||
secret_ref,
|
||||
secret_mask,
|
||||
expires_at,
|
||||
extra_json,
|
||||
now,
|
||||
secret_ciphertext=None,
|
||||
)
|
||||
|
||||
|
||||
def read_credential_plaintext(conn, credential_id: int) -> str:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT secret_storage, secret_ref, secret_ciphertext
|
||||
FROM credentials WHERE id = ?
|
||||
""",
|
||||
(int(credential_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"credential not found: id={credential_id}")
|
||||
|
||||
storage = (row[0] or "").strip().lower()
|
||||
secret_ref = row[1] or ""
|
||||
secret_ciphertext = row[2]
|
||||
|
||||
if storage == "local_encrypted":
|
||||
if not secret_ciphertext:
|
||||
raise CredentialDecryptError("missing secret_ciphertext")
|
||||
return decrypt_secret(str(secret_ciphertext))
|
||||
|
||||
if storage == "none":
|
||||
raise CredentialNotEncryptedError("credential storage is none")
|
||||
|
||||
if storage == "env":
|
||||
return read_secret("env", secret_ref)
|
||||
|
||||
if storage == "windows_credential":
|
||||
return read_secret("windows_credential", secret_ref)
|
||||
|
||||
raise ValueError(f"unsupported secret_storage: {storage!r}")
|
||||
|
||||
|
||||
def list_credentials_by_account(conn, account_id: int) -> list[dict[str, Any]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at, status,
|
||||
last_used_at, extra_json, created_at, updated_at
|
||||
FROM credentials
|
||||
WHERE account_id = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""",
|
||||
(int(account_id),),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
ex = r[11]
|
||||
parsed_ex: Any
|
||||
try:
|
||||
parsed_ex = json.loads(ex) if isinstance(ex, str) else (ex or {})
|
||||
except json.JSONDecodeError:
|
||||
parsed_ex = {}
|
||||
out.append(
|
||||
{
|
||||
"id": r[0],
|
||||
"account_id": r[1],
|
||||
"platform_key": r[2],
|
||||
"credential_label": r[3],
|
||||
"credential_type": r[4],
|
||||
"secret_storage": r[5],
|
||||
"secret_ref": r[6] or "",
|
||||
"secret_mask": r[7] or "",
|
||||
"expires_at": _unix_to_iso_local(r[8]),
|
||||
"status": r[9],
|
||||
"last_used_at": _unix_to_iso_local(r[10]),
|
||||
"extra_json": parsed_ex,
|
||||
"created_at": _unix_to_iso_local(r[12]),
|
||||
"updated_at": _unix_to_iso_local(r[13]),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def delete_credential(conn, credential_id: int) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_storage, secret_ref FROM credentials WHERE id = ?",
|
||||
(int(credential_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return
|
||||
storage = (row[0] or "").strip().lower()
|
||||
secret_ref = row[1] or ""
|
||||
if storage == "windows_credential" and secret_ref:
|
||||
delete_secret("windows_credential", secret_ref)
|
||||
cur.execute("DELETE FROM credentials WHERE id = ?", (int(credential_id),))
|
||||
30
scripts/db/platform_defaults.py
Normal file
30
scripts/db/platform_defaults.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""平台默认 auth_strategy 映射表。
|
||||
|
||||
只覆盖已知平台;其它平台需要时手工 UPDATE platforms SET default_auth_strategy=... WHERE platform_key=...
|
||||
"""
|
||||
|
||||
PLATFORM_DEFAULT_AUTH_STRATEGY = {
|
||||
# 物流仿真平台(monitor-competitor-rates 在用)
|
||||
"maersk": "qr_code_manual",
|
||||
"cosco": "qr_code_manual",
|
||||
"msc": "qr_code_manual",
|
||||
# 内容平台(典型扫码)
|
||||
"douyin": "qr_code_manual",
|
||||
"wechat_mp": "qr_code_manual",
|
||||
"xiaohongshu": "qr_code_manual",
|
||||
"bilibili": "qr_code_manual",
|
||||
"toutiao": "qr_code_manual",
|
||||
"sohu": "qr_code_manual",
|
||||
# 开发平台(2FA 标配)
|
||||
"github": "password_plus_2fa",
|
||||
"aws": "password_plus_2fa",
|
||||
"gcp": "password_plus_2fa",
|
||||
# 内部演示/仿真(账密自动)
|
||||
"icbc_sim": "password_auto",
|
||||
# API 平台
|
||||
"openai": "api_token",
|
||||
"anthropic": "api_token",
|
||||
"deepseek": "api_token",
|
||||
"doubao": "api_token",
|
||||
}
|
||||
@@ -6,11 +6,16 @@ Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
|
||||
sqlite_master, visible in DBeaver / Navicat.
|
||||
|
||||
Schema version management:
|
||||
PRAGMA user_version = 2 (set in connection.py after migration).
|
||||
- PRAGMA user_version = 2 (set in connection.py after ensure_schema).
|
||||
- _schema_meta schema_version: "2" after migrate_v1_to_v2, "2.1" after migrate_v2_to_v2_1.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -24,6 +29,7 @@ PLATFORMS_TABLE_SQL = textwrap.dedent("""\
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
|
||||
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
|
||||
default_auth_strategy TEXT, -- 新账号默认认证策略;可空,手工维护
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
@@ -57,6 +63,9 @@ ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
|
||||
last_error_code TEXT, -- 最近一次错误代码
|
||||
last_error_message TEXT, -- 最近一次错误消息
|
||||
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
|
||||
auth_strategy TEXT NOT NULL, -- 认证策略(见 db.auth_strategy)
|
||||
session_persistent INTEGER NOT NULL DEFAULT 1, -- 是否需要 profile 持久化 (0/1)
|
||||
device_fingerprint TEXT, -- device_bound 策略用;可空
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
@@ -84,9 +93,10 @@ CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
|
||||
platform_key TEXT NOT NULL, -- 平台唯一键
|
||||
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
|
||||
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
|
||||
secret_storage TEXT NOT NULL, -- none / env / windows_credential
|
||||
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none 时为空
|
||||
secret_storage TEXT NOT NULL, -- none / env / windows_credential / local_encrypted
|
||||
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none/local_encrypted 时为空
|
||||
secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文
|
||||
secret_ciphertext TEXT, -- secret_storage='local_encrypted' 时为 Fernet 密文;其它后端为 NULL
|
||||
expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
|
||||
last_used_at INTEGER, -- 最近使用时间
|
||||
@@ -98,7 +108,6 @@ CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
|
||||
|
||||
CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status);
|
||||
""")
|
||||
|
||||
@@ -128,6 +137,16 @@ ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schema meta — logical schema version (v1→v2 migration marker)
|
||||
# ---------------------------------------------------------------------------
|
||||
SCHEMA_META_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS _schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full DDL assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -140,6 +159,134 @@ ALL_DDL = (
|
||||
+ CREDENTIALS_INDEXES_SQL
|
||||
+ ACCOUNT_LEASES_TABLE_SQL
|
||||
+ ACCOUNT_LEASES_INDEXES_SQL
|
||||
+ SCHEMA_META_TABLE_SQL
|
||||
)
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def column_exists(conn: sqlite3.Connection, table: str, col: str) -> bool:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == col for row in cur.fetchall())
|
||||
|
||||
|
||||
def _schema_meta_get(conn: sqlite3.Connection, key: str) -> str | None:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM _schema_meta WHERE key = ?", (key,))
|
||||
row = cur.fetchone()
|
||||
return str(row[0]) if row and row[0] is not None else None
|
||||
|
||||
|
||||
def _schema_meta_upsert(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO _schema_meta (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
|
||||
"""Apply ALTER steps + backfills once; idempotent via _schema_meta.schema_version."""
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
|
||||
cur = conn.cursor()
|
||||
sv = _schema_meta_get(conn, "schema_version")
|
||||
if sv == "2" or (sv and sv.startswith("2.")):
|
||||
return
|
||||
|
||||
n_platform_updates = 0
|
||||
|
||||
# 1–2: platforms.default_auth_strategy
|
||||
if not column_exists(conn, "platforms", "default_auth_strategy"):
|
||||
cur.execute("ALTER TABLE platforms ADD COLUMN default_auth_strategy TEXT")
|
||||
|
||||
for pk, strat in PLATFORM_DEFAULT_AUTH_STRATEGY.items():
|
||||
cur.execute(
|
||||
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
|
||||
(strat, pk),
|
||||
)
|
||||
n_platform_updates += cur.rowcount
|
||||
|
||||
# 3: accounts columns (nullable auth_strategy during ALTER for legacy rows)
|
||||
if not column_exists(conn, "accounts", "auth_strategy"):
|
||||
cur.execute("ALTER TABLE accounts ADD COLUMN auth_strategy TEXT")
|
||||
if not column_exists(conn, "accounts", "session_persistent"):
|
||||
cur.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN session_persistent INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
if not column_exists(conn, "accounts", "device_fingerprint"):
|
||||
cur.execute("ALTER TABLE accounts ADD COLUMN device_fingerprint TEXT")
|
||||
|
||||
# 4: fill accounts.auth_strategy + session_persistent
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE accounts SET
|
||||
auth_strategy = COALESCE(
|
||||
(SELECT p.default_auth_strategy FROM platforms p
|
||||
WHERE p.platform_key = accounts.platform_key),
|
||||
?
|
||||
),
|
||||
session_persistent = COALESCE(session_persistent, 1)
|
||||
WHERE auth_strategy IS NULL OR TRIM(auth_strategy) = ''
|
||||
""",
|
||||
(AUTH_STRATEGY_PASSWORD_AUTO,),
|
||||
)
|
||||
m_accounts_updated = cur.rowcount
|
||||
|
||||
# 5: credentials.account_id (v1 may omit it)
|
||||
if not column_exists(conn, "credentials", "account_id"):
|
||||
cur.execute("ALTER TABLE credentials ADD COLUMN account_id INTEGER")
|
||||
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id)"
|
||||
)
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM platforms WHERE default_auth_strategy IS NULL")
|
||||
k_null_platforms = int(cur.fetchone()[0])
|
||||
|
||||
_schema_meta_upsert(conn, "schema_version", "2")
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
f"[migration] account-manager v1→v2: updated {n_platform_updates} platforms, "
|
||||
f"{m_accounts_updated} accounts.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[migration] platforms with default_auth_strategy=NULL: "
|
||||
f"{k_null_platforms} (manually fix later)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def migrate_v2_to_v2_1(conn: sqlite3.Connection) -> None:
|
||||
"""v2 → v2.1: credentials 表加 secret_ciphertext 列。幂等。"""
|
||||
cur = conn.cursor()
|
||||
if _schema_meta_get(conn, "schema_version") == "2.1":
|
||||
return
|
||||
|
||||
if column_exists(conn, "credentials", "secret_ciphertext"):
|
||||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
cur.execute("ALTER TABLE credentials ADD COLUMN secret_ciphertext TEXT")
|
||||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||||
conn.commit()
|
||||
print(
|
||||
"[migration] account-manager v2→v2.1: credentials.secret_ciphertext column added.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
"""CREATE TABLE/INDEX IF NOT EXISTS, then run migrate_v1_to_v2."""
|
||||
cur = conn.cursor()
|
||||
cur.executescript(ALL_DDL)
|
||||
conn.commit()
|
||||
migrate_v1_to_v2(conn)
|
||||
migrate_v2_to_v2_1(conn)
|
||||
|
||||
304
scripts/service/account_crypto_commands.py
Normal file
304
scripts/service/account_crypto_commands.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLI handlers for master key init and credential export/import (stage B)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.accounts_repo import _unix_to_iso_local
|
||||
from db.connection import get_conn, init_db
|
||||
from db.credentials_repo import insert_credential_encrypted
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret
|
||||
from service.master_key import (
|
||||
ENV_MASTER_KEY,
|
||||
MasterKeyExistsError,
|
||||
MasterKeyInvalidError,
|
||||
generate_master_key,
|
||||
normalize_master_key_input,
|
||||
write_master_key_file,
|
||||
)
|
||||
from service.secret_store import mask_secret, read_secret
|
||||
|
||||
|
||||
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||
return flag in argv
|
||||
|
||||
|
||||
def _get_opt(argv: list[str], flag: str, default=None):
|
||||
if flag in argv:
|
||||
idx = argv.index(flag)
|
||||
if idx + 1 < len(argv):
|
||||
return argv[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
def _key_preview(key_bytes: bytes) -> str:
|
||||
s = key_bytes.strip().decode("ascii")
|
||||
if len(s) <= 8:
|
||||
return "****"
|
||||
return s[:4] + "****" + s[-4:]
|
||||
|
||||
|
||||
def cmd_account_init_master_key(argv: list[str]) -> None:
|
||||
"""account init-master-key [--from-env] [--print] [--rotate]"""
|
||||
from_env = _has_flag(argv, "--from-env")
|
||||
do_print = _has_flag(argv, "--print")
|
||||
rotate = _has_flag(argv, "--rotate")
|
||||
|
||||
try:
|
||||
if from_env:
|
||||
raw = (os.environ.get(ENV_MASTER_KEY) or "").strip()
|
||||
if not raw:
|
||||
raise MasterKeyInvalidError("ACCOUNT_MANAGER_MASTER_KEY is not set")
|
||||
key = normalize_master_key_input(raw)
|
||||
else:
|
||||
key = generate_master_key()
|
||||
|
||||
if rotate:
|
||||
print(
|
||||
"[init-master-key] WARNING: --rotate overwrites master.key; "
|
||||
"ensure you have an export backup.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
path_written: Optional[str] = None
|
||||
if not do_print:
|
||||
path_written = write_master_key_file(key, allow_overwrite=rotate)
|
||||
|
||||
if do_print:
|
||||
line = json.dumps(
|
||||
{"success": True, "key": key.decode("ascii")},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
print(line)
|
||||
return
|
||||
|
||||
assert path_written is not None
|
||||
preview = _key_preview(key)
|
||||
print(
|
||||
json.dumps(
|
||||
{"success": True, "path": path_written, "key_preview": preview},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except MasterKeyExistsError as e:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {"code": e.code, "message": str(e)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except MasterKeyInvalidError as e:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {"code": e.code, "message": str(e)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_account_export_credentials(argv: list[str]) -> None:
|
||||
"""account export-credentials --plaintext"""
|
||||
if not _has_flag(argv, "--plaintext"):
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG",
|
||||
"message": "export requires explicit --plaintext acknowledgement",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
"[export] WARNING: plaintext credentials written to stdout. Handle with extreme care.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, secret_ciphertext,
|
||||
expires_at, extra_json, created_at, updated_at
|
||||
FROM credentials
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
cid = r[0]
|
||||
storage = (r[5] or "").strip().lower()
|
||||
secret_ref = r[6] or ""
|
||||
secret_mask = r[7] or ""
|
||||
ct = r[8]
|
||||
extra_raw = r[10]
|
||||
try:
|
||||
extra_obj = json.loads(extra_raw) if isinstance(extra_raw, str) else {}
|
||||
except json.JSONDecodeError:
|
||||
extra_obj = {}
|
||||
|
||||
plaintext: Optional[str] = None
|
||||
extra_out = dict(extra_obj)
|
||||
|
||||
if storage == "none":
|
||||
plaintext = None
|
||||
elif storage == "local_encrypted":
|
||||
try:
|
||||
if not ct:
|
||||
raise CredentialDecryptError("missing ciphertext")
|
||||
plaintext = decrypt_secret(str(ct))
|
||||
except CredentialDecryptError as e:
|
||||
print(
|
||||
f"[export] decrypt_failed id={cid} code={e.code} msg={e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
plaintext = None
|
||||
elif storage == "env":
|
||||
val = os.environ.get(secret_ref)
|
||||
if val is None:
|
||||
plaintext = None
|
||||
extra_out["export_note"] = "env var missing at export time"
|
||||
else:
|
||||
plaintext = val
|
||||
elif storage == "windows_credential":
|
||||
try:
|
||||
plaintext = read_secret("windows_credential", secret_ref)
|
||||
except (ValueError, OSError) as e:
|
||||
plaintext = None
|
||||
extra_out["export_note"] = f"windows_credential read failed: {e}"
|
||||
else:
|
||||
extra_out["export_note"] = f"unsupported storage at export: {storage}"
|
||||
|
||||
item = {
|
||||
"id": cid,
|
||||
"account_id": r[1],
|
||||
"platform_key": r[2],
|
||||
"credential_label": r[3],
|
||||
"credential_type": r[4],
|
||||
"secret_storage": r[5],
|
||||
"plaintext": plaintext,
|
||||
"secret_mask": secret_mask,
|
||||
"expires_at": _unix_to_iso_local(r[9]),
|
||||
"extra_json": extra_out,
|
||||
"created_at": _unix_to_iso_local(r[11]),
|
||||
"updated_at": _unix_to_iso_local(r[12]),
|
||||
}
|
||||
out.append(item)
|
||||
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _iso_maybe_to_unix(val: Any) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (int, float)):
|
||||
return int(val)
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
return int(dt.timestamp())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_account_import_credentials(argv: list[str]) -> None:
|
||||
"""account import-credentials [--replace-all] < stdin JSON array"""
|
||||
replace_all = _has_flag(argv, "--replace-all")
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[import] fatal: invalid JSON stdin: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not isinstance(data, list):
|
||||
print("[import] fatal: stdin JSON must be an array", file=sys.stderr)
|
||||
return
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if replace_all:
|
||||
cur.execute(
|
||||
"DELETE FROM credentials WHERE secret_storage = ?",
|
||||
("local_encrypted",),
|
||||
)
|
||||
now = int(time.time())
|
||||
for idx, row in enumerate(data):
|
||||
if not isinstance(row, dict):
|
||||
print(f"[import] skip line {idx}: not an object", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
pt = row.get("plaintext")
|
||||
if pt is None or str(pt).strip() == "":
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
plat = str(row.get("platform_key") or "").strip()
|
||||
label = str(row.get("credential_label") or "").strip()
|
||||
ctype = str(row.get("credential_type") or "").strip()
|
||||
if not plat or not label or not ctype:
|
||||
raise ValueError("missing platform_key/credential_label/credential_type")
|
||||
|
||||
account_id = row.get("account_id")
|
||||
aid = int(account_id) if account_id is not None else None
|
||||
|
||||
expires_at = _iso_maybe_to_unix(row.get("expires_at"))
|
||||
ex = row.get("extra_json")
|
||||
if isinstance(ex, dict):
|
||||
extra_json = json.dumps(ex, ensure_ascii=False)
|
||||
elif isinstance(ex, str):
|
||||
extra_json = ex
|
||||
else:
|
||||
extra_json = "{}"
|
||||
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=aid,
|
||||
platform_key=plat,
|
||||
credential_label=label,
|
||||
credential_type=ctype,
|
||||
plaintext=str(pt),
|
||||
expires_at=expires_at,
|
||||
extra_json=extra_json,
|
||||
now=now,
|
||||
)
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
print(f"[import] skip line {idx}: {e}", file=sys.stderr)
|
||||
skipped += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f"[import] inserted {inserted} credentials, skipped {skipped} lines (no plaintext).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -28,6 +28,7 @@ from db.accounts_repo import (
|
||||
find_accounts,
|
||||
find_credentials,
|
||||
get_account_by_id,
|
||||
get_active_lease_by_token,
|
||||
get_credential_by_id,
|
||||
get_platform_from_db,
|
||||
insert_account,
|
||||
@@ -36,10 +37,27 @@ from db.accounts_repo import (
|
||||
list_platforms_from_db,
|
||||
platform_exists,
|
||||
release_lease,
|
||||
resolve_auth_strategy_for_platform,
|
||||
update_account_error,
|
||||
update_account_last_used,
|
||||
update_account_session_status,
|
||||
update_credential_last_used,
|
||||
upsert_platform,
|
||||
)
|
||||
from db.auth_strategy import (
|
||||
ALL_AUTH_STRATEGIES,
|
||||
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||||
AUTH_STRATEGY_DEVICE_BOUND,
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||
AUTH_STRATEGY_PER_SESSION_MANUAL,
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
AUTH_STRATEGY_SSO_REDIRECT,
|
||||
)
|
||||
from db.credentials_repo import (
|
||||
CredentialNotEncryptedError,
|
||||
insert_credential_encrypted,
|
||||
list_credentials_by_account,
|
||||
read_credential_plaintext,
|
||||
)
|
||||
from db.connection import get_conn, init_db
|
||||
from util.constants import SKILL_SLUG
|
||||
@@ -55,6 +73,8 @@ from util.runtime_paths import (
|
||||
get_skill_data_dir,
|
||||
_runtime_paths_debug_text,
|
||||
)
|
||||
from service.crypto import CredentialDecryptError
|
||||
from service.master_key import MasterKeyMissingError, is_master_key_available
|
||||
from service.secret_store import (
|
||||
mask_secret,
|
||||
make_windows_credential_target,
|
||||
@@ -82,6 +102,20 @@ def _credential_ok_line(payload: dict) -> str:
|
||||
return json.dumps({"success": True, **payload}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _derive_session_persistent(auth_strategy: str) -> int:
|
||||
"""Derive default session_persistent from auth_strategy when CLI omits it."""
|
||||
if auth_strategy in (
|
||||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||
AUTH_STRATEGY_DEVICE_BOUND,
|
||||
AUTH_STRATEGY_SSO_REDIRECT,
|
||||
):
|
||||
return 1
|
||||
if auth_strategy in (AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_CLIENT_CERTIFICATE):
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def _insert_secret_holder_account(
|
||||
conn,
|
||||
*,
|
||||
@@ -108,6 +142,7 @@ def _insert_secret_holder_account(
|
||||
url=url,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy=resolve_auth_strategy_for_platform(conn, platform_key),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,15 +191,49 @@ def _remove_profile_dir(path: str) -> None:
|
||||
|
||||
def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]:
|
||||
"""Resolve platform key or print ERROR:INVALID_PLATFORM and return None."""
|
||||
key = resolve_platform_key((input_name or "").strip())
|
||||
if not key:
|
||||
_say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。")
|
||||
if hint:
|
||||
_say(hint)
|
||||
else:
|
||||
_say("支持:" + _platform_list_cn_for_help())
|
||||
raw = (input_name or "").strip()
|
||||
if not raw:
|
||||
_say("ERROR:INVALID_PLATFORM 平台名不能为空。")
|
||||
return None
|
||||
return key
|
||||
|
||||
# 先尝试 platforms.py 硬编码(兼容历史)
|
||||
key = resolve_platform_key(raw)
|
||||
if key:
|
||||
return key
|
||||
|
||||
# 再查 DB(支持动态注册的平台)
|
||||
try:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT platform_key FROM platforms WHERE platform_key = ? AND enabled = 1",
|
||||
(raw,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT platform_key, aliases_json FROM platforms WHERE enabled = 1"
|
||||
).fetchall()
|
||||
raw_lower = raw.lower()
|
||||
for r in rows:
|
||||
try:
|
||||
aliases = json.loads(r[1] or "[]")
|
||||
if raw_lower in [str(a).lower() for a in aliases]:
|
||||
return r[0]
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。")
|
||||
if hint:
|
||||
_say(hint)
|
||||
else:
|
||||
_say("支持:" + _platform_list_cn_for_help())
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -204,6 +273,55 @@ def cmd_platform_get(input_key: str) -> None:
|
||||
_say(_err_json("ERROR:INVALID_PLATFORM", f"Platform '{key}' not found in DB."))
|
||||
|
||||
|
||||
def cmd_platform_ensure(
|
||||
key: str,
|
||||
display_name: str,
|
||||
domain: str = "generic",
|
||||
url: str = "",
|
||||
auth_strategy: Optional[str] = None,
|
||||
aliases: Optional[str] = None,
|
||||
capabilities: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
幂等地注册或更新平台到 DB。
|
||||
输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"}
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
key = (key or "").strip()
|
||||
display_name = (display_name or key).strip()
|
||||
if not key:
|
||||
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
|
||||
return 1
|
||||
|
||||
aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False)
|
||||
capabilities_json = capabilities if capabilities else '{"web": true, "api_key": false, "rpa": true}'
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
existed = bool(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
|
||||
).fetchone()
|
||||
)
|
||||
upsert_platform(
|
||||
conn,
|
||||
key=key,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
default_url=url,
|
||||
aliases_json=aliases_json,
|
||||
capabilities_json=capabilities_json,
|
||||
default_auth_strategy=auth_strategy,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
action = "updated" if existed else "created"
|
||||
log.info("platform_ensure key=%s action=%s", key, action)
|
||||
_say(json.dumps({"ok": True, "platform_key": key, "action": action}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Account add-web
|
||||
# ============================================================================
|
||||
@@ -220,6 +338,10 @@ def cmd_account_add_web(
|
||||
url: Optional[str],
|
||||
extra_json_str: Optional[str],
|
||||
profile_dir_override: Optional[str],
|
||||
*,
|
||||
auth_strategy: Optional[str] = None,
|
||||
session_persistent: Optional[int] = None,
|
||||
device_fingerprint: Optional[str] = None,
|
||||
) -> None:
|
||||
"""account add-web — create a web/RPA account record."""
|
||||
log = get_skill_logger()
|
||||
@@ -261,6 +383,35 @@ def cmd_account_add_web(
|
||||
conn = get_conn()
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
if auth_strategy is None:
|
||||
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
|
||||
else:
|
||||
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||||
_say(_err_json(
|
||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||
f"不支持的 auth_strategy:{auth_strategy}",
|
||||
))
|
||||
return
|
||||
resolved_auth = auth_strategy
|
||||
|
||||
if session_persistent is None:
|
||||
sp = _derive_session_persistent(resolved_auth)
|
||||
else:
|
||||
if session_persistent not in (0, 1):
|
||||
_say(_err_json(
|
||||
"ERR_INVALID_SESSION_PERSISTENT",
|
||||
"session_persistent 必须是 0 或 1。",
|
||||
))
|
||||
return
|
||||
sp = session_persistent
|
||||
|
||||
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
||||
_say(_err_json(
|
||||
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
||||
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
||||
))
|
||||
return
|
||||
|
||||
new_id = insert_account(
|
||||
conn=conn,
|
||||
platform_key=key,
|
||||
@@ -275,6 +426,9 @@ def cmd_account_add_web(
|
||||
url=resolved_url,
|
||||
extra_json=resolved_extra,
|
||||
now=now,
|
||||
auth_strategy=resolved_auth,
|
||||
session_persistent=sp,
|
||||
device_fingerprint=device_fingerprint,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
@@ -298,6 +452,9 @@ def cmd_account_add_web(
|
||||
"provider_code": resolved_provider,
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"auth_strategy": resolved_auth,
|
||||
"session_persistent": sp,
|
||||
"device_fingerprint": device_fingerprint,
|
||||
}))
|
||||
|
||||
|
||||
@@ -327,9 +484,9 @@ def cmd_account_add_secret(
|
||||
return
|
||||
|
||||
storage_n = secret_storage.strip().lower()
|
||||
if storage_n not in ("none", "env", "windows_credential"):
|
||||
if storage_n not in ("none", "env", "windows_credential", "local_encrypted"):
|
||||
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
||||
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential"))
|
||||
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted"))
|
||||
return
|
||||
|
||||
# Validate storage/type combinations
|
||||
@@ -347,6 +504,123 @@ def cmd_account_add_secret(
|
||||
now = int(time.time())
|
||||
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
|
||||
|
||||
if storage_n == "local_encrypted":
|
||||
if not is_master_key_available():
|
||||
_say(_err_json(
|
||||
"ERR_MASTER_KEY_MISSING",
|
||||
"master key 未初始化。先跑 `account init-master-key`。",
|
||||
))
|
||||
return
|
||||
if not secret_stdin:
|
||||
_say(_err_json(
|
||||
"ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN",
|
||||
"local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。",
|
||||
))
|
||||
return
|
||||
if credential_type not in (
|
||||
"password",
|
||||
"api_key",
|
||||
"api_token",
|
||||
"bearer_token",
|
||||
"refresh_token",
|
||||
"oauth_client",
|
||||
):
|
||||
_say(_err_json(
|
||||
"ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED",
|
||||
f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。",
|
||||
))
|
||||
return
|
||||
if secret_ref:
|
||||
_say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).")
|
||||
|
||||
extra: dict = {}
|
||||
if extra_json_str:
|
||||
try:
|
||||
extra = json.loads(extra_json_str)
|
||||
except json.JSONDecodeError:
|
||||
extra = {}
|
||||
resolved_env = environment or "production"
|
||||
resolved_role = role or "api"
|
||||
extra["environment"] = resolved_env
|
||||
extra["role"] = resolved_role
|
||||
resolved_extra = json.dumps(extra, ensure_ascii=False)
|
||||
resolved_url = (platform_spec.get("default_url") or "").strip() or None
|
||||
resolved_provider = platform_spec.get("provider_code") or key
|
||||
|
||||
plaintext = sys.stdin.readline().rstrip("\r\n")
|
||||
if not plaintext:
|
||||
_say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。"))
|
||||
return
|
||||
secret_mask_out = mask_secret(plaintext)
|
||||
|
||||
conn = get_conn()
|
||||
cred_id: Optional[int] = None
|
||||
holder_id: Optional[int] = None
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
holder_id = account_id
|
||||
if holder_id is not None:
|
||||
acc = get_account_by_id(holder_id)
|
||||
if not acc:
|
||||
_say(_err_json(
|
||||
"ERROR:ACCOUNT_NOT_FOUND",
|
||||
f"account_id={holder_id} 不存在。",
|
||||
))
|
||||
return
|
||||
if holder_id is None:
|
||||
holder_id = _insert_secret_holder_account(
|
||||
conn,
|
||||
platform_key=key,
|
||||
account_label=safe_label,
|
||||
environment=resolved_env,
|
||||
role=resolved_role,
|
||||
provider_code=resolved_provider,
|
||||
url=resolved_url,
|
||||
now=now,
|
||||
)
|
||||
cred_id = insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=holder_id,
|
||||
platform_key=key,
|
||||
credential_label=safe_label,
|
||||
credential_type=credential_type,
|
||||
plaintext=plaintext,
|
||||
expires_at=None,
|
||||
extra_json=resolved_extra,
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
except MasterKeyMissingError as e:
|
||||
conn.rollback()
|
||||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||||
return
|
||||
except CredentialDecryptError as e:
|
||||
conn.rollback()
|
||||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||||
return
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
log.exception("account_add_secret_failure")
|
||||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log.info(
|
||||
"account_add_secret_encrypted_success cred_id=%s account_id=%s",
|
||||
cred_id,
|
||||
holder_id,
|
||||
)
|
||||
_say(_ok_json({
|
||||
"credential_id": cred_id,
|
||||
"account_id": holder_id,
|
||||
"platform_key": key,
|
||||
"credential_type": credential_type,
|
||||
"secret_storage": "local_encrypted",
|
||||
"secret_mask": secret_mask_out,
|
||||
}))
|
||||
return
|
||||
|
||||
# For env storage: just use the env var name as ref, mask is optional
|
||||
if storage_n == "env":
|
||||
secret_ref_value = secret_ref.strip()
|
||||
@@ -589,6 +863,121 @@ def cmd_account_get(account_id, include_credentials: bool = False) -> None:
|
||||
_say(json.dumps(acc, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_account_get_credential(
|
||||
account_id: int,
|
||||
*,
|
||||
reveal: bool = False,
|
||||
lease_token: Optional[str] = None,
|
||||
) -> None:
|
||||
"""account get-credential — metadata or plaintext when lease proves access."""
|
||||
log = get_skill_logger()
|
||||
log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal)
|
||||
|
||||
init_db()
|
||||
acc = get_account_by_id(account_id)
|
||||
if not acc:
|
||||
log.warning("account_get_credential_not_found id=%s", account_id)
|
||||
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
|
||||
return
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
creds_all = list_credentials_by_account(conn, account_id)
|
||||
creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"]
|
||||
if not creds:
|
||||
_say(_err_json(
|
||||
"ERR_CREDENTIAL_MISSING",
|
||||
f"账号 id={account_id} 没有活跃凭据。",
|
||||
))
|
||||
return
|
||||
cred = creds[0]
|
||||
|
||||
base_out = {
|
||||
"success": True,
|
||||
"account_id": account_id,
|
||||
"credential_id": cred["id"],
|
||||
"credential_type": cred["credential_type"],
|
||||
"secret_storage": cred["secret_storage"],
|
||||
"secret_mask": cred["secret_mask"],
|
||||
}
|
||||
|
||||
if not reveal:
|
||||
_say(json.dumps(base_out, ensure_ascii=False))
|
||||
return
|
||||
|
||||
tok = (lease_token or "").strip()
|
||||
if not tok:
|
||||
log.warning("account_get_credential_reveal_missing_token id=%s", account_id)
|
||||
_say(_err_json(
|
||||
"ERR_LEASE_INVALID",
|
||||
"reveal 必须带 --lease-token。",
|
||||
))
|
||||
return
|
||||
|
||||
lease = get_active_lease_by_token(conn, tok)
|
||||
lease_prefix = tok[:8]
|
||||
if lease is None:
|
||||
log.warning(
|
||||
"account_get_credential_reveal_bad_lease id=%s lease_prefix=%s",
|
||||
account_id,
|
||||
lease_prefix,
|
||||
)
|
||||
_say(_err_json(
|
||||
"ERR_LEASE_INVALID",
|
||||
"lease token 无效或已过期",
|
||||
))
|
||||
return
|
||||
|
||||
if int(lease["account_id"]) != int(account_id):
|
||||
log.warning(
|
||||
"account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s",
|
||||
account_id,
|
||||
lease["account_id"],
|
||||
lease_prefix,
|
||||
)
|
||||
_say(_err_json(
|
||||
"ERR_LEASE_INVALID",
|
||||
"lease token 不属于该 account",
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
pt = read_credential_plaintext(conn, int(cred["id"]))
|
||||
except MasterKeyMissingError as e:
|
||||
log.warning("account_get_credential_master_missing id=%s", account_id)
|
||||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||||
return
|
||||
except CredentialDecryptError as e:
|
||||
log.warning(
|
||||
"account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s",
|
||||
account_id,
|
||||
cred["id"],
|
||||
lease_prefix,
|
||||
)
|
||||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||||
return
|
||||
except CredentialNotEncryptedError:
|
||||
out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"}
|
||||
log.info(
|
||||
"account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s",
|
||||
account_id,
|
||||
cred["id"],
|
||||
lease_prefix,
|
||||
)
|
||||
_say(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
|
||||
log.info(
|
||||
"account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s",
|
||||
account_id,
|
||||
cred["id"],
|
||||
lease_prefix,
|
||||
)
|
||||
_say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Legacy alias
|
||||
def cmd_get(account_id) -> None:
|
||||
"""Legacy: get <id> — same as account get <id>."""
|
||||
@@ -707,6 +1096,9 @@ def cmd_account_pick_web(
|
||||
"role": acc["role"],
|
||||
"provider_code": acc["provider_code"],
|
||||
"session_status": acc["session_status"],
|
||||
"auth_strategy": acc["auth_strategy"],
|
||||
"session_persistent": acc["session_persistent"],
|
||||
"device_fingerprint": acc.get("device_fingerprint"),
|
||||
}
|
||||
|
||||
# Lease handling
|
||||
|
||||
36
scripts/service/crypto.py
Normal file
36
scripts/service/crypto.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fernet 加密 / 解密的薄封装。
|
||||
|
||||
不暴露 Fernet 对象;调用方只看 encrypt/decrypt 字符串。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from service.master_key import load_master_key
|
||||
from service.secret_store import mask_secret
|
||||
|
||||
|
||||
class CredentialDecryptError(Exception):
|
||||
"""Decryption failed (bad key or corrupted ciphertext)."""
|
||||
|
||||
code = "ERR_CREDENTIAL_DECRYPT_FAILED"
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: str) -> str:
|
||||
"""Encrypt using current master key; returns url-safe token string."""
|
||||
key = load_master_key()
|
||||
f = Fernet(key)
|
||||
tok = f.encrypt(plaintext.encode("utf-8"))
|
||||
return tok.decode("ascii")
|
||||
|
||||
|
||||
def decrypt_secret(ciphertext: str) -> str:
|
||||
"""Decrypt Fernet token string to plaintext."""
|
||||
key = load_master_key()
|
||||
f = Fernet(key)
|
||||
try:
|
||||
raw = f.decrypt(ciphertext.encode("ascii"))
|
||||
return raw.decode("utf-8")
|
||||
except InvalidToken as e:
|
||||
raise CredentialDecryptError("Fernet decrypt failed") from e
|
||||
142
scripts/service/master_key.py
Normal file
142
scripts/service/master_key.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""master key 加载与生成。
|
||||
|
||||
加载优先级(高到低):
|
||||
1. 环境变量 ACCOUNT_MANAGER_MASTER_KEY(base64-urlsafe 字符串,44 字符)
|
||||
2. 文件 {DATA_ROOT}/{USER_ID}/account-manager/master.key
|
||||
3. 都没有 → raise MasterKeyMissingError,错误码 ERR_MASTER_KEY_MISSING
|
||||
|
||||
不再自动生成——必须显式 `account init-master-key`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from util.runtime_paths import get_skill_data_dir
|
||||
|
||||
ENV_MASTER_KEY = "ACCOUNT_MANAGER_MASTER_KEY"
|
||||
_MASTER_FILENAME = "master.key"
|
||||
|
||||
|
||||
class MasterKeyMissingError(Exception):
|
||||
"""Raised when no valid master key can be resolved."""
|
||||
|
||||
code = "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
class MasterKeyExistsError(Exception):
|
||||
"""Raised when master.key already exists and overwrite is not allowed."""
|
||||
|
||||
code = "ERR_MASTER_KEY_EXISTS"
|
||||
|
||||
|
||||
class MasterKeyInvalidError(Exception):
|
||||
"""Raised when a supplied master key string cannot be used as a Fernet key."""
|
||||
|
||||
code = "ERR_MASTER_KEY_INVALID"
|
||||
|
||||
|
||||
def master_key_path() -> str:
|
||||
"""Absolute path to master.key alongside account-manager.db."""
|
||||
return os.path.join(get_skill_data_dir(), _MASTER_FILENAME)
|
||||
|
||||
|
||||
def _try_fernet_key(key_material: bytes) -> bytes | None:
|
||||
"""Return url-safe key bytes accepted by Fernet, or None if invalid."""
|
||||
try:
|
||||
raw = key_material.strip()
|
||||
if not raw:
|
||||
return None
|
||||
# Accept stored form: ascii urlsafe base64 string as bytes
|
||||
Fernet(raw)
|
||||
return raw
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_decode_manual_b64(s: str) -> bytes | None:
|
||||
"""If string decodes to 32 raw bytes, re-encode as Fernet url-safe key."""
|
||||
try:
|
||||
pad = "=" * ((4 - len(s) % 4) % 4)
|
||||
decoded = base64.urlsafe_b64decode(s + pad)
|
||||
if len(decoded) != 32:
|
||||
return None
|
||||
key_bytes = base64.urlsafe_b64encode(decoded)
|
||||
Fernet(key_bytes)
|
||||
return key_bytes
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_key_string(s: str) -> bytes | None:
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
fk = _try_fernet_key(s.encode("ascii"))
|
||||
if fk is not None:
|
||||
return fk
|
||||
return _try_decode_manual_b64(s)
|
||||
|
||||
|
||||
def load_master_key() -> bytes:
|
||||
"""Resolve master key (env → file); raises MasterKeyMissingError if unavailable."""
|
||||
env_val = (os.environ.get(ENV_MASTER_KEY) or "").strip()
|
||||
if env_val:
|
||||
kb = _normalize_key_string(env_val)
|
||||
if kb is not None:
|
||||
return kb
|
||||
|
||||
path = master_key_path()
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
txt = raw.strip().decode("ascii", errors="strict")
|
||||
kb = _normalize_key_string(txt)
|
||||
if kb is not None:
|
||||
return kb
|
||||
except OSError:
|
||||
pass
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
raise MasterKeyMissingError("No valid ACCOUNT_MANAGER_MASTER_KEY or master.key")
|
||||
|
||||
|
||||
def normalize_master_key_input(s: str) -> bytes:
|
||||
"""Validate user-supplied key text; raises MasterKeyInvalidError if unusable."""
|
||||
kb = _normalize_key_string((s or "").strip())
|
||||
if kb is None:
|
||||
raise MasterKeyInvalidError("invalid master key material")
|
||||
return kb
|
||||
|
||||
|
||||
def generate_master_key() -> bytes:
|
||||
"""Return a new Fernet key (44-char url-safe base64 as bytes)."""
|
||||
return Fernet.generate_key()
|
||||
|
||||
|
||||
def write_master_key_file(key: bytes, *, allow_overwrite: bool = False) -> str:
|
||||
"""Write key bytes to master_key_path(); chmod 0o600 best-effort."""
|
||||
path = master_key_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
if os.path.exists(path) and not allow_overwrite:
|
||||
raise MasterKeyExistsError("master.key already exists; use --rotate to overwrite")
|
||||
data = key.strip()
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def is_master_key_available() -> bool:
|
||||
try:
|
||||
load_master_key()
|
||||
return True
|
||||
except MasterKeyMissingError:
|
||||
return False
|
||||
20
scripts/service/rpa_helpers/__init__.py
Normal file
20
scripts/service/rpa_helpers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""account-manager 提供给业务 skill 的 RPA helper 库。
|
||||
|
||||
业务 skill 同进程 import 使用,不通过 CLI subprocess。
|
||||
"""
|
||||
from .auth_flows import ( # noqa: F401
|
||||
AuthStrategyError,
|
||||
LoginResult,
|
||||
ensure_logged_in,
|
||||
ensure_logged_in_api_token,
|
||||
ensure_logged_in_client_certificate,
|
||||
ensure_logged_in_device_bound,
|
||||
ensure_logged_in_password_auto,
|
||||
ensure_logged_in_password_plus_2fa,
|
||||
ensure_logged_in_password_with_captcha,
|
||||
ensure_logged_in_per_session_manual,
|
||||
ensure_logged_in_qr_code_manual,
|
||||
ensure_logged_in_sso_redirect,
|
||||
)
|
||||
from .browser import close_browser, launch_browser_with_profile # noqa: F401
|
||||
from .human import human_click, human_scroll_to, human_select, human_type, pause # noqa: F401
|
||||
451
scripts/service/rpa_helpers/auth_flows.py
Normal file
451
scripts/service/rpa_helpers/auth_flows.py
Normal file
@@ -0,0 +1,451 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth strategy helpers: ensure_logged_in_* + dispatcher (no DB / no CLI)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .human import human_click, human_type
|
||||
|
||||
|
||||
def _poll_until(
|
||||
predicate,
|
||||
timeout_sec: int,
|
||||
interval_sec: float = 2.0,
|
||||
) -> bool:
|
||||
"""轮询 predicate 直到返回 True 或超时。"""
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if predicate():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval_sec)
|
||||
return False
|
||||
|
||||
|
||||
def _stderr_prompt(msg: str) -> None:
|
||||
sys.stderr.write(f"[auth_flows] {msg}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResult:
|
||||
ok: bool
|
||||
needs_human: bool
|
||||
message: str
|
||||
|
||||
|
||||
class AuthStrategyError(Exception):
|
||||
"""auth_strategy 相关错误(不可自动化 / 不支持 / fingerprint 不匹配等)。"""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _account_extra(account: dict) -> dict:
|
||||
ex = account.get("extra_json")
|
||||
if ex is None:
|
||||
return {}
|
||||
if isinstance(ex, dict):
|
||||
return ex
|
||||
if isinstance(ex, str):
|
||||
try:
|
||||
return json.loads(ex)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _is_already_logged_in(page, account: dict) -> bool:
|
||||
"""是否已登录:仅当 extra_json 提供正向证据(URL 正则或可见 selector)时为 True。"""
|
||||
try:
|
||||
cur = page.url or ""
|
||||
except Exception:
|
||||
return False
|
||||
cur_stripped = cur.strip()
|
||||
if not cur_stripped:
|
||||
return False
|
||||
ul = cur_stripped.lower()
|
||||
if ul == "about:blank" or ul.startswith("about:blank"):
|
||||
return False
|
||||
|
||||
extra = _account_extra(account)
|
||||
|
||||
pattern = extra.get("logged_in_url_pattern")
|
||||
if isinstance(pattern, str) and pattern.strip():
|
||||
try:
|
||||
if re.search(pattern, cur_stripped):
|
||||
return True
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
sel = extra.get("logged_in_selector")
|
||||
if isinstance(sel, str) and sel.strip():
|
||||
try:
|
||||
if page.locator(sel.strip()).first.is_visible(timeout=500):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _current_machine_fingerprint() -> str:
|
||||
"""hostname + MAC 的 sha256 前 16 位。"""
|
||||
h = hashlib.sha256()
|
||||
h.update(socket.gethostname().encode("utf-8"))
|
||||
h.update(str(uuid.getnode()).encode("utf-8"))
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def _login_selectors(account: dict) -> tuple[str, str, str]:
|
||||
extra = _account_extra(account)
|
||||
selectors = extra.get("login_selectors") or {}
|
||||
login_id_sel = (
|
||||
selectors.get("login_id")
|
||||
or "input[name*='user'], input[name*='login'], input[name*='account'], input[type='email']"
|
||||
)
|
||||
password_sel = selectors.get("password") or "input[type='password']"
|
||||
submit_sel = (
|
||||
selectors.get("submit")
|
||||
or "button[type='submit'], button:has-text('登录'), button:has-text('Sign in')"
|
||||
)
|
||||
return str(login_id_sel), str(password_sel), str(submit_sel)
|
||||
|
||||
|
||||
def _require_login_id(account: dict) -> str | None:
|
||||
lid = account.get("login_id")
|
||||
if lid is None:
|
||||
return None
|
||||
s = str(lid).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def ensure_logged_in_password_auto(page, account: dict, password: str, **_kwargs) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(
|
||||
ok=True,
|
||||
needs_human=False,
|
||||
message="already logged in (profile reused)",
|
||||
)
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
human_click(page.locator(sub_sel).first)
|
||||
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), 30, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=False,
|
||||
message="login succeeded" if ok else "login timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_password_with_captcha(
|
||||
page,
|
||||
account: dict,
|
||||
password: str,
|
||||
*,
|
||||
captcha_value: str | None = None,
|
||||
wait_human_sec: int = 60,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
extra = _account_extra(account)
|
||||
selectors = extra.get("login_selectors") or {}
|
||||
captcha_sel = (
|
||||
selectors.get("captcha") or "input[name*='captcha'], input[name*='verify_code']"
|
||||
)
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
|
||||
if captcha_value is not None:
|
||||
human_type(page.locator(captcha_sel).first, str(captcha_value))
|
||||
human_click(page.locator(sub_sel).first)
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=False,
|
||||
message="login succeeded" if ok else "captcha flow timeout",
|
||||
)
|
||||
|
||||
_stderr_prompt("请输入图形验证码后回车继续(CTRL+C 取消)")
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="captcha human assist completed" if ok else "captcha human assist timeout",
|
||||
)
|
||||
|
||||
|
||||
def _is_2fa_page(page) -> bool:
|
||||
try:
|
||||
u = (page.url or "").lower()
|
||||
if any(x in u for x in ("2fa", "verify", "mfa")):
|
||||
return True
|
||||
otp = page.locator("input[name*='code'], input[name*='otp']")
|
||||
if otp.count() > 0:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def ensure_logged_in_password_plus_2fa(
|
||||
page,
|
||||
account: dict,
|
||||
password: str,
|
||||
*,
|
||||
wait_human_sec: int = 120,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
login_id = _require_login_id(account)
|
||||
if not login_id:
|
||||
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
|
||||
|
||||
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
|
||||
human_type(page.locator(lid_sel).first, login_id)
|
||||
human_type(page.locator(pwd_sel).first, password)
|
||||
human_click(page.locator(sub_sel).first)
|
||||
|
||||
_poll_until(lambda: _is_2fa_page(page) or _is_already_logged_in(page, account), 30, interval_sec=1.0)
|
||||
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="login succeeded without 2FA")
|
||||
|
||||
_stderr_prompt("请输入 2FA 验证码并提交")
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
|
||||
msg = "2FA completed" if ok else "2FA timeout ERR_LOGIN_2FA_TIMEOUT"
|
||||
return LoginResult(ok=ok, needs_human=True, message=msg)
|
||||
|
||||
|
||||
def _qr_locator(page):
|
||||
return page.locator("canvas, img[src*='qr'], img[alt*='二维码']")
|
||||
|
||||
|
||||
def ensure_logged_in_qr_code_manual(page, account: dict, *, wait_human_sec: int = 300, **_kwargs) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
qr = _qr_locator(page)
|
||||
try:
|
||||
qr.first.wait_for(state="visible", timeout=30000)
|
||||
except Exception:
|
||||
return LoginResult(ok=False, needs_human=True, message="qr code not found")
|
||||
|
||||
_stderr_prompt("请用手机扫描浏览器中的二维码")
|
||||
|
||||
def _qr_done() -> bool:
|
||||
if _is_already_logged_in(page, account):
|
||||
return True
|
||||
try:
|
||||
av = page.locator("img[alt*='avatar']")
|
||||
if av.count() > 0 and av.first.is_visible(timeout=300):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
ok = _poll_until(_qr_done, wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="qr scan completed" if ok else "qr scan timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_per_session_manual(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
wait_human_sec: int = 600,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
url = account.get("url") or ""
|
||||
if url:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
_stderr_prompt(f"请完整登录后等待 RPA 继续(最长 {wait_human_sec} 秒)")
|
||||
|
||||
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="per-session manual completed" if ok else "per-session manual timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_device_bound(
|
||||
page,
|
||||
account: dict,
|
||||
password: str | None = None,
|
||||
*,
|
||||
wait_human_sec: int = 120,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
expected_fp = account.get("device_fingerprint")
|
||||
if expected_fp:
|
||||
cur = _current_machine_fingerprint()
|
||||
if cur != str(expected_fp):
|
||||
raise AuthStrategyError(
|
||||
"ERR_DEVICE_FINGERPRINT_MISMATCH",
|
||||
f"current machine fingerprint {cur!r} does not match account's {expected_fp!r}",
|
||||
)
|
||||
if password:
|
||||
return ensure_logged_in_password_auto(page, account, password)
|
||||
return ensure_logged_in_per_session_manual(page, account, wait_human_sec=wait_human_sec)
|
||||
|
||||
|
||||
def ensure_logged_in_client_certificate(page, account: dict, **_kwargs) -> LoginResult:
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_AUTOMATABLE",
|
||||
"client_certificate strategy requires manual cert + PIN; not automatable by rpa_helpers",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in_api_token(page, account: dict, **_kwargs) -> LoginResult:
|
||||
return LoginResult(ok=True, needs_human=False, message="api_token: no browser login required")
|
||||
|
||||
|
||||
def _default_sso_hosts(account: dict) -> list[str]:
|
||||
ex = _account_extra(account)
|
||||
hosts = ex.get("sso_redirect_hosts")
|
||||
if isinstance(hosts, list) and hosts:
|
||||
return [str(h) for h in hosts if h]
|
||||
return ["accounts.google.com", "login.microsoftonline.com", "okta.com"]
|
||||
|
||||
|
||||
def _url_has_host_fragment(url: str, hosts: list[str]) -> bool:
|
||||
u = (url or "").lower()
|
||||
return any(h.lower() in u for h in hosts)
|
||||
|
||||
|
||||
def ensure_logged_in_sso_redirect(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
wait_human_sec: int = 300,
|
||||
**_kwargs,
|
||||
) -> LoginResult:
|
||||
if _is_already_logged_in(page, account):
|
||||
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
|
||||
url = account.get("url") or ""
|
||||
if not url:
|
||||
return LoginResult(ok=False, needs_human=True, message="account url missing for SSO flow")
|
||||
|
||||
target_netloc = (urlparse(url).netloc or "").lower()
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
hosts = _default_sso_hosts(account)
|
||||
seen_sso = _poll_until(lambda: _url_has_host_fragment(page.url or "", hosts), min(60, wait_human_sec), 2.0)
|
||||
if not seen_sso:
|
||||
return LoginResult(ok=False, needs_human=True, message="sso redirect not detected")
|
||||
|
||||
_stderr_prompt("请在弹出的 SSO 页面完成登录")
|
||||
|
||||
def _back_home() -> bool:
|
||||
try:
|
||||
cur = (urlparse(page.url).netloc or "").lower()
|
||||
if target_netloc and cur == target_netloc:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return _is_already_logged_in(page, account)
|
||||
|
||||
ok = _poll_until(_back_home, wait_human_sec, interval_sec=2.0)
|
||||
return LoginResult(
|
||||
ok=ok,
|
||||
needs_human=True,
|
||||
message="sso completed" if ok else "sso timeout",
|
||||
)
|
||||
|
||||
|
||||
def ensure_logged_in(
|
||||
page,
|
||||
account: dict,
|
||||
*,
|
||||
credentials: dict | None = None,
|
||||
**kwargs,
|
||||
) -> LoginResult:
|
||||
"""根据 account['auth_strategy'] 分派到对应 helper。"""
|
||||
strat = (account.get("auth_strategy") or "").strip()
|
||||
|
||||
if not strat:
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||
"account has no auth_strategy set",
|
||||
)
|
||||
|
||||
def _need_password() -> str:
|
||||
if not credentials or not credentials.get("plaintext"):
|
||||
raise AuthStrategyError(
|
||||
"ERR_CREDENTIAL_MISSING",
|
||||
f"auth_strategy={strat!r} requires credentials.plaintext",
|
||||
)
|
||||
return str(credentials["plaintext"])
|
||||
|
||||
if strat == "password_auto":
|
||||
return ensure_logged_in_password_auto(page, account, _need_password(), **kwargs)
|
||||
if strat == "password_with_captcha":
|
||||
return ensure_logged_in_password_with_captcha(page, account, _need_password(), **kwargs)
|
||||
if strat == "password_plus_2fa":
|
||||
return ensure_logged_in_password_plus_2fa(page, account, _need_password(), **kwargs)
|
||||
if strat == "qr_code_manual":
|
||||
return ensure_logged_in_qr_code_manual(page, account, **kwargs)
|
||||
if strat == "per_session_manual":
|
||||
return ensure_logged_in_per_session_manual(page, account, **kwargs)
|
||||
if strat == "device_bound":
|
||||
password = None
|
||||
if credentials and credentials.get("plaintext"):
|
||||
password = str(credentials["plaintext"])
|
||||
return ensure_logged_in_device_bound(page, account, password, **kwargs)
|
||||
if strat == "client_certificate":
|
||||
return ensure_logged_in_client_certificate(page, account)
|
||||
if strat == "api_token":
|
||||
return ensure_logged_in_api_token(page, account)
|
||||
if strat == "sso_redirect":
|
||||
return ensure_logged_in_sso_redirect(page, account, **kwargs)
|
||||
|
||||
raise AuthStrategyError(
|
||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||
f"unknown auth_strategy: {strat!r}",
|
||||
)
|
||||
74
scripts/service/rpa_helpers/browser.py
Normal file
74
scripts/service/rpa_helpers/browser.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unified Chromium launch with stealth + persistent profile for RPA helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from service.browser_service import resolve_chromium_channel
|
||||
from util.playwright_stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import BrowserContext, Page
|
||||
|
||||
|
||||
def launch_browser_with_profile(
|
||||
profile_dir: str,
|
||||
*,
|
||||
channel: str | None = None,
|
||||
headless: bool = False,
|
||||
extra_args: list[str] | None = None,
|
||||
locale: str = "zh-CN",
|
||||
) -> tuple["BrowserContext", "Page"]:
|
||||
"""启动浏览器(Chrome 优先 / Edge fallback),返回 (context, page)。
|
||||
|
||||
调用方应在 finally 中调用 close_browser(context)。
|
||||
"""
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
resolved_channel = channel if channel is not None else resolve_chromium_channel()
|
||||
if not resolved_channel:
|
||||
raise RuntimeError("no chrome/edge found on system")
|
||||
|
||||
_pw = sync_playwright().start()
|
||||
try:
|
||||
args, ignore_automation = persistent_context_launch_parts(extra_args=extra_args)
|
||||
launch_kwargs: dict = dict(
|
||||
user_data_dir=profile_dir,
|
||||
headless=headless,
|
||||
channel=resolved_channel,
|
||||
no_viewport=True,
|
||||
locale=locale,
|
||||
args=args,
|
||||
)
|
||||
if ignore_automation is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore_automation
|
||||
ctx = _pw.chromium.launch_persistent_context(**launch_kwargs)
|
||||
if stealth_enabled():
|
||||
ctx.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
setattr(ctx, "_openclaw_pw", _pw)
|
||||
return ctx, page
|
||||
except Exception:
|
||||
_pw.stop()
|
||||
raise
|
||||
|
||||
|
||||
def close_browser(ctx) -> None:
|
||||
"""关闭 context 并清理 Playwright manager。"""
|
||||
pw = getattr(ctx, "_openclaw_pw", None)
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
if pw is not None:
|
||||
try:
|
||||
pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
57
scripts/service/rpa_helpers/human.py
Normal file
57
scripts/service/rpa_helpers/human.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Human-like interaction primitives for Playwright locators (no logging)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
|
||||
def pause(min_ms: int = 200, max_ms: int = 800) -> None:
|
||||
"""随机停顿 min_ms ~ max_ms 毫秒。"""
|
||||
lo = min(min_ms, max_ms) / 1000.0
|
||||
hi = max(min_ms, max_ms) / 1000.0
|
||||
time.sleep(random.uniform(lo, hi))
|
||||
|
||||
|
||||
def human_type(
|
||||
locator,
|
||||
text: str,
|
||||
*,
|
||||
per_char_ms_range: tuple[int, int] = (50, 200),
|
||||
pre_pause: bool = True,
|
||||
) -> None:
|
||||
"""逐字符输入(locator.type + delay);locator 为 Playwright Locator。"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
if pre_pause:
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
lo, hi = per_char_ms_range
|
||||
delay_ms = random.uniform(float(lo), float(hi))
|
||||
locator.type(text, delay=int(delay_ms))
|
||||
|
||||
|
||||
def human_click(locator) -> None:
|
||||
"""人类化点击:scroll_into_view → pause → click → pause"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
|
||||
|
||||
def human_select(locator, value_or_label: str) -> None:
|
||||
"""人类化下拉选择:scroll_into_view → pause → click → pause → select_option"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
locator.click()
|
||||
pause()
|
||||
try:
|
||||
locator.select_option(value=value_or_label)
|
||||
except Exception:
|
||||
locator.select_option(label=value_or_label)
|
||||
|
||||
|
||||
def human_scroll_to(locator) -> None:
|
||||
"""滚动到元素并稍作停顿。"""
|
||||
locator.scroll_into_view_if_needed()
|
||||
pause()
|
||||
@@ -135,6 +135,12 @@ def read_secret(storage: str, secret_ref: str) -> str:
|
||||
)
|
||||
return val
|
||||
|
||||
elif storage_n == "local_encrypted":
|
||||
raise ValueError(
|
||||
"local_encrypted backend cannot be read via secret_ref. "
|
||||
"Use credentials_repo.read_credential_plaintext() instead."
|
||||
)
|
||||
|
||||
else:
|
||||
log.error("secret_resolve_failed unsupported storage=%s", storage)
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
@@ -304,3 +310,14 @@ def _win_cred_delete_impl(target: str) -> None:
|
||||
err = ctypes.get_last_error()
|
||||
if err != 1168:
|
||||
raise OSError(f"CredDeleteW failed with error {err}")
|
||||
|
||||
|
||||
def encrypt_secret_for_storage(secret_value: str) -> str:
|
||||
"""加密 secret 用于 secret_storage='local_encrypted'。
|
||||
|
||||
返回密文字符串,调用方负责写到 credentials.secret_ciphertext 列。
|
||||
"""
|
||||
from service.crypto import encrypt_secret
|
||||
|
||||
return encrypt_secret(secret_value)
|
||||
|
||||
|
||||
@@ -200,6 +200,89 @@ PLATFORMS: dict[str, dict[str, Any]] = {
|
||||
"aliases": ["公众号", "微信", "微信公众号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
# =========================================================================
|
||||
# Aligned with db.platform_defaults.PLATFORM_DEFAULT_AUTH_STRATEGY keys
|
||||
# =========================================================================
|
||||
"douyin": {
|
||||
"display_name": "抖音",
|
||||
"domain": "content",
|
||||
"provider_code": "douyin",
|
||||
"default_url": "https://www.douyin.com",
|
||||
"aliases": ["抖音", "Douyin"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"wechat_mp": {
|
||||
"display_name": "微信公众号",
|
||||
"domain": "content",
|
||||
"provider_code": "wechat_mp",
|
||||
"default_url": "https://mp.weixin.qq.com",
|
||||
"aliases": ["微信公众号", "公众号", "WeChat MP"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"display_name": "小红书",
|
||||
"domain": "content",
|
||||
"provider_code": "xiaohongshu",
|
||||
"default_url": "https://www.xiaohongshu.com",
|
||||
"aliases": ["小红书", "Xiaohongshu", "RED"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"bilibili": {
|
||||
"display_name": "哔哩哔哩",
|
||||
"domain": "content",
|
||||
"provider_code": "bilibili",
|
||||
"default_url": "https://www.bilibili.com",
|
||||
"aliases": ["B站", "哔哩哔哩", "Bilibili"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"github": {
|
||||
"display_name": "GitHub",
|
||||
"domain": "dev",
|
||||
"provider_code": "github",
|
||||
"default_url": "https://github.com/login",
|
||||
"aliases": ["GitHub", "github"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"aws": {
|
||||
"display_name": "AWS",
|
||||
"domain": "dev",
|
||||
"provider_code": "aws",
|
||||
"default_url": "https://console.aws.amazon.com",
|
||||
"aliases": ["AWS", "Amazon Web Services", "亚马逊云"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"gcp": {
|
||||
"display_name": "GCP",
|
||||
"domain": "dev",
|
||||
"provider_code": "gcp",
|
||||
"default_url": "https://console.cloud.google.com",
|
||||
"aliases": ["GCP", "Google Cloud", "谷歌云"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"icbc_sim": {
|
||||
"display_name": "工行仿真",
|
||||
"domain": "sim",
|
||||
"provider_code": "icbc_sim",
|
||||
"default_url": "http://localhost:5180",
|
||||
"aliases": ["工行仿真", "ICBC Simulator", "ICBC SIM"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"openai": {
|
||||
"display_name": "OpenAI API",
|
||||
"domain": "llm",
|
||||
"provider_code": "openai",
|
||||
"default_url": "https://platform.openai.com",
|
||||
"aliases": ["OpenAI", "openai", "GPT", "ChatGPT API"],
|
||||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||||
},
|
||||
"anthropic": {
|
||||
"display_name": "Anthropic",
|
||||
"domain": "llm",
|
||||
"provider_code": "anthropic",
|
||||
"default_url": "https://console.anthropic.com",
|
||||
"aliases": ["Anthropic", "anthropic", "Claude API"],
|
||||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -277,6 +360,8 @@ def seed_platforms(conn) -> None:
|
||||
Upsert all platform definitions into the 'platforms' table.
|
||||
Must be called after init_db() in the same connection lifecycle.
|
||||
"""
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
|
||||
log = get_skill_logger()
|
||||
log.info("platform_seed_start")
|
||||
cur = conn.cursor()
|
||||
@@ -285,12 +370,13 @@ def seed_platforms(conn) -> None:
|
||||
for key, spec in PLATFORMS.items():
|
||||
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
|
||||
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
|
||||
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
enabled, default_auth_strategy, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(platform_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
domain = excluded.domain,
|
||||
@@ -299,6 +385,7 @@ def seed_platforms(conn) -> None:
|
||||
aliases_json = excluded.aliases_json,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
enabled = 1,
|
||||
default_auth_strategy = excluded.default_auth_strategy,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
@@ -309,6 +396,7 @@ def seed_platforms(conn) -> None:
|
||||
spec.get("default_url", ""),
|
||||
aliases_json,
|
||||
capabilities_json,
|
||||
default_auth_strategy,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
|
||||
@@ -15,6 +15,13 @@ _scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
try:
|
||||
import cryptography # noqa: F401
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"Stage B requires 'cryptography'. pip install cryptography --break-system-packages"
|
||||
) from e
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
150
tests/test_cli_master_key.py
Normal file
150
tests/test_cli_master_key.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
|
||||
def _iso_cli_env(data_root: str, uid: str = "cli_u") -> dict:
|
||||
"""Align skill roots so subprocess + in-process tests hit the same DB."""
|
||||
return {
|
||||
"JIANGCHANG_DATA_ROOT": data_root,
|
||||
"JIANGCHANG_USER_ID": uid,
|
||||
"CLAW_DATA_ROOT": data_root,
|
||||
"CLAW_USER_ID": uid,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProcOut:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
|
||||
merged = {**os.environ, **env}
|
||||
merged.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
r = subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=merged,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
|
||||
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
|
||||
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
|
||||
|
||||
|
||||
def test_init_master_key_writes_file(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_mk_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
proc = _run_cli(env, ["account", "init-master-key"])
|
||||
assert proc.returncode == 0
|
||||
payload = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert payload["success"] is True
|
||||
assert os.path.isfile(payload["path"])
|
||||
|
||||
|
||||
def test_init_master_key_rejects_duplicate(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_mk_d_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
p1 = _run_cli(env, ["account", "init-master-key"])
|
||||
assert p1.returncode == 0
|
||||
p2 = _run_cli(env, ["account", "init-master-key"])
|
||||
assert p2.returncode == 0
|
||||
payload = json.loads(p2.stdout.strip().splitlines()[0])
|
||||
assert payload["success"] is False
|
||||
assert payload["error"]["code"] == "ERR_MASTER_KEY_EXISTS"
|
||||
|
||||
|
||||
def test_export_import_roundtrip(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="cli_ex_")
|
||||
data_root = os.path.join(tmp, "dr")
|
||||
os.makedirs(data_root)
|
||||
env = _iso_cli_env(data_root)
|
||||
|
||||
init = _run_cli(env, ["account", "init-master-key"])
|
||||
assert init.returncode == 0
|
||||
|
||||
# Insert encrypted credential via repo in-process (same paths as CLI)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "cli_u")
|
||||
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("CLAW_USER_ID", "cli_u")
|
||||
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
|
||||
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_cli_inline")
|
||||
|
||||
from db.connection import get_conn, init_db
|
||||
from db.credentials_repo import insert_credential_encrypted, read_credential_plaintext
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cid = insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="icbc_sim",
|
||||
credential_label="ICBC password label",
|
||||
credential_type="password",
|
||||
plaintext="roundtrip-secret",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=1_750_000_000,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM credentials")
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ex = _run_cli(env, ["account", "export-credentials", "--plaintext"])
|
||||
assert ex.returncode == 0
|
||||
arr = json.loads(ex.stdout.strip())
|
||||
assert len(arr) == 1
|
||||
assert arr[0]["plaintext"] == "roundtrip-secret"
|
||||
|
||||
imp = _run_cli(
|
||||
env,
|
||||
["account", "import-credentials", "--replace-all"],
|
||||
stdin=json.dumps(arr, ensure_ascii=False),
|
||||
)
|
||||
assert imp.returncode == 0
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM credentials WHERE secret_storage='local_encrypted'")
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
cur.execute("SELECT id FROM credentials LIMIT 1")
|
||||
new_id = int(cur.fetchone()[0])
|
||||
pt = read_credential_plaintext(conn, new_id)
|
||||
assert pt == "roundtrip-secret"
|
||||
finally:
|
||||
conn.close()
|
||||
511
tests/test_cli_stage_c.py
Normal file
511
tests/test_cli_stage_c.py
Normal file
@@ -0,0 +1,511 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Stage C: CLI — add-web v2, local_encrypted add-secret, pick-web fields, get-credential."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
def _cli_env(roots: dict, **extra: str) -> dict:
|
||||
e = {**os.environ, **extra}
|
||||
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
|
||||
e["JIANGCHANG_USER_ID"] = roots["user_id"]
|
||||
e["CLAW_DATA_ROOT"] = roots["data_root"]
|
||||
e["CLAW_USER_ID"] = roots["user_id"]
|
||||
return e
|
||||
|
||||
|
||||
def _db_path(roots: dict) -> str:
|
||||
return os.path.join(roots["data_root"], roots["user_id"], SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProcOut:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
r = subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
|
||||
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
|
||||
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
|
||||
|
||||
|
||||
def _parse_ok_data(line: str) -> dict:
|
||||
j = json.loads(line.strip().splitlines()[0])
|
||||
assert j.get("success") is True
|
||||
return j["data"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_cli_env():
|
||||
"""Function-scoped data root for subprocess CLI tests."""
|
||||
tmp = tempfile.mkdtemp(prefix="cli_stage_c_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "77701"
|
||||
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def with_master_key(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
monkeypatch.setenv("ACCOUNT_MANAGER_MASTER_KEY", key)
|
||||
yield key
|
||||
|
||||
|
||||
class TestAddWebV2:
|
||||
def test_add_web_explicit_auth_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"demo",
|
||||
"--auth-strategy",
|
||||
"qr_code_manual",
|
||||
"--session-persistent",
|
||||
"1",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["auth_strategy"] == "qr_code_manual"
|
||||
assert data["session_persistent"] == 1
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT auth_strategy, session_persistent FROM accounts WHERE id = ?",
|
||||
(data["id"],),
|
||||
).fetchone()
|
||||
assert row == ("qr_code_manual", 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_web_platform_default_maersk(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "demo2"],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["auth_strategy"] == "qr_code_manual"
|
||||
|
||||
def test_add_web_bad_auth_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"x",
|
||||
"--auth-strategy",
|
||||
"foo",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
n = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
|
||||
assert n == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_web_device_fingerprint_wrong_strategy(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"x",
|
||||
"--auth-strategy",
|
||||
"password_auto",
|
||||
"--device-fingerprint",
|
||||
"fp1",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE"
|
||||
|
||||
|
||||
class TestAddSecretLocalEncrypted:
|
||||
def test_add_secret_local_encrypted_success(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--label",
|
||||
"pw1",
|
||||
],
|
||||
stdin="my-pw\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
data = _parse_ok_data(proc.stdout)
|
||||
assert data["secret_storage"] == "local_encrypted"
|
||||
conn = sqlite3.connect(_db_path(iso_cli_env))
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT secret_storage, secret_mask, length(secret_ciphertext) FROM credentials WHERE id = ?",
|
||||
(data["credential_id"],),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "local_encrypted"
|
||||
assert row[1] # mask non-empty
|
||||
assert row[2] and row[2] > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_add_secret_local_encrypted_requires_stdin(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--label",
|
||||
"x",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN"
|
||||
|
||||
def test_add_secret_local_encrypted_type_not_allowed(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"browser_profile",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
],
|
||||
stdin="x\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED"
|
||||
|
||||
def test_add_secret_local_encrypted_master_missing(self, iso_cli_env, monkeypatch):
|
||||
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
|
||||
env = _cli_env(iso_cli_env)
|
||||
env.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
],
|
||||
stdin="pw\n",
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
j = json.loads(proc.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
class TestPickWebReturns:
|
||||
def test_pick_web_has_v2_fields(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "pw"],
|
||||
)
|
||||
assert aw.returncode == 0
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="secret-line\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
|
||||
assert pick.returncode == 0
|
||||
payload = json.loads(pick.stdout.strip().splitlines()[0])
|
||||
assert "auth_strategy" in payload
|
||||
assert "session_persistent" in payload
|
||||
assert "device_fingerprint" in payload
|
||||
|
||||
def test_pick_web_legacy_keys_present(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "legacy"],
|
||||
)
|
||||
assert aw.returncode == 0
|
||||
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
|
||||
assert pick.returncode == 0
|
||||
payload = json.loads(pick.stdout.strip().splitlines()[0])
|
||||
for k in ("id", "platform_key", "login_id", "profile_dir", "url"):
|
||||
assert k in payload
|
||||
|
||||
|
||||
class TestGetCredential:
|
||||
def test_get_credential_no_reveal_no_plaintext(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "g1"],
|
||||
)
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="hidden-pw\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
g = _run_cli(env, ["account", "get-credential", str(aid)])
|
||||
assert g.returncode == 0
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is True
|
||||
assert "plaintext" not in j
|
||||
|
||||
def test_get_credential_reveal_missing_lease_token(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aw = _run_cli(env, ["account", "add-web", "--platform", "maersk", "--label", "g2"])
|
||||
aid = _parse_ok_data(aw.stdout)["id"]
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(aid),
|
||||
],
|
||||
stdin="p\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
g = _run_cli(env, ["account", "get-credential", str(aid), "--reveal"])
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LEASE_INVALID"
|
||||
|
||||
def test_get_credential_wrong_lease_account(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
a1 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "acc1"],
|
||||
).stdout
|
||||
)["id"]
|
||||
sec1 = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a1),
|
||||
],
|
||||
stdin="s1\n",
|
||||
)
|
||||
assert sec1.returncode == 0
|
||||
|
||||
lease_out = _run_cli(
|
||||
env,
|
||||
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "t"],
|
||||
)
|
||||
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
|
||||
|
||||
a2 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "acc2"],
|
||||
).stdout
|
||||
)["id"]
|
||||
sec2 = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a2),
|
||||
],
|
||||
stdin="s2\n",
|
||||
)
|
||||
assert sec2.returncode == 0
|
||||
|
||||
bad = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(a2),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
tok,
|
||||
],
|
||||
)
|
||||
j = json.loads(bad.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is False
|
||||
assert j["error"]["code"] == "ERR_LEASE_INVALID"
|
||||
|
||||
def test_get_credential_reveal_success(self, iso_cli_env, with_master_key):
|
||||
env = _cli_env(iso_cli_env)
|
||||
a1 = _parse_ok_data(
|
||||
_run_cli(
|
||||
env,
|
||||
["account", "add-web", "--platform", "maersk", "--label", "okc"],
|
||||
).stdout
|
||||
)["id"]
|
||||
secret_text = "correct-horse-battery"
|
||||
sec = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-secret",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--credential-type",
|
||||
"password",
|
||||
"--secret-storage",
|
||||
"local_encrypted",
|
||||
"--secret-stdin",
|
||||
"--account-id",
|
||||
str(a1),
|
||||
],
|
||||
stdin=secret_text + "\n",
|
||||
)
|
||||
assert sec.returncode == 0
|
||||
|
||||
lease_out = _run_cli(
|
||||
env,
|
||||
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "h"],
|
||||
)
|
||||
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
|
||||
|
||||
g = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"get-credential",
|
||||
str(a1),
|
||||
"--reveal",
|
||||
"--lease-token",
|
||||
tok,
|
||||
],
|
||||
)
|
||||
j = json.loads(g.stdout.strip().splitlines()[0])
|
||||
assert j["success"] is True
|
||||
assert j["plaintext"] == secret_text
|
||||
143
tests/test_credentials_repo.py
Normal file
143
tests/test_credentials_repo.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from db.accounts_repo import insert_credential
|
||||
|
||||
|
||||
class TestCredentialsRepo:
|
||||
def test_encrypted_insert_and_read(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import (
|
||||
insert_credential_encrypted,
|
||||
list_credentials_by_account,
|
||||
read_credential_plaintext,
|
||||
)
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
key = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
|
||||
|
||||
conn = clean_db
|
||||
now = 1_700_000_000
|
||||
aid = insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=7,
|
||||
platform_key="icbc_sim",
|
||||
credential_label="工行密码",
|
||||
credential_type="password",
|
||||
plaintext="my-password",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
pt = read_credential_plaintext(conn, aid)
|
||||
assert pt == "my-password"
|
||||
assert "my-password" not in "".join(
|
||||
str(v) for v in list_credentials_by_account(conn, 7)
|
||||
)
|
||||
|
||||
def test_insert_validation(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import insert_credential_encrypted, insert_credential_external
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
|
||||
conn = clean_db
|
||||
now = 1
|
||||
with pytest.raises(ValueError, match="credential_type"):
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="weird",
|
||||
plaintext="x",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(ValueError, match="plaintext"):
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="password",
|
||||
plaintext="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(ValueError, match="secret_storage"):
|
||||
insert_credential_external(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="x",
|
||||
credential_label="l",
|
||||
credential_type="password",
|
||||
secret_storage="bogus",
|
||||
secret_ref=None,
|
||||
secret_mask="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
|
||||
def test_list_has_no_sensitive_keys(self, monkeypatch, clean_db):
|
||||
from db.credentials_repo import insert_credential_encrypted, list_credentials_by_account
|
||||
from service.master_key import ENV_MASTER_KEY
|
||||
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
|
||||
conn = clean_db
|
||||
now = 2
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=9,
|
||||
platform_key="p",
|
||||
credential_label="a",
|
||||
credential_type="api_key",
|
||||
plaintext="k1",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
insert_credential_encrypted(
|
||||
conn,
|
||||
account_id=9,
|
||||
platform_key="p",
|
||||
credential_label="b",
|
||||
credential_type="api_token",
|
||||
plaintext="k2",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now + 1,
|
||||
)
|
||||
conn.commit()
|
||||
rows = list_credentials_by_account(conn, 9)
|
||||
assert len(rows) == 2
|
||||
for d in rows:
|
||||
assert "plaintext" not in d
|
||||
assert "secret_ciphertext" not in d
|
||||
|
||||
def test_none_storage_read_fails(self, clean_db):
|
||||
from db.credentials_repo import CredentialNotEncryptedError, read_credential_plaintext
|
||||
|
||||
conn = clean_db
|
||||
now = 3
|
||||
cid = insert_credential(
|
||||
conn,
|
||||
account_id=None,
|
||||
platform_key="p",
|
||||
credential_label="n",
|
||||
credential_type="note",
|
||||
secret_storage="none",
|
||||
secret_ref=None,
|
||||
secret_mask="",
|
||||
expires_at=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
with pytest.raises(CredentialNotEncryptedError) as ei:
|
||||
read_credential_plaintext(conn, cid)
|
||||
assert ei.value.code == "ERR_CREDENTIAL_NOT_ENCRYPTED"
|
||||
47
tests/test_crypto.py
Normal file
47
tests/test_crypto.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from service.crypto import CredentialDecryptError, decrypt_secret, encrypt_secret # noqa: E402
|
||||
from service.master_key import ENV_MASTER_KEY # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def crypto_env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="crypto_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "c_uid")
|
||||
key = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
|
||||
yield key
|
||||
|
||||
|
||||
def test_encrypt_decrypt_roundtrip(crypto_env):
|
||||
pt = "my-password-123"
|
||||
ct = encrypt_secret(pt)
|
||||
assert decrypt_secret(ct) == pt
|
||||
|
||||
|
||||
def test_decrypt_invalid_token(crypto_env):
|
||||
with pytest.raises(CredentialDecryptError) as ei:
|
||||
decrypt_secret("AAAA-invalid-token-AAAA")
|
||||
assert ei.value.code == "ERR_CREDENTIAL_DECRYPT_FAILED"
|
||||
|
||||
|
||||
def test_decrypt_wrong_master_key(monkeypatch, crypto_env):
|
||||
pt = "secret-value"
|
||||
ct = encrypt_secret(pt)
|
||||
other = Fernet.generate_key().decode("ascii")
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, other)
|
||||
with pytest.raises(CredentialDecryptError):
|
||||
decrypt_secret(ct)
|
||||
60
tests/test_master_key.py
Normal file
60
tests/test_master_key.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from service.master_key import ( # noqa: E402
|
||||
ENV_MASTER_KEY,
|
||||
MasterKeyMissingError,
|
||||
load_master_key,
|
||||
write_master_key_file,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_mk(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="mk_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", "mk_uid")
|
||||
monkeypatch.delenv(ENV_MASTER_KEY, raising=False)
|
||||
yield tmp
|
||||
|
||||
|
||||
def test_env_priority_over_file(iso_mk, monkeypatch):
|
||||
key_env = Fernet.generate_key()
|
||||
key_file = Fernet.generate_key()
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, key_env.decode("ascii"))
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
assert load_master_key() == key_env
|
||||
|
||||
|
||||
def test_file_fallback(iso_mk):
|
||||
key_file = Fernet.generate_key()
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
assert load_master_key() == key_file
|
||||
|
||||
|
||||
def test_missing_raises(iso_mk):
|
||||
with pytest.raises(MasterKeyMissingError) as ei:
|
||||
load_master_key()
|
||||
assert ei.value.code == "ERR_MASTER_KEY_MISSING"
|
||||
|
||||
|
||||
def test_invalid_env_falls_back_to_file(iso_mk, monkeypatch):
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
|
||||
with pytest.raises(MasterKeyMissingError):
|
||||
load_master_key()
|
||||
|
||||
key_file = Fernet.generate_key()
|
||||
write_master_key_file(key_file, allow_overwrite=True)
|
||||
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
|
||||
assert load_master_key() == key_file
|
||||
18
tests/test_platform_alignment.py
Normal file
18
tests/test_platform_alignment.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Contract tests: PLATFORM_DEFAULT_AUTH_STRATEGY keys must resolve via PLATFORMS."""
|
||||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||||
from util.platforms import resolve_platform_key
|
||||
|
||||
|
||||
def test_platform_defaults_all_resolvable():
|
||||
for key in PLATFORM_DEFAULT_AUTH_STRATEGY:
|
||||
assert resolve_platform_key(key) == key
|
||||
|
||||
|
||||
def test_new_platform_aliases_work():
|
||||
assert resolve_platform_key("抖音") == "douyin"
|
||||
assert resolve_platform_key("douyin") == "douyin"
|
||||
assert resolve_platform_key("GitHub") == "github"
|
||||
assert resolve_platform_key("github") == "github"
|
||||
assert resolve_platform_key("OpenAI") == "openai"
|
||||
assert resolve_platform_key("openai") == "openai"
|
||||
157
tests/test_rpa_helpers_auth_flows.py
Normal file
157
tests/test_rpa_helpers_auth_flows.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_ensure_logged_in_dispatches_password_auto(monkeypatch):
|
||||
from service.rpa_helpers import auth_flows
|
||||
|
||||
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: False)
|
||||
monkeypatch.setattr(auth_flows, "_poll_until", lambda *a, **k: True)
|
||||
|
||||
loc = MagicMock()
|
||||
page = MagicMock()
|
||||
page.url = "http://example.com"
|
||||
page.locator.return_value.first = loc
|
||||
|
||||
account = {"auth_strategy": "password_auto", "login_id": "u", "url": "http://x"}
|
||||
r = auth_flows.ensure_logged_in(page, account, credentials={"plaintext": "pw"})
|
||||
assert r.ok is True
|
||||
page.goto.assert_called()
|
||||
assert loc.type.call_count >= 2
|
||||
loc.click.assert_called()
|
||||
|
||||
|
||||
def test_ensure_logged_in_dispatches_qr_code_manual(monkeypatch):
|
||||
from service.rpa_helpers import auth_flows
|
||||
|
||||
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: True)
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "qr_code_manual", "url": "http://x"}
|
||||
r = auth_flows.ensure_logged_in(page, account)
|
||||
assert r.ok is True
|
||||
|
||||
|
||||
def test_ensure_logged_in_client_certificate_raises():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "client_certificate"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_AUTOMATABLE"
|
||||
|
||||
|
||||
def test_ensure_logged_in_api_token_no_op():
|
||||
from service.rpa_helpers.auth_flows import ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "api_token"}
|
||||
r = ensure_logged_in(page, account)
|
||||
assert r.ok is True
|
||||
page.goto.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_logged_in_missing_credentials():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "password_auto", "login_id": "u"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account, credentials=None)
|
||||
assert ei.value.code == "ERR_CREDENTIAL_MISSING"
|
||||
|
||||
|
||||
def test_ensure_logged_in_unknown_strategy():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": "bogus"}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
|
||||
|
||||
def test_ensure_logged_in_no_strategy():
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
page = MagicMock()
|
||||
account = {"auth_strategy": ""}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account)
|
||||
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
|
||||
|
||||
|
||||
def test_device_bound_fingerprint_mismatch(monkeypatch):
|
||||
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
|
||||
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.auth_flows._current_machine_fingerprint",
|
||||
lambda: "right_fp",
|
||||
)
|
||||
page = MagicMock()
|
||||
account = {
|
||||
"auth_strategy": "device_bound",
|
||||
"device_fingerprint": "wrong_fp",
|
||||
"login_id": "u",
|
||||
"url": "http://x",
|
||||
}
|
||||
with pytest.raises(AuthStrategyError) as ei:
|
||||
ensure_logged_in(page, account, credentials={"plaintext": "pw"})
|
||||
assert ei.value.code == "ERR_DEVICE_FINGERPRINT_MISMATCH"
|
||||
|
||||
|
||||
def test_is_already_logged_in_default_false():
|
||||
from service.rpa_helpers.auth_flows import _is_already_logged_in
|
||||
|
||||
p = MagicMock()
|
||||
p.url = "about:blank"
|
||||
assert _is_already_logged_in(p, {}) is False
|
||||
|
||||
p.url = "https://www.douyin.com"
|
||||
assert _is_already_logged_in(p, {}) is False
|
||||
|
||||
p.url = "https://www.douyin.com/user/abc"
|
||||
assert _is_already_logged_in(
|
||||
p,
|
||||
{"extra_json": {"logged_in_url_pattern": r"/user/"}},
|
||||
) is True
|
||||
|
||||
loc_first = MagicMock()
|
||||
loc_first.is_visible.return_value = True
|
||||
loc = MagicMock()
|
||||
loc.first = loc_first
|
||||
p2 = MagicMock()
|
||||
p2.url = "https://example.com/home"
|
||||
p2.locator.return_value = loc
|
||||
assert _is_already_logged_in(
|
||||
p2,
|
||||
{"extra_json": {"logged_in_selector": "#avatar"}},
|
||||
) is True
|
||||
p2.locator.assert_called_with("#avatar")
|
||||
|
||||
|
||||
def test_qr_code_manual_default_no_hooks():
|
||||
from service.rpa_helpers.auth_flows import ensure_logged_in
|
||||
|
||||
qr_first = MagicMock()
|
||||
qr_first.wait_for.side_effect = TimeoutError("mock qr timeout")
|
||||
qr_loc = MagicMock()
|
||||
qr_loc.first = qr_first
|
||||
page = MagicMock()
|
||||
page.url = "about:blank"
|
||||
page.locator.return_value = qr_loc
|
||||
|
||||
account = {"auth_strategy": "qr_code_manual", "url": "http://login.example"}
|
||||
r = ensure_logged_in(page, account)
|
||||
page.goto.assert_called_once_with(
|
||||
"http://login.example",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=60000,
|
||||
)
|
||||
qr_first.wait_for.assert_called_once()
|
||||
assert r.ok is False
|
||||
assert r.message == "qr code not found"
|
||||
|
||||
61
tests/test_rpa_helpers_browser.py
Normal file
61
tests/test_rpa_helpers_browser.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_playwright(monkeypatch):
|
||||
pw = MagicMock()
|
||||
ctx = MagicMock()
|
||||
page = MagicMock()
|
||||
ctx.pages = [page]
|
||||
pw.chromium.launch_persistent_context.return_value = ctx
|
||||
|
||||
sp = MagicMock()
|
||||
sp.start.return_value = pw
|
||||
monkeypatch.setattr("service.rpa_helpers.browser.sync_playwright", lambda: sp)
|
||||
|
||||
return {"pw": pw, "ctx": ctx, "page": page, "sp": sp}
|
||||
|
||||
|
||||
def test_launch_browser_uses_chrome_channel_default(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.browser.resolve_chromium_channel",
|
||||
lambda: "chrome",
|
||||
)
|
||||
prof = tmp_path / "p1"
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
ctx, page = launch_browser_with_profile(str(prof))
|
||||
pw = mock_playwright["pw"]
|
||||
kw = pw.chromium.launch_persistent_context.call_args.kwargs
|
||||
assert kw["channel"] == "chrome"
|
||||
assert "--disable-blink-features=AutomationControlled" in kw["args"]
|
||||
assert ctx is mock_playwright["ctx"]
|
||||
assert page is mock_playwright["page"]
|
||||
|
||||
|
||||
def test_launch_browser_respects_explicit_channel(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
prof = tmp_path / "p2"
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
launch_browser_with_profile(str(prof), channel="msedge")
|
||||
kw = mock_playwright["pw"].chromium.launch_persistent_context.call_args.kwargs
|
||||
assert kw["channel"] == "msedge"
|
||||
|
||||
|
||||
def test_launch_browser_creates_profile_dir(mock_playwright, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"service.rpa_helpers.browser.resolve_chromium_channel",
|
||||
lambda: "chrome",
|
||||
)
|
||||
prof = tmp_path / "new_prof"
|
||||
assert not prof.is_dir()
|
||||
from service.rpa_helpers.browser import launch_browser_with_profile
|
||||
|
||||
launch_browser_with_profile(str(prof))
|
||||
assert prof.is_dir()
|
||||
54
tests/test_rpa_helpers_human.py
Normal file
54
tests/test_rpa_helpers_human.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_pause_respects_range(monkeypatch):
|
||||
sleeps = []
|
||||
|
||||
def _rec(s):
|
||||
sleeps.append(s)
|
||||
|
||||
monkeypatch.setattr("service.rpa_helpers.human.time.sleep", _rec)
|
||||
from service.rpa_helpers.human import pause
|
||||
|
||||
for _ in range(10):
|
||||
pause(50, 100)
|
||||
assert len(sleeps) == 10
|
||||
for s in sleeps:
|
||||
assert 0.04 <= s <= 0.12
|
||||
|
||||
|
||||
def test_human_type_calls_locator_methods():
|
||||
from service.rpa_helpers.human import human_type
|
||||
|
||||
loc = MagicMock()
|
||||
human_type(loc, "hello")
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
loc.type.assert_called_once()
|
||||
args, kwargs = loc.type.call_args
|
||||
assert args[0] == "hello"
|
||||
assert "delay" in kwargs
|
||||
assert 50 <= kwargs["delay"] <= 200
|
||||
|
||||
|
||||
def test_human_click_basic():
|
||||
from service.rpa_helpers.human import human_click
|
||||
|
||||
loc = MagicMock()
|
||||
human_click(loc)
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
|
||||
|
||||
def test_human_select_basic():
|
||||
from service.rpa_helpers.human import human_select
|
||||
|
||||
loc = MagicMock()
|
||||
human_select(loc, "opt1")
|
||||
loc.scroll_into_view_if_needed.assert_called()
|
||||
loc.click.assert_called()
|
||||
assert loc.select_option.call_count >= 1
|
||||
|
||||
377
tests/test_schema_migration.py
Normal file
377
tests/test_schema_migration.py
Normal file
@@ -0,0 +1,377 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for v2 schema DDL and migrate_v1_to_v2 / ensure_schema."""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
# Minimal v1-like DDL (no auth_strategy / session_persistent / device_fingerprint /
|
||||
# platforms.default_auth_strategy; credentials without account_id).
|
||||
_V1_DDL = """
|
||||
CREATE TABLE platforms (
|
||||
platform_key TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
provider_code TEXT,
|
||||
default_url TEXT,
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
account_label TEXT NOT NULL,
|
||||
login_id TEXT,
|
||||
login_id_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
tenant_id TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'production',
|
||||
role TEXT NOT NULL DEFAULT 'default',
|
||||
provider_code TEXT,
|
||||
profile_dir TEXT,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
session_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_used_at INTEGER,
|
||||
last_login_check_at INTEGER,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
credential_label TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
secret_storage TEXT NOT NULL,
|
||||
secret_ref TEXT,
|
||||
secret_mask TEXT,
|
||||
expires_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
last_used_at INTEGER,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE account_leases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
lease_token TEXT NOT NULL UNIQUE,
|
||||
holder TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
heartbeat_at INTEGER,
|
||||
released_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _db_path_for_env(root: str, uid: str) -> str:
|
||||
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
def _prep_logging():
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_migration_test")
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return [row[1] for row in cur.fetchall()]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_env(monkeypatch):
|
||||
with tempfile.TemporaryDirectory(prefix="acc_mgr_schema_", ignore_cleanup_errors=True) as tmp:
|
||||
data_root = os.path.join(tmp, "data-root")
|
||||
os.makedirs(data_root)
|
||||
uid = "migration_uid"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
db_path = _db_path_for_env(data_root, uid)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_prep_logging()
|
||||
yield {"tmp": tmp, "data_root": data_root, "uid": uid, "db_path": db_path}
|
||||
|
||||
|
||||
class TestFreshSchema:
|
||||
def test_fresh_schema_has_v2_columns_and_meta(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols_accounts = _column_names(conn, "accounts")
|
||||
assert "auth_strategy" in cols_accounts
|
||||
assert "session_persistent" in cols_accounts
|
||||
assert "device_fingerprint" in cols_accounts
|
||||
cols_plat = _column_names(conn, "platforms")
|
||||
assert "default_auth_strategy" in cols_plat
|
||||
cols_cred = _column_names(conn, "credentials")
|
||||
assert "account_id" in cols_cred
|
||||
assert "secret_ciphertext" in cols_cred
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None and row[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigrateFromV1:
|
||||
def test_v1_to_v2_migrates_and_preserves_rows(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
now = int(time.time())
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('keep_me', 'Keep', 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('keep_me', 'acc', 'u@test.com', 'email', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "[migration] account-manager v1→v2:" in err
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM accounts WHERE login_id = ?", ("u@test.com",))
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
cur.execute(
|
||||
"SELECT auth_strategy, session_persistent FROM accounts WHERE login_id = ?",
|
||||
("u@test.com",),
|
||||
)
|
||||
strat, sp = cur.fetchone()
|
||||
assert strat == "password_auto"
|
||||
assert int(sp) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_migration_idempotent(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db
|
||||
|
||||
init_db()
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "v1→v2" not in err
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cols = _column_names(conn, "accounts")
|
||||
assert cols.count("auth_strategy") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_platform_default_mapping_douyin(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('douyin', 'Douyin', 'content', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
("douyin",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_accounts_auth_strategy_priority(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
for pk, dn in (
|
||||
("maersk", "Maersk"),
|
||||
("unknown_platform", "Unknown"),
|
||||
):
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(pk, dn, now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('maersk', 'm', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('unknown_platform', 'u', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("maersk",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("unknown_platform",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "password_auto"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestInsertAccountValidation:
|
||||
def test_insert_account_rejects_invalid_strategy(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
from db.accounts_repo import insert_account
|
||||
from db.auth_strategy import AUTH_STRATEGY_QR_CODE_MANUAL
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
now = int(time.time())
|
||||
with pytest.raises(ValueError, match="invalid auth_strategy"):
|
||||
insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy="not_a_strategy",
|
||||
)
|
||||
aid = insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy=AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
)
|
||||
assert isinstance(aid, int) and aid > 0
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
104
tests/test_schema_migration_v21.py
Normal file
104
tests/test_schema_migration_v21.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
def _db_path(root: str, uid: str) -> str:
|
||||
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
def _prep_logging():
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_schema_v21")
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return [row[1] for row in cur.fetchall()]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_v21(monkeypatch):
|
||||
with tempfile.TemporaryDirectory(prefix="acc_mgr_v21_", ignore_cleanup_errors=True) as tmp:
|
||||
data_root = os.path.join(tmp, "data-root")
|
||||
os.makedirs(data_root)
|
||||
uid = "v21_uid"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
db_path = _db_path(data_root, uid)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_prep_logging()
|
||||
yield {"tmp": tmp, "db_path": db_path}
|
||||
|
||||
|
||||
class TestSchemaV21:
|
||||
def test_fresh_has_secret_ciphertext_and_meta(self, iso_v21):
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
if os.path.isfile(iso_v21["db_path"]):
|
||||
os.remove(iso_v21["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
assert "secret_ciphertext" in _column_names(conn, "credentials")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_v2_to_v21_idempotent(self, iso_v21, capsys):
|
||||
from db.connection import get_conn, init_db
|
||||
|
||||
db_path = iso_v21["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute("ALTER TABLE credentials DROP COLUMN secret_ciphertext")
|
||||
conn.execute(
|
||||
"UPDATE _schema_meta SET value = ? WHERE key = ?",
|
||||
("2", "schema_version"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "v2→v2.1" in err
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
assert "secret_ciphertext" in _column_names(conn, "credentials")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "2.1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err2 = capsys.readouterr().err
|
||||
assert "v2→v2.1" not in err2
|
||||
68
tests/test_seed_platforms_upgrade.py
Normal file
68
tests/test_seed_platforms_upgrade.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from db.connection import get_conn, init_db # noqa: E402
|
||||
from util.platforms import seed_platforms # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_seed_env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp(prefix="seed_plat_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "seed_user_1"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("CLAW_USER_ID", uid)
|
||||
yield
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _default_auth_maersk() -> str | None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
("maersk",),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_default_auth_strategy_filled_on_first_seed(iso_seed_env):
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
assert _default_auth_maersk() == "qr_code_manual"
|
||||
|
||||
|
||||
def test_default_auth_strategy_updated_on_reseed(iso_seed_env):
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
seed_platforms(conn)
|
||||
conn.execute(
|
||||
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
|
||||
("password_auto", "maersk"),
|
||||
)
|
||||
conn.commit()
|
||||
seed_platforms(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
assert _default_auth_maersk() == "qr_code_manual"
|
||||
33
tests/test_usage.py
Normal file
33
tests/test_usage.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Smoke test: built-in CLI usage lists routed account subcommands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
|
||||
|
||||
def test_usage_includes_all_account_subcommands():
|
||||
env = {**os.environ, "PYTHONPATH": _scripts}
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "cli.app"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=_scripts,
|
||||
)
|
||||
assert r.returncode == 1
|
||||
usage = r.stdout
|
||||
for cmd in (
|
||||
"init-master-key",
|
||||
"export-credentials",
|
||||
"import-credentials",
|
||||
"add-web",
|
||||
"get-credential",
|
||||
"pick-web",
|
||||
):
|
||||
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
||||
332
tests/test_v1_compatibility.py
Normal file
332
tests/test_v1_compatibility.py
Normal file
@@ -0,0 +1,332 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""v1 CLI compatibility — subprocess-only, mirrors monitor-competitor-rates calling patterns."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
MAIN_PY = os.path.join(_scripts, "main.py")
|
||||
|
||||
|
||||
def _cli_env(roots: dict) -> dict:
|
||||
e = {**os.environ}
|
||||
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
|
||||
e["JIANGCHANG_USER_ID"] = roots["user_id"]
|
||||
e["CLAW_DATA_ROOT"] = roots["data_root"]
|
||||
e["CLAW_USER_ID"] = roots["user_id"]
|
||||
return e
|
||||
|
||||
|
||||
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess:
|
||||
inp = stdin.encode("utf-8") if stdin is not None else None
|
||||
return subprocess.run(
|
||||
[sys.executable, MAIN_PY, *args],
|
||||
input=inp,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
cwd=os.path.dirname(_scripts),
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_json(stdout: str) -> dict:
|
||||
lines = [ln for ln in (stdout or "").strip().split("\n") if ln.strip()]
|
||||
assert lines, "stdout had no non-empty lines"
|
||||
return json.loads(lines[-1])
|
||||
|
||||
|
||||
def run_cli_json_last(env: dict, args: list[str], *, stdin: str | None = None) -> dict:
|
||||
"""Parse **last** stdout line as JSON (monitor `_parse_stdout_json` semantics)."""
|
||||
r = _run_cli(env, args, stdin=stdin)
|
||||
assert r.returncode == 0, (r.stdout, r.stderr)
|
||||
return _parse_last_json(r.stdout.decode("utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_cli_env():
|
||||
tmp = tempfile.mkdtemp(prefix="v1_compat_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
uid = "compat_user"
|
||||
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _add_monitor_account(env: dict) -> dict:
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"add-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--label",
|
||||
"monitor-test",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--tenant-id",
|
||||
"tenant-test",
|
||||
],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
return out["data"]
|
||||
|
||||
|
||||
class TestV1PickWeb:
|
||||
def test_pick_web_returns_v1_shape(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
add_data = _add_monitor_account(env)
|
||||
aid = add_data["id"]
|
||||
assert isinstance(aid, int)
|
||||
|
||||
pick = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--purpose",
|
||||
"maersk_sim_rpa",
|
||||
"--ttl-sec",
|
||||
"1800",
|
||||
],
|
||||
)
|
||||
|
||||
for key in ("id", "platform_key", "environment", "role", "profile_dir", "lease_token"):
|
||||
assert key in pick, f"pick-web 缺少 v1 字段 {key}"
|
||||
|
||||
assert isinstance(pick["id"], int)
|
||||
assert isinstance(pick["lease_token"], str) and len(pick["lease_token"]) > 0
|
||||
assert pick["platform_key"] == "maersk"
|
||||
assert pick["environment"] == "simulator"
|
||||
assert pick["role"] == "booking"
|
||||
|
||||
for v2_key in ("auth_strategy", "session_persistent", "device_fingerprint"):
|
||||
assert v2_key in pick, f"v2 字段 {v2_key} 应在 pick-web 返回中"
|
||||
|
||||
assert len(pick) >= 10
|
||||
|
||||
def test_pick_web_no_account_returns_legacy_error_code(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
],
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
payload = _parse_last_json(proc.stdout.decode("utf-8"))
|
||||
assert payload.get("success") is False
|
||||
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_pick_web_lease_race_returns_legacy_error_shape(self, iso_cli_env):
|
||||
"""Parallel picks share one account — second outcome may be NO_ACCOUNT or LEASE_CONFLICT."""
|
||||
env = _cli_env(iso_cli_env)
|
||||
_add_monitor_account(env)
|
||||
|
||||
args = [
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"3600",
|
||||
]
|
||||
|
||||
def _pick():
|
||||
return _run_cli(env, args)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
f1 = ex.submit(_pick)
|
||||
f2 = ex.submit(_pick)
|
||||
wait([f1, f2])
|
||||
proc_a = f1.result()
|
||||
proc_b = f2.result()
|
||||
|
||||
payloads = []
|
||||
for proc in (proc_a, proc_b):
|
||||
assert proc.returncode == 0
|
||||
payloads.append(_parse_last_json(proc.stdout.decode("utf-8")))
|
||||
|
||||
codes = []
|
||||
successes = 0
|
||||
for p in payloads:
|
||||
if p.get("success") is False:
|
||||
codes.append(p["error"]["code"])
|
||||
elif "lease_token" in p:
|
||||
successes += 1
|
||||
|
||||
assert successes >= 1
|
||||
assert "ERROR:LEASE_CONFLICT" in codes or "ERROR:NO_ACCOUNT" in codes
|
||||
|
||||
|
||||
class TestV1LeaseAndMarks:
|
||||
def test_lease_release_returns_success_json(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
_add_monitor_account(env)
|
||||
pick = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
tok = pick["lease_token"]
|
||||
rel = run_cli_json_last(env, ["lease", "release", tok])
|
||||
assert rel.get("success") is True
|
||||
assert rel["data"]["status"] == "released"
|
||||
|
||||
def test_mark_used_returns_success(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
out = run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||
assert out.get("success") is True
|
||||
assert int(out["data"]["id"]) == aid
|
||||
|
||||
def test_mark_session_accepts_v1_status_values(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
for status in ("likely_valid", "needs_login", "unknown"):
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
["account", "mark-session", str(aid), "--session-status", status],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
assert out["data"]["session_status"] == status
|
||||
|
||||
def test_mark_error_accepts_long_message(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
long_msg = "x" * 600
|
||||
out = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"mark-error",
|
||||
str(aid),
|
||||
"--code",
|
||||
"ERR_TEST",
|
||||
"--message",
|
||||
long_msg,
|
||||
],
|
||||
)
|
||||
assert out.get("success") is True
|
||||
assert out["data"]["error_code"] == "ERR_TEST"
|
||||
|
||||
|
||||
class TestV1EndToEnd:
|
||||
def test_full_monitor_sequence_end_to_end(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
aid = _add_monitor_account(env)["id"]
|
||||
|
||||
pick1 = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--purpose",
|
||||
"maersk_sim_rpa",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
tok = pick1["lease_token"]
|
||||
assert pick1["id"] == aid
|
||||
|
||||
run_cli_json_last(env, ["account", "mark-used", str(aid)])
|
||||
run_cli_json_last(
|
||||
env,
|
||||
["account", "mark-session", str(aid), "--session-status", "likely_valid"],
|
||||
)
|
||||
run_cli_json_last(env, ["lease", "release", tok])
|
||||
|
||||
pick2 = run_cli_json_last(
|
||||
env,
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
"maersk",
|
||||
"--environment",
|
||||
"simulator",
|
||||
"--role",
|
||||
"booking",
|
||||
"--lease",
|
||||
"--holder",
|
||||
"monitor-competitor-rates",
|
||||
"--ttl-sec",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
assert "lease_token" in pick2
|
||||
|
||||
acc = run_cli_json_last(env, ["account", "get", str(aid)])
|
||||
assert acc.get("session_status") == "likely_valid"
|
||||
|
||||
|
||||
class TestStdoutIsolation:
|
||||
def test_stderr_does_not_pollute_stdout_last_line(self, iso_cli_env):
|
||||
env = _cli_env(iso_cli_env)
|
||||
proc = _run_cli(env, ["account", "export-credentials", "--plaintext"])
|
||||
assert proc.returncode == 0
|
||||
assert b"[export] WARNING" in proc.stderr
|
||||
lines = [ln for ln in proc.stdout.decode("utf-8").strip().split("\n") if ln.strip()]
|
||||
payload = json.loads(lines[-1])
|
||||
assert isinstance(payload, list)
|
||||
Reference in New Issue
Block a user