Compare commits

...

34 Commits

Author SHA1 Message Date
12b1fec0b9 feat: add requirements.txt with cryptography for host venv install
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-11 16:07:23 +08:00
f7f9555683 chore: sync SKILL.md version with release tag v2.0.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:23:34 +08:00
549869b2bb Release v2.0.1: create Chrome shortcut in profile dir on add-web
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-07 16:22:59 +08:00
71bef19514 chore: auto release commit (2026-06-19 11:19:10)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-19 11:19:10 +08:00
75c7ea67d5 chore: auto release commit (2026-06-19 11:07:44)
Some checks failed
技能自动化发布 / release (push) Failing after 3s
2026-06-19 11:07:44 +08:00
23f4a2c11a chore: auto release commit (2026-06-19 09:53:29)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-19 09:53:29 +08:00
cb980704bf chore: auto release commit (2026-05-30 19:51:46)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-05-30 19:51:46 +08:00
a7bd6ff9d8 chore: auto release commit (2026-05-11 19:34:47)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-05-11 19:34:47 +08:00
4fe1351e1e test(compat): add v1 compatibility suite; docs: add V1_COMPATIBILITY.md
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:52:22 +08:00
fd5c432735 fix(db): sync default_auth_strategy on platform reseed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
2c7e648a72 fix(cli): add encryption commands to USAGE string
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
6fc4f1af1d docs: add RPA_HELPERS.md for same-process integration guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:56 +08:00
dadf89af3d docs: update SCHEMA.md / ERRORS.md / PLATFORMS.md / RUNTIME.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:49 +08:00
1792ba2a0d docs: update SKILL.md / README.md / CLI.md / INTEGRATION.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:42 +08:00
dbf5604090 fix(rpa_helpers): require positive evidence in _is_already_logged_in to avoid false success
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:44:46 +08:00
769ccec601 test(rpa_helpers): add mock-based tests for browser/human/auth_flows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
cfca82ed0c feat(rpa_helpers): export public API in package __init__
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
104aa91364 feat(rpa_helpers): add auth_flows.py with 9 ensure_logged_in_* and dispatcher
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
c23240d0ea feat(rpa_helpers): add human.py interaction primitives (pause/type/click/select/scroll)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
5d4425c060 feat(rpa_helpers): add browser.launch_browser_with_profile and close_browser
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
ef80d64f30 chore(platforms): align PLATFORMS registry with PLATFORM_DEFAULT_AUTH_STRATEGY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:48:27 +08:00
65d939dc5b test(cli): add stage C CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
04cafae3d7 feat(cli): argv routing and USAGE for stage C commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
a74157ff4a feat(service): extend add-web/add-secret/pick-web; add get-credential
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
4c7e4f9769 feat(db): add get_active_lease_by_token helper to accounts_repo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
e7f41d6e27 test(crypto+db+cli): add master key, crypto, credentials_repo, and CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:29 +08:00
07adc98e8c feat(cli): add init-master-key / export-credentials / import-credentials commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
5f8c93dee9 feat(db): add credentials_repo with encrypted insert/read API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
127e9b6019 feat(db): migrate credentials to v2.1 (add secret_ciphertext column)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
ee851f719b feat(crypto): add master key loader and Fernet encrypt/decrypt helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:27 +08:00
06aed39661 test(db): add schema migration tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
00e3116339 feat(db): migrate accounts/credentials/platforms to v2 schema
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
27d687becd feat(db): add auth_strategy constants and platform defaults
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:48 +08:00
91057fa3b7 Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-05-05 18:51:00 +08:00
70 changed files with 11068 additions and 1019 deletions

View File

@@ -1,57 +1,69 @@
---
name: 账号管理
description: "多平台账号搜狐号、头条、知乎、微信公众号、豆包、Kimi、DeepSeek 等)的本地登记与查询。当用户或编排要添加/删除账号、列出全部或某平台账号、按 id 查看单条账号信息、为网页自动化批量取账号 JSON 或从中选一条候选、在浏览器中打开已登记账号以登录或核对页面时,加载本技能。本技能不在库中记录「是否已登录」;具体子命令与参数以 references/CLI.md 为准。"
version: 1.0.55
name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.7
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: account-manager
emoji: "👤"
emoji: "🔐"
category: "通用"
allowed-tools:
- bash
---
# 账号管理account-manager
# 账号与凭据管理account-manager
在本地 SQLite 登记各平台账号与持久化用户目录路径,供其它技能通过子进程调用 `scripts/main.py` 读取或维护。**不在库中持久化「是否已登录」**`pick-web` 仅按规则选一条候选,**不表示**当前浏览器已登录
通用账号、凭据与浏览器 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`;非 `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** 才能解密。
## 何时使用本技能
出现下列**业务场景**时加载;真正执行时严格按 **`references/CLI.md`** 拼 argv勿臆造参数。子命令与源码函数的对应关系见该文件篇首映射表
1. **登记新账号**用户给出平台名与大陆 11 位手机号 —— `add`
2. **人读查看列表**:要看全部或某一平台的账号台账(多行文本);首参若能识别为平台,可用「只写平台名」或「平台名 + list」的简写 —— `list`(路由见 `cli/app.py`
3. **按 id 取一条机器可读记录**:编排或调试需要单行 JSON —— `get`
4. **按平台或全部拉列表机器可读**stdout 一行 JSON 数组,元素同 `get`,供跨技能编排 —— `list-json`
5. **为某平台挑一条网页自动化用号**stdout 一行 JSON按更新时间等规则选一条 —— `pick-web`
6. **在浏览器里打开已登记账号**:用户说「打开某平台这个账号」「用浏览器打开 id 为 x 的号」等 —— `open`(须已知账号 id本机 Chrome/Edge + Playwright channel不写库、不做登录检测
7. **删除账号**:按 id 删一条、按平台清空、或按平台+手机号删一条 —— `delete``id` / `platform` / `platform`+手机号 三种形式)
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>`)— 仅查看,不做站点侧登录判定
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 调用
语法、默认值、错误约定、**CLI → service** 映射**`references/CLI.md`**。
语法、默认值、错误约定见 **`references/CLI.md`**。
**重要约束**CLI 由宿主或工具执行;**不要把命令原文贴给最终用户看**。添加账号时用户只需在对话中提供**平台名与 11 位完整手机号****不要**引导具体宿主菜单路径;**不要**让用户填写 `profile_dir``add` 时由实现分配路径)。
## 支持的平台
## 平台名称与别名
入库为英文 `platform` 键;用户话术可为中文名或别名。见 `references/PLATFORMS.md`;错误前缀见 `references/ERRORS.md`
见 [`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)
## 跨技能集成
`get` / `list-json` / `pick-web` 的 stdout 协议见 `references/INTEGRATION.md`
stdout 协议与同进程 import 见 [`references/INTEGRATION.md`](references/INTEGRATION.md);登录 helper 细节见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)
## 依赖与运行时
## Secret 存储
- **Python**:共享宿主运行时;勿在技能目录内自建 `.venv`
- **浏览器**`open` 依赖本机 **Chrome 或 Edge****playwright**channel 模式)。详见 `references/RUNTIME.md`
- **环境变量**`JIANGCHANG_*` / `CLAW_*` 等与兄弟技能一致。
- `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)。
`assets/README.md``assets/examples/``assets/schemas/`
## 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` 等)保留。

View File

@@ -1,4 +1,6 @@
# 示例 JSON
- `get-response.json``python main.py get <id>` 成功时 **stdout 单行** 对象的形状参考(示例路径为虚构)。
- `list-json-array.json``list-json` 成功时 **stdout 单行** 数组的形状参考(本文件为多行排版便于阅读)。
- `get-response.json``account get <id>` 成功时 stdout 单行对象的形状参考(v2 schema展示为完整 JSON 对象)。
- `list-json-array.json``account list``list-json` 成功时 stdout 单行数组的形状参考(多行排版便于阅读)。
注意:实际输出为 **`{"success":true,"data":...}`** 包裹的格式get/list 系列或直接输出数组list-json 兼容入口)。

View File

@@ -1,10 +1,20 @@
{
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"success": true,
"data": {
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"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",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
}

View File

@@ -1,22 +1,36 @@
[
{
"id": 2,
"name": "知乎1号",
"platform": "zhihu",
"phone": "13900001111",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
"url": "https://www.zhihu.com",
"created_at": "2026-04-02T09:00:00",
"updated_at": "2026-04-02T09:00:00"
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"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",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
},
{
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"id": 2,
"platform_key": "deepseek",
"account_label": "DeepSeek production key",
"login_id": "",
"login_id_type": "unknown",
"tenant_id": "",
"environment": "production",
"role": "api",
"provider_code": "deepseek",
"profile_dir": "",
"url": "https://chat.deepseek.com",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T10:00:00",
"updated_at": "2026-05-01T10:00:00"
}
]

View File

@@ -2,18 +2,47 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
"title": "AccountRecord",
"description": "account-manager get / list-json / pick-web 返回的单条账号对象(核心字段extra_json 合并键不枚举",
"description": "account-manager account get / pick-web / list 返回的单条账号对象(v2 schema",
"type": "object",
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "created_at", "updated_at"],
"required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"platform": { "type": "string", "description": "内部英文键,如 sohu、zhihu" },
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
"id": { "type": "integer", "description": "账号主键" },
"platform_key": { "type": "string", "description": "平台唯一键,如 maersk、deepseek" },
"account_label": { "type": "string", "description": "人可读名称" },
"login_id": { "type": "string", "description": "登录标识email/phone/username/customer_code" },
"login_id_type": { "type": "string", "enum": ["email", "username", "phone", "customer_code", "api_account", "unknown"] },
"tenant_id": { "type": "string", "description": "租户/项目标识" },
"environment": { "type": "string", "enum": ["simulator", "staging", "production", "test"] },
"role": { "type": "string", "description": "booking / tracking / admin / sales / api / default" },
"provider_code": { "type": "string", "description": "供应商代码" },
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
"url": { "type": "string", "format": "uri" },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
"url": { "type": "string", "description": "账号默认入口 URL" },
"status": { "type": "string", "enum": ["active", "disabled", "needs_review"] },
"session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] },
"last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" },
"last_login_check_at": { "type": ["string", "null"] },
"last_error_code": { "type": "string" },
"last_error_message": { "type": "string" },
"created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" },
"updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" },
"credentials": {
"type": "array",
"description": "关联凭据(仅 account get --with-credentials 时返回)",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"credential_label": { "type": "string" },
"credential_type": { "type": "string" },
"secret_storage": { "type": "string" },
"secret_ref": { "type": "string" },
"secret_mask": { "type": "string" },
"status": { "type": "string" }
}
}
},
"lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" },
"lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" }
},
"additionalProperties": true
}

View File

@@ -1,105 +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 约定仍以本文与源码为准。
| CLI`main.py` 第一参或等价路由) | 实现(`scripts/service/` |
|----------------------------------|---------------------------|
| `add` | `account_service.cmd_add` |
| `list`;或首参为平台且 `list`/单参 | `account_service.cmd_list`(路由见 `cli/app.py` |
| `get` | `account_service.cmd_get` |
| `list-json` | `account_service.cmd_list_json` |
| `pick-web` | `account_service.cmd_pick_web` |
| `open` | `browser_service.cmd_open` |
| `delete` | `account_service.cmd_delete_by_id` / `cmd_delete_by_platform` / `cmd_delete_by_platform_phone` |
入口:`scripts/main.py``cli/app.py` → 上表。
## 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]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
| `platform get <platform>` | 获取数据库中的平台详情 |
### B. 加密初始化v2
| CLI | 说明 |
|---|---|
| `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.keystderr 会先打印警告;应先有 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 | 说明 |
|---|---|
| `account export-credentials --plaintext` | 将所有凭据行导出为 JSON **数组** 打到 stdoutstderr 有人类可读警告);**必须**显式 `--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` | 清理过期租约 |
| `account open <id>` / `open <id>` | 浏览器查看 |
### 兼容入口(旧版别名)
| CLI | 等价 / 说明 |
|---|---|
| `get <id>` | `account get <id>` |
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
| `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
| `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | 映射到 `account add-web``login_id_type=phone`),不传 v2 扩展参数 |
| `delete id\|platform <...>` | 删除账号 |
---
## account add-web扩参示例
```bash
# 扫码登录的抖音账号
python main.py account add-web \
--platform douyin \
--label "douyin-creator-1" \
--auth-strategy qr_code_manual \
--session-persistent 1
```
- `--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` 等。
---
## account add-secret`local_encrypted` 示例)
```bash
echo "my-icbc-password" | python main.py account add-secret \
--platform icbc_sim \
--credential-type password \
--secret-storage local_encrypted \
--secret-stdin \
--label "工行仿真主账号"
```
- `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"}}`
---
## account get-credential示例
```bash
# 不 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"
```
-`--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))。
---
## account export-credentials / import-credentials示例
```bash
# 备份stdout 为 JSON 数组,含解密后的 plaintext务必妥善销毁
python main.py account export-credentials --plaintext > backup-2026-05-11.json
# 在新机器恢复(需先 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``搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读子命令**`get``list-json``pick-web` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码前缀**:见 [ERRORS.md](ERRORS.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)。
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
## 内置 `_USAGE`(阶段 F
---
## 子命令一览
### `add` — 添加账号
```bash
python main.py add <平台> <手机号>
```
- 手机号为**中国大陆 11 位**`1` 开头,第二位 `39`),可含空格/分隔符,入库前会归一成数字。
- 同一 `platform` 下同一手机号不可重复。
### `list` — 列出账号(人类可读多行块)
```bash
python main.py list [平台|all|全部]
```
- 省略第二参数时等价于 `all`
- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`
### 简写:`python main.py <平台>` / `python main.py <平台> list`
- 若第一个参数能解析为平台,则等价于 `list <平台>`
### `get` — 按 id 输出**单行 JSON**
```bash
python main.py get <id>
```
- 成功:`stdout` **仅一行** JSON 对象UTF-8
- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。
### `list-json` — 列表的**单行 JSON 数组**(跨技能)
```bash
python main.py list-json <平台|all|全部> [--limit N]
```
- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。
- 默认 `limit=200`,实际 clamp 为 `1..500`
- 排序:按 `updated_at` 倒序。
### `pick-web` — 网页自动化候选账号(单行 JSON
```bash
python main.py pick-web <平台>
```
- 按该平台 `updated_at``created_at` 倒序选一条;**不**表示当前浏览器已登录。
- 失败:首行 `ERROR:`
### `open` — 仅打开浏览器(**不写库、不检测登录**
```bash
python main.py open <id>
```
- 需本机 Chrome/Edge + Playwright详见运行环境说明。
- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。
### `delete` — 删除库记录并尽量删除 profile 目录
```bash
python main.py delete id <id>
python main.py delete platform <平台>
python main.py delete platform <平台> <手机号>
```
- 第二段必须为 `id``platform`(小写不敏感)。
---
## 无参数 / 未知子命令
- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`
无参数运行 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)。

View File

@@ -1,11 +1,27 @@
# 终端前缀约定account-manager
# 终端错误码account-manager
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示
约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定
## `ERROR:` — CLI 参数 / 路由
## 机器 JSON 失败格式(常见)
```json
{
"success": false,
"error": {
"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 |
@@ -13,31 +29,122 @@
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
## `ERROR:` — 业务与数据
---
## ERROR: — 数据库 / 通用
| 前缀 | 含义 |
|------|------|
| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 |
| `ERROR:PHONE_REQUIRED` | 缺少手机号 |
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
|---|---|
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
## `ERROR:` — 浏览器 / 环境
---
## ERROR: — 平台与账号
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edgechannel 模式,**不**需 `playwright install chromium` |
|---|---|
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `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 存储方式 |
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
| `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
## 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:` 文本。

View File

@@ -1,36 +1,212 @@
# 与其它 Skill 集成account-manager
## 核心约束
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
## 两种集成方式
| 方式 | 适用场景 | 调用形态 |
|---|---|---|
| **A. CLI subprocessv1 起)** | 任意业务 skill一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
| **B. rpa_helpers 同进程 importv2** | 需要 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/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致否则写到错误库
使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`必须与最终用户会话一致——否则写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)
## 机器可读命令摘要
| 目的 | 命令 | 成功时 stdout |
|------|------|----------------|
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录 |
| 目的 | 命令 | 成功时 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:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
## 解析协议(稳妥做法)
## JSON 对象字段(与 `get` 一致)
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:`** 开头 → 视为失败(旧别名路径仍可能出现)。
典型字段见仓库 `assets/examples/get-response.json``assets/schemas/account-record.schema.json`
## JSON 对象字段
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
### `account pick-web`(典型)
**库中不再持久化「是否已登录」**;编排侧在网页自动化中自行处理登录(与 `pick-web` / `get` 返回的 `profile_dir` 同一浏览器会话)。
```json
{
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"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",
"session_status": "unknown",
"auth_strategy": "qr_code_manual",
"session_persistent": 1,
"device_fingerprint": null,
"lease_token": "…………",
"lease_expires_at": "2026-05-11T12:34:56"
}
```
## Publisher / 自动化流水线示例
`lease_*` 仅在传入 `--lease` 时出现;时间与仓库 `_unix_to_iso_local` 一致。
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url`
2. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录。
### `account get`
输出即数据库映射字典(字段与 [`SCHEMA.md`](SCHEMA.md) `accounts` 一致,`extra_json` 等为结构化 JSON
### `credential pick`
```json
{
"success": true,
"id": 1,
"platform_key": "deepseek",
"credential_label": "DeepSeek production key",
"credential_type": "api_key",
"secret_storage": "env",
"secret_ref": "DEEPSEEK_API_KEY",
"secret_mask": "****7890",
"status": "active"
}
```
`--reveal` 时附加 **`secret`**。请勿向终端用户打印 reveal 结果。
### `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)。
## 业务流程示例
### 物流 RPACLI
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
2. 解析 JSON → 取 `id``profile_dir``url``lease_token`
3.`profile_dir` 启动持久化浏览器上下文
4. 业务完成后:`lease release <lease_token>`
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
### LLM API 调用CLI
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
2. 解析 JSON → 取 `secret` 字段
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:` 前缀** 为准;不要仅依赖退出码。

View File

@@ -1,24 +1,101 @@
# 平台与别名account-manager
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
入库字段 `accounts.platform` 使用下表 **英文键**小写。CLI 与用户话术可使用 **展示名****别名**
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias大小写不敏感
| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL |
|---------------------|------------------|---------------------------|------------------|----------|
| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | (同 label | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com |
| `kimi` | Kimi | (同 label | 月之暗面 | https://kimi.moonshot.cn |
| `deepseek` | DeepSeek | (同 label | — | https://chat.deepseek.com |
| `doubao` | 豆包 | (同 label | — | https://www.doubao.com |
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | Gemini | 谷歌Gemini、Google Gemini、google gemini、Bard | https://gemini.google.com |
---
## 解析规则(摘要
## 物流平台(`domain = logistics`9
- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。
- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM``ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 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、谷歌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 |
---
## 内容平台(`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 |
---
## 开发控制台(`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` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。

View File

@@ -1,52 +1,61 @@
---
description: "统一管理搜狐、头条、知乎、微信、豆包、Kimi、DeepSeek 等多平台账号与浏览器配置目录。添加账号后,其它技能(如大模型写作、平台发布)可按平台自动选用对应环境。"
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
---
# 👤 账号管理
# 🔐 账号与凭据管理
把你在各平台的账号**登记到本地**,后续「用大模型写稿」「发布到搜狐号」等技能才能按平台选用对应账号
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境
> 本技能**只负责登记账号**,不在此处判断「有没有登录网站」。真正打开网页时,由对应业务(例如大模型网页版)在同窗口里提示你完成登录
> 本技能**唯一**账号/凭据管理入口。业务 skill 不自己散落密码、API Key而是通过 CLI或与 CLI 组合的同进程 [`RPA_HELPERS.md`](RPA_HELPERS.md))获取
## 它能帮你做什么
- **添加账号** —一个平台(如豆包、搜狐号),在对话里说出 **11 位大陆手机号**即可
- **查看列表** —— 按平台或全部列出已登记的账号
- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录
- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`Fernet 密文入库)
- **查看/列出账号** — 按平台、环境、角色等多维度筛选
- **给 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`)。
## 你可以这样对它说
- 「帮我添加一个豆包账号,手机号 138xxxx。」
-列出我所有的搜狐号账号。」
-删除头条号里手机尾号 3701 的那条。」
-我想看看 3 号账号在浏览器里打开是什么页面。」
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
-添加 DeepSeek 的 API Key存在 Windows 凭据管理器里。」
-列出所有物流平台的账号。」
-用浏览器打开 3 号账号看看页面。」
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
- 「我要把工行仿真账号的密码加密保存,下次 RPA 自动登录用。」
- 「给我导出所有加密凭据,明天换台电脑要用。」
## 你需要提供什么
| 场景 | 你需要给的 |
|---|---|
| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) |
| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 |
| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
## 失败了怎么办
- **提示手机号格式不对**:需 **1 开头、第二位 39、共 11 位数字**
- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。
- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
| 添加 API Key / 加密密码 | **平台名称** + **凭据类型**api_key 等)+ **secret**stdin / 环境变量引用等) |
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge |
## 它不做什么
- 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责)
- 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息)
- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责)
- 不在此处包办所有网站的 DOM 自动化细节(具体抓取逻辑在各业务 skill
- 不代替内容管理、发布、大模型调用等业务技能
- `windows_credential` / `env` 路径下不把明文写入 SQLite`local_encrypted` 仅存 Fernet 密文
## 相关技能
## Secret 存储方式
- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。
- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。
| 方式 | 说明 |
|---|---|
| `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
View 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)。

View File

@@ -1,48 +1,81 @@
# 运行环境与数据路径account-manager
## 数据落盘位置(当前实现)
## 数据落盘位置
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**`jiangchang-platform-kit` 同源`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`
- **Profile**`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成
- **日志**:与其它技能共用**用户级**目录 `{数据根}/{用户id}/logs/jiangchang.log`(按日轮转)
- **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`**`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli`
- 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**或宿主等价开关CLI 会在 **stderr** 打印实际路径
## 本地 CLI 默认值`runtime_env`
## 本地 CLI 默认值
各技能 **`main.py`** 在 `sys.path` 就绪后、import 业务包之前调用 **`apply_cli_local_defaults()`**(实现见 `jiangchang_skill_core.runtime_env`
各技能 `main.py` 调用 `apply_cli_local_defaults()`
- 代码内 **`CLI_LOCAL_DEV_ENABLED = True`** 时,若当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入平台默认数据根与 **`DEFAULT_LOCAL_USER_ID`**(与 kit 中常量一致)
- 代码内为 **`False`** 时,可设环境变量 **`JIANGCHANG_CLI_LOCAL_DEV=1`** 达到同样效果
- **发版/嵌入宿主前**:将各技能 vendored 的 `runtime_env.py``CLI_LOCAL_DEV_ENABLED` 改为 **`False`**,或完全依赖宿主注入。
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值
- 发版前改为 `False` 或依赖宿主注入
## Secret 存储
| 方式 | 说明 | 适用场景 |
|---|---|---|
| `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
| `local_encrypted` | Fernet 密文存 SQLite`secret_ciphertext`),密钥来自 master key | RPA 密码落地;需先 `account init-master-key` |
- **禁止**把明文写入日志。
- `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)。
## 日志
实现见 `scripts/jiangchang_skill_core/unified_logging.py``util/logging_config.py` 仅 re-export。
| 变量 | 作用 |
|------|------|
|---|---|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径(默认 `{数据根}/{用户id}/logs/jiangchang.log` |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 浏览器(open
## 浏览器(`account open` / rpa_helpers
`open``service/browser_service.py` 与子进程 `service/open_child_runner.py` 启动持久化上下文,仅用于人工查看;**不做**登录态 DOM 检测
- 本机需安装 Chrome 或 Edge
- Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
- 使用 **channel 模式**`chrome` / `msedge`**不下载 Chromium**。
- **不要执行** `playwright install chromium`
依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright` 包**(匠厂共享 `python-runtime` 已包含)。运行时使用 **Playwright `channel=chrome|msedge`** 调系统浏览器,**不要**执行 `playwright install chromium`(避免下载自带 Chromium
### 关键浏览器参数(摘录)
## 常见问题(简要)
- `headless=False`
- `no_viewport=True`
- `args` 包含 `--start-maximized`
- stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`
- **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge。
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
### rpa_helpers
登录编排封装见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。

View File

@@ -1,28 +1,144 @@
# 数据存储(account-manager)
# 数据库表结构(account-manager v2
## 数据库路径
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
(或宿主映射的 `CLAW_DATA_ROOT` / `CLAW_USER_ID`,解析细节见 [`RUNTIME.md`](RUNTIME.md)。)
`JIANGCHANG_DATA_ROOT``JIANGCHANG_USER_ID` 由宿主注入,与兄弟技能保持一致。路径解析细节见 [RUNTIME.md](RUNTIME.md)。
## Schema 版本
## `accounts` 表(核心字段)
- `PRAGMA user_version = 2`
- `_schema_meta.schema_version = "2.1"`(字符串;迁移完成后取值)
- `id` —— 自增主键,CLI 与跨技能常称「账号 id」
- `name` —— 展示用名称(如「豆包1号」)
- `platform` —— 平台英文键(小写,如 `doubao``sohu`),见 [PLATFORMS.md](PLATFORMS.md)
- `phone` —— 归一化后的 11 位数字串
- `profile_dir` —— 持久化浏览器用户数据目录绝对路径,供 Playwright 等复用
- `url` —— 该平台默认入口 URL
- `extra_json` —— 可选扩展(JSON 文本),`get` 成功时可能并入返回对象
- `created_at` / `updated_at` —— Unix 秒;`pick-web` 等按更新时间排序选候选
启动时 `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 | 展示名 |
| `domain` | TEXT NOT NULL | `logistics` / `llm` / `content` / `dev` / `sim` / …(以代码注册为准) |
| `provider_code` | TEXT | 供应商代码 |
| `default_url` | TEXT | 默认入口 URL |
| `aliases_json` | TEXT | 别名 JSON 数组 |
| `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`
---
## 2. `accounts` — 账号身份
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `platform_key` | TEXT NOT NULL | 对应 `platforms.platform_key` |
| `account_label` | TEXT NOT NULL | 人可读名称 |
| `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` / … |
| `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`
---
## 3. `credentials` — 凭据API Key / Token / Password …)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `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 tokenv2.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 秒 |
**原则**:不把 **明文** secret 写入 SQLite`local_encrypted` 仅存 **密文**。外部后端env / Windows CM仍在运行时解析。
**索引:** `idx_credentials_platform_type``idx_credentials_status`**`idx_credentials_account``migrate_v1_to_v2``CREATE INDEX IF NOT EXISTS`**
---
## 4. `account_leases` — RPA 并发锁
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER NOT NULL | `accounts.id` |
| `lease_token` | TEXT UNIQUE | 随机 token |
| `holder` | TEXT NOT NULL | 持有者标识 |
| `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`
---
## 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 文件布局
浏览器配置目录通常位于技能数据目录下的 `profiles/` 分层路径,由实现生成。删除账号时实现会尽量同时删除对应目录,见 [CLI.md](CLI.md) `delete` 说明。
```
{DATA_ROOT}/{USER_ID}/account-manager/
├── account-manager.db
├── master.key # 若执行过 init-master-key也可用环境变量代替
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
删除账号时实现会尽量同步删除对应目录。
## 与 JSON 输出的对应关系
`get` / `list-json` / `pick-web` 单行 JSON 的键名与上表及 `extra_json` 展开规则见 [INTEGRATION.md](INTEGRATION.md) 与仓库 `assets/schemas/`
CLI JSON 字段与上表对应;若有封装层(`_ok_json`),详见 [`CLI.md`](CLI.md) / [`INTEGRATION.md`](INTEGRATION.md)。

View 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。

View File

@@ -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
}

View 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
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
# Fernet 加密local_encrypted 凭据、master key、CLI 启动 import依赖此包。
cryptography>=42.0.0,<45

View File

@@ -1,15 +1,50 @@
"""CLI 入口:用法说明与 argv 分发。"""
import sys
# -*- coding: utf-8 -*-
"""
CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
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
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_add,
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,
cmd_account_mark_used,
cmd_account_pick_web,
cmd_credential_pick,
cmd_credential_resolve,
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_get,
cmd_lease_cleanup,
cmd_lease_list,
cmd_lease_release,
cmd_list,
cmd_list_json,
cmd_pick_web,
cmd_platform_ensure,
cmd_platform_get,
cmd_platform_list,
)
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
@@ -17,97 +52,305 @@ from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
from util.runtime_paths import _maybe_print_paths_debug_cli
# =========================================================================
# Help text
# =========================================================================
def _cli_print_full_usage() -> None:
"""总览(未带子命令或需要完整说明时)。"""
print("用法概览(将 main.py 换为你的路径):")
print(" python main.py add <平台中文名> <手机号>")
print(" python main.py list [平台|all|全部]")
print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON")
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
print(" python main.py get <id>")
print(" python main.py open <id>")
print(" python main.py delete id <id>")
print(" python main.py delete platform <平台>")
print(" python main.py delete platform <平台> <手机号>")
print(" python main.py <平台> # 等价于只列该平台")
print(" python main.py <平台> list # 同上")
print()
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu")
print(" " + _platform_list_cn_for_help())
_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|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>]
[--role <role>] [--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>]
account mark-session <id> --session-status <status>
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>]
[--role <role>] [--reveal]
credential resolve --id <credential_id> [--reveal]
租约管理:
lease release <lease_token>
lease list [--active]
lease cleanup
浏览器:
account open <id>
open <id> # 兼容别名
兼容入口(旧版):
get <id>
list-json <platform|all> [--limit N]
pick-web <platform>
list [platform|all]
add <platform> <phone>
delete id|platform <...>
"""
def _cli_fail_add() -> None:
print("ERROR:CLI_ADD_MISSING_ARGS")
print()
print("【add 添加账号】参数不足。")
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
print(" 必填:中国大陆 11 位手机号1 开头,第二位 39同平台下不可重复")
print()
print("示例:")
print(" python main.py add 搜狐号 13800138000")
print(" python main.py add 知乎 13900001111")
print()
print("支持的平台:" + _platform_list_cn_for_help())
# =========================================================================
# Argument parsing helpers
# =========================================================================
def _get_opt(argv: list[str], flag: str, default=None):
"""Get value of --flag from argv, returning default if not found."""
if flag in argv:
idx = argv.index(flag)
if idx + 1 < len(argv):
return argv[idx + 1]
return default
def _cli_fail_need_account_id(subcmd: str) -> None:
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
print()
print(f"{subcmd}】缺少必填参数:账号 id数据库自增整数可先 list 查看)。")
print("示例:")
print(f" python main.py {subcmd} 1")
def _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _cli_fail_delete() -> None:
print("ERROR:CLI_DELETE_MISSING_ARGS")
print()
print("【delete 删除账号】参数不足。支持三种方式:")
print(" 按 idpython main.py delete id <id>")
print(" 按平台删该平台下全部python main.py delete platform <平台中文名或英文键>")
print(" 按平台+手机号python main.py delete platform <平台> <手机号>")
print()
print("示例:")
print(" python main.py delete id 3")
print(" python main.py delete platform 搜狐号")
print(" python main.py delete platform 头条号 18925203701")
print()
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
v = _get_opt(argv, flag)
if v is not None:
try:
return int(v)
except ValueError:
return default
return default
def _get_all_opts(argv: list[str], flag: str) -> list[str]:
"""Collect all values for a repeatable flag (e.g. --platform-alias)."""
vals: list[str] = []
i = 0
while i < len(argv):
if argv[i] == flag and i + 1 < len(argv):
vals.append(argv[i + 1])
i += 2
else:
i += 1
return vals
def _parse_platform_aliases(argv: list[str]) -> list[str]:
aliases: list[str] = []
for raw in _get_all_opts(argv, "--platform-alias"):
for part in (raw or "").split(","):
s = part.strip()
if s and s not in aliases:
aliases.append(s)
return aliases
def _parse_platform_caller_meta(argv: list[str]):
from service.account_service import PlatformCallerMeta
display_name = _get_opt(argv, "--platform-display-name")
url = _get_opt(argv, "--platform-url")
domain = _get_opt(argv, "--platform-domain")
auth_strategy = _get_opt(argv, "--platform-auth-strategy")
aliases = _parse_platform_aliases(argv)
if not any([display_name, url, domain, auth_strategy, aliases]):
return None
return PlatformCallerMeta(
display_name=display_name,
url=url,
domain=domain,
auth_strategy=auth_strategy,
aliases=aliases,
)
# =========================================================================
# Main dispatch
# =========================================================================
def main() -> None:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
log = get_skill_logger()
if len(sys.argv) < 2:
_cli_print_full_usage()
print(_USAGE)
sys.exit(1)
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
get_skill_logger().info("cli_start argv=%s", sys.argv)
cmd = sys.argv[1]
log.info("cli_start argv=%s", sys.argv)
av = sys.argv
cmd = av[1]
_maybe_print_paths_debug_cli()
# Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd)
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
cmd_list(plat_from_first)
elif plat_from_first and len(sys.argv) == 2:
cmd_list(plat_from_first)
elif cmd == "add":
if len(sys.argv) < 4:
_cli_fail_add()
# ---- platform subcommand ----
if cmd == "platform":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get | ensure")
sys.exit(1)
cmd_add(sys.argv[2], sys.argv[3])
elif cmd == "list":
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
sub = av[2]
if sub == "list":
domain = _get_opt(av, "--domain")
cmd_platform_list(domain)
elif sub == "get":
if len(av) < 4:
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 或 ensure。")
sys.exit(1)
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_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>。")
sys.exit(1)
aid = av[3]
with_creds = _has_flag(av, "--with-credentials")
cmd_account_get(aid, include_credentials=with_creds)
elif sub == "list":
_cmd_account_list(av)
elif sub == "pick-web":
_cmd_pick_web_new(av)
elif sub == "mark-session":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
sys.exit(1)
aid = av[3]
ss = _get_opt(av, "--session-status", "unknown")
cmd_account_mark_session(aid, ss)
elif sub == "mark-error":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
sys.exit(1)
aid = av[3]
ec = _get_opt(av, "--code", "")
em = _get_opt(av, "--message", "")
cmd_account_mark_error(aid, ec, em)
elif sub == "mark-used":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
sys.exit(1)
aid = av[3]
cmd_account_mark_used(aid)
elif sub == "open":
if len(av) < 4:
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)
sys.exit(1)
# ---- credential subcommand ----
elif cmd == "credential":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS credential 需要子命令pick | resolve")
sys.exit(1)
sub = av[2]
if sub == "pick":
_cmd_cred_pick(av)
elif sub == "resolve":
_cmd_cred_resolve(av)
else:
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
sys.exit(1)
# ---- lease subcommand ----
elif cmd == "lease":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令release | list | cleanup")
sys.exit(1)
sub = av[2]
if sub == "release":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1)
cmd_lease_release(av[3])
elif sub == "list":
active = _has_flag(av, "--active")
cmd_lease_list(active)
elif sub == "cleanup":
cmd_lease_cleanup()
else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
sys.exit(1)
# ---- Legacy aliases ----
elif cmd == "get":
if len(sys.argv) < 3:
_cli_fail_need_account_id("get")
print("ERROR:CLI_GET_MISSING_ID")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "list-json":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
print("用法python main.py list-json <平台|all|全部> [--limit N]")
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
sys.exit(1)
av = sys.argv[2:]
platform_arg = av[0]
@@ -120,21 +363,45 @@ def main() -> None:
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
print("用法python main.py pick-web <平台中文名或英文键>")
print("说明:按 updated_at 选一条账号;是否已登录由调用方网页会话自行处理。")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
if len(sys.argv) < 3:
_cli_fail_need_account_id("open")
print("ERROR:CLI_OPEN_MISSING_ID")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "list":
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
cmd_list(platform_arg)
elif cmd == "add":
if len(sys.argv) < 4:
print("ERROR:CLI_ADD_MISSING_ARGS")
sys.exit(1)
# Legacy add maps to add-web for backward compat
cmd_account_add_web(
platform_input=sys.argv[2],
login_id=sys.argv[3],
login_id_type="phone",
label=None,
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
elif cmd == "delete":
if len(sys.argv) < 4:
_cli_fail_delete()
print("ERROR:CLI_DELETE_MISSING_ARGS")
sys.exit(1)
mode = (sys.argv[2] or "").strip().lower()
if mode == "id":
@@ -145,17 +412,181 @@ def main() -> None:
else:
cmd_delete_by_platform(sys.argv[3])
else:
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]},请使用 id 或 platform")
_cli_fail_delete()
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
sys.exit(1)
# ---- Platform shorthand: "python main.py <platform>" ----
elif plat_from_first:
cmd_list(sys.argv[1])
# ---- Unknown ----
else:
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法")
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
print()
_cli_print_full_usage()
print()
print("说明list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
print(_USAGE)
sys.exit(1)
# =========================================================================
# Sub-parsers for complex commands
# =========================================================================
def _cmd_add_web(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
login_id = _get_opt(argv, "--login-id")
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "default")
provider_code = _get_opt(argv, "--provider-code")
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>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_add_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
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,
platform_meta=platform_meta,
)
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")
label = _get_opt(argv, "--label")
storage = _get_opt(argv, "--secret-storage", "env")
stdin_mode = _has_flag(argv, "--secret-stdin")
sref = _get_opt(argv, "--secret-ref")
aid = _get_int_opt(argv, "--account-id", 0) or None
env = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "api")
extra_json_str = _get_opt(argv, "--extra-json")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
return
cmd_account_add_secret(
platform_input=plat,
credential_type=ctype,
label=label,
secret_storage=storage,
secret_stdin=stdin_mode,
secret_ref=sref,
account_id=aid,
environment=env,
role=role,
extra_json_str=extra_json_str,
)
def _cmd_account_list(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
limit=limit,
)
def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_pick_web(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")
env = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
reveal = _has_flag(argv, "--reveal")
if not plat:
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
return
cmd_credential_pick(
platform_input=plat,
credential_type=ctype,
environment=env,
role=role,
reveal=reveal,
)
def _cmd_cred_resolve(argv: list[str]) -> None:
cid = _get_int_opt(argv, "--id", 0)
reveal = _has_flag(argv, "--reveal")
if cid <= 0:
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
return
cmd_credential_resolve(cid, reveal=reveal)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View 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,
}
)

View File

@@ -1,21 +1,115 @@
"""SQLite connection and schema bootstrap."""
import sqlite3
# -*- coding: utf-8 -*-
"""
SQLite connection with schema migration (v1 -> v2).
from db.schema import ACCOUNTS_TABLE_SQL
Migration strategy:
- Check PRAGMA user_version.
- If version < 2 and old 'accounts' table exists, back it up as
'accounts_legacy_backup_<timestamp>' before creating new tables.
- New tables are created with CREATE TABLE IF NOT EXISTS.
- Logs all migration steps.
"""
import os
import sqlite3
import time
from typing import Optional
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
def get_conn():
return sqlite3.connect(get_db_path())
def get_conn() -> sqlite3.Connection:
"""Open a connection to the skill database (autocommit off)."""
conn = sqlite3.connect(get_db_path())
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
def init_db():
def _backup_old_accounts(conn: sqlite3.Connection) -> bool:
"""Rename old 'accounts' table to 'accounts_legacy_backup_<ts>' if it exists with old schema.
Returns True if backup was performed."""
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cur.fetchone():
return False
# Check if it's the old schema (has 'phone' column but no 'platform_key')
# We just check the column count — old schema had fewer columns.
cur.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cur.fetchall()]
# Old schema: id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at = 9
# New schema: many more. If it looks like old, back it up.
has_legacy_structure = "phone" in columns and "platform_key" not in columns
if not has_legacy_structure:
return False
ts = int(time.time())
backup_name = f"accounts_legacy_backup_{ts}"
cur.execute(f"ALTER TABLE accounts RENAME TO {backup_name}")
conn.commit()
logger = get_skill_logger()
logger.info(
"schema_migration_backup old_table=accounts backup_table=%s",
backup_name,
)
return True
def _check_schema_version(conn: sqlite3.Connection) -> int:
cur = conn.cursor()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
return int(row[0]) if row else 0
def init_db() -> None:
"""Idempotent: migrate if needed, create tables, set schema version."""
log = get_skill_logger()
log.info("schema_init_start")
conn = get_conn()
try:
old_version = _check_schema_version(conn)
log.info("schema_current_version=%s", old_version)
if old_version < SCHEMA_VERSION:
if old_version < 2:
backed_up = _backup_old_accounts(conn)
if backed_up:
log.info("schema_init_backup_done old_version=%s", old_version)
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,
old_version,
)
else:
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
except Exception:
log.exception("schema_init_failure")
raise
finally:
conn.close()
def get_db_version() -> int:
"""Return current PRAGMA user_version without initializing."""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if not cur.fetchone():
cur.executescript(ACCOUNTS_TABLE_SQL)
conn.commit()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
return int(row[0]) if row else 0
finally:
conn.close()

View 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),))

View 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",
}

View File

@@ -1,16 +1,292 @@
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
ACCOUNTS_TABLE_SQL = """
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
platform TEXT NOT NULL, -- 平台标识, 与内置 PLATFORMS 键一致, 如 sohu
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
url TEXT, -- 平台入口或登录页 URL
extra_json TEXT, -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
);
# -*- coding: utf-8 -*-
"""
Database schema v2 — Credential & Browser Profile Manager.
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 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.)
# ---------------------------------------------------------------------------
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS platforms (
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
domain TEXT NOT NULL, -- 业务域logistics / llm / content / ecommerce / generic
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
default_url TEXT, -- 默认入口 URL
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
);
""")
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
""")
# ---------------------------------------------------------------------------
# accounts — An identity that can be used (web account, RPA profile, API account)
# ---------------------------------------------------------------------------
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
tenant_id TEXT, -- 租户/客户/项目标识,可空
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录web/rpa 账号使用
url TEXT, -- 账号默认入口 URL为空时用 platforms.default_url
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
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
);
""")
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
""")
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
# Application-level checks are used when login_id is provided.
# ---------------------------------------------------------------------------
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
# ---------------------------------------------------------------------------
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
account_id INTEGER, -- 可空;关联 accounts.id有些 API Key 可直接挂平台,也可挂具体账号
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 / local_encrypted
secret_ref TEXT, -- env 时为环境变量名windows_credential 时为 target namenone/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, -- 最近使用时间
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
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_status ON credentials(status);
""")
# ---------------------------------------------------------------------------
# account_leases — RPA / profile concurrency locks
# Prevents multiple tasks from opening the same profile_dir simultaneously.
# ---------------------------------------------------------------------------
ACCOUNT_LEASES_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS account_leases (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 租约主键(自增)
account_id INTEGER NOT NULL, -- 关联 accounts.id
lease_token TEXT NOT NULL UNIQUE, -- 随机 token用于 release / heartbeat
holder TEXT NOT NULL, -- 持有者,例如 monitor-competitor-rates 或 run id
purpose TEXT, -- 用途,例如 maersk_sim_rpa / booking_collect
expires_at INTEGER NOT NULL, -- 租约过期时间Unix 秒 UTC
heartbeat_at INTEGER, -- 最近一次心跳时间
released_at INTEGER, -- 主动释放时间
status TEXT NOT NULL DEFAULT 'active', -- active / released / expired
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_leases_account_status ON account_leases(account_id, status);
CREATE INDEX IF NOT EXISTS idx_leases_token ON account_leases(lease_token);
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
# ---------------------------------------------------------------------------
ALL_DDL = (
PLATFORMS_TABLE_SQL
+ PLATFORMS_INDEXES_SQL
+ ACCOUNTS_TABLE_SQL
+ ACCOUNTS_INDEXES_SQL
+ CREDENTIALS_TABLE_SQL
+ 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
# 12: 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)

View File

@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""Shared helpers for account service command modules."""
import json
import os
import shutil
import sys
from dataclasses import dataclass, field
from typing import Optional
from db.accounts_repo import (
insert_account,
resolve_auth_strategy_for_platform,
ensure_platform_from_caller_meta,
)
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.connection import get_conn, init_db
from util.logging_config import get_skill_logger
from util.platforms import (
DEFAULT_CAPABILITIES_JSON,
PlatformResolveResult,
_platform_list_cn_for_help,
is_valid_platform_key_format,
resolve_platform_key_dynamic,
seed_platforms,
)
def _map_secret_read_error(exc: BaseException) -> tuple[str, str]:
"""Map secret read/write exceptions to ERROR:* codes (no secret text in code)."""
msg = str(exc)
if "SECRET_ENV_MISSING" in msg:
return "ERROR:SECRET_ENV_MISSING", msg
if "WINDOWS_CREDENTIAL_UNAVAILABLE" in msg:
return "ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE", msg
return "ERROR:SECRET_READ_FAILED", msg
def _credential_ok_line(payload: dict) -> str:
"""Single-line JSON for credential commands (machine integration)."""
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,
*,
platform_key: str,
account_label: str,
environment: str,
role: str,
provider_code: str,
url: Optional[str],
now: int,
) -> int:
"""Create a minimal accounts row so credentials can join on environment/role."""
return insert_account(
conn=conn,
platform_key=platform_key,
account_label=account_label,
login_id=None,
login_id_type="api_account",
tenant_id=None,
environment=environment,
role=role,
provider_code=provider_code,
profile_dir=None,
url=url,
extra_json="{}",
now=now,
auth_strategy=resolve_auth_strategy_for_platform(conn, platform_key),
)
def _err_json(code: str, message: str) -> str:
"""Return a JSON error line."""
return json.dumps(
{"success": False, "error": {"code": code, "message": message}},
ensure_ascii=False,
)
def _ok_json(data) -> str:
"""Return a JSON success line."""
return json.dumps(
{"success": True, "data": data},
ensure_ascii=False,
)
def _say(msg: str) -> None:
"""Print to stdout."""
print(msg)
def _say_err(msg: str) -> None:
"""Print to stderr."""
print(msg, file=sys.stderr)
def _fail_human(code: str, hint_lines: list[str]) -> None:
"""Print human-readable error block."""
_say(f"ERROR:{code}")
for line in hint_lines:
_say(line)
def _remove_profile_dir(path: str) -> None:
p = (path or "").strip()
if not p or not os.path.isdir(p):
return
try:
shutil.rmtree(p)
except OSError as e:
_say_err(f"WARNING: 未能删除用户数据目录(可手工删):{p}\n {e}")
@dataclass
class PlatformCallerMeta:
"""调用方为未注册自定义平台提供的自动注册元数据pick-web / add-web"""
display_name: Optional[str] = None
url: Optional[str] = None
domain: Optional[str] = None
auth_strategy: Optional[str] = None
aliases: list[str] = field(default_factory=list)
def is_sufficient_for_auto_register(self) -> bool:
return bool((self.display_name or "").strip() and (self.url or "").strip())
def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None:
code = result.error_code or "INVALID_PLATFORM"
if not code.startswith("ERROR:"):
code = f"ERROR:{code}"
_say(_err_json(code, result.message or "无法识别平台。"))
if hint:
_say(hint)
def _resolve_or_auto_register(
platform_input: str,
meta: Optional[PlatformCallerMeta] = None,
*,
hint: str = "",
) -> Optional[str]:
"""解析平台;未注册时可在元数据充足时自动 ensure幂等、仅填空字段"""
init_db()
conn = get_conn()
try:
result = resolve_platform_key_dynamic(platform_input, conn=conn)
if result.ok:
return result.platform_key
if result.error_code in ("PLATFORM_DISABLED", "INVALID_PLATFORM_KEY", "INVALID_PLATFORM"):
_emit_platform_resolve_error(result, hint=hint)
return None
if result.error_code != "PLATFORM_NOT_REGISTERED":
_emit_platform_resolve_error(result, hint=hint)
if result.error_code not in (
"PLATFORM_NOT_REGISTERED",
"INVALID_PLATFORM_KEY",
"PLATFORM_DISABLED",
):
_say("支持:" + _platform_list_cn_for_help())
return None
raw_key = (platform_input or "").strip().lower()
if not is_valid_platform_key_format(raw_key):
_emit_platform_resolve_error(
PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=result.message or f"平台 key 格式非法:「{platform_input}」。",
),
hint=hint,
)
return None
if not meta or not meta.is_sufficient_for_auto_register():
_emit_platform_resolve_error(
PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {raw_key} 尚未注册。"
"请传入 --platform-display-name 与 --platform-url"
"或先执行 account platform ensure。"
),
),
hint=hint,
)
return None
if meta.auth_strategy and meta.auth_strategy not in ALL_AUTH_STRATEGIES:
_say(_err_json(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"不支持的 --platform-auth-strategy{meta.auth_strategy}",
))
return None
seed_platforms(conn)
action = ensure_platform_from_caller_meta(
conn,
raw_key,
display_name=(meta.display_name or "").strip(),
domain=(meta.domain or "generic").strip() or "generic",
default_url=(meta.url or "").strip(),
aliases=meta.aliases,
default_auth_strategy=meta.auth_strategy,
capabilities_json=DEFAULT_CAPABILITIES_JSON,
)
get_skill_logger().info(
"platform_auto_registered key=%s action=%s", raw_key, action
)
return raw_key
finally:
conn.close()
def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]:
"""Resolve platform key or emit structured JSON error and return None."""
init_db()
conn = get_conn()
try:
result = resolve_platform_key_dynamic(input_name, conn=conn)
finally:
conn.close()
if result.ok:
return result.platform_key
_emit_platform_resolve_error(result, hint=hint)
if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"):
_say("支持:" + _platform_list_cn_for_help())
return None

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*-
"""Account creation CLI commands (add-web, add-secret)."""
import json
import os
import sys
import time
from typing import Optional
from db.accounts_repo import (
get_account_by_id,
insert_account,
insert_credential,
resolve_auth_strategy_for_platform,
)
from db.auth_strategy import (
ALL_AUTH_STRATEGIES,
AUTH_STRATEGY_DEVICE_BOUND,
)
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted
from service._svc_common import (
PlatformCallerMeta,
_derive_session_persistent,
_err_json,
_insert_secret_holder_account,
_ok_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
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,
write_secret,
)
from util.logging_config import get_skill_logger
from util.platforms import get_platform_spec, seed_platforms
from util.profile_shortcut import create_profile_browser_shortcut
from util.runtime_paths import get_default_profile_dir_for_web
def cmd_account_add_web(
platform_input: str,
login_id: Optional[str],
login_id_type: str,
label: Optional[str],
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
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,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""account add-web — create a web/RPA account record."""
log = get_skill_logger()
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
platform_input, login_id_type, environment, role)
key = _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
init_db()
now = int(time.time())
conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}"
extra = {}
if extra_json_str:
try:
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
return
if profile_dir_override:
resolved_profile_dir = os.path.abspath(profile_dir_override)
else:
resolved_profile_dir = get_default_profile_dir_for_web(
platform_key=key,
label=safe_label,
login_id=login_id,
domain=platform_spec.get("domain", "generic"),
)
os.makedirs(resolved_profile_dir, exist_ok=True)
resolved_url = (url or "").strip() or platform_spec.get("default_url", "")
resolved_provider = provider_code or platform_spec.get("provider_code") or key
resolved_extra = json.dumps(extra, ensure_ascii=False)
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,
account_label=safe_label,
login_id=login_id or None,
login_id_type=login_id_type,
tenant_id=tenant_id or None,
environment=environment,
role=role,
provider_code=resolved_provider,
profile_dir=resolved_profile_dir,
url=resolved_url,
extra_json=resolved_extra,
now=now,
auth_strategy=resolved_auth,
session_persistent=sp,
device_fingerprint=device_fingerprint,
)
conn.commit()
except Exception:
conn.rollback()
log.exception("account_add_web_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log)
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
ok_payload = {
"id": new_id,
"platform_key": key,
"account_label": safe_label,
"profile_dir": resolved_profile_dir,
"url": resolved_url,
"environment": environment,
"role": role,
"tenant_id": tenant_id or "",
"provider_code": resolved_provider,
"status": "active",
"session_status": "unknown",
"auth_strategy": resolved_auth,
"session_persistent": sp,
"device_fingerprint": device_fingerprint,
}
if shortcut_path:
ok_payload["profile_shortcut"] = shortcut_path
_say(_ok_json(ok_payload))
def cmd_account_add_secret(
platform_input: str,
credential_type: str,
label: Optional[str],
secret_storage: str,
secret_stdin: bool,
secret_ref: Optional[str],
account_id: Optional[int],
environment: Optional[str],
role: Optional[str],
extra_json_str: Optional[str],
) -> None:
"""account add-secret — create a credential record with masked secret."""
log = get_skill_logger()
log.info("account_add_secret_attempt platform=%s type=%s storage=%s",
platform_input, credential_type, secret_storage)
key = _resolve_or_fail(platform_input)
if not key:
return
storage_n = secret_storage.strip().lower()
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 / local_encrypted"))
return
if storage_n == "none" and credential_type not in ("browser_profile", "note"):
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
"storage=none 仅允许 credential_type=browser_profile 或 note。"))
return
if storage_n == "env" and not secret_ref:
_say(_err_json("ERROR:SECRET_REF_MISSING",
"env 存储时必须提供 --secret-ref 环境变量名。"))
return
init_db()
conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
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
if storage_n == "env":
secret_ref_value = secret_ref.strip()
secret_mask_value = mask_secret(f"env:{secret_ref_value}")
extra = {}
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
extra_json_final = 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
conn = get_conn()
try:
seed_platforms(conn)
holder_id = account_id
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(
conn=conn,
account_id=holder_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=secret_ref_value,
secret_mask=secret_mask_value,
expires_at=None,
extra_json=extra_json_final,
now=now,
)
conn.commit()
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_success id=%s platform=%s storage=env ref=%s",
cred_id, key, secret_ref_value)
_say(_ok_json({
"account_id": holder_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": secret_ref_value,
"secret_mask": secret_mask_value,
"warning": "env 存储方式:请确保环境变量在运行时可用。",
}))
return
if storage_n == "windows_credential":
if not secret_stdin:
_say(_err_json("ERROR:CLI_BAD_ARGS",
"windows_credential 需要 --secret-stdin 从 stdin 读取 secret。"))
return
secret_value = sys.stdin.readline().strip()
if not secret_value:
_say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。"))
return
secret_mask_value = mask_secret(secret_value)
cred_label_for_target = safe_label
target = make_windows_credential_target(key, credential_type, cred_label_for_target)
try:
write_secret("windows_credential", target, secret_value)
except (OSError, ValueError) as e:
log.error("secret_write_failed storage=windows_credential error=%s", str(e))
_say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}"))
return
extra = {}
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
extra_json_final = 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
conn = get_conn()
try:
seed_platforms(conn)
holder_id = account_id
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(
conn=conn,
account_id=holder_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=target,
secret_mask=secret_mask_value,
expires_at=None,
extra_json=extra_json_final,
now=now,
)
conn.commit()
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_success id=%s platform=%s storage=windows_credential",
cred_id, key)
secret_value = ""
_say(_ok_json({
"account_id": holder_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": target,
"secret_mask": secret_mask_value,
}))
return
conn = get_conn()
try:
seed_platforms(conn)
cred_id = insert_credential(
conn=conn,
account_id=account_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json=extra_json_str or "{}",
now=now,
)
conn.commit()
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_success id=%s platform=%s storage=none", cred_id, key)
_say(_ok_json({
"account_id": account_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": "",
"secret_mask": "",
}))

View 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,
)

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""Account status mark CLI commands."""
from db.accounts_repo import (
get_account_by_id,
update_account_error,
update_account_last_used,
update_account_session_status,
)
from service._svc_common import _err_json, _ok_json, _say
from util.logging_config import get_skill_logger
def cmd_account_mark_session(account_id: int, session_status: str) -> None:
"""account mark-session <id> --session-status <status>"""
log = get_skill_logger()
log.info("account_mark_session id=%s status=%s", account_id, session_status)
valid_statuses = ("unknown", "likely_valid", "needs_login", "expired")
if session_status not in valid_statuses:
_say(_err_json("ERROR:CLI_BAD_ARGS",
f"session_status 必须为 {valid_statuses} 之一。"))
return
acc = get_account_by_id(account_id)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
return
update_account_session_status(account_id, session_status)
_say(_ok_json({"id": account_id, "session_status": session_status}))
def cmd_account_mark_error(account_id: int, error_code: str, error_message: str) -> None:
"""account mark-error <id> --code <code> --message <message>"""
log = get_skill_logger()
log.info("account_mark_error id=%s code=%s", account_id, error_code)
acc = get_account_by_id(account_id)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
return
update_account_error(account_id, error_code, error_message)
_say(_ok_json({"id": account_id, "error_code": error_code}))
def cmd_account_mark_used(account_id: int) -> None:
"""account mark-used <id>"""
log = get_skill_logger()
log.info("account_mark_used id=%s", account_id)
acc = get_account_by_id(account_id)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
return
update_account_last_used(account_id)
_say(_ok_json({"id": account_id, "marked": "used"}))

View File

@@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
"""Account query and pick CLI commands."""
import json
import uuid
from typing import Optional
from db.accounts_repo import (
acquire_lease,
find_account_ids,
find_accounts,
find_credentials,
get_account_by_id,
get_active_lease_by_token,
update_account_last_used,
)
from db.connection import get_conn, init_db
from db.credentials_repo import (
CredentialNotEncryptedError,
list_credentials_by_account,
read_credential_plaintext,
)
from service._svc_common import (
PlatformCallerMeta,
_err_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
from service.crypto import CredentialDecryptError
from service.master_key import MasterKeyMissingError
from util.logging_config import get_skill_logger
from util.platforms import seed_platforms
from util.runtime_paths import _runtime_paths_debug_text
def cmd_account_get(account_id, include_credentials: bool = False) -> None:
"""Get a single account by id. Returns JSON."""
log = get_skill_logger()
log.info("cli_start subcommand=account_get id=%r", account_id)
init_db()
acc = get_account_by_id(account_id)
if not acc:
log.warning("account_get_not_found id=%r", account_id)
_say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。")
_say_err(_runtime_paths_debug_text())
return
if include_credentials:
creds = find_credentials(
platform_key=acc["platform_key"],
limit=20,
)
linked_creds = [c for c in creds if c["account_id"] == acc["id"]]
if not linked_creds:
linked_creds = [c for c in creds if c["account_id"] is None]
acc["credentials"] = [
{
"id": c["id"],
"credential_label": c["credential_label"],
"credential_type": c["credential_type"],
"secret_storage": c["secret_storage"],
"secret_ref": c["secret_ref"],
"secret_mask": c["secret_mask"],
"status": c["status"],
}
for c in linked_creds
]
_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()
def cmd_get(account_id) -> None:
"""Legacy: get <id> — same as account get <id>."""
cmd_account_get(account_id, include_credentials=False)
def cmd_account_list(
platform_input: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
limit: int = 200,
) -> None:
"""account list — output JSON array of accounts matching filters."""
log = get_skill_logger()
log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s",
platform_input, environment, tenant_id, role)
init_db()
key = None
if platform_input and platform_input not in ("all", "全部", ""):
key = _resolve_or_fail(platform_input)
if not key:
return
accounts = find_accounts(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
limit=limit,
)
_say(json.dumps(accounts, ensure_ascii=False))
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
"""Legacy: list-json <platform|all> [--limit N]"""
log = get_skill_logger()
log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit)
init_db()
raw = (platform_input or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = None
else:
key = _resolve_or_fail(raw)
if not key:
return
lim = max(1, min(int(limit), 500))
accounts = find_accounts(platform_key=key, limit=lim)
_say(json.dumps(accounts, ensure_ascii=False))
def cmd_account_pick_web(
platform_input: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
do_lease: bool = False,
ttl_sec: int = 900,
holder: Optional[str] = None,
purpose: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""Pick a web/RPA account for use. Optionally acquire a lease."""
log = get_skill_logger()
log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
platform_input, environment, tenant_id, role, do_lease)
key = _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
init_db()
conn = get_conn()
try:
seed_platforms(conn)
finally:
conn.close()
candidate_ids = find_account_ids(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
)
if not candidate_ids:
_say(_err_json("ERROR:NO_ACCOUNT",
"没有符合条件的可用账号status=active 且未被其他 lease 占用)。"))
return
picked_id = candidate_ids[0]
acc = get_account_by_id(picked_id)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。"))
return
update_account_last_used(picked_id)
result = {
"id": acc["id"],
"platform_key": acc["platform_key"],
"account_label": acc["account_label"],
"login_id": acc["login_id"],
"profile_dir": acc["profile_dir"],
"url": acc["url"],
"tenant_id": acc["tenant_id"],
"environment": acc["environment"],
"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_info = None
if do_lease:
lease_token = uuid.uuid4().hex[:32]
resolved_holder = holder or "unknown"
try:
lease_info = acquire_lease(
account_id=picked_id,
lease_token=lease_token,
holder=resolved_holder,
purpose=purpose,
ttl_sec=ttl_sec,
)
result["lease_token"] = lease_info["lease_token"]
result["lease_expires_at"] = lease_info["expires_at"]
except ValueError as e:
log.warning("account_pick_web_lease_conflict id=%s", picked_id)
_say(_err_json("ERROR:LEASE_CONFLICT", str(e)))
return
log.info("account_pick_web_success id=%s token_prefix=%s",
picked_id, lease_info["lease_token"][:8] if lease_info else "none")
_say(json.dumps(result, ensure_ascii=False))
def cmd_pick_web(platform_input: str) -> None:
"""Legacy: pick-web <platform> — same as account pick-web without lease."""
cmd_account_pick_web(platform_input, do_lease=False)

View File

@@ -1,317 +1,68 @@
"""账号用例list / get / add / delete / pick-web含终端输出供 CLI 调用)。"""
import json
import os
import shutil
import sys
import time
# -*- coding: utf-8 -*-
"""
Account service layer — use cases invoked by CLI commands.
from db.accounts_repo import (
count_platform_accounts_conn,
default_name_for_platform,
delete_account_by_id_conn,
delete_account_platform_phone_conn,
delete_accounts_by_platform_conn,
fetch_delete_row_by_id_conn,
fetch_ids_for_list_json,
fetch_ids_profile_for_platform_conn,
fetch_list_rows,
fetch_pick_web_candidate_id,
fetch_row_platform_phone_conn,
get_account_by_id,
has_duplicate_phone_conn,
insert_account_row,
normalize_account_id,
Each function handles:
1. Input validation
2. Logging key nodes
3. Calling the repository layer
4. Formatting JSON/stdout output
Raw secret is NEVER logged or printed in plaintext.
Implementation is split across service/*_commands.py modules to stay within
PyArmor trial per-file line limits. This module re-exports the public API.
"""
from service._svc_common import PlatformCallerMeta
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
from service.account_mark_commands import (
cmd_account_mark_error,
cmd_account_mark_session,
cmd_account_mark_used,
)
from db.connection import get_conn, init_db
from util.logging_config import get_skill_logger
from util.platforms import (
PLATFORM_URLS,
_PLATFORM_PRIMARY_CN,
_is_valid_cn_mobile11,
_normalize_phone_digits,
_platform_list_cn_for_help,
resolve_platform_key,
from service.account_query_commands import (
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
cmd_account_pick_web,
cmd_get,
cmd_list_json,
cmd_pick_web,
)
from util.runtime_paths import _runtime_paths_debug_text, get_default_profile_dir
from service.credential_commands import cmd_credential_pick, cmd_credential_resolve
from service.legacy_commands import (
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_list,
)
from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release
from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list
def _remove_profile_dir(path: str) -> None:
p = (path or "").strip()
if not p or not os.path.isdir(p):
return
try:
shutil.rmtree(p)
except OSError as e:
print(f"⚠️ 未能删除用户数据目录(可手工删):{p}\n {e}", file=sys.stderr)
def cmd_list(platform="all", limit: int = 10):
get_skill_logger().info("list filter=%r", platform)
init_db()
raw = (platform or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = "all"
else:
key = resolve_platform_key(raw)
if not key:
get_skill_logger().warning("list_invalid_platform raw=%r", raw)
print(f"ERROR:INVALID_PLATFORM_LIST 无法识别的平台「{raw}")
print("支持:" + _platform_list_cn_for_help())
return
if limit <= 0:
limit = 10
rows = fetch_list_rows(key, int(limit))
found = bool(rows)
if found:
get_skill_logger().info("list_ok rows=%s key=%s", len(rows), key)
sep_line = "_" * 39
for idx, row in enumerate(rows):
(
aid,
name,
plat,
phone,
profile_dir,
url,
extra_json,
created_at,
updated_at,
) = row
print(f"id{aid}")
print(f"name{name or ''}")
print(f"platform{plat or ''}")
print(f"phone{phone or ''}")
print(f"profile_dir{profile_dir or ''}")
print(f"url{url or ''}")
print(f"extra_json{extra_json or ''}")
print(f"created_at{int(created_at) if created_at is not None else ''}")
print(f"updated_at{int(updated_at) if updated_at is not None else ''}")
if idx != len(rows) - 1:
print(sep_line)
print()
if not found:
print("ERROR:NO_ACCOUNTS_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
def cmd_get(account_id):
get_skill_logger().info("get account_id=%r", account_id)
acc = get_account_by_id(account_id)
if acc:
print(json.dumps(acc, ensure_ascii=False))
return
get_skill_logger().warning("get_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
"""
跨技能接口stdout 仅输出一行 JSON 数组,元素结构与 get 单条一致。
平台可为 all/全部 或各平台中文名/英文键;按 updated_at 倒序,最多 limit 条(上限 500
"""
get_skill_logger().info("list_json filter=%r limit=%s", platform_input, limit)
init_db()
raw = (platform_input or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = "all"
else:
key = resolve_platform_key(raw)
if not key:
print("ERROR:INVALID_PLATFORM_LIST_JSON 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
lim = max(1, min(int(limit), 500))
ids = fetch_ids_for_list_json(key, lim)
out = []
for aid in ids:
acc = get_account_by_id(aid)
if acc:
out.append(acc)
print(json.dumps(out, ensure_ascii=False))
def cmd_pick_web(platform_input: str):
"""
供 llm-manager 等:取该平台用于网页自动化的账号候选。
按 updated_at、created_at 倒序选一条;是否已登录由调用方(如网页自动化)在同一会话内处理。
成功stdout 仅一行 JSON与 get 一致);失败:首行 ERROR:。
"""
get_skill_logger().info("pick_web platform_input=%r", platform_input)
key = resolve_platform_key((platform_input or "").strip())
if not key:
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
init_db()
picked = fetch_pick_web_candidate_id(key)
if picked is None:
print("ERROR:NO_ACCOUNT 该平台在账号库中没有任何记录,请先执行 add。")
return
acc = get_account_by_id(picked)
if not acc:
print("ERROR:ACCOUNT_NOT_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
return
print(json.dumps(acc, ensure_ascii=False))
def cmd_add(platform_input: str, phone: str):
"""添加账号platform 可为中文展示名或英文键;手机号必填;同平台下同手机号不可重复。"""
log = get_skill_logger()
log.info("add_attempt platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
log.warning("add_invalid_platform input=%r", platform_input)
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
phone_raw = (phone or "").strip()
if not phone_raw:
log.warning("add_phone_missing")
print("ERROR:PHONE_REQUIRED 手机号为必填。")
return
phone_norm = _normalize_phone_digits(phone_raw)
if not _is_valid_cn_mobile11(phone_norm):
log.warning("add_phone_invalid digits_len=%s", len(phone_norm or ""))
print(
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
"以 1 开头、第二位为 39示例 13800138000"
)
return
url = PLATFORM_URLS[key]
now = int(time.time())
phone_store = phone_norm
conn = get_conn()
try:
if has_duplicate_phone_conn(conn, key, phone_store):
log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:])
print(
f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。"
)
return
next_idx = count_platform_accounts_conn(conn, key) + 1
name = default_name_for_platform(key, next_idx)
profile_dir = get_default_profile_dir(key, phone_store, None)
new_id = insert_account_row(conn, name, key, phone_store, profile_dir, url, now)
conn.commit()
finally:
conn.close()
print(
f"✅ 已保存账号ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}"
)
log.info(
"add_success id=%s platform=%s profile_dir=%s",
new_id,
key,
profile_dir,
)
def cmd_delete_by_id(account_id) -> None:
log = get_skill_logger()
log.info("delete_by_id account_id=%r", account_id)
init_db()
aid = normalize_account_id(account_id)
if aid is None or (isinstance(aid, str) and not str(aid).isdigit()):
log.warning("delete_invalid_id")
print("ERROR:DELETE_INVALID_ID 账号 id 须为正整数。")
return
conn = get_conn()
try:
row = fetch_delete_row_by_id_conn(conn, int(aid))
if not row:
log.warning("delete_by_id_not_found id=%s", aid)
print("ERROR:ACCOUNT_NOT_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
return
rid, name, plat, phone, profile_dir = row
delete_account_by_id_conn(conn, rid)
conn.commit()
finally:
conn.close()
_remove_profile_dir(profile_dir)
print(
f"✅ 已删除账号ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(plat, plat)} | 手机 {phone or '(无)'}"
)
log.info("delete_by_id_done id=%s platform=%s", rid, plat)
def cmd_delete_by_platform(platform_input: str) -> None:
log = get_skill_logger()
log.info("delete_by_platform platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
log.warning("delete_by_platform_invalid_platform")
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
conn = get_conn()
try:
rows = fetch_ids_profile_for_platform_conn(conn, key)
if not rows:
log.warning("delete_by_platform_empty platform=%s", key)
print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。")
return
delete_accounts_by_platform_conn(conn, key)
conn.commit()
finally:
conn.close()
for _rid, pdir in rows:
_remove_profile_dir(pdir)
print(
f"✅ 已删除 {_PLATFORM_PRIMARY_CN.get(key, key)} 下共 {len(rows)} 条账号及对应用户数据目录。"
)
log.info("delete_by_platform_done platform=%s count=%s", key, len(rows))
def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
log = get_skill_logger()
log.info("delete_by_platform_phone platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
phone_raw = (phone or "").strip()
if not phone_raw:
print("ERROR:PHONE_REQUIRED 手机号为必填。")
return
phone_norm = _normalize_phone_digits(phone_raw)
if not _is_valid_cn_mobile11(phone_norm):
print(
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
"以 1 开头、第二位为 39。"
)
return
conn = get_conn()
try:
row = fetch_row_platform_phone_conn(conn, key, phone_norm)
if not row:
print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。")
return
rid, name, profile_dir = row
delete_account_platform_phone_conn(conn, key, phone_norm)
conn.commit()
finally:
conn.close()
_remove_profile_dir(profile_dir)
print(
f"✅ 已删除账号ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}"
)
log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key)
__all__ = [
"PlatformCallerMeta",
"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",
"cmd_account_mark_used",
"cmd_account_pick_web",
"cmd_credential_pick",
"cmd_credential_resolve",
"cmd_delete_by_id",
"cmd_delete_by_platform",
"cmd_delete_by_platform_phone",
"cmd_get",
"cmd_lease_cleanup",
"cmd_lease_list",
"cmd_lease_release",
"cmd_list",
"cmd_list_json",
"cmd_pick_web",
"cmd_platform_ensure",
"cmd_platform_get",
"cmd_platform_list",
]

View File

@@ -1,21 +1,28 @@
"""Chromium/Edge 检测与 Playwright仅打开浏览器不写库、不做登录态 DOM 检测)。"""
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
from db.accounts_repo import get_account_by_id
from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORM_URLS
from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import若以「脚本路径」启动子进程
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
@@ -26,13 +33,12 @@ def _win_find_exe(candidates):
return None
def resolve_chromium_channel():
def resolve_chromium_executable():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None
"""
if sys.platform != "win32":
return "chrome"
return None
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
@@ -46,7 +52,7 @@ def resolve_chromium_channel():
]
)
if chrome:
return "chrome"
return chrome
edge = _win_find_exe(
[
@@ -56,23 +62,70 @@ def resolve_chromium_channel():
]
)
if edge:
return "msedge"
return edge
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
exe = resolve_chromium_executable()
if not exe:
return None
if os.path.basename(exe).lower() == "msedge.exe":
return "msedge"
return "chrome"
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def resolve_account_browser_url(account: dict, conn=None) -> str:
"""account.url 优先,其次 platforms 表 default_url最后内置 PLATFORMS。"""
url = (account.get("url") or "").strip()
if url:
return url
pk = (account.get("platform_key") or "").strip()
if not pk:
return ""
close_conn = False
if conn is None:
init_db()
conn = get_conn()
close_conn = True
try:
plat = get_platform_from_db(conn, pk)
if plat and (plat.get("default_url") or "").strip():
return plat["default_url"].strip()
finally:
if close_conn:
conn.close()
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。"""
get_skill_logger().info("open account_id=%r", account_id)
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
get_skill_logger().warning("open_not_found account_id=%r", account_id)
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
@@ -85,23 +138,35 @@ def cmd_open(account_id):
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 playwright install chromium"
"ERROR:需要 playwright Python 包"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
conn = get_conn()
try:
url = resolve_account_browser_url(target, conn=conn)
finally:
conn.close()
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
channel,
target.get("platform_key"),
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
@@ -115,6 +180,10 @@ def cmd_open(account_id):
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
except Exception as e:
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
raise
finally:
try:
os.unlink(cfg_path)

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""Credential pick and resolve CLI commands."""
from typing import Optional
from db.accounts_repo import find_credentials, get_credential_by_id, update_credential_last_used
from db.connection import init_db
from service._svc_common import (
_credential_ok_line,
_err_json,
_map_secret_read_error,
_resolve_or_fail,
_say,
)
from service.secret_store import read_secret
from util.logging_config import get_skill_logger
def cmd_credential_pick(
platform_input: str,
credential_type: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
reveal: bool = False,
) -> None:
"""Pick a credential for a platform, optionally with reveal."""
log = get_skill_logger()
log.info("cli_start subcommand=credential_pick platform=%s type=%s env=%s role=%s reveal=%s",
platform_input, credential_type, environment, role, reveal)
key = _resolve_or_fail(platform_input)
if not key:
return
init_db()
creds = find_credentials(
platform_key=key,
credential_type=credential_type,
environment=environment,
role=role,
status="active",
limit=1,
)
if not creds:
_say(_err_json("ERROR:CREDENTIAL_NOT_FOUND",
"没有符合条件的可用凭据。"))
return
cred = creds[0]
update_credential_last_used(cred["id"])
result = {
"id": cred["id"],
"platform_key": cred["platform_key"],
"credential_label": cred["credential_label"],
"credential_type": cred["credential_type"],
"secret_storage": cred["secret_storage"],
"secret_ref": cred["secret_ref"],
"secret_mask": cred["secret_mask"],
"status": cred["status"],
}
if reveal:
try:
secret_value = read_secret(cred["secret_storage"], cred["secret_ref"])
result["secret"] = secret_value
log.info(
"credential_pick_reveal_success id=%s storage=%s",
cred["id"],
cred["secret_storage"],
)
except (ValueError, OSError) as e:
code, msg = _map_secret_read_error(e)
log.error("credential_pick_reveal_failure id=%s error=%s", cred["id"], msg)
_say(_err_json(code, msg))
return
else:
log.info("credential_pick_success id=%s (masked)", cred["id"])
_say(_credential_ok_line(result))
def cmd_credential_resolve(credential_id: int, reveal: bool = False) -> None:
"""Resolve a credential by id."""
log = get_skill_logger()
log.info("cli_start subcommand=credential_resolve id=%s reveal=%s", credential_id, reveal)
init_db()
cred = get_credential_by_id(credential_id)
if not cred:
_say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", f"凭据 id={credential_id} 不存在。"))
return
if cred["status"] != "active":
_say(_err_json("ERROR:CREDENTIAL_DISABLED", f"凭据 id={credential_id} 状态为 {cred['status']},不可用。"))
return
result = {
"id": cred["id"],
"account_id": cred["account_id"],
"platform_key": cred["platform_key"],
"credential_label": cred["credential_label"],
"credential_type": cred["credential_type"],
"secret_storage": cred["secret_storage"],
"secret_ref": cred["secret_ref"],
"secret_mask": cred["secret_mask"],
"status": cred["status"],
}
if reveal:
try:
secret_value = read_secret(cred["secret_storage"], cred["secret_ref"])
result["secret"] = secret_value
log.info("credential_resolve_reveal_success id=%s", credential_id)
except (ValueError, OSError) as e:
code, msg = _map_secret_read_error(e)
log.error("credential_resolve_reveal_failure id=%s error=%s", credential_id, msg)
_say(_err_json(code, msg))
return
else:
log.info("credential_resolve_success id=%s (masked)", credential_id)
_say(_credential_ok_line(result))

36
scripts/service/crypto.py Normal file
View 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

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""Lease management CLI commands."""
from db.accounts_repo import cleanup_expired_leases, list_active_leases, release_lease
from service._svc_common import _err_json, _ok_json, _say
from util.logging_config import get_skill_logger
def cmd_lease_release(lease_token: str) -> None:
"""lease release <token>"""
log = get_skill_logger()
log.info("cli_start subcommand=lease_release token_prefix=%s", lease_token[:8])
ok = release_lease(lease_token)
if ok:
_say(_ok_json({"lease_token": lease_token, "status": "released"}))
else:
_say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。"))
def cmd_lease_list(active_only: bool = False) -> None:
"""lease list [--active]"""
log = get_skill_logger()
log.info("cli_start subcommand=lease_list active_only=%s", active_only)
leases = list_active_leases()
if active_only:
pass
_say(_ok_json({"leases": leases}))
def cmd_lease_cleanup() -> None:
"""lease cleanup — mark expired leases as expired."""
log = get_skill_logger()
log.info("cli_start subcommand=lease_cleanup")
count = cleanup_expired_leases()
_say(_ok_json({"cleaned": count}))

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Legacy delete and human-readable list CLI commands."""
from db.accounts_repo import get_account_by_id, find_accounts
from db.connection import get_conn, init_db
from service._svc_common import (
_remove_profile_dir,
_resolve_or_fail,
_say,
_say_err,
)
from util.logging_config import get_skill_logger
from util.platforms import get_platform_spec
from util.runtime_paths import _runtime_paths_debug_text
def cmd_delete_by_id(account_id) -> None:
"""delete id <id> — legacy"""
log = get_skill_logger()
log.info("delete_by_id id=%r", account_id)
acc = get_account_by_id(account_id)
if not acc:
_say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。")
_say_err(_runtime_paths_debug_text())
return
profile_dir = acc.get("profile_dir", "")
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE id = ?", (int(account_id),))
cur.execute("DELETE FROM credentials WHERE account_id = ?", (int(account_id),))
conn.commit()
finally:
conn.close()
_remove_profile_dir(profile_dir)
log.info("delete_by_id_done id=%s", account_id)
_say(f"✅ 已删除账号 ID {account_id}")
def cmd_delete_by_platform(platform_input: str) -> None:
"""delete platform <platform> — legacy"""
log = get_skill_logger()
log.info("delete_by_platform platform=%r", platform_input)
key = _resolve_or_fail(platform_input)
if not key:
return
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id, profile_dir FROM accounts WHERE platform_key = ?", (key,))
rows = cur.fetchall()
if not rows:
_say("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。")
return
for rid, pdir in rows:
_remove_profile_dir(pdir)
cur.execute("DELETE FROM accounts WHERE platform_key = ?", (key,))
cur.execute(
"DELETE FROM credentials WHERE platform_key = ? AND account_id IS NOT NULL",
(key,),
)
conn.commit()
log.info("delete_by_platform_done platform=%s count=%s", key, len(rows))
display = get_platform_spec(key, conn=conn).get("display_name", key)
_say(f"✅ 已删除 {display} 下共 {len(rows)} 条账号。")
finally:
conn.close()
def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
"""Legacy: delete by platform+phone — now uses login_id matching"""
log = get_skill_logger()
log.info("delete_by_platform_phone platform=%r", platform_input)
key = _resolve_or_fail(platform_input)
if not key:
return
phone_norm = "".join(c for c in phone if c.isdigit())
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, profile_dir FROM accounts WHERE platform_key = ? AND login_id = ?",
(key, phone_norm),
)
row = cur.fetchone()
if not row:
_say("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。")
return
rid, pdir = row
_remove_profile_dir(pdir)
cur.execute("DELETE FROM accounts WHERE id = ?", (rid,))
cur.execute("DELETE FROM credentials WHERE account_id = ?", (rid,))
conn.commit()
log.info("delete_by_platform_phone_done id=%s", rid)
_say(f"✅ 已删除账号 ID {rid}")
finally:
conn.close()
def cmd_list(platform="all", limit: int = 10) -> None:
"""Legacy: human-readable list output."""
log = get_skill_logger()
log.info("list filter=%r", platform)
init_db()
accounts = find_accounts(
platform_key=None if (not platform or platform in ("all", "全部")) else platform,
limit=limit,
)
if not accounts:
_say("ERROR:NO_ACCOUNTS_FOUND")
_say_err(_runtime_paths_debug_text())
return
log.info("list_ok rows=%s", len(accounts))
for i, acc in enumerate(accounts):
_say(f"id{acc['id']}")
_say(f"label{acc['account_label']}")
_say(f"platform{acc['platform_key']}")
_say(f"login_id{acc['login_id']}")
_say(f"env{acc['environment']} role{acc['role']}")
_say(f"status{acc['status']} session{acc['session_status']}")
_say(f"profile_dir{acc['profile_dir']}")
_say(f"url{acc['url']}")
_say(f"created_at{acc['created_at']}")
_say(f"updated_at{acc['updated_at']}")
if acc.get("last_error_code"):
_say(f"last_error{acc['last_error_code']} {acc.get('last_error_message', '')}")
if i < len(accounts) - 1:
_say("_______________________________________")
_say("")

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""master key 加载与生成。
加载优先级(高到低):
1. 环境变量 ACCOUNT_MANAGER_MASTER_KEYbase64-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

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Platform management CLI commands."""
import json
from typing import Optional
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
from db.connection import get_conn, init_db
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
from util.logging_config import get_skill_logger
from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms
def cmd_platform_list(domain: Optional[str] = None) -> None:
"""platform list [--domain logistics|llm|content]"""
log = get_skill_logger()
log.info("cli_start subcommand=platform_list domain=%s", domain)
init_db()
conn = get_conn()
try:
seed_platforms(conn)
platforms = list_platforms_from_db(conn, domain=domain)
finally:
conn.close()
_say(_ok_json({"platforms": platforms}))
def cmd_platform_get(input_key: str) -> None:
"""platform get <platform>"""
log = get_skill_logger()
log.info("cli_start subcommand=platform_get key=%s", input_key)
key = _resolve_or_fail(input_key)
if not key:
return
init_db()
conn = get_conn()
try:
plat = get_platform_from_db(conn, key)
finally:
conn.close()
if plat:
_say(_ok_json(plat))
else:
_say(_err_json(
"ERROR:PLATFORM_NOT_REGISTERED",
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
if not is_valid_platform_key_format(key):
_say(_err_json(
"ERROR:INVALID_PLATFORM_KEY",
f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。",
))
return 1
aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False)
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
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

View 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

View 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}",
)

View 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

View 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 + delaylocator 为 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()

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
"""
Secret storage abstraction layer.
Supported storage backends:
- none : No secret stored (e.g., browser profile login state)
- env : Secret is read from environment variable at runtime
- windows_credential : Secret stored in Windows Credential Manager (ctypes)
Raw secret values are NEVER logged or written to SQLite.
"""
import os
import re
import sys
import uuid
from util.logging_config import get_skill_logger
def mask_secret(secret: str) -> str:
"""
Return a masked version of the secret for display/storage.
Never returns the full original secret or a reversible prefix.
Examples:
- Long API key -> \"****abcd\" (last 4 chars only, after separator)
- Short / empty -> \"****\"
"""
if secret is None:
return "****"
s = str(secret).strip()
if not s:
return "****"
if len(s) <= 4:
return "****"
return "****" + s[-4:]
def make_windows_credential_target(
platform_key: str,
credential_type: str,
credential_id_or_label: str,
) -> str:
"""
Generate a Windows Credential Manager target name for a credential.
Format: openclaw/account-manager/{user_id}/{platform_key}/{credential_type}/{uuid_short}
"""
user_id = (
(os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
or "_anon"
)
safe_label = re.sub(r"[^a-zA-Z0-9_-]", "_", credential_id_or_label)[:32]
uid = uuid.uuid4().hex[:8]
return (
f"openclaw/account-manager/{user_id}/"
f"{platform_key}/{credential_type}/{safe_label}_{uid}"
)
# ---------------------------------------------------------------------------
# Write
# ---------------------------------------------------------------------------
def write_secret(storage: str, secret_ref: str, secret_value: str) -> None:
"""
Write a secret to the specified storage backend.
Raises ValueError on unsupported storage or write failure.
"""
log = get_skill_logger()
log.info(
"secret_write_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_write_success storage=none ref=%s", secret_ref)
return
elif storage_n == "env":
log.info("secret_write_success storage=env ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_write_impl(secret_ref, secret_value)
log.info(
"secret_write_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
log.error("secret_write_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
def read_secret(storage: str, secret_ref: str) -> str:
"""
Read/retrieve a secret from the specified storage backend.
Returns the secret value as a string.
Raises ValueError on unsupported storage, missing ref, or read failure.
"""
log = get_skill_logger()
log.info(
"secret_resolve_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_resolve_success storage=none")
return ""
elif storage_n == "env":
val = os.environ.get(secret_ref)
if val is None:
log.error("secret_resolve_failed env_missing ref=%s", secret_ref)
raise ValueError(
f"Environment variable '{secret_ref}' is not set (SECRET_ENV_MISSING)"
)
log.info("secret_resolve_success storage=env ref=%s", secret_ref)
return val
elif storage_n == "windows_credential":
val = _win_cred_read_impl(secret_ref)
log.info(
"secret_resolve_success storage=windows_credential ref=%s",
secret_ref[:48],
)
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}")
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def delete_secret(storage: str, secret_ref: str) -> None:
"""Delete a secret from the specified storage backend."""
log = get_skill_logger()
log.info(
"secret_delete_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
return
elif storage_n == "env":
log.info("secret_delete_env_nop ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_delete_impl(secret_ref)
log.info(
"secret_delete_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Windows Credential Manager implementation (ctypes)
# ---------------------------------------------------------------------------
_WINCRED_TYPE_GENERIC = 1 # CRED_TYPE_GENERIC
_WINCRED_PERSIST_LOCAL_MACHINE = 2 # CRED_PERSIST_LOCAL_MACHINE
def _win_cred_available() -> bool:
"""Check if Windows Credential Manager API is available."""
if sys.platform != "win32":
return False
try:
import ctypes # noqa: F401
return True
except ImportError:
return False
def _win_cred_unavailable() -> OSError:
return OSError(
"Windows Credential Manager is not available on this platform "
"(WINDOWS_CREDENTIAL_UNAVAILABLE)"
)
def _win_cred_write_impl(target: str, secret_value: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
secret_bytes = secret_value.encode("utf-16-le")
blob_arr = (ctypes.c_byte * len(secret_bytes)).from_buffer_copy(secret_bytes)
cred = CREDENTIALW()
cred.Type = _WINCRED_TYPE_GENERIC
cred.TargetName = target
cred.CredentialBlobSize = len(secret_bytes)
cred.CredentialBlob = ctypes.cast(blob_arr, ctypes.POINTER(ctypes.c_byte))
cred.Persist = _WINCRED_PERSIST_LOCAL_MACHINE
cred.UserName = "account-manager"
advapi32 = ctypes.windll.advapi32
if not advapi32.CredWriteW(ctypes.byref(cred), 0):
err = ctypes.get_last_error()
raise OSError(f"CredWriteW failed with error {err} (SECRET_WRITE_FAILED)")
del blob_arr, cred
def _win_cred_read_impl(target: str) -> str:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
PCREDENTIALW = ctypes.POINTER(CREDENTIALW)
advapi32 = ctypes.windll.advapi32
advapi32.CredReadW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.POINTER(PCREDENTIALW),
]
advapi32.CredReadW.restype = wintypes.BOOL
advapi32.CredFree.argtypes = [ctypes.c_void_p]
advapi32.CredFree.restype = None
ppcred = PCREDENTIALW()
if not advapi32.CredReadW(target, _WINCRED_TYPE_GENERIC, 0, ctypes.byref(ppcred)):
err = ctypes.get_last_error()
if err == 1168:
raise ValueError(
f"Credential not found in Windows Credential Manager: target={target}"
)
raise OSError(f"CredReadW failed with error {err} (SECRET_READ_FAILED)")
try:
cred = ppcred.contents
blob_size = int(cred.CredentialBlobSize)
if blob_size <= 0 or not cred.CredentialBlob:
return ""
raw = ctypes.string_at(cred.CredentialBlob, blob_size)
return raw.decode("utf-16-le")
finally:
advapi32.CredFree(ppcred)
def _win_cred_delete_impl(target: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
advapi32 = ctypes.windll.advapi32
if not advapi32.CredDeleteW(target, _WINCRED_TYPE_GENERIC, 0):
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)

View File

@@ -1,145 +1,581 @@
"""平台配置、别名解析、手机号校验。"""
import re
# -*- coding: utf-8 -*-
"""
Platform definitions and resolution.
# 平台唯一配置表:只改这里即可。键 = 入库的 platformlabel = 帮助/提示里的展示名;
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
# - login_logged_out_selectors按平台追加误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
PLATFORMS = {
"sohu": {
"url": "https://mp.sohu.com",
"label": "搜狐号",
"prefix": "搜狐",
"aliases": ["搜狐"],
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
"""
import json
import re
import time
from dataclasses import dataclass
from typing import Any, Optional
from util.logging_config import get_skill_logger
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
# ---------------------------------------------------------------------------
# Platform specification fields:
# display_name Human-readable name (e.g. "Maersk")
# domain Business domain: logistics / llm / content / ecommerce / generic
# provider_code Supplier code (logistics); can be same as key
# default_url Default entry URL (empty string if none)
# aliases List of name aliases (Chinese/English, for CLI resolution)
# capabilities Dict of capability flags:
# {"web": bool, "api_key": bool, "rpa": bool}
# ---------------------------------------------------------------------------
PLATFORMS: dict[str, dict[str, Any]] = {
# =========================================================================
# Logistics platforms
# =========================================================================
"maersk": {
"display_name": "Maersk",
"domain": "logistics",
"provider_code": "maersk",
"default_url": "https://www.maersk.com",
"aliases": ["马士基", "maersk", "Maersk"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"toutiao": {
"url": "https://mp.toutiao.com/",
"label": "头条号",
"prefix": "头条",
"aliases": ["头条"],
"cosco": {
"display_name": "COSCO",
"domain": "logistics",
"provider_code": "cosco",
"default_url": "",
"aliases": ["中远海运", "COSCO", "cosco"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"zhihu": {
"url": "https://www.zhihu.com",
"label": "知乎",
"aliases": ["知乎号"],
"msc": {
"display_name": "MSC",
"domain": "logistics",
"provider_code": "msc",
"default_url": "",
"aliases": ["MSC", "地中海航运"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"wechat": {
"url": "https://mp.weixin.qq.com",
"label": "微信公众号",
"prefix": "微信",
"aliases": ["公众号", "微信"],
"cma_cgm": {
"display_name": "CMA CGM",
"domain": "logistics",
"provider_code": "cma_cgm",
"default_url": "",
"aliases": ["达飞", "CMA CGM", "cma cgm"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"kimi": {
"url": "https://kimi.moonshot.cn",
"label": "Kimi",
"aliases": ["月之暗面"],
"evergreen": {
"display_name": "Evergreen",
"domain": "logistics",
"provider_code": "evergreen",
"default_url": "",
"aliases": ["长荣", "Evergreen", "evergreen"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cargo_wise": {
"display_name": "CargoWise",
"domain": "logistics",
"provider_code": "cargo_wise",
"default_url": "",
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"freightos": {
"display_name": "Freightos",
"domain": "logistics",
"provider_code": "freightos",
"default_url": "",
"aliases": ["Freightos", "freightos"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"webcargo": {
"display_name": "WebCargo",
"domain": "logistics",
"provider_code": "webcargo",
"default_url": "",
"aliases": ["WebCargo", "web cargo"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"sinotrans": {
"display_name": "Sinotrans",
"domain": "logistics",
"provider_code": "sinotrans",
"default_url": "",
"aliases": ["中外运", "Sinotrans", "sinotrans"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
# =========================================================================
# LLM / AI platforms
# =========================================================================
"deepseek": {
"url": "https://chat.deepseek.com",
"label": "DeepSeek",
"display_name": "DeepSeek",
"domain": "llm",
"provider_code": "deepseek",
"default_url": "https://chat.deepseek.com",
"aliases": ["DeepSeek", "deepseek"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"doubao": {
"url": "https://www.doubao.com",
"label": "豆包",
"display_name": "Doubao",
"domain": "llm",
"provider_code": "doubao",
"default_url": "https://www.doubao.com",
"aliases": ["豆包", "Doubao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"kimi": {
"display_name": "Kimi",
"domain": "llm",
"provider_code": "kimi",
"default_url": "https://kimi.moonshot.cn",
"aliases": ["Kimi", "月之暗面"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"qianwen": {
"url": "https://tongyi.aliyun.com",
"label": "通义千问",
"prefix": "通义",
"aliases": ["通义", "千问"],
"display_name": "Qianwen",
"domain": "llm",
"provider_code": "qianwen",
"default_url": "https://tongyi.aliyun.com",
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yiyan": {
"url": "https://yiyan.baidu.com",
"label": "文心一言",
"prefix": "文心",
"aliases": ["文心", "一言"],
"display_name": "YiYan",
"domain": "llm",
"provider_code": "yiyan",
"default_url": "https://yiyan.baidu.com",
"aliases": ["文心一言", "文心", "一言", "YiYan"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yuanbao": {
"url": "https://yuanbao.tencent.com",
"label": "腾讯元宝",
"prefix": "元宝",
"aliases": ["元宝"],
"display_name": "Yuanbao",
"domain": "llm",
"provider_code": "yuanbao",
"default_url": "https://yuanbao.tencent.com",
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"gemini": {
"url": "https://gemini.google.com",
"label": "Gemini",
"prefix": "Gemini",
"aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"],
"display_name": "Gemini",
"domain": "llm",
"provider_code": "gemini",
"default_url": "https://gemini.google.com",
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"minimax": {
"display_name": "MiniMax",
"domain": "llm",
"provider_code": "minimax",
"default_url": "",
"aliases": ["MiniMax", "minimax"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
# =========================================================================
# Content platforms (carried forward from v1)
# =========================================================================
"sohu": {
"display_name": "搜狐号",
"domain": "content",
"provider_code": "sohu",
"default_url": "https://mp.sohu.com",
"aliases": ["搜狐", "搜狐号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"toutiao": {
"display_name": "头条号",
"domain": "content",
"provider_code": "toutiao",
"default_url": "https://mp.toutiao.com/",
"aliases": ["头条", "头条号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"zhihu": {
"display_name": "知乎",
"domain": "content",
"provider_code": "zhihu",
"default_url": "https://www.zhihu.com",
"aliases": ["知乎", "知乎号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"wechat": {
"display_name": "微信公众号",
"domain": "content",
"provider_code": "wechat",
"default_url": "https://mp.weixin.qq.com",
"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},
},
}
# 未登录页共性主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等Playwright :has-text 可覆盖多数站点。
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
'button:has-text("登录")',
'role=button[name="登录"]',
'a:has-text("登录")',
'h4:has-text("登录")',
'span.semi-button-content:has-text("登录")',
)
def _build_platform_derived():
urls = {}
name_prefix = {}
primary_cn = {}
alias_to_key = {}
def _register_alias(alias: str, key: str) -> None:
if not alias or not str(alias).strip():
return
a = str(alias).strip()
if a not in alias_to_key:
alias_to_key[a] = key
lo = a.lower()
if lo not in alias_to_key:
alias_to_key[lo] = key
# ---------------------------------------------------------------------------
# Derived alias map
# ---------------------------------------------------------------------------
def _build_alias_map() -> dict[str, str]:
"""Build a case-insensitive flat alias -> key map."""
m: dict[str, str] = {}
for key, spec in PLATFORMS.items():
urls[key] = spec["url"]
primary_cn[key] = spec["label"]
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
_register_alias(key, key)
_register_alias(spec["label"], key)
for a in spec.get("aliases") or []:
_register_alias(a, key)
return urls, name_prefix, primary_cn, alias_to_key
# Register the key itself (lowercase)
m[key.lower()] = key
# Register all aliases (original + lowercase)
for alias in [spec["display_name"]] + spec.get("aliases", []):
a = str(alias).strip()
if a:
m[a] = key
m[a.lower()] = key
return m
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
_PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str):
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
if name is None:
def resolve_platform_key(name: str) -> Optional[str]:
"""
Resolve user input to a canonical platform key (built-in PLATFORMS only).
Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized.
"""
if not name:
return None
s = str(name).strip()
if not s:
return None
sl = s.lower()
if sl in PLATFORM_URLS:
# Direct key match
if sl in PLATFORMS:
return sl
return _PLATFORM_ALIAS_TO_KEY.get(s)
# Alias map
if sl in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[sl]
if s in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[s]
return None
def _normalize_phone_digits(phone: str) -> str:
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
d = re.sub(r"\D", "", (phone or "").strip())
if d.startswith("86") and len(d) >= 13:
d = d[2:]
return d
def is_valid_platform_key_format(name: str) -> bool:
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
s = (name or "").strip().lower()
if not s:
return False
return bool(PLATFORM_KEY_PATTERN.match(s))
def _is_valid_cn_mobile11(digits: str) -> bool:
"""中国大陆 11 位手机号1 开头,第二位 39共 11 位数字。"""
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
def _parse_aliases_json(val: Any) -> list[str]:
if val is None:
return []
try:
data = json.loads(val) if isinstance(val, str) else val
if isinstance(data, list):
return [str(a).strip() for a in data if str(a).strip()]
except (json.JSONDecodeError, TypeError, ValueError):
pass
return []
@dataclass(frozen=True)
class PlatformResolveResult:
platform_key: Optional[str] = None
error_code: Optional[str] = None
message: Optional[str] = None
@property
def ok(self) -> bool:
return bool(self.platform_key) and not self.error_code
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
"""在 platforms 表中解析:精确 keyenabled=1与 aliases_json。"""
raw_stripped = (raw or "").strip()
raw_lower = raw_stripped.lower()
if is_valid_platform_key_format(raw_stripped):
row = conn.execute(
"SELECT platform_key, enabled FROM platforms WHERE platform_key = ?",
(raw_lower,),
).fetchone()
if row:
if not row[1]:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=f"平台 {row[0]} 已禁用。",
)
return PlatformResolveResult(platform_key=row[0])
rows = conn.execute(
"SELECT platform_key, enabled, aliases_json FROM platforms"
).fetchall()
for platform_key, enabled, aliases_json in rows:
if not enabled:
continue
names = {platform_key.lower()}
for alias in _parse_aliases_json(aliases_json):
names.add(alias.lower())
names.add(alias)
if raw_lower in names or raw_stripped in names:
return PlatformResolveResult(platform_key=platform_key)
return PlatformResolveResult()
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
"""
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
"""
raw = (input_name or "").strip()
if not raw:
return PlatformResolveResult(
error_code="INVALID_PLATFORM",
message="平台名不能为空。",
)
builtin = resolve_platform_key(raw)
if builtin:
return PlatformResolveResult(platform_key=builtin)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
db_result = _resolve_platform_from_db(conn, raw)
if db_result.ok or db_result.error_code:
return db_result
finally:
if close_conn:
conn.close()
if not is_valid_platform_key_format(raw):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=(
f"平台 key 格式非法:「{raw}」。"
"建议使用小写字母、数字、下划线或连字符,例如 jiangchang。"
),
)
key_hint = raw.lower()
return PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {key_hint} 尚未注册,请先执行 "
f"account platform ensure --key {key_hint} --display-name <名称> [--url <url>]"
),
)
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
spec = PLATFORMS.get(platform_key)
if spec:
return dict(spec)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
from db.accounts_repo import get_platform_from_db
plat = get_platform_from_db(conn, platform_key)
if plat:
return {
"display_name": plat["display_name"],
"domain": plat.get("domain") or "generic",
"provider_code": plat.get("provider_code") or platform_key,
"default_url": plat.get("default_url") or "",
"aliases": plat.get("aliases") or [],
"capabilities": plat.get("capabilities") or {},
}
finally:
if close_conn:
conn.close()
return {}
def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated built-in key."""
return PLATFORMS.get(key)
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
"""List all platforms, optionally filtered by domain.
Returns a list of dicts with metadata."""
result = []
for key, spec in PLATFORMS.items():
if domain and spec["domain"] != domain:
continue
result.append({
"platform_key": key,
"display_name": spec["display_name"],
"domain": spec["domain"],
"provider_code": spec.get("provider_code"),
"default_url": spec.get("default_url", ""),
"aliases": spec.get("aliases", []),
"capabilities": spec.get("capabilities", {}),
"enabled": True,
})
return result
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()
now = int(time.time())
upsert_count = 0
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, 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 = excluded.default_auth_strategy,
updated_at = excluded.updated_at
""",
(
key,
spec["display_name"],
spec["domain"],
spec.get("provider_code"),
spec.get("default_url", ""),
aliases_json,
capabilities_json,
default_auth_strategy,
now,
now,
),
)
upsert_count += 1
conn.commit()
log.info("platform_seed_done count=%s", upsert_count)
# ---------------------------------------------------------------------------
# Legacy compatibility helpers (for old account_service v1 code references)
# ---------------------------------------------------------------------------
# Kept for backward compatibility so old imports don't break immediately.
# New code should use PLATFORMS dict directly and resolve_platform_key().
_PLATFORM_PRIMARY_CN: dict[str, str] = {
k: spec["display_name"] for k, spec in PLATFORMS.items()
}
PLATFORM_URLS: dict[str, str] = {
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
}
def _platform_list_cn_for_help() -> str:
"""帮助文案:一行中文展示名(不重复内部键)"""
parts = []
for k in sorted(PLATFORM_URLS.keys()):
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
return "".join(parts)
"""帮助文案:一行展示名,供 CLI 用法提示"""
return "".join(
spec["display_name"] for _k, spec in sorted(
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
)
)

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""在 Profile 目录内创建浏览器快捷方式Windows .lnk"""
from __future__ import annotations
import logging
import os
import re
import subprocess
import sys
from typing import Optional
from service.browser_service import resolve_chromium_executable
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def _lnk_safe_filename(name: str, fallback: str = "browser") -> str:
t = _WIN_LNK_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
return t or fallback
def _ps_single_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def create_profile_browser_shortcut(
profile_dir: str,
account_label: str,
log: Optional[logging.Logger] = None,
) -> Optional[str]:
"""
在 profile_dir 内创建 {account_label}.lnk目标为系统 Chrome/Edge + --user-data-dir。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。
"""
if log is None:
log = logging.getLogger(__name__)
if sys.platform != "win32":
log.debug("profile_shortcut_skip platform=%s", sys.platform)
return None
browser_exe = resolve_chromium_executable()
if not browser_exe:
log.warning("profile_shortcut_skip reason=no_chromium")
return None
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
lnk_path = os.path.join(profile_abs, lnk_name)
arguments = f'--user-data-dir="{profile_abs}"'
working_dir = os.path.dirname(browser_exe)
ps = (
"$WshShell = New-Object -ComObject WScript.Shell; "
f"$s = $WshShell.CreateShortcut({_ps_single_quote(lnk_path)}); "
f"$s.TargetPath = {_ps_single_quote(browser_exe)}; "
f"$s.Arguments = {_ps_single_quote(arguments)}; "
f"$s.IconLocation = {_ps_single_quote(browser_exe + ',0')}; "
f"$s.WorkingDirectory = {_ps_single_quote(working_dir)}; "
"$s.Save()"
)
try:
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError) as exc:
log.warning("profile_shortcut_failed profile_dir=%s error=%s", profile_abs, exc)
return None
if not os.path.isfile(lnk_path):
log.warning("profile_shortcut_missing_after_create path=%s", lnk_path)
return None
log.info("profile_shortcut_created path=%s browser=%s", lnk_path, browser_exe)
return lnk_path

View File

@@ -1,4 +1,7 @@
"""数据目录、profile 路径、CLI 路径调试输出。"""
# -*- coding: utf-8 -*-
"""
Data directory, profile path, CLI path debugging output.
"""
import os
import re
import sys
@@ -6,21 +9,20 @@ from typing import Optional
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
from util.constants import SKILL_SLUG
from util.platforms import _PLATFORM_PRIMARY_CN
def get_skill_data_dir():
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path():
def get_db_path() -> str:
return os.path.join(get_skill_data_dir(), "account-manager.db")
def _runtime_paths_debug_text():
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
def _runtime_paths_debug_text() -> str:
"""For debugging: print current data path info to stderr."""
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
return (
@@ -33,8 +35,8 @@ def _runtime_paths_debug_text():
)
def _maybe_print_paths_debug_cli():
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
def _maybe_print_paths_debug_cli() -> None:
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
if v in ("1", "true", "yes", "on"):
print(_runtime_paths_debug_text(), file=sys.stderr)
@@ -49,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset(
def _fs_safe_segment(name: str, fallback: str) -> str:
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback"""
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
if not t:
@@ -59,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str:
return t
def get_default_profile_dir(
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
def get_default_profile_dir_for_web(
platform_key: str,
label: str,
login_id: Optional[str] = None,
domain: str = "generic",
) -> str:
"""
默认可读路径profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
Generate a browser profile directory path.
Structure:
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
This keeps profiles organized by domain and platform for easy
identification in file explorer.
"""
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
ph = (phone or "").strip()
if ph:
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
else:
phone_seg = _fs_safe_segment(
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
)
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
label_seg = _fs_safe_segment(label, platform_key)
login_suffix = ""
if login_id:
safe_login = _fs_safe_segment(login_id, "")
if safe_login:
login_suffix = f"_{safe_login[:16]}"
path = os.path.join(
get_skill_data_dir(),
"profiles",
_fs_safe_segment(domain, "generic"),
_fs_safe_segment(platform_key, "unknown"),
f"{label_seg}{login_suffix}",
)
os.makedirs(path, exist_ok=True)
return path

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# account-manager tests

56
tests/conftest.py Normal file
View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""
Test fixtures for account-manager tests.
All tests use an isolated temporary database under a fake JIANGCHANG_DATA_ROOT
so they never touch real user data.
"""
import os
import sys
import tempfile
import shutil
# Ensure scripts/ is on the path
_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)
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
@pytest.fixture(scope="session")
def fake_env():
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
fake_uid = "99999"
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
os.environ["JIANGCHANG_USER_ID"] = fake_uid
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
@pytest.fixture(scope="function")
def clean_db(fake_env):
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
# Import after env vars are set
from db.connection import get_conn, init_db
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
init_db()
conn = get_conn()
conn.execute("DELETE FROM account_leases")
conn.execute("DELETE FROM credentials")
conn.execute("DELETE FROM accounts")
conn.commit()
yield conn
conn.close()

21
tests/run_tests.py Normal file
View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""
Test runner for account-manager.
Run:
pytest tests/ -v
python tests/run_tests.py -v
"""
import os
import sys
_script_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
if __name__ == "__main__":
import pytest
tests_dir = os.path.dirname(os.path.abspath(__file__))
args = [tests_dir, "-v"] + sys.argv[1:]
sys.exit(pytest.main(args))

View File

@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""
Tests for account CRUD operations and pick-web filtering.
"""
import json
import os
import pytest
class TestAccountAddWeb:
"""account add-web creates records correctly without requiring phone numbers."""
def test_add_web_email_login_id(self, clean_db):
"""Email login_id should be accepted (no 11-digit phone restriction)."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="maersk",
login_id="demo@maersk.com",
login_id_type="email",
label="Maersk sim test",
tenant_id="tenant-demo",
environment="simulator",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
# Should succeed with JSON output
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
assert accs[0]["login_id"] == "demo@maersk.com"
assert accs[0]["login_id_type"] == "email"
assert accs[0]["environment"] == "simulator"
assert accs[0]["role"] == "booking"
assert accs[0]["tenant_id"] == "tenant-demo"
assert accs[0]["status"] == "active"
assert accs[0]["platform_key"] == "maersk"
def test_add_web_no_login_id(self, clean_db):
"""login_id can be None (e.g. for browser_profile-only accounts)."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="deepseek",
login_id=None,
login_id_type="unknown",
label="DeepSeek prod",
tenant_id=None,
environment="production",
role="api",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="deepseek")
assert len(accs) == 1
assert accs[0]["login_id"] == ""
def test_add_web_profile_dir_created(self, clean_db, fake_env):
"""profile_dir should be auto-created under fake_env data root."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="maersk",
login_id="test@example.com",
login_id_type="email",
label="Profile dir test",
tenant_id="tenant-test",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
profile_dir = accs[0]["profile_dir"]
assert profile_dir, "profile_dir should be non-empty"
assert os.path.isdir(profile_dir), f"profile_dir should be created: {profile_dir}"
# The path should be under the fake data root
assert profile_dir.startswith(fake_env["data_root"]), \
f"profile_dir should be under fake data root: {profile_dir}"
def test_add_web_extra_json(self, clean_db):
"""extra_json field is stored and retrieved correctly."""
from service.account_service import cmd_account_add_web
extra = {"api_version": "v1", "timeout": 30}
cmd_account_add_web(
platform_input="cma_cgm",
login_id="cma@test.com",
login_id_type="email",
label="CMA CGM test",
tenant_id="tenant-cma",
environment="production",
role="documentation",
provider_code=None,
url=None,
extra_json_str=json.dumps(extra),
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="cma_cgm")
assert len(accs) == 1
raw_ex = accs[0].get("extra_json")
extra_parsed = raw_ex if isinstance(raw_ex, dict) else json.loads(raw_ex or "{}")
assert extra_parsed["api_version"] == "v1"
assert extra_parsed["timeout"] == 30
class TestAccountGetList:
"""account get/list operations."""
def test_get_account_found(self, clean_db):
"""account get returns JSON with correct account fields."""
from service.account_service import cmd_account_add_web, cmd_account_get
import io
import sys
# Add account first
cmd_account_add_web(
platform_input="evergreen",
login_id="ship@evergreen.com",
login_id_type="email",
label="Evergreen booking",
tenant_id="tenant-evg",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts_()
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_get(accs[0]["id"])
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
data = json.loads(output)
assert data["platform_key"] == "evergreen"
assert data["login_id"] == "ship@evergreen.com"
def test_get_account_not_found(self, clean_db):
"""account get for unknown id returns ERROR:ACCOUNT_NOT_FOUND."""
from service.account_service import cmd_account_get
import io
import sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_get(9999)
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
assert "ERROR:ACCOUNT_NOT_FOUND" in output
def test_list_platform_filter(self, clean_db):
"""account list --platform filters by platform."""
from service.account_service import cmd_account_add_web, cmd_account_list
import io
import sys
cmd_account_add_web("maersk", "a@maersk.com", "email", "m", "t1", "prod", "booking", None, None, None, None)
cmd_account_add_web("maersk", "b@maersk.com", "email", "m2", "t1", "prod", "documentation", None, None, None, None)
cmd_account_add_web("cma_cgm", "c@cma.com", "email", "c", "t2", "prod", "booking", None, None, None, None)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_list(platform_input="maersk")
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
accs = json.loads(output)
assert len(accs) == 2
assert all(a["platform_key"] == "maersk" for a in accs)
def find_accounts_():
from db.accounts_repo import find_accounts
return find_accounts(limit=200)
class TestPickWebFiltering:
"""pick-web filtering by environment / role / tenant_id."""
def _add(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
def test_filter_environment(self, clean_db):
"""pick-web --environment simulator selects only simulator account."""
self._add("maersk", "sim@maersk.com", "S", "t1", "simulator", "booking")
self._add("maersk", "prod@maersk.com", "P", "t1", "production", "booking")
self._add("maersk", "prod2@maersk.com", "P2", "t1", "production", "documentation")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment="simulator", tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["login_id"] == "sim@maersk.com"
assert result["environment"] == "simulator"
def test_filter_role(self, clean_db):
"""pick-web --role booking selects only booking role."""
self._add("maersk", "a@maersk.com", "A", "t1", "simulator", "booking")
self._add("maersk", "b@maersk.com", "B", "t1", "simulator", "documentation")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment=None, tenant_id=None, role="documentation", do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["role"] == "documentation"
def test_filter_tenant(self, clean_db):
"""pick-web --tenant-id filters correctly."""
self._add("cma_cgm", "a@cma.com", "A", "tenant-a", "production", "booking")
self._add("cma_cgm", "b@cma.com", "B", "tenant-b", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("cma_cgm", environment=None, tenant_id="tenant-b", role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["tenant_id"] == "tenant-b"
def test_no_accounts_returns_error(self, clean_db):
"""pick-web when no account matches returns ERROR:NO_ACCOUNT."""
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment="nonexistent", tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
err = json.loads(output)
assert err["success"] is False
assert err["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_platform_alias_chinese(self, clean_db):
"""pick-web resolves Chinese platform alias correctly."""
self._add("maersk", "test@test.com", "Test", "t", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("马士基", environment=None, tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["platform_key"] == "maersk"

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""
Browser launch helper checks (no real browser started).
"""
import pytest
class TestPlaywrightLaunchHelpers:
"""Persistent context launch parts must match RPA contract."""
def test_persistent_context_includes_maximized(self):
from util.playwright_stealth import persistent_context_launch_parts
args, _ignore = persistent_context_launch_parts()
assert "--start-maximized" in args
def test_open_child_runner_launch_keywords_documented(self):
"""launch_persistent_context kwargs contract (static read of source)."""
import os
path = os.path.join(
os.path.dirname(__file__),
"..",
"scripts",
"service",
"open_child_runner.py",
)
path = os.path.abspath(path)
with open(path, encoding="utf-8") as f:
src = f.read()
assert "launch_persistent_context" in src
assert "headless=False" in src
assert "no_viewport=True" in src
assert '"channel"' in src or "'channel'" in src
class TestResolveChromiumChannel:
"""resolve_chromium_channel returns a channel string when browsers exist."""
def test_returns_string_or_none(self):
from service.browser_service import resolve_chromium_channel
ch = resolve_chromium_channel()
assert ch is None or ch in ("chrome", "msedge")

View 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
View 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

340
tests/test_credentials.py Normal file
View File

@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""
Tests for credential operations, secret storage, and reveal.
Note: windows_credential integration tests are skipped by default
(the real Windows Credential Manager requires admin and a real user session).
Secret store logic for windows_credential is tested via mocking.
"""
import json
import os
import pytest
class TestCredentialEnvStorage:
"""credential add-secret env / pick --reveal / SQLite no-secret verification."""
def test_add_secret_env_saves_ref_not_value(self, clean_db):
"""add-secret with storage=env stores only the env var name, not the secret."""
from service.account_service import cmd_account_add_secret
from db.connection import get_conn
# Set the env var so the command can read it if needed
os.environ["TEST_DEEPSEEK_KEY"] = "sk-test-1234567890abcdef"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="DeepSeek test key",
secret_storage="env",
secret_stdin=False,
secret_ref="TEST_DEEPSEEK_KEY",
account_id=None,
environment="production",
role="api",
extra_json_str=None,
)
finally:
del os.environ["TEST_DEEPSEEK_KEY"]
conn = get_conn()
cur = conn.cursor()
cur.execute(
"SELECT secret_ref, secret_mask, secret_storage FROM credentials WHERE platform_key = ?",
("deepseek",),
)
row = cur.fetchone()
assert row is not None, "credential should be inserted"
secret_ref, secret_mask, secret_storage = row
assert secret_storage == "env"
assert secret_ref == "TEST_DEEPSEEK_KEY", "secret_ref should be the env var name"
# secret_mask should be a masked form of "env:TEST_DEEPSEEK_KEY", NOT the actual secret
assert "sk-test" not in secret_mask, "secret_mask must not contain the actual secret value"
assert "****" in secret_mask, "mask should contain ****"
conn.close()
def test_credential_pick_reveal_reads_environ(self, clean_db):
"""credential pick --reveal retrieves secret value from os.environ."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["MY_SECRET_KEY"] = "sk-actual-secret-value-abcdefgh"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Reveal test",
secret_storage="env",
secret_stdin=False,
secret_ref="MY_SECRET_KEY",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
# credential pick --reveal
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["secret"] == "sk-actual-secret-value-abcdefgh"
assert result["secret_mask"] != result["secret"], "mask should differ from secret"
finally:
del os.environ["MY_SECRET_KEY"]
def test_credential_pick_no_reveal_masks(self, clean_db):
"""credential pick (no --reveal) does NOT include the secret field."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["MASK_TEST_KEY"] = "super-secret-value-xyz"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Mask test",
secret_storage="env",
secret_stdin=False,
secret_ref="MASK_TEST_KEY",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert "secret" not in result, "no secret field when reveal=False"
assert result["secret_mask"] != "super-secret-value-xyz"
finally:
del os.environ["MASK_TEST_KEY"]
def test_env_missing_returns_error(self, clean_db):
"""credential pick --reveal when env var is missing returns SECRET_ENV_MISSING."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
# Add credential with a ref to a non-existent env var
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Missing env test",
secret_storage="env",
secret_stdin=False,
secret_ref="THIS_ENV_VAR_DOES_NOT_EXIST_AT_ALL",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:SECRET_ENV_MISSING"
class TestCredentialNoneStorage:
"""credential add-secret storage=none (browser_profile / note)."""
def test_add_secret_none(self, clean_db):
"""storage=none works for browser_profile credential type."""
from service.account_service import cmd_account_add_secret
cmd_account_add_secret(
platform_input="maersk",
credential_type="browser_profile",
label="Maersk browser session",
secret_storage="none",
secret_stdin=False,
secret_ref=None,
account_id=None,
environment="simulator",
role="booking",
extra_json_str=None,
)
from db.connection import get_conn
conn = get_conn()
cur = conn.cursor()
cur.execute(
"SELECT secret_storage, secret_ref, secret_mask FROM credentials WHERE platform_key = ?",
("maersk",),
)
row = cur.fetchone()
assert row is not None
assert row[0] == "none"
assert row[1] is None or row[1] == ""
conn.close()
class TestCredentialWindowsCredentialMocked:
"""Mocked tests for windows_credential secret store logic."""
def test_mask_secret_never_returns_full_value(self):
"""mask_secret must never return the actual secret value."""
from service.secret_store import mask_secret
secrets = [
"sk-test-1234567890abcdefghijklmnop",
"short",
"abc",
"a" * 100,
"",
None,
]
for s in secrets:
if s is None or s == "":
masked = mask_secret(s)
assert masked == "****"
continue
result = mask_secret(s)
# The mask should never equal the original
assert result != s, f"mask_secret({s!r}) must not equal original"
# The mask should always contain "****"
assert "****" in result, f"mask should contain '****': {result}"
# For long secrets, original should not appear in mask
assert s not in result, f"original secret must not appear in mask: {s}"
def test_read_secret_env_success(self):
"""read_secret with storage=env returns the os.environ value."""
os.environ["TEST_ENV_FOR_READ"] = "env-secret-value-xyz"
try:
from service.secret_store import read_secret
val = read_secret("env", "TEST_ENV_FOR_READ")
assert val == "env-secret-value-xyz"
finally:
del os.environ["TEST_ENV_FOR_READ"]
def test_read_secret_env_missing_raises(self):
"""read_secret with storage=env raises ValueError when env var is missing."""
# Ensure the env var is not set
os.environ.pop("NONEXISTENT_VAR_FOR_TEST_123", None)
from service.secret_store import read_secret
with pytest.raises(ValueError) as exc_info:
read_secret("env", "NONEXISTENT_VAR_FOR_TEST_123")
assert "SECRET_ENV_MISSING" in str(exc_info.value)
def test_read_secret_none(self):
"""read_secret with storage=none returns empty string."""
from service.secret_store import read_secret
val = read_secret("none", "ignored-ref")
assert val == ""
def test_write_secret_none_is_noop(self):
"""write_secret with storage=none does not raise."""
from service.secret_store import write_secret
# Should not raise
write_secret("none", "ignored-ref", "any-secret-value")
def test_windows_credential_write_fails_on_non_win32(self, monkeypatch):
"""On non-Windows, windows_credential write raises OSError."""
import sys
monkeypatch.setattr(sys, "platform", "linux")
from service.secret_store import write_secret
with pytest.raises(OSError) as exc_info:
write_secret("windows_credential", "some-target", "some-secret")
assert "WINDOWS_CREDENTIAL_UNAVAILABLE" in str(exc_info.value) or \
"not available" in str(exc_info.value).lower()
class TestCredentialPickFiltering:
"""credential pick filtering by credential_type / environment."""
def test_pick_by_credential_type(self, clean_db):
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["CREDS_TYPE_KEY_A"] = "val-a"
os.environ["CREDS_TYPE_KEY_B"] = "val-b"
try:
cmd_account_add_secret(
"deepseek", "api_key", "A", "env", False, "CREDS_TYPE_KEY_A",
None, None, None, None,
)
cmd_account_add_secret(
"deepseek", "model_param", "B", "env", False, "CREDS_TYPE_KEY_B",
None, None, None, None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="model_param", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["credential_type"] == "model_param"
finally:
del os.environ["CREDS_TYPE_KEY_A"]
del os.environ["CREDS_TYPE_KEY_B"]
def test_pick_no_credential_returns_error(self, clean_db):
"""credential pick when none matches returns CREDENTIAL_NOT_FOUND."""
from service.account_service import cmd_credential_pick
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="nonexistent_type", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:CREDENTIAL_NOT_FOUND"
class TestSQLiteNoPlaintextSecret:
"""Verify that SQLite never stores actual secret values."""
def test_credentials_table_never_has_plaintext(self, clean_db):
"""All credential rows should have secret_mask != original secret value."""
from service.account_service import cmd_account_add_secret
from db.connection import get_conn
test_secret = "SQLITE_NO_PLAINTEXT_TEST_SECRET_987654321"
os.environ["SQLITE_NO_PLAINTEXT_TEST"] = test_secret
try:
cmd_account_add_secret(
"deepseek", "api_key", "SQLite test", "env", False,
"SQLITE_NO_PLAINTEXT_TEST", None, None, None, None,
)
finally:
del os.environ["SQLITE_NO_PLAINTEXT_TEST"]
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT secret_ref, secret_mask FROM credentials")
rows = cur.fetchall()
conn.close()
for secret_ref, secret_mask in rows:
# secret_ref for env storage is the env var name, not the secret
if secret_ref == "SQLITE_NO_PLAINTEXT_TEST":
# If it matches our test env var name, check the mask
assert test_secret not in (secret_mask or ""), \
f"Plaintext secret must not appear in secret_mask or secret_ref columns. mask={secret_mask}"

View 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
View 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)

View File

@@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""自定义平台动态解析、platform ensure、add-web / pick-web / browser URL。"""
from __future__ import annotations
import json
import os
import pytest
JIANGCHANG_URL = "https://jc2009.com/admin/index.html"
def _ensure_jiangchang(clean_db) -> None:
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
domain="generic",
url=JIANGCHANG_URL,
auth_strategy=None,
aliases=json.dumps(["jc", "匠厂后台"], ensure_ascii=False),
capabilities=None,
)
assert rc == 0
class TestBuiltinPlatformsUnchanged:
def test_douyin_alias(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("抖音", conn=clean_db)
assert result.ok
assert result.platform_key == "douyin"
def test_toutiao_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("toutiao", conn=clean_db)
assert result.ok
assert result.platform_key == "toutiao"
class TestDynamicPlatformResolution:
def test_unregistered_valid_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("unknown_custom", conn=clean_db)
assert not result.ok
assert result.error_code == "PLATFORM_NOT_REGISTERED"
assert "platform ensure" in (result.message or "")
def test_invalid_platform_key_format(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db)
assert not result.ok
assert result.error_code == "INVALID_PLATFORM_KEY"
def test_empty_platform(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("", conn=clean_db)
assert result.error_code == "INVALID_PLATFORM"
def test_registered_jiangchang(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jiangchang", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
def test_registered_alias_jc(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jc", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
class TestPlatformEnsure:
def test_ensure_returns_json(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
url=JIANGCHANG_URL,
)
assert rc == 0
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["ok"] is True
assert payload["platform_key"] == "jiangchang"
assert payload["action"] in ("created", "updated")
class TestCustomPlatformAccounts:
def test_add_web_after_ensure(self, clean_db):
from service.account_service import cmd_account_add_web
from db.accounts_repo import find_accounts
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台 手工登录",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts(platform_key="jiangchang")
assert len(accs) == 1
assert accs[0]["platform_key"] == "jiangchang"
assert accs[0]["url"] == JIANGCHANG_URL
assert accs[0]["profile_dir"]
def test_pick_web_after_ensure(self, clean_db, capsys):
from service.account_service import cmd_account_add_web, cmd_account_pick_web
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
cmd_account_pick_web(
platform_input="jiangchang",
do_lease=True,
holder="test-holder",
ttl_sec=300,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["platform_key"] == "jiangchang"
assert payload["profile_dir"]
assert payload.get("lease_token")
def test_pick_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web
cmd_account_pick_web(platform_input="unknown_custom")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert "--platform-display-name" in payload["error"]["message"]
def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="unknown_custom",
login_id="manual",
login_id_type="manual",
label="x",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
class TestBrowserServiceUrlResolution:
def test_custom_platform_default_url_from_db(self, clean_db):
from db.accounts_repo import insert_account, upsert_platform
from service.browser_service import resolve_account_browser_url
import time
now = int(time.time())
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url=JIANGCHANG_URL,
)
aid = insert_account(
conn=clean_db,
platform_key="jiangchang",
account_label="test",
login_id=None,
login_id_type="manual",
tenant_id=None,
environment="production",
role="admin",
provider_code="jiangchang",
profile_dir="/tmp/test-profile",
url="",
extra_json="{}",
now=now,
auth_strategy="qr_code_manual",
)
clean_db.commit()
account = {
"id": aid,
"platform_key": "jiangchang",
"url": "",
}
assert resolve_account_browser_url(account, conn=clean_db) == JIANGCHANG_URL
def test_account_url_overrides_platform_default(self, clean_db):
from service.browser_service import resolve_account_browser_url
account = {
"platform_key": "jiangchang",
"url": "https://custom.example.com/admin",
}
assert (
resolve_account_browser_url(account, conn=clean_db)
== "https://custom.example.com/admin"
)
class TestPlatformKeyFormatValidation:
def test_ensure_rejects_invalid_key(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="江厂 空格", display_name="bad")
assert rc == 1
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
JIANGCHANG_META_KWARGS = dict(
display_name="匠厂后台",
url=JIANGCHANG_URL,
domain="jc2009.com",
auth_strategy="qr_code_manual",
aliases=["jiangchang", "匠厂", "匠厂后台", "jc2009"],
)
class TestAutoRegisterFromCallerMeta:
def _meta(self):
from service.account_service import PlatformCallerMeta
return PlatformCallerMeta(**JIANGCHANG_META_KWARGS)
def test_pick_web_existing_platform_unchanged(self, clean_db, capsys):
from service.account_service import cmd_account_add_web, cmd_account_pick_web
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
cmd_account_pick_web(platform_input="jiangchang", do_lease=True, holder="h1")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["platform_key"] == "jiangchang"
assert payload.get("profile_dir")
def test_pick_web_unregistered_without_meta(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web
cmd_account_pick_web(platform_input="auto_reg_plat")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert "--platform-display-name" in payload["error"]["message"]
def test_pick_web_unregistered_with_meta_auto_registers(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
from util.platforms import resolve_platform_key_dynamic
meta = PlatformCallerMeta(
display_name="自动注册测试",
url="https://example.com/admin",
domain="example.com",
auth_strategy="qr_code_manual",
aliases=["auto_reg_plat"],
)
cmd_account_pick_web(platform_input="auto_reg_plat", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db)
assert result.ok
assert result.platform_key == "auto_reg_plat"
def test_add_web_unregistered_with_meta_auto_registers(self, clean_db):
from service.account_service import cmd_account_add_web, PlatformCallerMeta
from db.accounts_repo import find_accounts
meta = PlatformCallerMeta(
display_name="匠厂后台",
url=JIANGCHANG_URL,
domain="jc2009.com",
auth_strategy="qr_code_manual",
aliases=["jiangchang"],
)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
platform_meta=meta,
)
accs = find_accounts(platform_key="jiangchang")
assert len(accs) == 1
assert accs[0]["url"] == JIANGCHANG_URL
def test_invalid_platform_key_no_register(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
meta = PlatformCallerMeta(display_name="", url="https://x.com")
cmd_account_pick_web(platform_input="bad key!", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
row = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key LIKE '%bad%'"
).fetchone()[0]
assert row == 0
def test_disabled_platform_not_re_enabled(self, clean_db, capsys):
from db.accounts_repo import upsert_platform
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url=JIANGCHANG_URL,
)
clean_db.execute("UPDATE platforms SET enabled = 0 WHERE platform_key = 'jiangchang'")
clean_db.commit()
meta = PlatformCallerMeta(display_name="匠厂后台", url=JIANGCHANG_URL)
cmd_account_pick_web(platform_input="jiangchang", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:PLATFORM_DISABLED"
enabled = clean_db.execute(
"SELECT enabled FROM platforms WHERE platform_key = 'jiangchang'"
).fetchone()[0]
assert enabled == 0
def test_auto_register_idempotent(self, clean_db):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
meta = PlatformCallerMeta(
display_name="幂等测试",
url="https://example.com/admin",
aliases=["idem_plat"],
)
cmd_account_pick_web(platform_input="idem_plat", platform_meta=meta)
cmd_account_pick_web(platform_input="idem_plat", platform_meta=meta)
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'idem_plat'"
).fetchone()[0]
assert count == 1

242
tests/test_lease.py Normal file
View File

@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""
Tests for lease acquire / conflict / release / cleanup.
"""
import json
import time
import pytest
class TestLeaseAcquire:
"""Lease acquisition on pick-web."""
def _add_account(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
def test_pick_web_lease_returns_token(self, clean_db):
"""pick-web --lease returns lease_token in result."""
self._add_account("maersk", "test@maersk.com", "Test", "t1", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"maersk",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="test-holder",
purpose="rpa-run",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert "lease_token" in result
assert len(result["lease_token"]) == 32
assert "lease_expires_at" in result
assert result["lease_token"] not in ("", "none")
def test_active_lease_blocks_pick(self, clean_db):
"""When the only matching account is leased, pick-web finds no eligible row."""
from db.accounts_repo import acquire_lease, find_account_ids
self._add_account("cma_cgm", "test@cma.com", "CMA", "t1", "production", "booking")
ids = find_account_ids("cma_cgm")
assert len(ids) == 1
acc_id = ids[0]
acquire_lease(
account_id=acc_id,
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
holder="first-holder",
purpose="first-task",
ttl_sec=900,
)
assert find_account_ids("cma_cgm") == []
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"cma_cgm",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="second-holder",
purpose="second-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_release_then_pick(self, clean_db):
"""After release, the same account can be picked again."""
from db.accounts_repo import acquire_lease, release_lease, find_account_ids
self._add_account("evergreen", "ship@evergreen.com", "EVG", "t1", "production", "booking")
ids = find_account_ids("evergreen")
acc_id = ids[0]
token = "z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4"
acquire_lease(acc_id, token, "holder", "purpose", 900)
ok = release_lease(token)
assert ok, "release should succeed"
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"evergreen",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="new-holder",
purpose="new-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue().strip().splitlines()[-1])
assert result["id"] == acc_id
assert "lease_token" in result
class TestLeaseRelease:
"""lease release command."""
def test_release_unknown_token(self, clean_db):
"""release of non-existent token returns LEASE_NOT_FOUND."""
from service.account_service import cmd_lease_release
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_release("nonexistent_token_123456789012")
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
class TestLeaseCleanup:
"""lease cleanup / expired lease handling."""
def test_expired_lease_allows_pick(self, clean_db):
"""After cleanup marks expired, account can be picked again."""
from db.accounts_repo import acquire_lease, cleanup_expired_leases, find_account_ids
self._add_account_("cargo_wise", "cw@cargowise.com", "CW", "t1", "production", "booking")
ids = find_account_ids("cargo_wise")
acc_id = ids[0]
# Acquire lease with very short TTL (1 second)
acquire_lease(acc_id, "shortlived1234567890123456789012", "holder", "short", 1)
# Wait for it to expire
time.sleep(1.5)
count = cleanup_expired_leases()
assert count >= 1, "at least one lease should be cleaned"
# Now pick-web should succeed
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"cargo_wise",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="after-cleanup",
purpose="fresh-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue().strip().splitlines()[-1])
assert result["id"] == acc_id
assert "lease_token" in result
def _add_account_(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
class TestLeaseList:
"""lease list command."""
def test_list_no_leases(self, clean_db):
"""lease list --active returns empty when no leases."""
from service.account_service import cmd_lease_list
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_list(active_only=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["data"]["leases"] == []
def test_list_with_active_lease(self, clean_db):
"""lease list --active shows active lease after acquisition."""
from db.accounts_repo import acquire_lease, find_account_ids
from service.account_service import cmd_account_add_web, cmd_lease_list
import io, sys
cmd_account_add_web(
"sinotrans", "sinotest@sinotrans.com", "email", "Sino", "t1", "production", "booking",
None, None, None, None,
)
ids = find_account_ids("sinotrans")
acquire_lease(ids[0], "listtest1234567890123456789012345", "holder", "purpose", 900)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_list(active_only=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
leases = result["data"]["leases"]
assert len(leases) == 1
assert leases[0]["account_id"] == ids[0]
assert leases[0]["status"] == "active"

60
tests/test_master_key.py Normal file
View 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

View 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"

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""resolve_platform_key_dynamic 与 JSON 解析边界。"""
from __future__ import annotations
import json
import pytest
class TestResolvePlatformKeyDynamicParsing:
def test_builtin_still_works_without_db_row(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("马士基", conn=clean_db)
assert result.platform_key == "maersk"
def test_disabled_platform_returns_disabled(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import resolve_platform_key_dynamic
upsert_platform(
clean_db,
key="custom_off",
display_name="Off",
default_url="",
)
clean_db.execute(
"UPDATE platforms SET enabled = 0 WHERE platform_key = ?",
("custom_off",),
)
clean_db.commit()
result = resolve_platform_key_dynamic("custom_off", conn=clean_db)
assert result.error_code == "PLATFORM_DISABLED"
def test_get_platform_spec_from_db(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import get_platform_spec
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url="https://jc2009.com/admin/index.html",
)
spec = get_platform_spec("jiangchang", conn=clean_db)
assert spec["display_name"] == "匠厂后台"
assert spec["default_url"] == "https://jc2009.com/admin/index.html"
def test_malformed_aliases_json_does_not_crash(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
import time
now = int(time.time())
clean_db.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, NULL, ?, ?)
""",
(
"bad_alias",
"Bad",
"generic",
"bad_alias",
"",
"not-json",
"{}",
now,
now,
),
)
clean_db.commit()
result = resolve_platform_key_dynamic("bad_alias", conn=clean_db)
assert result.platform_key == "bad_alias"

83
tests/test_platforms.py Normal file
View File

@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""
Platform resolution tests.
"""
import pytest
class TestPlatformResolution:
"""Platform key resolution: key / display_name / aliases -> canonical key."""
def test_maersk_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("maersk") == "maersk"
def test_maersk_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("马士基") == "maersk"
def test_maersk_alias_upper(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("MAERSK") == "maersk"
def test_cma_cgm_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("cma_cgm") == "cma_cgm"
def test_cma_cgm_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("达飞") == "cma_cgm"
def test_cma_cgm_alias_display(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("CMA CGM") == "cma_cgm"
def test_deepseek_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("deepseek") == "deepseek"
def test_deepseek_alias_capitalize(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("DeepSeek") == "deepseek"
def test_unknown_returns_none(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("not_a_platform_xyz") is None
def test_empty_returns_none(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("") is None
assert resolve_platform_key(None) is None
def test_list_platforms_all(self):
from util.platforms import list_platforms
all_plat = list_platforms()
assert any(p["platform_key"] == "maersk" for p in all_plat)
assert any(p["platform_key"] == "deepseek" for p in all_plat)
assert any(p["platform_key"] == "sohu" for p in all_plat)
def test_list_platforms_logistics_only(self):
from util.platforms import list_platforms
log_plat = list_platforms("logistics")
assert any(p["platform_key"] == "maersk" for p in log_plat)
assert any(p["platform_key"] == "cma_cgm" for p in log_plat)
assert not any(p["platform_key"] == "deepseek" for p in log_plat)
def test_list_platforms_llm_only(self):
from util.platforms import list_platforms
llm_plat = list_platforms("llm")
assert any(p["platform_key"] == "deepseek" for p in llm_plat)
assert any(p["platform_key"] == "doubao" for p in llm_plat)
assert not any(p["platform_key"] == "maersk" for p in llm_plat)
def test_evergreen_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("evergreen") == "evergreen"
def test_evergreen_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("长荣") == "evergreen"
def test_cargo_wise_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("cargo_wise") == "cargo_wise"

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""Profile 目录浏览器快捷方式测试。"""
import os
import sys
import pytest
from util.profile_shortcut import _lnk_safe_filename, create_profile_browser_shortcut
class TestLnkSafeFilename:
def test_strips_forbidden_chars(self) -> None:
assert _lnk_safe_filename('小红书/测试:账号') == "小红书_测试_账号"
def test_empty_uses_fallback(self) -> None:
assert _lnk_safe_filename(" ") == "browser"
class TestCreateProfileBrowserShortcut:
def test_skips_non_windows(self, tmp_path, monkeypatch) -> None:
monkeypatch.setattr("util.profile_shortcut.sys.platform", "linux")
out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None
def test_skips_when_no_browser(self, tmp_path, monkeypatch) -> None:
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
monkeypatch.setattr(
"util.profile_shortcut.resolve_chromium_executable",
lambda: None,
)
out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable
browser_exe = resolve_chromium_executable()
if not browser_exe:
pytest.skip("no Chrome/Edge installed")
label = "ShortcutTest"
out = create_profile_browser_shortcut(str(tmp_path), label)
assert out is not None
assert os.path.isfile(out)
assert out.endswith(f"{label}.lnk")
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_add_web_creates_shortcut(self, clean_db, fake_env, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable
from service.account_service import cmd_account_add_web
from db.accounts_repo import find_accounts
if not resolve_chromium_executable():
pytest.skip("no Chrome/Edge installed")
label = "Maersk shortcut test"
cmd_account_add_web(
platform_input="maersk",
login_id="shortcut@test.com",
login_id_type="email",
label=label,
tenant_id="tenant-test",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
profile_dir = accs[0]["profile_dir"]
lnk_path = os.path.join(profile_dir, f"{label}.lnk")
assert os.path.isfile(lnk_path), f"expected shortcut at {lnk_path}"

View 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"

View 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()

View 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

View 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()

View 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

View 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
View 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}"

View 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)