Compare commits

..

50 Commits

Author SHA1 Message Date
3ce0cc40a1 Release v2.0.25 force-release lease row action
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-12 16:18:36 +08:00
3f1cde1f99 fix: validate platform keys at the gate and stop auto-register
All checks were successful
技能自动化发布 / release (push) Successful in 6s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 15:40:30 +08:00
9a20e7fc69 feat: simplify account create/edit and add row copy
All checks were successful
技能自动化发布 / release (push) Successful in 8s
Data-management add/edit now use short forms with enums; copy clones a platform account into a new empty profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:48:46 +08:00
097dde0ebb fix: open account browser maximized without Chrome first-run
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Human open now uses start-maximized and no-first-run flags, and profile shortcuts share the same args plus the platform URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:11:05 +08:00
718aeefd44 fix: unify row open labels and silent success
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Rename 打开文件位置 to 打开目录, and print empty SAS message so the host skips 执行成功 after opening Explorer or the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:15:26 +08:00
123195fcd9 fix: align add-web form fields to column names for lookup
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 15:14:31 +08:00
ba991a2462 feat: bind accounts create to add-web via replaces
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 14:41:36 +08:00
e9455cbb86 feat: data-management lookup for platform/account fields
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 13:37:07 +08:00
87877010ac fix: account open launches Chrome/Edge directly like reveal-profile
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 11:58:18 +08:00
0f54204a61 fix: account open fire-and-forget; open-browser direct+sync
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 11:43:11 +08:00
f48e8aa75d feat: row actions use interaction direct/confirm
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-07-31 11:15:05 +08:00
f5bab01421 feat: 数据管理行内打开文件位置与打开浏览器
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 10:02:00 +08:00
cbbec7cdfc fix: add init-db CLI for host install/upgrade metadata seed
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-26 16:03:27 +08:00
5f0d9e45f8 feat: add Chinese display metadata for data management UI
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-26 15:46:18 +08:00
7bac647673 feat: resolve/pick accounts by login_id or label
All checks were successful
技能自动化发布 / release (push) Successful in 11s
2026-07-18 15:45:52 +08:00
8d36e193b4 chore: sync SKILL.md version with release tag v2.0.10
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-18 14:39:43 +08:00
e441b0c58e feat: account ensure-web (pick-or-create for first-run UX)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-18 14:37:16 +08:00
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
76 changed files with 11451 additions and 1529 deletions

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
本文件对应技能市场 **「更新日志」** Tab。
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节。
## 2.0.25
- 「账号租约」表新增行右键「强制释放租约」:仅状态为 active 时显示,确认后立刻释放,无需租约令牌
- 同步提供 CLI:`lease force-release --id <租约编号>`
## 2.0.24
- (历史版本,见 git tag)

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号与凭据管理 name: 账号与凭据管理
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流平台(Maersk、COSCO、MSC 等)、LLM 平台(DeepSeek、豆包等)、内容平台。业务 skill 通过 CLI 获取账号,不直接读 SQLite。" description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 1.0.56 version: 2.0.25
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -14,24 +14,37 @@ allowed-tools:
# 账号与凭据管理(account-manager) # 账号与凭据管理(account-manager)
通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据(API Key/Token 不存明文)、RPA 并发锁(lease),供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息。 通用账号、凭据与浏览器 Profile 管理器。在本地 SQLite 管理平台注册、账号身份、凭据元数据;可选 Fernet 加密密码入库;提供租约(lease)防并发;供其它技能通过 CLI 调用 `scripts/main.py` 或通过 **同进程** [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md) 完成登录流程。
## 核心原则 ## 核心原则
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。 1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己散落存储账号、密码、API Key、Token。
2. **SQLite 只保存元数据** — `secret_ref`、`secret_mask`,不保存原始 secret 明文。 2. **SQLite 主要保存元数据** — `secret_ref`、`secret_mask`;非 `local_encrypted` 路径不在库中存明文。
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session,不保存网页登录密码。 3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session。
4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。 4. **业务 skill 优先通过 CLI 获取结构化账号 JSON** — 需要时再组合同进程 rpa_helpers(见集成文档)。
5. **9 种登录策略统一接口**:`auth_strategy` 标识账号登录方式,业务 skill 可调用 `ensure_logged_in` 自动分派。
6. **加密 secret 双钥分离**:master key 文件默认与数据库同目录,也可用环境变量覆盖;**数据库密文 + 合法 master key** 才能解密。
## 何时使用本技能 ## 何时使用本技能
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir。 1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
2. **登记 API Key/Token 账号**:`account add-secret` — 平台 + 凭据类型 + secret 存储方式。 2. **登记平台**:`platform ensure --key <小写键> --display-name <展示名>`。键与展示名必须分开;`add-web` / `ensure-web` / `pick-web` 只引用已注册平台,**不会**根据 `--platform-display-name` 自动建平台。数据管理「平台信息 → 新增」走本命令。
3. **查看/列出账号**:`account get/list` — JSON 输出。 3. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。 4. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
5. **获取 API Key**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 5. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做登录检测。 6. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 7. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
8. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
9. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
11. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
12. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
13. **管理 lease**:`lease release/force-release/list/cleanup` — 防止 RPA 并发;数据管理「账号租约」行右键可强制释放。
14. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
16. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
17. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
18. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
## CLI 调用 ## CLI 调用
@@ -39,20 +52,25 @@ allowed-tools:
## 支持的平台 ## 支持的平台
见 `references/PLATFORMS.md`;支持物流、LLM、内容三大业务域。 见 [`references/PLATFORMS.md`](references/PLATFORMS.md);代码注册表按业务域分为 logistics / llm / content / dev / sim(实际数量与别名以 `scripts/util/platforms.py` 为准)。
## 数据与环境 ## 数据与环境
库路径、profile 布局、环境变量见 `references/RUNTIME.md`;表结构见 `references/SCHEMA.md`。 库路径、profile 布局、环境变量见 [`references/RUNTIME.md`](references/RUNTIME.md);表结构见 [`references/SCHEMA.md`](references/SCHEMA.md)。
## 跨技能集成 ## 跨技能集成
`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`。 stdout 协议与同进程 import 见 [`references/INTEGRATION.md`](references/INTEGRATION.md);登录 helper 细节见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
## Secret 存储 ## Secret 存储
- `none`:无 secret(浏览器 profile 登录态) - `none`:无 secret(浏览器 profile 登录态)
- `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取 - `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager - `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
- `local_encrypted`:Fernet 加密后存 SQLite(`secret_ciphertext`),密钥从 `ACCOUNT_MANAGER_MASTER_KEY` 或 `master.key` 读取(须先 `account init-master-key`)
详见 `references/RUNTIME.md`。 详见 [`references/RUNTIME.md`](references/RUNTIME.md)。
## v2 升级说明
v1→v2 为**平滑迁移**:启动时 `ensure_schema` 幂等执行 `migrate_v1_to_v2` 与 `migrate_v2_to_v2_1`,自动补列与 `_schema_meta.schema_version`(最终为 `"2.1"`)。旧有三种 `secret_storage`(`none` / `env` / `windows_credential`)仍可用;CLI 旧别名(`get` / `pick-web` / `add` 等)保留。

View File

@@ -1,5 +1,6 @@
# assets # assets
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。

291
assets/actions.json Normal file
View File

@@ -0,0 +1,291 @@
{
"schemaVersion": 1,
"skill": "account-manager",
"actions": [
{
"id": "platform-ensure",
"label": "新增平台",
"description": "登记平台:小写英文键 + 展示名必须分开。不会把中文名当成键。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["platforms"]
},
"inputSchema": {
"type": "object",
"required": ["platform_key", "display_name"],
"properties": {
"platform_key": {
"type": "string",
"title": "平台键",
"description": "小写英文/数字/下划线或连字符,例如 douyin、deepseek。不要填中文。"
},
"display_name": {
"type": "string",
"title": "展示名称",
"description": "给人看的名称,可以是中文,例如 抖音。"
},
"default_url": {
"type": "string",
"title": "默认链接"
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "platform",
"args": [
"ensure",
"--key", "{{platform_key}}",
"--display-name", "{{display_name}}",
"--url", "{{default_url}}"
]
}
},
{
"id": "account-add-web",
"label": "新增账号",
"description": "登记网页/RPA 账号并自动创建浏览器 Profile 目录(走 account add-web,非宿主直写库)。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["accounts"]
},
"inputSchema": {
"type": "object",
"required": ["platform_key", "account_label"],
"properties": {
"platform_key": {
"type": "string",
"title": "平台",
"display_type": "lookup",
"lookup": {
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1
}
},
"account_label": {
"type": "string",
"title": "账号名称"
},
"login_id": {
"type": "string",
"title": "登录账号"
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "account",
"args": [
"add-web",
"--platform", "{{platform_key}}",
"--label", "{{account_label}}",
"--login-id", "{{login_id}}"
]
}
},
{
"id": "account-update",
"label": "编辑账号",
"description": "只改名称、登录账号、标识类型、入口链接和启用状态。",
"replaces": "update",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["accounts"],
"inputMapping": {
"accountId": "$row.$pk",
"account_label": "$row.account_label",
"login_id": "$row.login_id",
"login_id_type": "$row.login_id_type",
"url": "$row.url",
"status": "$row.status"
}
},
"inputSchema": {
"type": "object",
"required": ["accountId", "account_label", "status"],
"properties": {
"accountId": {
"type": "number",
"title": "编号",
"readOnly": true
},
"account_label": {
"type": "string",
"title": "账号名称"
},
"login_id": {
"type": "string",
"title": "登录账号"
},
"login_id_type": {
"type": "string",
"title": "登录标识类型",
"enum": ["unknown", "phone", "email", "username", "customer_code", "api_account"]
},
"url": {
"type": "string",
"title": "入口链接",
"display_type": "url"
},
"status": {
"type": "string",
"title": "状态",
"enum": ["active", "disabled", "needs_review"]
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "account",
"args": [
"update",
"--id", "{{accountId}}",
"--label", "{{account_label}}",
"--login-id", "{{login_id}}",
"--login-id-type", "{{login_id_type}}",
"--url", "{{url}}",
"--status", "{{status}}"
]
}
},
{
"id": "account-copy",
"label": "复制",
"description": "复制为同平台新账号:新空 Profile,登录账号留空,名称自动加「副本」。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"readOnly": true
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["copy", "{{accountId}}"]
}
},
{
"id": "reveal-profile",
"label": "打开目录",
"description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"description": "accounts 表主键",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["reveal-profile", "{{accountId}}"]
}
},
{
"id": "open-browser",
"label": "打开浏览器",
"description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开目录同为一点即开。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"description": "accounts 表主键",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["open", "{{accountId}}"]
}
},
{
"id": "lease-force-release",
"label": "强制释放租约",
"description": "强制释放这条活跃租约,让账号立刻可被其它任务占用。仅状态为 active 时可用。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": {
"message": "强制释放后,其它任务可立刻占用该账号。若原任务仍在跑,可能冲突。是否继续?"
},
"bind": {
"tables": ["account_leases"],
"inputMapping": {
"leaseId": "$row.$pk"
},
"visibleWhen": {
"status": "active"
}
},
"inputSchema": {
"type": "object",
"required": ["leaseId"],
"properties": {
"leaseId": {
"type": "number",
"title": "租约编号",
"description": "当前行编号,一般无需修改",
"readOnly": true
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "lease",
"args": ["force-release", "--id", "{{leaseId}}"]
}
}
]
}

View File

@@ -1,160 +1,235 @@
# account-manager CLI 速查 # account-manager CLI 速查
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py` 表示在 `scripts/` 下执行。 将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行。
## 子命令与实现(与代码一致) > **说明**:内置 `_USAGE`(无参数启动 CLI)已列出加密三类子命令;更完整的 JSON 约定仍以本文与源码为准。
### 平台管理
## stdout / JSON 约定(集成必读)
- 若干命令输出 **单层 JSON 对象**(例如 `account pick-web`、`account get`、`account get-credential` 成功时),**没有**统一的 `{"success":true}` 外层包裹。
- `account add-web`、`account add-secret`(等多数字命令)成功时为 **`{"success": true, "data": {...}}`**。
- `credential pick` / `credential resolve` 成功时为 **`{"success": true, ...字段}`**。
- `platform list` / `platform get` 成功时为 **`{"success": true, "data": ...}`**。
- 失败行多为 **`{"success": false, "error": {"code": "...", "message": "..."}}`**(个别老旧路径仍可能打印 `ERROR:` 前缀文本,首行解析逻辑见 [INTEGRATION.md](INTEGRATION.md))。
---
## 子命令速查(按业务流程)
### 0. 运行时初始化(宿主标准)
| CLI | 说明 | | CLI | 说明 |
|-----|------| |---|---|
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 | | `init-db` | 幂等初始化本地 SQLite、schema 迁移与数据管理中文元数据(`_jiangchang_*`)。成功输出 JSON:`{"ok":true,"skill":"account-manager","db_path":"..."}`。**匠厂安装/升级技能时会调用本命令**;业务命令内部也会触发同等 `init_db()`。 |
| `platform get <platform>` | 获取平台详情 |
### 账号管理 ### A. 平台管理
| CLI | 说明 | | CLI | 说明 |
|-----|------| |---|---|
| `account add-web --platform <p> --login-id <id> [...]` | 创建网页/RPA 账号 | | `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
| `account add-secret --platform <p> --credential-type <type> [...]` | 创建 API Key/Token 凭据 | | `platform get <platform>` | 获取数据库中的平台详情 |
| `account get <id> [--with-credentials]` | 获取单条账号 JSON | | `platform ensure --key <key> --display-name <name> [--url …]` | **登记/更新平台**。`--key` 必须已是小写英文键(如 `douyin`);`--display-name` **必填**(可中文)。不会从 `add-web` / `pick-web` 自动建平台。 |
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>]` | 列出账号 JSON 数组 |
| `account pick-web --platform <p> [--lease] [--ttl-sec 900]` | 挑选网页自动化候选 | ### B. 加密初始化(v2)
| `account mark-session <id> --session-status <status>` | 标记登录状态 |
| 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.key(stderr 会先打印警告;应先有 export 备份) |
### C. 账号管理(v2 扩展)
| CLI | 说明 |
|---|---|
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;省略 `--login-id-type` 时按登录账号推断;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
| `account copy <id>` | 复制为同平台新账号(新空 Profile,名称加「副本」,不拷登录账号/凭证/设备指纹) |
| `account update --id <id> [--label] [--login-id] [--login-id-type] [--url] [--status]` | 更新数据管理编辑表单上的短字段 |
| `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 resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` |
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
| `account list [--platform <p>] [...] [--login-id <id>] [--label <label>] [--limit N]` | 输出账号 JSON **数组**;可按登录标识/备注筛选 |
| `account pick-web --platform <p> [...] [--login-id <…>] [--label <…>] [--lease] [...] [--ensure]` | 挑选网页自动化候选;可按手机号/用户名/备注精确筛选;`--ensure` 等同 `ensure-web` |
| `account ensure-web --platform <p> [--login-id <…>] [--label <…>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;指定 `--login-id`/`--label` 时按该标识筛选/创建;均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT` |
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 | | `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 | | `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile(仅查看) | | `account open <id>` | 本进程直接打开系统 Chrome/Edge(独立 profile;最大化;跳过首次欢迎页;打开平台 URL) |
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
### 凭据管理 ### D. 加密备份与迁移(v2)
| CLI | 说明 | | CLI | 说明 |
|-----|------| |---|---|
| `credential pick --platform <p> [--type <type>] [--reveal]` | 挑选凭据(默认隐藏 secret) | | `account export-credentials --plaintext` | 将所有凭据行导出为 JSON **数组** 打到 stdout(stderr 有人类可读警告);**必须**显式 `--plaintext` |
| `credential resolve --id <credential_id> [--reveal]` | 按 ID 解析凭据 | | `account import-credentials [--replace-all]` | 从 stdin 读 JSON 数组,仅对带 `plaintext` 的行调用加密插入;结果摘要打到 **stderr** |
### 租约管理 ### E. 凭据 / 租约 / 浏览器(v1 兼容入口保留)
以下命令仍推荐使用,行为与 v1 时期一致,适合「只拿 API Key」或脚本兼容:
| CLI | 说明 | | 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 release <lease_token>` | 释放租约 |
| `lease force-release --id <租约编号>` | 按行编号强制释放活跃租约(数据管理右键) |
| `lease list [--active]` | 列出租约 | | `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 | | `lease cleanup` | 清理过期租约 |
| `account open <id>` / `open <id>` | 打开浏览器 |
### 浏览器 | `account reveal-profile <id>` | 打开目录 |
| CLI | 说明 |
|-----|------|
| `account open <id>` | 打开浏览器查看(仅查看) |
| `open <id>` | 同上(兼容别名) |
### 兼容入口(旧版别名) ### 兼容入口(旧版别名)
| CLI | 等价于 | | CLI | 等价 / 说明 |
|-----|--------| |---|---|
| `get <id>` | `account get <id>` | | `get <id>` | `account get <id>` |
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 | | `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
| `pick-web <platform>` | `account pick-web --platform <platform>` | | `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
| `list [platform\|all]` | 人类可读列表 | | `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | `account add-web`(phone 作为 login_id) | | `add <platform> <phone>` | 映射到 `account add-web`(`login_id_type=phone`),不传 v2 扩展参数 |
| `delete id\|platform <...>` | 删除账号 | | `delete id\|platform <...>` | 删除账号 |
--- ---
## account add-web ## account add-web(扩参示例)
```bash ```bash
# 扫码登录的抖音账号
python main.py account add-web \ python main.py account add-web \
--platform maersk \ --platform douyin \
--login-id demo@maersk.com \ --label "douyin-creator-1" \
--login-id-type email \ --auth-strategy qr_code_manual \
--label "Maersk simulator booking" \ --session-persistent 1
--tenant-id tenant-demo \
--environment simulator \
--role booking \
--provider-code maersk \
--url http://localhost:5180/industries/logistics/maersk/login
``` ```
- `--platform`(必填):平台键/别名 - `--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`**。
- `--login-id`:登录标识(email/username/phone/customer_code) - `--session-persistent`:`0` 或 `1`;省略时由代码推导:`qr_code_manual`、`password_plus_2fa`、`device_bound`、`sso_redirect` → `1`;`per_session_manual`、`client_certificate` → `0`;其余默认 `1`。
- `--login-id-type`:默认 `unknown` - `--device-fingerprint`:**仅当** `--auth-strategy=device_bound` 时允许设置;否则 **`ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE`**。
- `--label`:人可读名称,不填则自动拼接 - 成功 stdout:`{"success": true, "data": { ... }}`,`data` 内含 `id`、`platform_key`、`profile_dir`、`auth_strategy`、`session_persistent`、`device_fingerprint` 等。
- `--tenant-id`:租户/项目标识
- `--environment`:默认 `production`
- `--role`:默认 `default`
- `--provider-code`:默认取平台 provider_code
- `--url`:默认取平台 default_url
- `--extra-json`:扩展 JSON
- `--profile-dir`:高级调试用,不推荐
输出 JSON,包含 `id`、`profile_dir`、`url` 等。 ---
## account add-secret ## account copy / update(数据管理)
```bash ```bash
# env 方式 python main.py account copy 3
python main.py account add-secret \ python main.py account update --id 4 --label "抖音 备用" --login-id 13900001111 --status active
--platform deepseek \ ```
--credential-type api_key \
--label "DeepSeek env key" \
--secret-storage env \
--secret-ref DEEPSEEK_API_KEY \
--environment production \
--role api
# windows_credential 方式 - `copy`:复制平台、环境、角色、入口链接、登录策略;**新建空 Profile**;登录账号留空;名称自动「副本」/「副本 2」。不复制凭证、设备指纹、会话状态。
echo "sk-test-1234" | python main.py account add-secret \ - `update`:只改名称、登录账号、标识类型、入口链接、状态。空入口链接回退为平台默认 URL。
--platform deepseek \ - 数据管理「新增」只填平台 / 账号名称 / 登录账号;「编辑」走 `replaces: update` 短表单;行内「复制」为 `interaction: direct`。
--credential-type api_key \
--label "DeepSeek production key" \ ---
--secret-storage windows_credential \
## 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 \ --secret-stdin \
--environment production \ --label "工行仿真主账号"
--role api
``` ```
- `--secret-storage`:`env` / `windows_credential` / `none` - `local_encrypted` **必须** `--secret-stdin`(否则 **`ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN`**)。
- `--secret-stdin`:从 stdin 读取 secret(仅 windows_credential) - 允许的 `credential_type`:`password`、`api_key`、`api_token`、`bearer_token`、`refresh_token`、`oauth_client`(`browser_profile` / `note` 会被 **`ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED`** 拒绝)。
- `--secret-ref`:环境变量名(仅 env) - stdin 为空:**`ERR_LOCAL_ENCRYPTED_EMPTY_STDIN`**。
- `none` 仅允许 `credential_type=browser_profile` 或 `note` - master key 未就绪:**`ERR_MASTER_KEY_MISSING`**。
- 成功:`{"success": true, "data": {"credential_id", "account_id", "platform_key", "credential_type", "secret_storage", "secret_mask"}}`。
输出 JSON,包含 `account_id`(未指定 `--account-id` 时会自动创建一条用于挂载凭据的账号记录)、`credential_id`、`secret_mask`、`secret_ref`,**不包含 secret 明文**。 ---
## account pick-web ## account get-credential(示例)
```bash ```bash
python main.py account pick-web \ # 不 reveal:仅返回 metadata + mask
--platform maersk \ python main.py account get-credential 42
--environment simulator \
--tenant-id tenant-demo \ # reveal:必须带合法 lease-token(通常来自 pick-web --lease)
--role booking \ LEASE=$(python main.py account pick-web --platform icbc_sim --lease --holder my-rpa | jq -r .lease_token)
--lease \ python main.py account get-credential 42 --reveal --lease-token "$LEASE"
--ttl-sec 900 \
--holder monitor-competitor-rates \
--purpose maersk_sim_rpa
``` ```
- 筛选 status=active 且未被 active lease 占用的账号 - 无 `--reveal`:单行 JSON,`success`/`account_id`/`credential_id`/`secret_storage`/`secret_mask` 等。
- 优先 session_status != expired - `--reveal`:`secret_storage=local_encrypted` 等在 lease 校验通过后追加 **`plaintext`**;若底层存储为 `none`,返回 **`plaintext: null`** 及说明字段(并非抛错)。
- 优先 last_used_at 为空的账号 - 常见错误:`ERR_LEASE_INVALID`、`ERR_CREDENTIAL_MISSING`、`ERR_MASTER_KEY_MISSING`、`ERR_CREDENTIAL_DECRYPT_FAILED`(见 [ERRORS.md](ERRORS.md))。
- `--lease`:创建 lease,返回 `lease_token` 和 `lease_expires_at`
- `--ttl-sec`:租约时长,默认 900 秒
## credential pick ---
成功时输出单行 JSON:`{"success": true, ...fields }`;失败时为 [ERRORS.md](ERRORS.md) 的机器 JSON。 ## account export-credentials / import-credentials(示例)
```bash ```bash
# 默认不返回 secret # 备份(stdout 为 JSON 数组,含解密后的 plaintext;务必妥善销毁)
python main.py credential pick --platform deepseek --type api_key --environment production --role api python main.py account export-credentials --plaintext > backup-2026-05-11.json
# --reveal 才返回 secret 明文 # 在新机器恢复(需先 init-master-key,使 master key 可用)
python main.py credential pick --platform deepseek --type api_key --environment production --role api --reveal python main.py account import-credentials --replace-all < backup-2026-05-11.json
``` ```
- 未带 `--plaintext`:**`ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG`**(单行 JSON)。
- `import-credentials` 成功时 **stdout 通常为空**,进度与计数在 **stderr**。
---
## account init-master-key(示例)
```bash
# 首次初始化(写入 master.key)
python main.py account init-master-key
# 打印 key 备份到密码管理器(不落盘)
python main.py account init-master-key --print
# 从环境变量恢复写入文件
ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-master-key --from-env
```
- 文件已存在且未 `--rotate`:**`ERR_MASTER_KEY_EXISTS`**(JSON)。
- `--from-env` 字符串非法:**`ERR_MASTER_KEY_INVALID`**。
---
## account pick-web(字段)
成功时为 **单行 JSON 对象**(非 `_ok_json` 包裹)。核心字段:
- `id`, `platform_key`, `account_label`, `login_id`, `profile_dir`, `url`, `tenant_id`, `environment`, `role`, `provider_code`, `session_status`
- **v2**:`auth_strategy`, `session_persistent`, `device_fingerprint`
- 带 `--lease`:`lease_token`, `lease_expires_at`(ISO8601 **本地时间**字符串,由内部 Unix 秒字段转换而来)
---
## 9 种 `auth_strategy` 简表(参考)
| 策略键 | 适用场景 | 是否要 plaintext(典型) | 是否要人介入 |
|---|---|---|---|
| `password_auto` | 标准账密表单 | 是 | 否 |
| `password_with_captcha` | 带图形验证码 | 是 | 是(填码阶段) |
| `password_plus_2fa` | 短信/邮件/OTP 2FA | 是 | 是(OTP 阶段) |
| `qr_code_manual` | 扫码登录 | 否 | 是(扫码阶段) |
| `per_session_manual` | 银行/政务等每次手动 | 否 | 是 |
| `device_bound` | 绑机器 / U-Key 等 | 可选(有密码则自动填) | 视情况 |
| `client_certificate` | 客户端证书 + PIN | — | 不可自动化(rpa_helpers 抛错) |
| `api_token` | 无浏览器登录 | 否 | 否 |
| `sso_redirect` | OAuth / SSO 跳转 | 否 | 是(SSO 页阶段) |
业务 skill 同进程调用详见 [RPA_HELPERS.md](RPA_HELPERS.md)。
---
## 通用说明 ## 通用说明
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**展示名、别名或英文键**(如 `sohu`、`搜狐号`、`maersk`、`马士基`)。详见 [PLATFORMS.md](PLATFORMS.md)。 - **平台参数**:已注册的英文键、或能唯一对应到一个键的 `display_name`/alias(不区分大小写)。冲突别名(如「公众号」同时对应 `wechat` / `wechat_mp`)会返回 `AMBIGUOUS_PLATFORM`,请改传明确键。未注册的键**不会**自动插入 `platforms` 表。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**:库文件、profile 目录由环境变量决定。详见 [RUNTIME.md](RUNTIME.md)。 - **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
- **机器可读命令**:成功时输出单行 JSON;失败时首行 `ERROR:`。详见 [INTEGRATION.md](INTEGRATION.md)。 - **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码**:见 [ERRORS.md](ERRORS.md)。 - **错误码**:见 [ERRORS.md](ERRORS.md)。
## 内置 `_USAGE`(阶段 F)
无参数运行 CLI(例如 `python scripts/main.py` / `python -m cli.app`,cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。

View File

@@ -1,23 +1,27 @@
# 终端错误码(account-manager) # 终端错误码(account-manager)
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。 约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON,`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定。
## 新增机器 JSON 失败格式 ## 机器 JSON 失败格式(常见)
```json ```json
{ {
"success": false, "success": false,
"error": { "error": {
"code": "ERROR:...", "code": "……",
"message": "..." "message": "……"
} }
} }
``` ```
部分 CLI(例如遗留 `ERROR:INVALID_PLATFORM`)仍可能在成功行前额外打印 hint——请以 [`INTEGRATION.md`](INTEGRATION.md) 的建议解析顺序为准。
---
## ERROR:CLI — CLI 参数 / 路由 ## ERROR:CLI — CLI 参数 / 路由
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 | | `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id | | `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id | | `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
@@ -28,34 +32,50 @@
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 | | `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 | | `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
## ERROR: — 数据库 ---
## ERROR: — 数据库 / 通用
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:DB_ERROR` | SQLite 写入失败等 | | `ERROR:DB_ERROR` | SQLite 写入失败等 |
---
## ERROR: — 平台与账号 ## ERROR: — 平台与账号
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 | | `ERROR:INVALID_PLATFORM` | 平台名为空,或中文/人话名称无法对应到已注册平台 |
| `ERROR:INVALID_PLATFORM_KEY` | 键格式非法(大写、空格、中文当键等);展示名不能当键 |
| `ERROR:PLATFORM_NOT_REGISTERED` | 键格式合法但尚未 `platform ensure`;**已拒绝写入**,不会自动建平台 |
| `ERROR:AMBIGUOUS_PLATFORM` | 展示名/别名对应多个键(如「公众号」→ `wechat` 与 `wechat_mp`),请改传明确键 |
| `ERROR:PLATFORM_ALIAS_CONFLICT` | `platform ensure` 的展示名/别名已被其它平台占用 |
| `ERROR:INVALID_PLATFORM_DISPLAY_NAME` | `platform ensure` 未提供 `--display-name` |
| `ERROR:PLATFORM_DISABLED` | 平台已禁用 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 | | `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 | | `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled | | `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含:均被 active 未过期 lease 占用时) | | `ERROR:NO_ACCOUNT` | `pick-web` / `resolve` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
| `ERROR:AMBIGUOUS_ACCOUNT` | `resolve` / 带 `--login-id`/`--label` 的 `pick-web`:匹配到多条,需改用 `--id` 或补 `--platform` |
| `ERROR:LEASE_CONFLICT` | `ensure-web` / `pick-web --ensure`:已有账号但均被租约占用(**不会**自动再建);或同账号重复 acquire lease |
---
## ERROR: — 租约 ## ERROR: — 租约
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease | | `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 | | `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约(含 force-release 时行不存在或已非 active) |
| `ERROR:LEASE_EXPIRED` | 租约已过期 | | `ERROR:LEASE_EXPIRED` | 租约已过期 |
---
## ERROR: — 凭据与 Secret ## ERROR: — 凭据与 Secret
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 | | `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 | | `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 | | `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
@@ -65,15 +85,75 @@
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 | | `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 | | `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
---
## ERROR: — 浏览器 / 环境 ## ERROR: — 浏览器 / 环境
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |---|---|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge | | `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 | | `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
| `ERROR:REVEAL_PROFILE_FAILED` | `account reveal-profile` 无法打开目录(系统调用失败) |
## 非前缀输出 ---
- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**。 ## v2 新增错误码(机器可读 JSON,`code` 字段通常 **无** `ERROR:` 前缀)
- 失败时首行 `ERROR:`,可能有后续说明行。
- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `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

@@ -2,31 +2,64 @@
## 核心约束 ## 核心约束
业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。 业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
新平台必须先 `platform ensure --key <小写键> --display-name <展示名>` 再引用。`add-web` / `ensure-web` / `pick-web` **不会**根据 `--platform-display-name` 自动插入 `platforms` 行。
## 两种集成方式
| 方式 | 适用场景 | 调用形态 |
|---|---|---|
| **A. CLI subprocess(v1 起)** | 任意业务 skill;一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
| **B. rpa_helpers 同进程 import(v2)** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
常见组合:**CLI** 负责 `ensure-web`(或 `pick-web --ensure`)/ lease / `get-credential --reveal`;**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
业务技能首启推荐:`account ensure-web --platform <p> [--label ...] [--lease --holder <skill>]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。
指定某人读账号时:`account resolve --platform <p> --login-id <手机号或用户名>`,或 `pick-web --platform <p> --login-id <…> --lease`;多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT`。
## 推荐调用方式 ## 推荐调用方式
使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。 使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`)必须与最终用户会话一致——否则会写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)。
## 机器可读命令摘要 ## 机器可读命令摘要
| 目的 | 命令 | 成功时 stdout | | 目的 | 命令 | 成功时 stdout(摘要) |
|------|------|----------------| |---|---|---|
| 取单条账号 JSON | `account get <id>` | 单行 JSON 对象 | | 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
| 批量账号 JSON | `account list --platform <p> [--environment <e>]` | 单行 JSON 数组 | | 登记平台 | `platform ensure --key <小写键> --display-name <展示名>` | `{"ok": true, "platform_key": "...", "action": "created\|updated"}` |
| 网页自动化候选 | `account pick-web --platform <p> [--lease]` | 单行 JSON 对象 | | 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
| 获取 API Key | `credential pick --platform <p> --type api_key [--reveal]` | 单行 JSON(`success: true` + 字段) | | 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上 | | 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
| 有则用无则建 | `account ensure-web ...` / `pick-web --ensure` | **同 pick-web**;本次新建时多 `account_ensured: true` |
| 用人话定位账号 | `account resolve --login-id …` / `--label …` / `--query …` | **同 account get**(单行账号对象) |
| 取加密凭据明文 | `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 copy <id>` | `{"success": true, "message": "已复制为「…」", "data": {...}}` |
| 编辑网页账号 | `account update --id <id> ...` | `{"success": true, "message": "已保存", "data": {...}}` |
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
## 解析协议 更完整的命令表见 [`CLI.md`](CLI.md)。
1. 读取子进程 **stdout** 首行。 ## 解析协议(稳妥做法)
2. 若首行以 **`ERROR:`** 开头 → 失败。
3. 否则对首行做 `json.loads`。 1. 读取子进程 **stdout**;若有多行,**集成应以首行为准**(export/import 例外:`export` 整块 stdout 即 JSON;`import` 读 stderr)。
2. **优先尝试 `json.loads`**:
- 若为 dict 且 `success is False` → 读取 `error.code` / `error.message`。
- 若 dict 且 `success is True` 且存在 `data` → 业务字段通常在 `data` 内(`add-web` / `add-secret` / `platform`)。
3. 若 JSON 首字段不是上述统一格式(例如 `pick-web`)→ 直接按字段读取。
4. **遗留文本失败**:首行以 **`ERROR:`** 开头 → 视为失败(旧别名路径仍可能出现)。
## JSON 对象字段 ## JSON 对象字段
### account get / pick-web ### `account pick-web`(典型)
```json ```json
{ {
@@ -34,26 +67,28 @@
"platform_key": "maersk", "platform_key": "maersk",
"account_label": "Maersk simulator booking", "account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com", "login_id": "demo@maersk.com",
"login_id_type": "email", "profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"tenant_id": "tenant-demo", "tenant_id": "tenant-demo",
"environment": "simulator", "environment": "simulator",
"role": "booking", "role": "booking",
"provider_code": "maersk", "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", "session_status": "unknown",
"last_used_at": "2026-05-01T10:00:00", "auth_strategy": "qr_code_manual",
"created_at": "2026-05-01T09:00:00", "session_persistent": 1,
"updated_at": "2026-05-01T09:00:00" "device_fingerprint": null,
"lease_token": "…………",
"lease_expires_at": "2026-05-11T12:34:56"
} }
``` ```
pick-web 额外字段(带 lease 时): `lease_*` 仅在传入 `--lease` 时出现;时间与仓库 `_unix_to_iso_local` 一致。
- `lease_token`: 租约 token
- `lease_expires_at`: 过期时间
### credential pick ### `account get`
输出即数据库映射字典(字段与 [`SCHEMA.md`](SCHEMA.md) `accounts` 一致,`extra_json` 等为结构化 JSON)。
### `credential pick`
```json ```json
{ {
@@ -69,13 +104,48 @@ pick-web 额外字段(带 lease 时):
} }
``` ```
**只有 `--reveal` 时才返回 `secret` 字段。业务 skill 不要向最终用户展示 reveal 输出。** 仅 `--reveal` 时附加 **`secret`**。请勿向终端用户打印 reveal 结果。
失败时使用统一 JSON:`{"success": false, "error": {"code": "ERROR:...", "message": "..."}}`(见 [ERRORS.md](ERRORS.md))。 ### `account get-credential`
成功示例:
```json
{
"success": true,
"account_id": 42,
"credential_id": 7,
"credential_type": "password",
"secret_storage": "local_encrypted",
"secret_mask": "****abcd",
"plaintext": "……"
}
```
失败时使用统一 JSON:`{"success": false, "error": {"code": "...", "message": "..."}}`(亦可能夹杂历史 `ERROR:` 文本命令——见 [`ERRORS.md`](ERRORS.md))。
## rpa_helpers 同进程使用(摘要)
```python
import sys
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
sys.path.insert(0, f"{AM_ROOT}/scripts")
from service.rpa_helpers import (
launch_browser_with_profile,
close_browser,
ensure_logged_in,
)
```
**务必**:`sys.path` 指向 **`account-manager/scripts`**,因此 import 形态为 **`from service.rpa_helpers ...`**(而不是 `from scripts.service.rpa_helpers ...`)。
完整 API、登录判定 hook、`LoginResult` / `AuthStrategyError` 说明见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
## 业务流程示例 ## 业务流程示例
### 物流 RPA ### 物流 RPA(CLI)
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill` 1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
2. 解析 JSON → 取 `id`、`profile_dir`、`url`、`lease_token` 2. 解析 JSON → 取 `id`、`profile_dir`、`url`、`lease_token`
@@ -83,12 +153,71 @@ pick-web 额外字段(带 lease 时):
4. 业务完成后:`lease release <lease_token>` 4. 业务完成后:`lease release <lease_token>`
5. 如遇登录失效:`account mark-session <id> --session-status needs_login` 5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
### LLM API 调用 ### LLM API 调用(CLI)
1. `credential pick --platform deepseek --type api_key --environment production --reveal` 1. `credential pick --platform deepseek --type api_key --environment production --reveal`
2. 解析 JSON → 取 `secret` 字段 2. 解析 JSON → 取 `secret` 字段
3. 在业务进程内存中使用,不写文件、不打日志 3. 仅在内存中使用,不写磁盘、不打日志
### 加密密码 + 自动登录(CLI + rpa_helpers)
```python
import json
import subprocess
import sys
AM = r"D:\OpenClaw\client-commons\account-manager"
PY = sys.executable
MAIN = f"{AM}/scripts/main.py"
sys.path.insert(0, f"{AM}/scripts")
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
def am_json(argv: list[str]) -> dict:
r = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
line = (r.stdout or "").splitlines()[0] if r.stdout else ""
return json.loads(line)
# 1) pick 账号 + lease
acc = am_json(
[
"account",
"pick-web",
"--platform",
"icbc_sim",
"--lease",
"--holder",
"my-rpa",
]
)
# 2) 取明文密码(必须 reveal + lease)
cred = am_json(
[
"account",
"get-credential",
str(acc["id"]),
"--reveal",
"--lease-token",
acc["lease_token"],
]
)
# 3) 浏览器 + 登录
ctx, page = launch_browser_with_profile(acc["profile_dir"])
try:
result = ensure_logged_in(page, acc, credentials={"plaintext": cred["plaintext"]})
if not result.ok:
print(result.message, file=sys.stderr)
sys.exit(1)
# …业务逻辑…
finally:
close_browser(ctx)
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
```
## 退出码 ## 退出码
请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准;不要仅依赖退出码。 请以 **stdout JSON / `ERROR:` 前缀** 为准;不要仅依赖退出码。

View File

@@ -1,50 +1,108 @@
# 平台与别名(account-manager) # 平台与别名(account-manager)
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。 **权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
## 物流平台 解析:
| 英文键 | 展示名 | 供应商代码 | 别名 | - **引用**(`add-web` / `ensure-web` / `pick-web` / `platform get`):`resolve_platform_key_dynamic` — 已有键精确匹配;已有键可大小写折叠(`DeepSeek` → `deepseek`);展示名/别名仅在**唯一**对应一个键时通过。未注册键**拒绝写入**,不会自动 `INSERT`。
|--------|--------|-----------|------| - **登记**(仅 `platform ensure`):键必须符合 `^[a-z0-9][a-z0-9_-]{1,63}$` 且**本身已是小写**(`HaoKan` 非法);`--display-name` 必填;别名不得抢其它平台已占用的展示名/别名。
| `maersk` | Maersk | maersk | 马士基 |
| `cosco` | COSCO | cosco | 中远海运 |
| `msc` | MSC | msc | 地中海航运 |
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 |
| `evergreen` | Evergreen | evergreen | 长荣 |
| `cargo_wise` | CargoWise | cargo_wise | Cargo Wise |
| `freightos` | Freightos | freightos | — |
| `webcargo` | WebCargo | webcargo | Web Cargo |
| `sinotrans` | Sinotrans | sinotrans | 中外运 |
## LLM / AI 平台 `resolve_platform_key(name)` 仍是内置表的静态别名映射(无 DB、冲突别名后写覆盖),新代码请走动态解析。
| 英文键 | 展示名 | 别名 | 默认 URL | ---
|--------|--------|------|----------|
| `deepseek` | DeepSeek | — | https://chat.deepseek.com | ## 物流平台(`domain = logistics`,9)
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 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 | | `doubao` | Doubao | 豆包 | https://www.doubao.com |
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn | | `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com | | `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com | | `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com | | `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | 谷歌Gemini、Google Gemini、Bard | https://gemini.google.com | | `gemini` | Gemini | Gemini、谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
| `minimax` | MiniMax | — | — | | `minimax` | MiniMax | MiniMax | (空) |
| `openai` | OpenAI API | OpenAI、GPT、ChatGPT API | https://platform.openai.com |
| `anthropic` | Anthropic | Anthropic、Claude API | https://console.anthropic.com |
## 内容平台 ---
| 英文键 | 展示名 | 别名 | 默认 URL | ## 内容平台(`domain = content`,8)
|--------|--------|------|----------|
| `sohu` | 搜狐号 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 公众号、微信 | https://mp.weixin.qq.com |
## 解析规则 | 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `sohu` | 搜狐号 | 搜狐、搜狐号 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条、头条号 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | 知乎、知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 公众号、微信、微信公众号 | https://mp.weixin.qq.com |
| `douyin` | 抖音 | 抖音、Douyin | https://www.douyin.com |
| `wechat_mp` | 微信公众号 | 微信公众号、公众号、WeChat MP | https://mp.weixin.qq.com |
| `xiaohongshu` | 小红书 | 小红书、Xiaohongshu、RED | https://www.xiaohongshu.com |
| `bilibili` | 哔哩哔哩 | B站、哔哩哔哩、Bilibili | https://www.bilibili.com |
- 英文键本身、各 `display_name`、各 `aliases` 条目(及小写形式)均可解析到对应键。 ---
- 不区分大小写。
- 无法识别时输出 `ERROR:INVALID_PLATFORM`,并附带支持的平台列表。 ## 开发控制台(`domain = dev`,3)
- 解析函数:`resolve_platform_key(name)` — 支持 key、display_name、aliases。
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `github` | GitHub | GitHub、github | https://github.com/login |
| `aws` | AWS | AWS、Amazon Web Services、亚马逊云 | https://console.aws.amazon.com |
| `gcp` | GCP | GCP、Google Cloud、谷歌云 | https://console.cloud.google.com |
---
## 仿真平台(`domain = sim`,1)
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `icbc_sim` | 工行仿真 | 工行仿真、ICBC Simulator、ICBC SIM | http://localhost:5180 |
---
## `platform list --domain` 说明
CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cmd_platform_list`)。`dev` / `sim` 域平台请 **省略 `--domain`** 获取完整列表后在 JSON 中筛选,或直接 `platform get <key>`。
---
## 内置默认 `auth_strategy`
`db/platform_defaults.py` 中的 `PLATFORM_DEFAULT_AUTH_STRATEGY` 提供 **17** 个平台的初始映射(迁移时写入 `platforms.default_auth_strategy`,可被数据库手工覆盖):
| 平台键 | 默认 `auth_strategy` |
|---|---|
| `maersk`, `cosco`, `msc` | `qr_code_manual` |
| `douyin`, `wechat_mp`, `xiaohongshu`, `bilibili`, `toutiao`, `sohu` | `qr_code_manual` |
| `github`, `aws`, `gcp` | `password_plus_2fa` |
| `icbc_sim` | `password_auto` |
| `openai`, `anthropic`, `deepseek`, `doubao` | `api_token` |
其余注册平台若 DB 中 `default_auth_strategy` 为空,则在创建账号时回落到 `resolve_auth_strategy_for_platform`(内置映射 → 否则 `password_auto`)。业务可在 `add-web` 显式传入 `--auth-strategy`。
---
## 平台注册到数据库 ## 平台注册到数据库
`seed_platforms(conn)` 在每次 `init_db()` 后自动执行,将 `PLATFORMS` 字典 upsert 到 `platforms` 表。 `seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。
业务 skill 要加新平台:先 `platform ensure --key <小写键> --display-name <展示名> [--url …]`,再 `add-web` / `ensure-web`。不要把中文名或 `HaoKan` 这种大小写混写当成键;也不要指望 `--platform-display-name` 自动建行。

View File

@@ -1,21 +1,25 @@
--- ---
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。" description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
--- ---
# 🔐 账号与凭据管理 # 🔐 账号与凭据管理
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。 把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key,而是通过 CLI 调用获取。 > 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己散落密码、API Key,而是通过 CLI(或与 CLI 组合的同进程 [`RPA_HELPERS.md`](RPA_HELPERS.md))获取。
## 它能帮你做什么 ## 它能帮你做什么
- **登记自定义平台** — `platform ensure --key douyin --display-name 抖音`;键必须是小写英文,展示名可以是中文。其它技能不能靠 add-web 自动建平台。
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。 - **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
- **添加 API Key/Token** — 安全存储:API Key 明文存入 Windows Credential Manager 或环境变量引用,SQLite 只保存元数据和打码值。 - **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。 - **查看/列出账号** — 按平台、环境、角色等多维度筛选。
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发。 - **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发;输出含 `auth_strategy` / `session_persistent` / `device_fingerprint`。
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。 - **给 LLM 技能用** — `credential pick` 安全获取 API Key。
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。 - **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
- **加密存储密码** — Fernet 加密,密码在 SQLite 中以密文形式存在;只有持有 master key 才能解密。
- **9 种登录策略** — 账密自动、账密+图形验证码、账密+2FA、扫码、每次手动登录、设备绑定、客户端证书、API Token、SSO 跳转;业务 skill 可调用统一入口 `ensure_logged_in`(见 [`RPA_HELPERS.md`](RPA_HELPERS.md))。
- **加密备份与迁移** — `export-credentials --plaintext` 导出 JSON,`import-credentials` 在新机器重新加密写入(需先 `init-master-key`)。
## 你可以这样对它说 ## 你可以这样对它说
@@ -24,21 +28,23 @@ description: "通用 Credential & Browser Profile Manager:管理网页账号
- 「列出所有物流平台的账号。」 - 「列出所有物流平台的账号。」
- 「用浏览器打开 3 号账号看看页面。」 - 「用浏览器打开 3 号账号看看页面。」
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」 - 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
- 「我要把工行仿真账号的密码加密保存,下次 RPA 自动登录用。」
- 「给我导出所有加密凭据,明天换台电脑要用。」
## 你需要提供什么 ## 你需要提供什么
| 场景 | 你需要给的 | | 场景 | 你需要给的 |
|---|---| |---|---|
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) | | 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
| 添加 API Key | **平台名称** + **凭据类型**(api_key 等)+ **secret**(通过 stdin 或环境变量) | | 添加 API Key / 加密密码 | **平台名称** + **凭据类型**(api_key 等)+ **secret**(stdin / 环境变量引用等) |
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 | | 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) | | 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
## 它不做什么 ## 它不做什么
- 不在此处判定各网站「是否已登录」(由业务技能自行处理) - 不在此处包办所有网站的 DOM 自动化细节(具体抓取逻辑在各业务 skill)
- 不代替内容管理、发布、大模型调用等业务技能 - 不代替内容管理、发布、大模型调用等业务技能
- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值) - `windows_credential` / `env` 路径下不把明文写入 SQLite;`local_encrypted` 仅存 Fernet 密文
## Secret 存储方式 ## Secret 存储方式
@@ -47,7 +53,10 @@ description: "通用 Credential & Browser Profile Manager:管理网页账号
| `none` | 无 secret(浏览器 profile 登录态) | | `none` | 无 secret(浏览器 profile 登录态) |
| `env` | 引用环境变量名,运行时读取 | | `env` | 引用环境变量名,运行时读取 |
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager | | `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.25

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

@@ -2,58 +2,80 @@
## 数据落盘位置 ## 数据落盘位置
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**;`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。 `get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(兼容 **`JIANGCHANG_*` / `CLAW_*` 别名**);`scripts/util/runtime_paths.py` 在此基础上拼接技能子目录。
- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**;未设置且未走本地注入时,Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`。 - **数据根目录**:默认读取宿主注入变量;开发模式下未注入时可能落到 Windows `D:\jiangchang-data` 等(详见 runtime_env 实现)。
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。 - **用户 / 工作空间 id**:默认 `_anon`(以实际注入为准)。
- **技能数据目录**:`{数据根}/{用户id}/account-manager/` - **技能数据目录**:`{数据根}/{用户id}/account-manager/`
- **SQLite**:`account-manager.db` - **SQLite**:`account-manager.db`
- **Master Key**:`master.key`(仅当执行过 `account init-master-key` 且未仅用环境变量场景)
- **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/` - **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log` - **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
## 调试:为什么「list 没数据」 ## 调试:为什么「list 没数据」
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。 - 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径。 - 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(或宿主等价开关)后,CLI 会在 **stderr** 打印实际路径。
## 本地 CLI 默认值 ## 本地 CLI 默认值
各技能 `main.py` 调用 `apply_cli_local_defaults()`: 各技能 `main.py` 调用 `apply_cli_local_defaults()`:
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。 - `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
- 发版前改为 `False` 或依赖宿主注入。 - 发版前改为 `False` 或依赖宿主注入。
## Secret 存储 ## Secret 存储
| 方式 | 说明 | 适用场景 | | 方式 | 说明 | 适用场景 |
|------|------|----------| |---|---|---|
| `none` | 不存 secret | 浏览器 profile 登录态 | | `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 只存环境变量名,运行时从 `os.environ` 读取 | 轻量测试 | | `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | 真实 secret 存 Windows Credential Manager | Windows 本地正式部署 | | `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
| `local_encrypted` | Fernet 密文存 SQLite(`secret_ciphertext`),密钥来自 master key | RPA 密码落地;需先 `account init-master-key` |
- **不把 secret 明文写入 SQLite** - **禁止**把明文写入日志。
- **不在日志中记录 secret 明文** - `credential pick --reveal` / `credential resolve --reveal` 仍会输出明文 secret——仅限受控环境。
- 只有 `credential pick --reveal` 或 `credential resolve --reveal` 才返回 secret
## Master Key
| 变量 | 作用 |
|---|---|
| `ACCOUNT_MANAGER_MASTER_KEY` | Fernet key(典型为 url-safe Base64 **44** 字符);**优先级高于** `master.key` 文件 |
**加载优先级(解密路径)**:环境变量 → `master.key` 文件 → `ERR_MASTER_KEY_MISSING`。
**安全建议**
- 生产环境优先 **环境变量 / Secret Manager** 注入,不在磁盘留 `master.key`。
- 桌面环境可使用文件(默认尽力写入私有权限)。
- 备份 master key 至密码管理器;**丢失将导致所有 `local_encrypted` 凭据不可解密**。
详见 [`CLI.md`](CLI.md)(`init-master-key`)、[`ERRORS.md`](ERRORS.md)。
## 日志 ## 日志
| 变量 | 作用 | | 变量 | 作用 |
|------|------| |---|---|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` | | `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr | | `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 | | `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` | | `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 | | `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 浏览器(open) ## 浏览器(`account open` / rpa_helpers)
- 本机已安装 Chrome 或 Edge - 本机需安装 Chrome 或 Edge。
- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含) - Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
- 使用 **channel 模式**(`channel=chrome|msedge`),**不下载 Chromium** - 使用 **channel 模式**(`chrome` / `msedge`),**不下载 Chromium**。
- **不要执行 `playwright install chromium`** - **不要执行** `playwright install chromium`。
### 关键浏览器参数 ### 关键浏览器参数(摘录)
- `headless=False` - `headless=False`
- `no_viewport=True` - `no_viewport=True`
- `args` 包含 `--start-maximized` - `args` 包含 `--start-maximized`
- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制) - stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`)
### rpa_helpers
登录编排封装见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。

View File

@@ -2,121 +2,143 @@
## 数据库路径 ## 数据库路径
`{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)。)
路径解析细节见 [RUNTIME.md](RUNTIME.md)。
## Schema 版本 ## Schema 版本
`PRAGMA user_version = 2` - `PRAGMA user_version = 2`
- `_schema_meta.schema_version = "2.1"`(字符串;迁移完成后取值)
## 1. platforms — 平台注册表 启动时 `ensure_schema` 自动执行 `migrate_v1_to_v2` 与 `migrate_v2_to_v2_1`,幂等。
---
## 1. `platforms` — 平台注册表
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |---|---|---|
| `platform_key` | TEXT PK | 平台唯一键(maersk / deepseek / sohu 等) | | `platform_key` | TEXT PK | 平台唯一键(`maersk` / `deepseek` / `sohu` …) |
| `display_name` | TEXT NOT NULL | 展示名(Maersk / DeepSeek / 搜狐号) | | `display_name` | TEXT NOT NULL | 展示名 |
| `domain` | TEXT NOT NULL | 业务域:logistics / llm / content / ecommerce / generic | | `domain` | TEXT NOT NULL | `logistics` / `llm` / `content` / `dev` / `sim` / …(以代码注册为准) |
| `provider_code` | TEXT | 供应商代码,物流 profile 可复用 | | `provider_code` | TEXT | 供应商代码 |
| `default_url` | TEXT | 默认入口 URL | | `default_url` | TEXT | 默认入口 URL |
| `aliases_json` | TEXT | 别名 JSON 数组 | | `aliases_json` | TEXT | 别名 JSON 数组 |
| `capabilities_json` | TEXT | 能力声明 JSON(`{"web":true,"api_key":true,"rpa":true}`) | | `capabilities_json` | TEXT | `{"web": bool, "api_key": bool, "rpa": bool}` |
| `enabled` | INTEGER | 是否启用(1=yes 0=no) | | `enabled` | INTEGER | 是否启用(1/0) |
| `default_auth_strategy` | TEXT | 新账号默认 `auth_strategy`;可由迁移回填 |
| `created_at` | INTEGER | Unix 秒 UTC | | `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC | | `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_platforms_domain`, `idx_platforms_provider_code` **索引:** `idx_platforms_domain`,`idx_platforms_provider_code`
## 2. accounts — 账号身份 ---
## 2. `accounts` — 账号身份
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |---|---|---|
| `id` | INTEGER PK AUTO | 主键 | | `id` | INTEGER PK AUTO | 主键 |
| `platform_key` | TEXT NOT NULL | 对应 platforms.platform_key | | `platform_key` | TEXT NOT NULL | 对应 `platforms.platform_key` |
| `account_label` | TEXT NOT NULL | 人可读名称 | | `account_label` | TEXT NOT NULL | 人可读名称 |
| `login_id` | TEXT | 登录标识(email/phone/username/customer_code),可空 | | `login_id` | TEXT | 登录标识(可空) |
| `login_id_type` | TEXT | email / username / phone / customer_code / api_account / unknown | | `login_id_type` | TEXT | `email` / `username` / `phone` / … |
| `tenant_id` | TEXT | 租户/项目标识,可空 | | `tenant_id` | TEXT | 租户,可空 |
| `environment` | TEXT | simulator / staging / production / test | | `environment` | TEXT | `simulator` / `staging` / `production` / … |
| `role` | TEXT | booking / tracking / admin / sales / api / default | | `role` | TEXT | `booking` / `tracking` / `api` / … |
| `provider_code` | TEXT | 冗余供应商码 | | `provider_code` | TEXT | 冗余供应商码 |
| `profile_dir` | TEXT | Playwright 持久化浏览器 profile 目录 | | `profile_dir` | TEXT | Playwright profile 目录 |
| `url` | TEXT | 账号默认入口 URL | | `url` | TEXT | 账号入口 URL |
| `status` | TEXT | active / disabled / needs_review | | `status` | TEXT | `active` / `disabled` / … |
| `session_status` | TEXT | unknown / likely_valid / needs_login / expired | | `session_status` | TEXT | `unknown` / `likely_valid` / … |
| `last_used_at` | INTEGER | 最近 pick/使用时间 | | `last_used_at` | INTEGER | Unix 秒 |
| `last_login_check_at` | INTEGER | 最近登录检查时间 | | `last_login_check_at` | INTEGER | Unix 秒 |
| `last_error_code` | TEXT | 最近错误代码 | | `last_error_code` | TEXT | 文本错误码 |
| `last_error_message` | TEXT | 最近错误消息 | | `last_error_message` | TEXT | 错误说明 |
| `extra_json` | TEXT | 扩展字段 JSON | | `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 | | `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC | | `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_accounts_platform`, `idx_accounts_pick_web`, `idx_accounts_provider`, `idx_accounts_status` **索引:** `idx_accounts_platform`,`idx_accounts_pick_web`,`idx_accounts_provider`,`idx_accounts_status`
**唯一约束说明:** `(platform_key, login_id, tenant_id, environment, role)` 通过应用层检查,因为 SQLite 允许多条 NULL login_id。 ---
## 3. credentials — 凭据(API Key / Token / Password) ## 3. `credentials` — 凭据(API Key / Token / Password …)
**核心原则:SQLite 只保存元数据,不保存 secret 明文。**
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |---|---|---|
| `id` | INTEGER PK AUTO | 主键 | | `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER | 关联 accounts.id,可空(平台级 API Key) | | `account_id` | INTEGER | 关联 `accounts.id`,可空(历史平台级 Key) |
| `platform_key` | TEXT NOT NULL | 平台唯一键 | | `platform_key` | TEXT NOT NULL | 平台键 |
| `credential_label` | TEXT NOT NULL | 人可读名称 | | `credential_label` | TEXT NOT NULL | 展示名称 |
| `credential_type` | TEXT | api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note | | `credential_type` | TEXT NOT NULL | `api_key` / `password` / … |
| `secret_storage` | TEXT | none / env / windows_credential | | `secret_storage` | TEXT NOT NULL | `none` / `env` / `windows_credential` / `local_encrypted` |
| `secret_ref` | TEXT | env 时为环境变量名;windows_credential 时为 target name | | `secret_ref` | TEXT | `env` 变量名 / `windows_credential` target;其它后端可为空 |
| `secret_mask` | TEXT | 打码展示(sk-****abcd),不可反推 | | `secret_mask` | TEXT | 打码展示 |
| `expires_at` | INTEGER | 过期时间 | | `secret_ciphertext` | TEXT | `local_encrypted` 专用 Fernet token(v2.1 增加) |
| `status` | TEXT | active / disabled / expired / needs_rotation | | `expires_at` | INTEGER | Unix 秒 |
| `last_used_at` | INTEGER | 最近使用时间 | | `status` | TEXT | `active` / `disabled` / … |
| `extra_json` | TEXT | 扩展字段 JSON | | `last_used_at` | INTEGER | Unix 秒 |
| `created_at` | INTEGER | Unix 秒 UTC | | `extra_json` | TEXT | JSON |
| `updated_at` | INTEGER | Unix 秒 UTC | | `created_at` | INTEGER | Unix 秒 |
| `updated_at` | INTEGER | Unix 秒 |
**索引:** `idx_credentials_platform_type`, `idx_credentials_account`, `idx_credentials_status` **原则**:不把 **明文** secret 写入 SQLite;`local_encrypted` 仅存 **密文**。外部后端(env / Windows CM)仍在运行时解析。
## 4. account_leases — RPA 并发锁 **索引:** `idx_credentials_platform_type`,`idx_credentials_status`,**`idx_credentials_account`(`migrate_v1_to_v2` 中 `CREATE INDEX IF NOT EXISTS`)**
防止多个任务同时打开同一个 profile_dir。 ---
## 4. `account_leases` — RPA 并发锁
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |---|---|---|
| `id` | INTEGER PK AUTO | 主键 | | `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER NOT NULL | 关联 accounts.id | | `account_id` | INTEGER NOT NULL | `accounts.id` |
| `lease_token` | TEXT UNIQUE | 随机 token,用于 release/heartbeat | | `lease_token` | TEXT UNIQUE | 随机 token |
| `holder` | TEXT NOT NULL | 持有者标识 | | `holder` | TEXT NOT NULL | 持有者标识 |
| `purpose` | TEXT | 用途说明 | | `purpose` | TEXT | 用途 |
| `expires_at` | INTEGER NOT NULL | 过期时间 | | `expires_at` | INTEGER NOT NULL | Unix 秒 UTC |
| `heartbeat_at` | INTEGER | 最近心跳时间 | | `heartbeat_at` | INTEGER | Unix 秒 |
| `released_at` | INTEGER | 主动释放时间 | | `released_at` | INTEGER | Unix 秒 |
| `status` | TEXT | active / released / expired | | `status` | TEXT | `active` / `released` / `expired` |
| `created_at` | INTEGER | Unix 秒 UTC | | `created_at` | INTEGER | Unix 秒 |
| `updated_at` | INTEGER | Unix 秒 UTC | | `updated_at` | INTEGER | Unix 秒 |
**索引:** `idx_leases_account_status`, `idx_leases_token`, `idx_leases_expires` **索引:** `idx_leases_account_status`,`idx_leases_token`,`idx_leases_expires`
**约束:** ---
- acquire 前先把过期 active lease 标记为 expired
- 同一 account_id 同时只能有一个 active 未过期 lease ## 5. `_schema_meta` — Schema 元数据
- release 必须校验 lease_token
| 字段 | 类型 | 说明 |
|---|---|---|
| `key` | TEXT PRIMARY KEY | 键名 |
| `value` | TEXT NOT NULL | 值 |
**已知键**
- `schema_version` — 逻辑 schema 版本字符串:`migrate_v1_to_v2` 完成后写入 `"2"`,`migrate_v2_to_v2_1` 完成后更新为 **`"2.1"`**。
---
## Profile 文件布局 ## Profile 文件布局
``` ```
{DATA_ROOT}/{USER_ID}/account-manager/ {DATA_ROOT}/{USER_ID}/account-manager/
├── account-manager.db ├── account-manager.db
├── master.key # 若执行过 init-master-key(也可用环境变量代替)
└── profiles/ └── profiles/
└── {domain}/ └── {domain}/
└── {platform_key}/ └── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/ └── {safe_label}[_{login_id_suffix}]/
``` ```
删除账号时实现会尽量同时删除对应目录。 删除账号时实现会尽量同步删除对应目录。
## 与 JSON 输出的对应关系 ## 与 JSON 输出的对应关系
`account get/list/pick-web` 和 `credential pick` 的 JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`。 CLI JSON 字段与上表对应;若有封装层(`_ok_json`),详见 [`CLI.md`](CLI.md) / [`INTEGRATION.md`](INTEGRATION.md)。

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()] [CmdletBinding()]
param( param(
[string]$Prefix = "v", [string]$Prefix = "v",
[string]$Message = "正式发布", [string]$Message = "正式发布",
[switch]$AutoCommit, [switch]$AutoCommit,
[switch]$RequireClean, [switch]$RequireClean,
[string]$CommitMessage, [string]$CommitMessage,
[switch]$DryRun [switch]$DryRun
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path function Invoke-Git {
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" param([Parameter(Mandatory = $true)][string]$Args)
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) Write-Host ">> git $Args" -ForegroundColor DarkGray
# 将 git 的 stderr 合并进 stdout,避免 PowerShell 在 $ErrorActionPreference=Stop 下
if (-not (Test-Path $sharedScript)) { # 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
throw "Shared release script not found: $sharedScript" & cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
} }
& $sharedScript @PSBoundParameters function Get-GitOutput {
exit $LASTEXITCODE 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

@@ -3,41 +3,56 @@
CLI entry point: argv dispatch for account-manager v2. CLI entry point: argv dispatch for account-manager v2.
Subcommand tree: Subcommand tree:
platform list/get platform list/get/ensure
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used account add-web|copy|update|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile
init-master-key|export-credentials|import-credentials
credential pick|resolve credential pick|resolve
lease release|list|cleanup lease release|force-release|list|cleanup
(legacy) add|list|get|list-json|pick-web|open|delete (legacy) add|list|get|list-json|pick-web|open|delete
Machine commands output single-line JSON where appropriate. Machine commands output single-line JSON where appropriate.
""" """
import sys 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 ( from service.account_service import (
cmd_account_add_secret, cmd_account_add_secret,
cmd_account_add_web, cmd_account_add_web,
cmd_account_copy,
cmd_account_ensure_web,
cmd_account_update,
cmd_account_get, cmd_account_get,
cmd_account_get_credential,
cmd_account_list, cmd_account_list,
cmd_account_mark_error, cmd_account_mark_error,
cmd_account_mark_session, cmd_account_mark_session,
cmd_account_mark_used, cmd_account_mark_used,
cmd_account_pick_web, cmd_account_pick_web,
cmd_account_resolve,
cmd_credential_pick, cmd_credential_pick,
cmd_credential_resolve, cmd_credential_resolve,
cmd_delete_by_id, cmd_delete_by_id,
cmd_delete_by_platform, cmd_delete_by_platform,
cmd_delete_by_platform_phone, cmd_delete_by_platform_phone,
cmd_get, cmd_get,
cmd_init_db,
cmd_lease_cleanup, cmd_lease_cleanup,
cmd_lease_force_release,
cmd_lease_list, cmd_lease_list,
cmd_lease_release, cmd_lease_release,
cmd_list, cmd_list,
cmd_list_json, cmd_list_json,
cmd_pick_web, cmd_pick_web,
cmd_platform_ensure,
cmd_platform_get, cmd_platform_get,
cmd_platform_list, cmd_platform_list,
) )
from service.browser_service import cmd_open from service.browser_service import cmd_open, cmd_reveal_profile
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging 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.platforms import _platform_list_cn_for_help, resolve_platform_key
@@ -49,30 +64,56 @@ from util.runtime_paths import _maybe_print_paths_debug_cli
_USAGE = """account-manager v2 — Credential & Browser Profile Manager _USAGE = """account-manager v2 — Credential & Browser Profile Manager
运行时 / 数据管理:
init-db # 幂等初始化本地库与数据管理中文元数据(宿主安装/升级会调用)
平台管理: 平台管理:
platform list [--domain logistics|llm|content] platform list [--domain logistics|llm|content]
platform get <platform> 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> account add-web --platform <p> [--login-id <id>] [--login-id-type <type>]
[--label <label>] [--tenant-id <t>] [--environment <env>] [--label <label>] [--tenant-id <t>] [--environment <env>]
[--role <role>] [--provider-code <code>] [--url <url>] [--role <role>] [--provider-code <code>] [--url <url>]
[--extra-json <json>] [--profile-dir <path>] [--extra-json <json>] [--profile-dir <path>]
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
account copy <id>
account update --id <id> [--label <label>] [--login-id <id>]
[--login-id-type <type>] [--url <url>] [--status active|disabled|needs_review]
account add-secret --platform <p> --credential-type <type> account add-secret --platform <p> --credential-type <type>
--secret-storage env|windows_credential|none --secret-storage env|windows_credential|none|local_encrypted
[--secret-ref <env_var>] [--secret-stdin] [--secret-ref <env_var>] [--secret-stdin]
[--label <label>] [--account-id <id>] [--label <label>] [--account-id <id>]
[--environment <env>] [--role <role>] [--environment <env>] [--role <role>]
account get <id> [--with-credentials] account get <id> [--with-credentials]
account get-credential <account_id> [--reveal --lease-token <token>]
account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>]
[--label <备注>] [--query <id或login_id或备注>]
# 用人话定位一条账号(成功输出同 account get)
account list [--platform <p>] [--environment <env>] account list [--platform <p>] [--environment <env>]
[--tenant-id <t>] [--role <role>] [--limit N] [--tenant-id <t>] [--role <role>]
[--login-id <id>] [--label <label>] [--limit N]
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
[--role <role>] [--lease] [--ttl-sec 900] [--role <role>] [--login-id <手机号/用户名>] [--label <备注>]
[--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>] [--holder <holder>] [--purpose <purpose>]
[--ensure] # 同 ensure-web:无账号时自动登记再 pick
account ensure-web --platform <p> [--login-id <手机号/用户名>] [--label <备注>]
[--environment <env>] [--tenant-id <t>] [--role <role>]
[--lease] [--ttl-sec 900] [--holder <h>] [--purpose <p>]
# 有账号则 pick;零账号则按参数 add-web 后再 pick
# 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT
# 指定 --login-id/--label 时按该人读标识筛选/创建
account mark-session <id> --session-status <status> account mark-session <id> --session-status <status>
account mark-error <id> --code <code> --message <msg> account mark-error <id> --code <code> --message <msg>
account mark-used <id> account mark-used <id>
account open <id> account open <id>
account reveal-profile <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>] credential pick --platform <p> [--type <type>] [--environment <env>]
@@ -81,11 +122,13 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
租约管理: 租约管理:
lease release <lease_token> lease release <lease_token>
lease force-release --id <租约编号>
lease list [--active] lease list [--active]
lease cleanup lease cleanup
浏览器: 浏览器 / Profile:
account open <id> account open <id>
account reveal-profile <id> # 在文件管理器中打开 profile_dir
open <id> # 兼容别名 open <id> # 兼容别名
兼容入口(旧版): 兼容入口(旧版):
@@ -125,6 +168,48 @@ def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
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 # Main dispatch
# ========================================================================= # =========================================================================
@@ -145,10 +230,14 @@ def main() -> None:
# Determine if first arg is a platform key (for legacy shorthand) # Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd) plat_from_first = resolve_platform_key(cmd)
# ---- init-db(宿主 install/upgrade 标准入口)----
if cmd == "init-db":
sys.exit(cmd_init_db())
# ---- platform subcommand ---- # ---- platform subcommand ----
if cmd == "platform": if cmd == "platform":
if len(av) < 3: if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get") print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get | ensure")
sys.exit(1) sys.exit(1)
sub = av[2] sub = av[2]
if sub == "list": if sub == "list":
@@ -159,21 +248,57 @@ def main() -> None:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。") print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1) sys.exit(1)
cmd_platform_get(av[3]) 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,
domain=args.domain,
url=args.url,
auth_strategy=args.auth_strategy,
aliases=args.aliases,
capabilities=args.capabilities,
))
else: else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。") print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list、get 或 ensure。")
sys.exit(1) sys.exit(1)
# ---- account subcommand ---- # ---- account subcommand ----
elif cmd == "account": elif cmd == "account":
if len(av) < 3: if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。") print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / ensure-web / get / …)。")
print(_USAGE) print(_USAGE)
sys.exit(1) sys.exit(1)
sub = av[2] sub = av[2]
if sub == "add-web": if sub == "add-web":
_cmd_add_web(av) _cmd_add_web(av)
elif sub == "copy":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account copy 需要 <id>。")
sys.exit(1)
cmd_account_copy(av[3])
elif sub == "update":
_cmd_account_update(av)
elif sub == "ensure-web":
_cmd_ensure_web(av)
elif sub == "add-secret": elif sub == "add-secret":
_cmd_add_secret(av) _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": elif sub == "get":
if len(av) < 4: if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。") print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
@@ -183,6 +308,8 @@ def main() -> None:
cmd_account_get(aid, include_credentials=with_creds) cmd_account_get(aid, include_credentials=with_creds)
elif sub == "list": elif sub == "list":
_cmd_account_list(av) _cmd_account_list(av)
elif sub == "resolve":
_cmd_account_resolve(av)
elif sub == "pick-web": elif sub == "pick-web":
_cmd_pick_web_new(av) _cmd_pick_web_new(av)
elif sub == "mark-session": elif sub == "mark-session":
@@ -211,6 +338,17 @@ def main() -> None:
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。") print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
sys.exit(1) sys.exit(1)
cmd_open(av[3]) cmd_open(av[3])
elif sub == "reveal-profile":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account reveal-profile 需要 <id>。")
sys.exit(1)
cmd_reveal_profile(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: else:
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。") print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
print(_USAGE) print(_USAGE)
@@ -233,7 +371,7 @@ def main() -> None:
# ---- lease subcommand ---- # ---- lease subcommand ----
elif cmd == "lease": elif cmd == "lease":
if len(av) < 3: if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | list | cleanup") print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | force-release | list | cleanup")
sys.exit(1) sys.exit(1)
sub = av[2] sub = av[2]
if sub == "release": if sub == "release":
@@ -241,13 +379,25 @@ def main() -> None:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。") print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1) sys.exit(1)
cmd_lease_release(av[3]) cmd_lease_release(av[3])
elif sub == "force-release":
lease_id = _get_opt(av, "--id")
if lease_id is None and len(av) >= 4 and not str(av[3]).startswith("-"):
lease_id = av[3]
if lease_id is None:
print("ERROR:CLI_BAD_ARGS lease force-release 需要 --id <租约编号>。")
sys.exit(1)
try:
cmd_lease_force_release(int(lease_id))
except ValueError:
print("ERROR:CLI_BAD_ARGS 租约编号必须是数字。")
sys.exit(1)
elif sub == "list": elif sub == "list":
active = _has_flag(av, "--active") active = _has_flag(av, "--active")
cmd_lease_list(active) cmd_lease_list(active)
elif sub == "cleanup": elif sub == "cleanup":
cmd_lease_cleanup() cmd_lease_cleanup()
else: else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。") print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、force-release、list 或 cleanup。")
sys.exit(1) sys.exit(1)
# ---- Legacy aliases ---- # ---- Legacy aliases ----
@@ -343,7 +493,7 @@ def main() -> None:
def _cmd_add_web(argv: list[str]) -> None: def _cmd_add_web(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "") plat = _get_opt(argv, "--platform", "")
login_id = _get_opt(argv, "--login-id") login_id = _get_opt(argv, "--login-id")
login_id_type = _get_opt(argv, "--login-id-type", "unknown") login_id_type = _get_opt(argv, "--login-id-type")
label = _get_opt(argv, "--label") label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id") tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production") environment = _get_opt(argv, "--environment", "production")
@@ -352,10 +502,22 @@ def _cmd_add_web(argv: list[str]) -> None:
url = _get_opt(argv, "--url") url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json") extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir") 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: if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。") print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_add_web( cmd_account_add_web(
platform_input=plat, platform_input=plat,
login_id=login_id, login_id=login_id,
@@ -368,9 +530,53 @@ def _cmd_add_web(argv: list[str]) -> None:
url=url, url=url,
extra_json_str=extra_json_str, extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override, profile_dir_override=profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
) )
def _cmd_account_update(argv: list[str]) -> None:
from db.accounts_repo import _UNSET
aid = _get_opt(argv, "--id")
if not aid and len(argv) >= 4 and not str(argv[3]).startswith("-"):
aid = argv[3]
label = _get_opt(argv, "--label")
login_id_type = _get_opt(argv, "--login-id-type")
status = _get_opt(argv, "--status")
login_id = _UNSET
if "--login-id" in argv:
login_id = _get_opt(argv, "--login-id") or ""
url = _UNSET
if "--url" in argv:
url = _get_opt(argv, "--url") or ""
if not aid:
print("ERROR:CLI_BAD_ARGS account update 需要 --id <id>。")
return
cmd_account_update(
aid,
label=label,
login_id=login_id,
login_id_type=login_id_type,
url=url,
status=status,
)
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: def _cmd_add_secret(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "") plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--credential-type", "api_key") ctype = _get_opt(argv, "--credential-type", "api_key")
@@ -405,38 +611,117 @@ def _cmd_account_list(argv: list[str]) -> None:
env = _get_opt(argv, "--environment") env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id") tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role") role = _get_opt(argv, "--role")
login_id = _get_opt(argv, "--login-id")
label = _get_opt(argv, "--label")
limit = _get_int_opt(argv, "--limit", 200) limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list( cmd_account_list(
platform_input=plat, platform_input=plat,
environment=env, environment=env,
tenant_id=tenant, tenant_id=tenant,
role=role, role=role,
login_id=login_id,
label=label,
limit=limit, limit=limit,
) )
def _cmd_account_resolve(argv: list[str]) -> None:
cmd_account_resolve(
account_id=_get_opt(argv, "--id"),
platform_input=_get_opt(argv, "--platform"),
login_id=_get_opt(argv, "--login-id"),
label=_get_opt(argv, "--label"),
query=_get_opt(argv, "--query"),
)
def _cmd_pick_web_new(argv: list[str]) -> None: def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "") plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment") env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id") tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role") role = _get_opt(argv, "--role")
login_id = _get_opt(argv, "--login-id")
label = _get_opt(argv, "--label")
do_lease = _has_flag(argv, "--lease") do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900) ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder") holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose") purpose = _get_opt(argv, "--purpose")
do_ensure = _has_flag(argv, "--ensure")
if not plat: if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。") print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return return
platform_meta = _parse_platform_caller_meta(argv)
if do_ensure:
_cmd_ensure_web(argv)
return
cmd_account_pick_web( cmd_account_pick_web(
platform_input=plat, platform_input=plat,
environment=env, environment=env,
tenant_id=tenant, tenant_id=tenant,
role=role, role=role,
login_id=login_id,
label=label,
do_lease=do_lease, do_lease=do_lease,
ttl_sec=ttl, ttl_sec=ttl,
holder=holder, holder=holder,
purpose=purpose, purpose=purpose,
platform_meta=platform_meta,
)
def _cmd_ensure_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")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
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")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
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 ensure-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_ensure_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,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
) )

View File

@@ -10,7 +10,9 @@ import time
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from db.auth_strategy import ALL_AUTH_STRATEGIES, AUTH_STRATEGY_PASSWORD_AUTO
from db.connection import get_conn, init_db from db.connection import get_conn, init_db
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
@@ -32,8 +34,30 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
return None return None
_UNSET = object()
def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str:
"""Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto."""
cur = conn.cursor()
cur.execute(
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
(platform_key,),
)
row = cur.fetchone()
if row and row[0]:
s = str(row[0])
if s in ALL_AUTH_STRATEGIES:
return s
mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key)
if mapped and mapped in ALL_AUTH_STRATEGIES:
return mapped
return AUTH_STRATEGY_PASSWORD_AUTO
def _acc_row_to_dict(row) -> dict[str, Any]: def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema.""" """Convert an accounts table row tuple to a dict matching the v2 schema."""
dev_fp = row[20]
return { return {
"id": row[0], "id": row[0],
"platform_key": row[1], "platform_key": row[1],
@@ -53,8 +77,11 @@ def _acc_row_to_dict(row) -> dict[str, Any]:
"last_error_code": row[15] or "", "last_error_code": row[15] or "",
"last_error_message": row[16] or "", "last_error_message": row[16] or "",
"extra_json": _safe_json_loads(row[17]), "extra_json": _safe_json_loads(row[17]),
"created_at": _unix_to_iso_local(row[18]), "auth_strategy": row[18],
"updated_at": _unix_to_iso_local(row[19]), "session_persistent": int(row[19]) if row[19] is not None else 1,
"device_fingerprint": dev_fp if dev_fp is not None else None,
"created_at": _unix_to_iso_local(row[21]),
"updated_at": _unix_to_iso_local(row[22]),
} }
@@ -74,14 +101,14 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
cur.execute( cur.execute(
"SELECT platform_key, display_name, domain, provider_code, " "SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, " "default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key", "default_auth_strategy, created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
(domain,), (domain,),
) )
else: else:
cur.execute( cur.execute(
"SELECT platform_key, display_name, domain, provider_code, " "SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, " "default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms ORDER BY platform_key" "default_auth_strategy, created_at, updated_at FROM platforms ORDER BY platform_key"
) )
rows = cur.fetchall() rows = cur.fetchall()
result = [] result = []
@@ -95,8 +122,9 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
"aliases": _safe_json_loads(r[5], []), "aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}), "capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]), "enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]), "default_auth_strategy": r[8],
"updated_at": _unix_to_iso_local(r[9]), "created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
}) })
return result return result
@@ -106,7 +134,7 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
cur.execute( cur.execute(
"SELECT platform_key, display_name, domain, provider_code, " "SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, " "default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE platform_key = ?", "default_auth_strategy, created_at, updated_at FROM platforms WHERE platform_key = ?",
(platform_key,), (platform_key,),
) )
r = cur.fetchone() r = cur.fetchone()
@@ -121,8 +149,9 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
"aliases": _safe_json_loads(r[5], []), "aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}), "capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]), "enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]), "default_auth_strategy": r[8],
"updated_at": _unix_to_iso_local(r[9]), "created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
} }
@@ -144,20 +173,30 @@ def insert_account(
url: Optional[str], url: Optional[str],
extra_json: Optional[str], extra_json: Optional[str],
now: int, now: int,
auth_strategy: str,
session_persistent: int = 1,
device_fingerprint: Optional[str] = None,
) -> int: ) -> int:
if auth_strategy not in ALL_AUTH_STRATEGIES:
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
if session_persistent not in (0, 1):
raise ValueError("session_persistent must be 0 or 1")
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
""" """
INSERT INTO accounts INSERT INTO accounts
(platform_key, account_label, login_id, login_id_type, tenant_id, (platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url, environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at) status, session_status, extra_json,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?) auth_strategy, session_persistent, device_fingerprint,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?, ?, ?, ?)
""", """,
( (
platform_key, account_label, login_id, login_id_type, tenant_id, platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url, environment, role, provider_code, profile_dir, url,
extra_json or "{}", now, now, extra_json or "{}", auth_strategy, session_persistent, device_fingerprint,
now, now,
), ),
) )
return int(cur.lastrowid) return int(cur.lastrowid)
@@ -176,6 +215,7 @@ def get_account_by_id(account_id) -> Optional[dict]:
profile_dir, url, status, session_status, profile_dir, url, status, session_status,
last_used_at, last_login_check_at, last_used_at, last_login_check_at,
last_error_code, last_error_message, extra_json, last_error_code, last_error_message, extra_json,
auth_strategy, session_persistent, device_fingerprint,
created_at, updated_at created_at, updated_at
FROM accounts WHERE id = ? FROM accounts WHERE id = ?
""", """,
@@ -189,6 +229,26 @@ def get_account_by_id(account_id) -> Optional[dict]:
conn.close() conn.close()
def _append_human_ref_filters(
conditions: list[str],
params: list,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
table_alias: str = "",
) -> None:
"""Optional case-insensitive exact match on login_id / account_label."""
prefix = f"{table_alias}." if table_alias else ""
lid = (login_id or "").strip()
if lid:
conditions.append(f"LOWER({prefix}login_id) = LOWER(?)")
params.append(lid)
lab = (label or "").strip()
if lab:
conditions.append(f"LOWER({prefix}account_label) = LOWER(?)")
params.append(lab)
def find_accounts( def find_accounts(
platform_key: Optional[str] = None, platform_key: Optional[str] = None,
environment: Optional[str] = None, environment: Optional[str] = None,
@@ -196,6 +256,8 @@ def find_accounts(
role: Optional[str] = None, role: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
provider_code: Optional[str] = None, provider_code: Optional[str] = None,
login_id: Optional[str] = None,
label: Optional[str] = None,
limit: int = 200, limit: int = 200,
) -> list[dict]: ) -> list[dict]:
"""Find accounts by optional filters. Returns list of dicts.""" """Find accounts by optional filters. Returns list of dicts."""
@@ -223,6 +285,9 @@ def find_accounts(
if provider_code: if provider_code:
conditions.append("provider_code = ?") conditions.append("provider_code = ?")
params.append(provider_code) params.append(provider_code)
_append_human_ref_filters(
conditions, params, login_id=login_id, label=label
)
where = "" where = ""
if conditions: if conditions:
@@ -232,7 +297,9 @@ def find_accounts(
"SELECT id, platform_key, account_label, login_id, login_id_type, " "SELECT id, platform_key, account_label, login_id, login_id_type, "
"tenant_id, environment, role, provider_code, profile_dir, url, " "tenant_id, environment, role, provider_code, profile_dir, url, "
"status, session_status, last_used_at, last_login_check_at, " "status, session_status, last_used_at, last_login_check_at, "
"last_error_code, last_error_message, extra_json, created_at, updated_at " "last_error_code, last_error_message, extra_json, "
"auth_strategy, session_persistent, device_fingerprint, "
"created_at, updated_at "
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?" f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
) )
params.append(int(limit)) params.append(int(limit))
@@ -242,11 +309,48 @@ def find_accounts(
conn.close() conn.close()
def count_active_accounts(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> int:
"""Count active accounts matching filters (ignores lease occupancy)."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["status = 'active'", "platform_key = ?"]
params: list = [platform_key]
if environment:
conditions.append("environment = ?")
params.append(environment)
if tenant_id:
conditions.append("tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("role = ?")
params.append(role)
_append_human_ref_filters(
conditions, params, login_id=login_id, label=label
)
where = " AND ".join(conditions)
cur.execute(f"SELECT COUNT(*) FROM accounts WHERE {where}", params)
row = cur.fetchone()
return int(row[0] or 0) if row else 0
finally:
conn.close()
def find_account_ids( def find_account_ids(
platform_key: str, platform_key: str,
environment: Optional[str] = None, environment: Optional[str] = None,
tenant_id: Optional[str] = None, tenant_id: Optional[str] = None,
role: Optional[str] = None, role: Optional[str] = None,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> list[int]: ) -> list[int]:
"""Return account IDs matching filters, excluding those with active leases.""" """Return account IDs matching filters, excluding those with active leases."""
conn = get_conn() conn = get_conn()
@@ -264,6 +368,9 @@ def find_account_ids(
if role: if role:
conditions.append("a.role = ?") conditions.append("a.role = ?")
params.append(role) params.append(role)
_append_human_ref_filters(
conditions, params, login_id=login_id, label=label, table_alias="a"
)
# Exclude accounts with active non-expired leases # Exclude accounts with active non-expired leases
now = _now_unix() now = _now_unix()
@@ -328,6 +435,94 @@ def update_account_error(
conn.close() conn.close()
def update_account(
conn,
account_id: int,
*,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: Any = _UNSET,
) -> None:
"""Optional field updates for accounts v2 columns."""
sets: list[str] = []
params: list[Any] = []
if auth_strategy is not None:
if auth_strategy not in ALL_AUTH_STRATEGIES:
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
sets.append("auth_strategy = ?")
params.append(auth_strategy)
if session_persistent is not None:
if session_persistent not in (0, 1):
raise ValueError("session_persistent must be 0 or 1")
sets.append("session_persistent = ?")
params.append(session_persistent)
if device_fingerprint is not _UNSET:
sets.append("device_fingerprint = ?")
params.append(device_fingerprint)
if not sets:
return
sets.append("updated_at = ?")
params.append(_now_unix())
params.append(int(account_id))
cur = conn.cursor()
cur.execute(
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
tuple(params),
)
def list_account_labels(conn) -> list[str]:
cur = conn.cursor()
cur.execute("SELECT account_label FROM accounts WHERE account_label IS NOT NULL")
return [str(row[0]) for row in cur.fetchall() if row[0]]
def update_account_user_fields(
conn,
account_id: int,
*,
account_label: Optional[str] = None,
login_id: Any = _UNSET,
login_id_type: Optional[str] = None,
url: Any = _UNSET,
status: Optional[str] = None,
) -> None:
"""Patch fields the data-management edit form is allowed to change."""
sets: list[str] = []
params: list[Any] = []
if account_label is not None:
label = str(account_label).strip()
if not label:
raise ValueError("account_label must not be empty")
sets.append("account_label = ?")
params.append(label)
if login_id is not _UNSET:
text = str(login_id).strip() if login_id is not None else ""
sets.append("login_id = ?")
params.append(text or None)
if login_id_type is not None:
typed = str(login_id_type).strip() or "unknown"
sets.append("login_id_type = ?")
params.append(typed)
if url is not _UNSET:
text = str(url).strip() if url is not None else ""
sets.append("url = ?")
params.append(text)
if status is not None:
sets.append("status = ?")
params.append(status)
if not sets:
return
sets.append("updated_at = ?")
params.append(_now_unix())
params.append(int(account_id))
cur = conn.cursor()
cur.execute(
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
tuple(params),
)
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool: def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -353,19 +548,20 @@ def insert_credential(
expires_at: Optional[int], expires_at: Optional[int],
extra_json: Optional[str], extra_json: Optional[str],
now: int, now: int,
secret_ciphertext: Optional[str] = None,
) -> int: ) -> int:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
""" """
INSERT INTO credentials INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type, (account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, status, secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, status,
extra_json, created_at, updated_at) extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""", """,
( (
account_id, platform_key, credential_label, credential_type, account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at,
extra_json or "{}", now, now, extra_json or "{}", now, now,
), ),
) )
@@ -599,6 +795,59 @@ def release_lease(lease_token: str) -> bool:
conn.close() conn.close()
def release_lease_by_id(lease_id: int) -> Optional[dict]:
"""Force-release an active lease by row id. Returns released row summary or None."""
log = get_skill_logger()
log.info("lease_force_release_attempt lease_id=%s", lease_id)
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"SELECT id, account_id, lease_token, holder, purpose, status "
"FROM account_leases WHERE id = ?",
(int(lease_id),),
)
row = cur.fetchone()
if not row:
log.warning("lease_force_release_not_found lease_id=%s", lease_id)
return None
status = str(row[5] or "")
if status != "active":
log.warning(
"lease_force_release_not_active lease_id=%s status=%s",
lease_id,
status,
)
return None
cur.execute(
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
"WHERE id = ? AND status = 'active'",
(now, now, int(lease_id)),
)
conn.commit()
if cur.rowcount == 0:
return None
token = str(row[2] or "")
log.info(
"lease_force_release_success lease_id=%s account_id=%s token_prefix=%s",
lease_id,
row[1],
token[:8] if token else "",
)
return {
"id": int(row[0]),
"account_id": int(row[1]),
"lease_token": token,
"holder": row[3] or "",
"purpose": row[4] or "",
"status": "released",
"released_at": _unix_to_iso_local(now),
}
finally:
conn.close()
def list_active_leases() -> list[dict]: def list_active_leases() -> list[dict]:
"""List all active (non-expired, non-released) leases.""" """List all active (non-expired, non-released) leases."""
conn = get_conn() conn = get_conn()
@@ -654,6 +903,188 @@ def cleanup_expired_leases() -> int:
conn.close() conn.close()
def get_active_lease_by_token(conn, lease_token: str) -> Optional[dict[str, Any]]:
"""Look up a lease by token: active and not expired.
Returns dict with id, account_id, lease_token, holder, purpose, expires_at (Unix sec),
status; or None if not found / inactive / expired.
"""
cur = conn.cursor()
now = _now_unix()
cur.execute(
"""
SELECT id, account_id, lease_token, holder, purpose, expires_at, status
FROM account_leases
WHERE lease_token = ? AND status = 'active' AND expires_at > ?
""",
(lease_token, now),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"lease_token": row[2],
"holder": row[3],
"purpose": row[4],
"expires_at": int(row[5]),
"status": row[6],
}
def upsert_platform(
conn,
key: str,
display_name: str,
domain: str = "generic",
provider_code: str | None = None,
default_url: str = "",
aliases_json: str = "[]",
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
default_auth_strategy: str | None = None,
) -> None:
"""幂等地注册或更新平台到 DB。"""
now = int(time.time())
if provider_code is None:
provider_code = key
conn.execute(
"""
INSERT INTO platforms (
platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name,
domain = excluded.domain,
provider_code = excluded.provider_code,
default_url = excluded.default_url,
aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json,
enabled = 1,
default_auth_strategy = COALESCE(excluded.default_auth_strategy,
platforms.default_auth_strategy),
updated_at = excluded.updated_at
""",
(
key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
default_auth_strategy, now, now,
),
)
conn.commit()
def ensure_platform_from_caller_meta(
conn,
key: str,
*,
display_name: str,
domain: str = "generic",
default_url: str = "",
aliases: list[str] | None = None,
default_auth_strategy: str | None = None,
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
) -> str:
"""
调用方元数据自动注册平台:不存在则创建;已存在则仅填充空字段,不覆盖非空自定义值。
返回 created | updated | unchanged。
"""
now = int(time.time())
provider_code = key
alias_list: list[str] = []
for item in [key, display_name, *(aliases or [])]:
s = str(item).strip()
if s and s not in alias_list:
alias_list.append(s)
aliases_json = json.dumps(alias_list, ensure_ascii=False)
row = conn.execute(
"""
SELECT display_name, domain, provider_code, default_url, aliases_json,
capabilities_json, default_auth_strategy, enabled
FROM platforms WHERE platform_key = ?
""",
(key,),
).fetchone()
if row is None:
upsert_platform(
conn,
key=key,
display_name=display_name,
domain=domain,
provider_code=provider_code,
default_url=default_url,
aliases_json=aliases_json,
capabilities_json=capabilities_json,
default_auth_strategy=default_auth_strategy,
)
return "created"
cur_display, cur_domain, cur_provider, cur_url, cur_aliases_json, cur_caps, cur_auth, enabled = row
if not enabled:
return "unchanged"
merged_display = (cur_display or "").strip() or display_name
merged_domain = (cur_domain or "").strip() or domain
merged_provider = (cur_provider or "").strip() or provider_code
merged_url = (cur_url or "").strip() or (default_url or "").strip()
merged_auth = cur_auth or default_auth_strategy
existing_aliases = _safe_json_loads(cur_aliases_json, [])
if not isinstance(existing_aliases, list):
existing_aliases = []
merged_aliases: list[str] = []
for item in list(existing_aliases) + alias_list:
s = str(item).strip()
if s and s not in merged_aliases:
merged_aliases.append(s)
merged_aliases_json = json.dumps(merged_aliases, ensure_ascii=False)
merged_caps = cur_caps or capabilities_json
unchanged = (
merged_display == (cur_display or "")
and merged_domain == (cur_domain or "")
and merged_provider == (cur_provider or "")
and merged_url == (cur_url or "")
and merged_aliases_json == (cur_aliases_json or "[]")
and merged_auth == cur_auth
)
if unchanged:
return "unchanged"
conn.execute(
"""
UPDATE platforms SET
display_name = ?,
domain = ?,
provider_code = ?,
default_url = ?,
aliases_json = ?,
capabilities_json = ?,
default_auth_strategy = ?,
updated_at = ?
WHERE platform_key = ?
""",
(
merged_display,
merged_domain,
merged_provider,
merged_url,
merged_aliases_json,
merged_caps,
merged_auth,
now,
key,
),
)
conn.commit()
return "updated"
# ============================================================================ # ============================================================================
# Internal helpers # Internal helpers
# ============================================================================ # ============================================================================

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

@@ -14,7 +14,8 @@ import sqlite3
import time import time
from typing import Optional from typing import Optional
from db.schema import ALL_DDL, SCHEMA_VERSION from db.display_metadata import init_display_metadata
from db.schema import SCHEMA_VERSION, ensure_schema
from util.constants import SKILL_SLUG from util.constants import SKILL_SLUG
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
from util.runtime_paths import get_db_path from util.runtime_paths import get_db_path
@@ -67,13 +68,6 @@ def _check_schema_version(conn: sqlite3.Connection) -> int:
return int(row[0]) if row else 0 return int(row[0]) if row else 0
def _create_new_tables(conn: sqlite3.Connection) -> None:
"""Execute ALL_DDL (all CREATE TABLE/INDEX IF NOT EXISTS)."""
cur = conn.cursor()
cur.executescript(ALL_DDL)
conn.commit()
def init_db() -> None: def init_db() -> None:
"""Idempotent: migrate if needed, create tables, set schema version.""" """Idempotent: migrate if needed, create tables, set schema version."""
log = get_skill_logger() log = get_skill_logger()
@@ -88,10 +82,15 @@ def init_db() -> None:
backed_up = _backup_old_accounts(conn) backed_up = _backup_old_accounts(conn)
if backed_up: if backed_up:
log.info("schema_init_backup_done old_version=%s", old_version) log.info("schema_init_backup_done old_version=%s", old_version)
_create_new_tables(conn)
cur = conn.cursor() ensure_schema(conn)
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit() cur = conn.cursor()
init_display_metadata(cur)
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
if old_version < SCHEMA_VERSION:
log.info( log.info(
"schema_init_done version=%s migrated_from=%s", "schema_init_done version=%s migrated_from=%s",
SCHEMA_VERSION, SCHEMA_VERSION,
@@ -99,12 +98,6 @@ def init_db() -> None:
) )
else: else:
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION) log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
# Ensure all tables exist even if schema version matches
_create_new_tables(conn)
cur = conn.cursor()
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
except Exception: except Exception:
log.exception("schema_init_failure") log.exception("schema_init_failure")
raise raise

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,593 @@
"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。
SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
"""
from __future__ import annotations
import json
from typing import Iterable
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
PLATFORMS_TABLE = "platforms"
ACCOUNTS_TABLE = "accounts"
CREDENTIALS_TABLE = "credentials"
ACCOUNT_LEASES_TABLE = "account_leases"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
# Data-management lookup: host resolves options_json object into select options.
PLATFORM_KEY_LOOKUP_JSON = json.dumps(
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
},
ensure_ascii=False,
)
ACCOUNT_ID_LOOKUP_JSON = json.dumps(
{
"ref_table": "accounts",
"value_column": "id",
"label_column": "account_label",
},
ensure_ascii=False,
)
def _enum_options(pairs: tuple[tuple[str, str], ...]) -> str:
return json.dumps(
[{"value": value, "label": label} for value, label in pairs],
ensure_ascii=False,
)
LOGIN_ID_TYPE_OPTIONS_JSON = _enum_options(
(
("unknown", "未指定"),
("phone", "手机号"),
("email", "邮箱"),
("username", "用户名"),
("customer_code", "客户代码"),
("api_account", "API 账号"),
)
)
ENVIRONMENT_OPTIONS_JSON = _enum_options(
(
("production", "生产"),
("simulator", "仿真"),
("staging", "预发"),
("test", "测试"),
)
)
ROLE_OPTIONS_JSON = _enum_options(
(
("default", "默认"),
("booking", "订舱"),
("tracking", "跟踪"),
("api", "API"),
("admin", "管理"),
("sales", "销售"),
)
)
ACCOUNT_STATUS_OPTIONS_JSON = _enum_options(
(
("active", "启用"),
("disabled", "停用"),
("needs_review", "待复核"),
)
)
SESSION_STATUS_OPTIONS_JSON = _enum_options(
(
("unknown", "未知"),
("likely_valid", "可能有效"),
("needs_login", "需要登录"),
("expired", "已过期"),
)
)
AUTH_STRATEGY_OPTIONS_JSON = _enum_options(
(
("password_auto", "账密自动"),
("password_with_captcha", "账密+验证码"),
("password_plus_2fa", "账密+2FA"),
("qr_code_manual", "扫码"),
("per_session_manual", "每次手动"),
("device_bound", "设备绑定"),
("client_certificate", "客户端证书"),
("api_token", "API Token"),
("sso_redirect", "SSO 跳转"),
)
)
YES_NO_OPTIONS_JSON = _enum_options((("1", "是"), ("0", "否")))
ENABLED_OPTIONS_JSON = _enum_options((("1", "启用"), ("0", "停用")))
DOMAIN_OPTIONS_JSON = _enum_options(
(
("logistics", "物流"),
("llm", "大模型"),
("content", "内容"),
("dev", "开发"),
("sim", "仿真"),
("generic", "通用"),
)
)
USER_BUSINESS_TABLES = (
PLATFORMS_TABLE,
ACCOUNTS_TABLE,
CREDENTIALS_TABLE,
ACCOUNT_LEASES_TABLE,
)
_JIANGCHANG_TABLES_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
)
"""
_JIANGCHANG_COLUMNS_DDL = """
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
def _col(
column_name: str,
display_name: str,
*,
description: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
display_type: str | None = None,
options_json: str | None = None,
) -> dict[str, object]:
return {
"column_name": column_name,
"display_name": display_name,
"description": description,
"visible": visible,
"searchable": searchable,
"editable": editable,
"display_type": display_type,
"options_json": options_json,
}
def _ts(column_name: str, display_name: str, description: str) -> dict[str, object]:
return _col(
column_name,
display_name,
description=description,
searchable=0,
editable=0,
display_type=DATETIME_UNIX_SECONDS,
)
PLATFORMS_TABLE_METADATA = {
"table_name": PLATFORMS_TABLE,
"display_name": "平台信息",
"description": "已注册平台(物流、LLM、内容等)及默认登录策略",
"sort_order": 1,
"visible": 1,
"readonly": 0,
}
PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0),
_col("display_name", "展示名称", description="平台在界面上的显示名称"),
_col(
"domain",
"业务域",
description="物流 / 大模型 / 内容等",
options_json=DOMAIN_OPTIONS_JSON,
),
_col("provider_code", "供应商代码", description="供应商或平台代码,可空"),
_col("default_url", "默认链接", description="平台默认入口 URL", display_type="url"),
_col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0),
_col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0),
_col(
"enabled",
"是否启用",
description="启用或停用该平台",
options_json=ENABLED_OPTIONS_JSON,
),
_col(
"default_auth_strategy",
"默认登录策略",
description="新建账号时的默认登录方式",
options_json=AUTH_STRATEGY_OPTIONS_JSON,
),
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
)
ACCOUNTS_TABLE_METADATA = {
"table_name": ACCOUNTS_TABLE,
"display_name": "账号信息",
"description": "网页/RPA 账号身份与浏览器 Profile 路径",
"sort_order": 2,
"visible": 1,
"readonly": 0,
}
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="账号唯一编号", editable=0),
_col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
editable=0,
),
_col("account_label", "账号名称", description="人可读的账号名称"),
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
_col(
"login_id_type",
"登录标识类型",
description="未指定 / 手机号 / 邮箱 / 用户名等",
options_json=LOGIN_ID_TYPE_OPTIONS_JSON,
),
_col("tenant_id", "租户编号", description="租户或客户标识,可空", editable=0),
_col(
"environment",
"环境",
description="生产 / 仿真 / 预发 / 测试",
options_json=ENVIRONMENT_OPTIONS_JSON,
editable=0,
),
_col(
"role",
"角色",
description="默认 / 订舱 / 跟踪 / API 等",
options_json=ROLE_OPTIONS_JSON,
editable=0,
),
_col("provider_code", "供应商代码", description="冗余供应商码,便于查询", editable=0),
_col("profile_dir", "浏览器配置目录", description="Playwright 持久化 Profile 路径", editable=0),
_col("url", "入口链接", description="账号默认入口 URL", display_type="url"),
_col(
"status",
"状态",
description="启用 / 停用 / 待复核",
options_json=ACCOUNT_STATUS_OPTIONS_JSON,
),
_col(
"session_status",
"会话状态",
description="运行时登录态,由技能回写",
options_json=SESSION_STATUS_OPTIONS_JSON,
editable=0,
),
_ts("last_used_at", "最后使用时间", "最近一次被业务技能使用的时间"),
_ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"),
_col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0),
_col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0),
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
_col(
"auth_strategy",
"登录方式",
description="跟随平台默认,一般无需手改",
options_json=AUTH_STRATEGY_OPTIONS_JSON,
editable=0,
),
_col(
"session_persistent",
"持久会话",
description="是否需要持久化浏览器会话",
options_json=YES_NO_OPTIONS_JSON,
editable=0,
),
_col("device_fingerprint", "设备指纹", description="设备绑定策略专用,可空", editable=0),
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
)
CREDENTIALS_TABLE_METADATA = {
"table_name": CREDENTIALS_TABLE,
"display_name": "凭证信息",
"description": "API Key、Token、加密密码等凭据元数据(不含明文)",
"sort_order": 3,
"visible": 1,
"readonly": 0,
}
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="凭据唯一编号", editable=0),
_col(
"account_id",
"账号编号",
description="关联的 accounts.id,可空",
display_type="lookup",
options_json=ACCOUNT_ID_LOOKUP_JSON,
),
_col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
),
_col("credential_label", "凭据名称", description="人可读的凭据名称"),
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),
_col("secret_ref", "密钥引用", description="环境变量名或凭据管理器 target", editable=0, searchable=0),
_col("secret_mask", "密钥掩码", description="打码后的 secret 展示", editable=0, searchable=0),
_col(
"secret_ciphertext",
"密钥密文",
description="local_encrypted 时的 Fernet 密文,不可手工编辑",
editable=0,
searchable=0,
),
_ts("expires_at", "过期时间", "凭据过期时间,可空"),
_col("status", "状态", description="active / disabled / expired 等"),
_ts("last_used_at", "最后使用时间", "最近一次使用时间"),
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
)
ACCOUNT_LEASES_TABLE_METADATA = {
"table_name": ACCOUNT_LEASES_TABLE,
"display_name": "账号租约",
"description": "RPA 并发租约,防止多任务同时使用同一浏览器 Profile",
"sort_order": 4,
"visible": 1,
"readonly": 1,
}
ACCOUNT_LEASES_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="租约唯一编号", editable=0),
_col("account_id", "账号编号", description="关联的 accounts.id", editable=0),
_col("lease_token", "租约令牌", description="用于 release 的随机 token", editable=0, searchable=1),
_col("holder", "持有者", description="持有租约的业务技能或任务标识", editable=0),
_col("purpose", "用途", description="租约用途说明", editable=0),
_ts("expires_at", "过期时间", "租约过期时间"),
_ts("heartbeat_at", "心跳时间", "最近一次心跳时间"),
_ts("released_at", "释放时间", "主动释放时间,可空"),
_col("status", "状态", description="active / released / expired", editable=0),
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
)
_TABLE_SEEDS: tuple[tuple[dict[str, object], tuple[dict[str, object], ...]], ...] = (
(PLATFORMS_TABLE_METADATA, PLATFORMS_COLUMN_METADATA),
(ACCOUNTS_TABLE_METADATA, ACCOUNTS_COLUMN_METADATA),
(CREDENTIALS_TABLE_METADATA, CREDENTIALS_COLUMN_METADATA),
(ACCOUNT_LEASES_TABLE_METADATA, ACCOUNT_LEASES_COLUMN_METADATA),
)
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
def upsert_table_metadata(
cursor,
*,
table_name: str,
display_name: str,
description: str | None = None,
sort_order: int | None = None,
visible: int = 1,
readonly: int = 0,
) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_tables (
table_name, display_name, description, sort_order, visible, readonly
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
sort_order = excluded.sort_order,
visible = excluded.visible,
readonly = excluded.readonly
""",
(table_name, display_name, description, sort_order, visible, readonly),
)
def upsert_column_metadata(
cursor,
*,
table_name: str,
column_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
display_type: str | None = None,
options_json: str | None = None,
) -> None:
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description,
display_order, visible, searchable, editable, display_type, options_json
)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
display_order = NULL,
visible = excluded.visible,
searchable = excluded.searchable,
editable = excluded.editable,
display_type = excluded.display_type,
options_json = excluded.options_json
""",
(
table_name,
column_name,
display_name,
description,
visible,
searchable,
editable,
display_type,
options_json,
),
)
def seed_column_metadata_batch(
cursor,
*,
table_name: str,
columns: Iterable[dict[str, object]],
valid_column_names: Iterable[str] | None = None,
) -> None:
for column in columns:
upsert_column_metadata(
cursor,
table_name=table_name,
column_name=str(column["column_name"]),
display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None,
visible=int(column.get("visible", 1)),
searchable=int(column.get("searchable", 1)),
editable=int(column.get("editable", 1)),
display_type=str(column["display_type"]) if column.get("display_type") else None,
options_json=str(column["options_json"]) if column.get("options_json") else None,
)
if valid_column_names is not None:
prune_stale_column_metadata(cursor, table_name, valid_column_names)
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
valid = set(valid_column_names)
cursor.execute(
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
(table_name,),
)
for (column_name,) in cursor.fetchall():
if str(column_name) not in valid:
cursor.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
(table_name, column_name),
)
def get_table_readonly(cursor, table_name: str) -> int | None:
cursor.execute(
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
cursor.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
physical = list_physical_column_names(cursor, table_name)
cursor.execute(
"""
SELECT column_name, editable
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
editable_set = {
str(column_name)
for column_name, editable in cursor.fetchall()
if editable is not None and int(editable) == 1
}
return [name for name in physical if name in editable_set]
def table_allows_manual_create(cursor, table_name: str) -> bool:
readonly = get_table_readonly(cursor, table_name)
if readonly is None or int(readonly) != 0:
return False
return bool(list_editable_business_columns(cursor, table_name))
def seed_business_table_display_metadata(cursor, table_meta: dict[str, object], columns: tuple[dict[str, object], ...]) -> None:
table_name = str(table_meta["table_name"])
upsert_table_metadata(
cursor,
table_name=table_name,
display_name=str(table_meta["display_name"]),
description=str(table_meta["description"]) if table_meta.get("description") else None,
sort_order=int(table_meta["sort_order"]) if table_meta.get("sort_order") is not None else None,
visible=int(table_meta.get("visible", 1)),
readonly=int(table_meta.get("readonly", 0)),
)
seed_column_metadata_batch(
cursor,
table_name=table_name,
columns=columns,
valid_column_names=[str(column["column_name"]) for column in columns],
)
def init_display_metadata(cursor) -> None:
init_display_metadata_tables(cursor)
for table_meta, columns in _TABLE_SEEDS:
seed_business_table_display_metadata(cursor, table_meta, columns)
def iter_user_tables(cursor) -> Iterable[str]:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
for (name,) in cursor.fetchall():
if name in METADATA_TABLES:
continue
yield str(name)
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def list_physical_column_names(cursor, table_name: str) -> list[str]:
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
rows = cursor.fetchall()
rows.sort(key=lambda row: int(row[0]))
return [str(row[1]) for row in rows]

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

@@ -6,11 +6,16 @@ Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
sqlite_master, visible in DBeaver / Navicat. sqlite_master, visible in DBeaver / Navicat.
Schema version management: Schema version management:
PRAGMA user_version = 2 (set in connection.py after migration). - PRAGMA user_version = 2 (set in connection.py after ensure_schema).
- _schema_meta schema_version: "2" after migrate_v1_to_v2, "2.1" after migrate_v2_to_v2_1.
""" """
import sqlite3
import sys
import textwrap import textwrap
from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# platforms — Registry of all platforms (logistics, LLM, content, etc.) # platforms — Registry of all platforms (logistics, LLM, content, etc.)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -24,6 +29,7 @@ PLATFORMS_TABLE_SQL = textwrap.dedent("""\
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名 aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true} capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no) enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
default_auth_strategy TEXT, -- 新账号默认认证策略;可空,手工维护
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
); );
@@ -57,6 +63,9 @@ ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
last_error_code TEXT, -- 最近一次错误代码 last_error_code TEXT, -- 最近一次错误代码
last_error_message TEXT, -- 最近一次错误消息 last_error_message TEXT, -- 最近一次错误消息
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON 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 created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
); );
@@ -84,9 +93,10 @@ CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
platform_key TEXT NOT NULL, -- 平台唯一键 platform_key TEXT NOT NULL, -- 平台唯一键
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key 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 credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
secret_storage TEXT NOT NULL, -- none / env / windows_credential secret_storage TEXT NOT NULL, -- none / env / windows_credential / local_encrypted
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none 时为空 secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none/local_encrypted 时为空
secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文 secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文
secret_ciphertext TEXT, -- secret_storage='local_encrypted' 时为 Fernet 密文;其它后端为 NULL
expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空 expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
last_used_at INTEGER, -- 最近使用时间 last_used_at INTEGER, -- 最近使用时间
@@ -98,7 +108,6 @@ CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\ CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type); CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type);
CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status); CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status);
""") """)
@@ -128,6 +137,16 @@ ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at); 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 # Full DDL assembly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -140,6 +159,134 @@ ALL_DDL = (
+ CREDENTIALS_INDEXES_SQL + CREDENTIALS_INDEXES_SQL
+ ACCOUNT_LEASES_TABLE_SQL + ACCOUNT_LEASES_TABLE_SQL
+ ACCOUNT_LEASES_INDEXES_SQL + ACCOUNT_LEASES_INDEXES_SQL
+ SCHEMA_META_TABLE_SQL
) )
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
def column_exists(conn: sqlite3.Connection, table: str, col: str) -> bool:
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == col for row in cur.fetchall())
def _schema_meta_get(conn: sqlite3.Connection, key: str) -> str | None:
cur = conn.cursor()
cur.execute("SELECT value FROM _schema_meta WHERE key = ?", (key,))
row = cur.fetchone()
return str(row[0]) if row and row[0] is not None else None
def _schema_meta_upsert(conn: sqlite3.Connection, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO _schema_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
"""Apply ALTER steps + backfills once; idempotent via _schema_meta.schema_version."""
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
cur = conn.cursor()
sv = _schema_meta_get(conn, "schema_version")
if sv == "2" or (sv and sv.startswith("2.")):
return
n_platform_updates = 0
# 1–2: platforms.default_auth_strategy
if not column_exists(conn, "platforms", "default_auth_strategy"):
cur.execute("ALTER TABLE platforms ADD COLUMN default_auth_strategy TEXT")
for pk, strat in PLATFORM_DEFAULT_AUTH_STRATEGY.items():
cur.execute(
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
(strat, pk),
)
n_platform_updates += cur.rowcount
# 3: accounts columns (nullable auth_strategy during ALTER for legacy rows)
if not column_exists(conn, "accounts", "auth_strategy"):
cur.execute("ALTER TABLE accounts ADD COLUMN auth_strategy TEXT")
if not column_exists(conn, "accounts", "session_persistent"):
cur.execute(
"ALTER TABLE accounts ADD COLUMN session_persistent INTEGER NOT NULL DEFAULT 1"
)
if not column_exists(conn, "accounts", "device_fingerprint"):
cur.execute("ALTER TABLE accounts ADD COLUMN device_fingerprint TEXT")
# 4: fill accounts.auth_strategy + session_persistent
cur.execute(
"""
UPDATE accounts SET
auth_strategy = COALESCE(
(SELECT p.default_auth_strategy FROM platforms p
WHERE p.platform_key = accounts.platform_key),
?
),
session_persistent = COALESCE(session_persistent, 1)
WHERE auth_strategy IS NULL OR TRIM(auth_strategy) = ''
""",
(AUTH_STRATEGY_PASSWORD_AUTO,),
)
m_accounts_updated = cur.rowcount
# 5: credentials.account_id (v1 may omit it)
if not column_exists(conn, "credentials", "account_id"):
cur.execute("ALTER TABLE credentials ADD COLUMN account_id INTEGER")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id)"
)
cur.execute("SELECT COUNT(*) FROM platforms WHERE default_auth_strategy IS NULL")
k_null_platforms = int(cur.fetchone()[0])
_schema_meta_upsert(conn, "schema_version", "2")
conn.commit()
print(
f"[migration] account-manager v1→v2: updated {n_platform_updates} platforms, "
f"{m_accounts_updated} accounts.",
file=sys.stderr,
)
print(
"[migration] platforms with default_auth_strategy=NULL: "
f"{k_null_platforms} (manually fix later)",
file=sys.stderr,
)
def migrate_v2_to_v2_1(conn: sqlite3.Connection) -> None:
"""v2 → v2.1: credentials 表加 secret_ciphertext 列。幂等。"""
cur = conn.cursor()
if _schema_meta_get(conn, "schema_version") == "2.1":
return
if column_exists(conn, "credentials", "secret_ciphertext"):
_schema_meta_upsert(conn, "schema_version", "2.1")
conn.commit()
return
cur.execute("ALTER TABLE credentials ADD COLUMN secret_ciphertext TEXT")
_schema_meta_upsert(conn, "schema_version", "2.1")
conn.commit()
print(
"[migration] account-manager v2→v2.1: credentials.secret_ciphertext column added.",
file=sys.stderr,
)
def ensure_schema(conn: sqlite3.Connection) -> None:
"""CREATE TABLE/INDEX IF NOT EXISTS, then run migrate_v1_to_v2."""
cur = conn.cursor()
cur.executescript(ALL_DDL)
conn.commit()
migrate_v1_to_v2(conn)
migrate_v2_to_v2_1(conn)

View File

@@ -0,0 +1,190 @@
# -*- 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,
)
from db.auth_strategy import (
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.platforms import (
PlatformResolveResult,
_platform_list_cn_for_help,
resolve_platform_key_dynamic,
)
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:
"""调用方可附带的平台展示信息。不再用于自动插入 platforms 行。"""
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)
_RESOLVE_CODES_WITHOUT_PLATFORM_LIST = frozenset(
{
"PLATFORM_NOT_REGISTERED",
"INVALID_PLATFORM_KEY",
"PLATFORM_DISABLED",
"AMBIGUOUS_PLATFORM",
"PLATFORM_ALIAS_CONFLICT",
"INVALID_PLATFORM_DISPLAY_NAME",
}
)
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]:
"""解析已注册平台;未注册则拒绝。meta 仅兼容旧调用方,不再自动建平台。"""
del meta
return _resolve_or_fail(platform_input, hint=hint)
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 _RESOLVE_CODES_WITHOUT_PLATFORM_LIST:
_say("支持:" + _platform_list_cn_for_help())
return None

View File

@@ -0,0 +1,590 @@
# -*- 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.account_identity import resolve_login_id_type
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 create_web_account_record(
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,
platform_key: Optional[str] = None,
) -> Optional[dict]:
"""Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout)."""
log = get_skill_logger()
login_id_type = resolve_login_id_type(login_id_type, login_id)
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
platform_input, login_id_type, environment, role)
key = platform_key or _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return None
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 None
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 None
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 None
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 None
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 None
finally:
conn.close()
shortcut_path = create_profile_browser_shortcut(
resolved_profile_dir, safe_label, log, url=resolved_url,
)
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
return ok_payload
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."""
ok_payload = create_web_account_record(
platform_input,
login_id,
login_id_type,
label,
tenant_id,
environment,
role,
provider_code,
url,
extra_json_str,
profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
)
if ok_payload:
_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,174 @@
# -*- coding: utf-8 -*-
"""account ensure-web — pick if present, otherwise add-web then pick."""
from typing import Optional
from db.accounts_repo import count_active_accounts
from db.connection import get_conn, init_db
from service._svc_common import PlatformCallerMeta, _err_json, _resolve_or_auto_register, _say
from service.account_add_commands import create_web_account_record
from service.account_query_commands import cmd_account_pick_web
from util.logging_config import get_skill_logger
from util.platforms import seed_platforms
def cmd_account_ensure_web(
platform_input: str,
*,
login_id: Optional[str] = None,
login_id_type: Optional[str] = None,
label: Optional[str] = None,
tenant_id: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
provider_code: Optional[str] = None,
url: Optional[str] = None,
extra_json_str: Optional[str] = None,
profile_dir_override: Optional[str] = None,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: 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 account; if none exist for filters, create one then pick.
Does NOT create a new account when matching accounts exist but are all leased.
Success stdout matches ``account pick-web`` (plus ``account_ensured`` when created).
"""
log = get_skill_logger()
log.info(
"account_ensure_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()
lid = (login_id or "").strip() or None
lab = (label or "").strip() or None
existing_count = count_active_accounts(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
login_id=lid,
label=lab,
)
created = False
if existing_count == 0:
create_env = (environment or "").strip() or "production"
create_role = (role or "").strip() or "default"
create_label = lab or None
if not create_label:
create_label = f"{key} 默认"
if lid:
create_label = f"{key} {lid}"
created_payload = create_web_account_record(
platform_input,
lid,
login_id_type,
create_label,
tenant_id,
create_env,
create_role,
provider_code,
url,
extra_json_str,
profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
platform_key=key,
)
if not created_payload:
return
created = True
log.info(
"account_ensure_web_created id=%s platform=%s login_id=%s profile_dir=%s",
created_payload.get("id"),
key,
lid,
created_payload.get("profile_dir"),
)
else:
log.info(
"account_ensure_web_reuse platform=%s existing_active=%s login_id=%s label=%s",
key,
existing_count,
lid,
lab,
)
# Capture pick output so we can annotate / remap lease-busy errors.
import io
import json
import sys
buf = io.StringIO()
old_out = sys.stdout
sys.stdout = buf
try:
cmd_account_pick_web(
platform_input,
environment=environment,
tenant_id=tenant_id,
role=role,
login_id=lid,
label=lab,
do_lease=do_lease,
ttl_sec=ttl_sec,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
)
finally:
sys.stdout = old_out
raw = buf.getvalue().strip()
if not raw:
_say(_err_json("ERROR:NO_ACCOUNT", "ensure 后未能挑选到账号。"))
return
try:
payload = json.loads(raw)
except json.JSONDecodeError:
_say(raw)
return
if isinstance(payload, dict) and payload.get("success") is False:
err = (payload.get("error") or {}) if isinstance(payload.get("error"), dict) else {}
code = str(err.get("code") or "")
if code == "ERROR:NO_ACCOUNT" and existing_count > 0 and not created:
scope = "该筛选条件下" if (lid or lab) else "该平台下"
_say(_err_json(
"ERROR:LEASE_CONFLICT",
f"{scope}已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。",
))
return
_say(raw)
return
if isinstance(payload, dict) and created:
payload["account_ensured"] = True
_say(json.dumps(payload, ensure_ascii=False))
return
_say(raw)

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,233 @@
# -*- coding: utf-8 -*-
"""Account copy / update CLI commands (data-management 复制 / 编辑)."""
from __future__ import annotations
import json
from typing import Any, Optional
from db.accounts_repo import (
_UNSET,
get_account_by_id,
list_account_labels,
update_account_user_fields,
)
from db.connection import get_conn, init_db
from service._svc_common import _err_json, _say
from service.account_add_commands import create_web_account_record
from util.account_identity import (
KNOWN_ACCOUNT_STATUSES,
next_copy_label,
resolve_login_id_type,
)
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
def _extra_json_str(account: dict) -> Optional[str]:
extra = account.get("extra_json")
if extra is None or extra == "":
return None
if isinstance(extra, (dict, list)):
return json.dumps(extra, ensure_ascii=False)
text = str(extra).strip()
return text or None
def _ok_result(data: dict, message: str) -> str:
return json.dumps(
{
"type": "result",
"schema_version": 1,
"status": "success",
"message": message,
"success": True,
"data": data,
},
ensure_ascii=False,
)
def cmd_account_copy(account_id) -> None:
"""Clone a web account: same platform/strategy, new empty Profile, empty login_id."""
log = get_skill_logger()
log.info("account_copy_attempt id=%r", account_id)
try:
aid = int(account_id)
except (TypeError, ValueError):
_say(_err_json("ERROR:CLI_BAD_ARGS", "account copy 需要整数 id。"))
return
init_db()
source = get_account_by_id(aid)
if not source:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
return
conn = get_conn()
try:
labels = list_account_labels(conn)
finally:
conn.close()
new_label = next_copy_label(str(source.get("account_label") or ""), labels)
tenant = str(source.get("tenant_id") or "").strip() or None
provider = str(source.get("provider_code") or "").strip() or None
url = str(source.get("url") or "").strip() or None
extra = _extra_json_str(source)
session_persistent = source.get("session_persistent")
try:
session_persistent_i = int(session_persistent) if session_persistent is not None else None
except (TypeError, ValueError):
session_persistent_i = None
ok_payload = create_web_account_record(
str(source.get("platform_key") or ""),
None,
str(source.get("login_id_type") or "unknown"),
new_label,
tenant,
str(source.get("environment") or "production") or "production",
str(source.get("role") or "default") or "default",
provider,
url,
extra,
None,
auth_strategy=source.get("auth_strategy"),
session_persistent=session_persistent_i,
device_fingerprint=None,
platform_key=str(source.get("platform_key") or "") or None,
)
if not ok_payload:
return
ok_payload["copied_from_id"] = aid
log.info(
"account_copy_success from=%s to=%s label=%s",
aid,
ok_payload.get("id"),
new_label,
)
_say(_ok_result(ok_payload, f"已复制为「{new_label}」"))
def cmd_account_update(
account_id,
*,
label: Optional[str] = None,
login_id: Any = _UNSET,
login_id_type: Optional[str] = None,
url: Any = _UNSET,
status: Optional[str] = None,
) -> None:
"""Update the short set of user-facing account fields."""
log = get_skill_logger()
log.info("account_update_attempt id=%r", account_id)
try:
aid = int(account_id)
except (TypeError, ValueError):
_say(_err_json("ERROR:CLI_BAD_ARGS", "account update 需要整数 --id。"))
return
init_db()
current = get_account_by_id(aid)
if not current:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
return
next_label = None
if label is not None:
next_label = str(label).strip()
if not next_label:
_say(_err_json("ERROR:CLI_BAD_ARGS", "账号名称不能为空。"))
return
next_status = None
if status is not None:
next_status = str(status).strip()
if next_status not in KNOWN_ACCOUNT_STATUSES:
_say(_err_json(
"ERROR:CLI_BAD_ARGS",
f"不支持的状态:{status}。支持:active / disabled / needs_review。",
))
return
resolved_login_id = _UNSET
if login_id is not _UNSET:
resolved_login_id = str(login_id).strip() if login_id is not None else ""
next_type = None
if login_id_type is not None or resolved_login_id is not _UNSET:
lid_for_infer = (
resolved_login_id
if resolved_login_id is not _UNSET
else current.get("login_id")
)
explicit = login_id_type if login_id_type is not None else current.get("login_id_type")
next_type = resolve_login_id_type(str(explicit or ""), lid_for_infer)
resolved_url = _UNSET
if url is not _UNSET:
text = str(url).strip() if url is not None else ""
if text:
resolved_url = text
else:
conn = get_conn()
try:
seed_platforms(conn)
spec = get_platform_spec(str(current.get("platform_key") or ""), conn=conn)
finally:
conn.close()
resolved_url = str(spec.get("default_url") or "")
conn = get_conn()
try:
update_account_user_fields(
conn,
aid,
account_label=next_label,
login_id=resolved_login_id,
login_id_type=next_type,
url=resolved_url,
status=next_status,
)
conn.commit()
except ValueError as exc:
conn.rollback()
_say(_err_json("ERROR:CLI_BAD_ARGS", str(exc)))
return
except Exception:
conn.rollback()
log.exception("account_update_failure")
_say(_err_json("ERROR:DB_ERROR", "Database update failed."))
return
finally:
conn.close()
updated = get_account_by_id(aid)
if not updated:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
return
profile_dir = str(updated.get("profile_dir") or "").strip()
if profile_dir:
create_profile_browser_shortcut(
profile_dir,
str(updated.get("account_label") or ""),
log,
url=str(updated.get("url") or "") or None,
)
log.info("account_update_success id=%s", aid)
_say(_ok_result(
{
"id": updated["id"],
"platform_key": updated.get("platform_key"),
"account_label": updated.get("account_label"),
"login_id": updated.get("login_id") or "",
"login_id_type": updated.get("login_id_type"),
"url": updated.get("url") or "",
"status": updated.get("status"),
},
"已保存",
))

View File

@@ -0,0 +1,478 @@
# -*- 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,
login_id: Optional[str] = None,
label: 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 login_id=%s label=%s",
platform_input,
environment,
tenant_id,
role,
login_id,
label,
)
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,
login_id=login_id,
label=label,
limit=limit,
)
_say(json.dumps(accounts, ensure_ascii=False))
def cmd_account_resolve(
*,
account_id: Optional[str] = None,
platform_input: Optional[str] = None,
login_id: Optional[str] = None,
label: Optional[str] = None,
query: Optional[str] = None,
) -> None:
"""Resolve one account by id, login_id, label, or free-form query.
Success: same JSON shape as ``account get``.
Errors: ACCOUNT_NOT_FOUND / NO_ACCOUNT / AMBIGUOUS_ACCOUNT / CLI_BAD_ARGS.
"""
log = get_skill_logger()
init_db()
aid_raw = (account_id or "").strip()
q = (query or "").strip()
lid = (login_id or "").strip()
lab = (label or "").strip()
# Free-form: digits → id; otherwise treat as login_id (then label fallback).
if q and not aid_raw and not lid and not lab:
if q.isdigit():
aid_raw = q
else:
lid = q
if aid_raw:
try:
aid = int(aid_raw)
except (TypeError, ValueError):
_say(_err_json("ERROR:CLI_BAD_ARGS", f"账号 id 必须是整数:{aid_raw}"))
return
acc = get_account_by_id(aid)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
return
log.info("account_resolve_by_id id=%s", aid)
_say(json.dumps(acc, ensure_ascii=False))
return
if not lid and not lab:
_say(_err_json(
"ERROR:CLI_BAD_ARGS",
"account resolve 需要 --id、<数字 query>、--login-id 或 --label(也可用 --query 用户名/手机号)。",
))
return
key = None
if platform_input and platform_input not in ("all", "全部", ""):
key = _resolve_or_fail(platform_input)
if not key:
return
elif not platform_input:
# login_id/label without platform: search all platforms
key = None
matches = find_accounts(
platform_key=key,
login_id=lid or None,
label=lab or None,
status="active",
limit=20,
)
# If query was used as login_id and miss, retry as label.
if not matches and q and not login_id and not label and not q.isdigit():
matches = find_accounts(
platform_key=key,
label=q,
status="active",
limit=20,
)
if not matches:
ref = lid or lab or q
_say(_err_json(
"ERROR:NO_ACCOUNT",
f"未找到匹配的账号(ref={ref!r}"
+ (f", platform={key}" if key else "")
+ ")。",
))
return
if len(matches) > 1:
ids = [m.get("id") for m in matches[:10]]
_say(_err_json(
"ERROR:AMBIGUOUS_ACCOUNT",
f"匹配到多条账号,请改用 --id 或补充 --platform。候选 id={ids}",
))
return
acc = matches[0]
log.info(
"account_resolve_ok id=%s login_id=%s label=%s",
acc.get("id"),
acc.get("login_id"),
acc.get("account_label"),
)
_say(json.dumps(acc, 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,
login_id: Optional[str] = None,
label: 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 login_id=%s label=%s lease=%s",
platform_input,
environment,
tenant_id,
role,
login_id,
label,
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()
lid = (login_id or "").strip() or None
lab = (label or "").strip() or None
# Human-ref filters should be unique; ambiguous active matches → explicit error.
if lid or lab:
active_matches = find_accounts(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
status="active",
login_id=lid,
label=lab,
limit=20,
)
if len(active_matches) > 1:
ids = [m.get("id") for m in active_matches[:10]]
_say(_err_json(
"ERROR:AMBIGUOUS_ACCOUNT",
f"login_id/label 匹配到多条账号,请改用 account get <id>。候选 id={ids}",
))
return
candidate_ids = find_account_ids(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
login_id=lid,
label=lab,
)
if not candidate_ids:
hint = ""
if lid or lab:
hint = f"(login_id={lid!r} label={lab!r})"
_say(_err_json(
"ERROR:NO_ACCOUNT",
f"没有符合条件的可用账号{hint}(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)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
""" """
Chromium/Edge browser profile opener. Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context. Uses system Chrome/Edge executable with --user-data-dir (same profile as RPA shortcuts).
Does NOT download Playwright Chromium. Does NOT download Playwright Chromium.
Does NOT check login status. Does NOT check login status.
Does NOT write secret to SQLite. Does NOT write secret to SQLite.
@@ -11,20 +11,12 @@ import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
from db.connection import init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import get_skill_logger
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates): def _win_find_exe(candidates):
for p in candidates: for p in candidates:
@@ -33,13 +25,12 @@ def _win_find_exe(candidates):
return None return None
def resolve_chromium_channel(): def resolve_chromium_executable():
""" """
Playwright channel: 'chrome' | 'msedge' | None Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None。
Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。
""" """
if sys.platform != "win32": if sys.platform != "win32":
return "chrome" return None
pf = os.environ.get("ProgramFiles", r"C:\Program Files") pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
@@ -53,7 +44,7 @@ def resolve_chromium_channel():
] ]
) )
if chrome: if chrome:
return "chrome" return chrome
edge = _win_find_exe( edge = _win_find_exe(
[ [
@@ -63,21 +54,148 @@ def resolve_chromium_channel():
] ]
) )
if edge: if edge:
return "msedge" return edge
return None 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"
# 给人核对/手工登录用:跳过 Chrome 首次欢迎页并最大化。不要带 Playwright 的 --enable-automation。
HUMAN_CHROME_FLAGS = (
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
)
def human_chrome_launch_args(profile_dir, url=None):
"""chrome.exe 之后的参数(Popen 列表形式,路径不额外加引号)。"""
profile_abs = os.path.abspath(profile_dir)
args = [f"--user-data-dir={profile_abs}", *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
args.append(cleaned)
return args
def human_chrome_shortcut_arguments(profile_dir, url=None):
"""Windows .lnk Arguments 字符串(user-data-dir 带引号)。"""
profile_abs = os.path.abspath(profile_dir)
parts = [f'--user-data-dir="{profile_abs}"', *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
parts.append(cleaned)
return " ".join(parts)
def _print_browser_install_hint(): def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:") print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
print(" • Chrome: https://www.google.com/chrome/") print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge") 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_reveal_profile(account_id):
"""
在系统文件管理器中打开该账号的 profile_dir。
不写数据库、不启动浏览器。
"""
log = get_skill_logger()
init_db()
log.info("reveal_profile_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("reveal_profile_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
sys.exit(1)
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("reveal_profile_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
sys.exit(1)
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
try:
if sys.platform == "win32":
# explorer 即使成功也常返回非 0;用 startfile 打开目录更稳妥
os.startfile(profile_abs) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.run(["open", profile_abs], check=False)
else:
subprocess.run(["xdg-open", profile_abs], check=False)
except OSError as exc:
log.error("reveal_profile_error account_id=%s error=%s", account_id, str(exc))
print(f"ERROR:REVEAL_PROFILE_FAILED {exc}")
sys.exit(1)
log.info("reveal_profile_ok account_id=%s profile_dir=%s", account_id, profile_abs)
print(
json.dumps(
{
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True,
"data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
"profile_dir": profile_abs,
},
},
ensure_ascii=False,
)
)
def cmd_open(account_id): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。
不写数据库、不判定是否已登录。 与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
最大化、跳过 Chrome 首次欢迎页;不写数据库、不判定是否已登录、不起 Playwright。
""" """
log = get_skill_logger() log = get_skill_logger()
init_db() init_db()
@@ -87,63 +205,92 @@ def cmd_open(account_id):
if not target: if not target:
log.warning("browser_open_error account_id=%r not_found", account_id) log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
return sys.exit(1)
channel = resolve_chromium_channel() browser_exe = resolve_chromium_executable()
if not channel: if not browser_exe:
_print_browser_install_hint() channel = resolve_chromium_channel()
return if sys.platform == "win32" or not channel:
_print_browser_install_hint()
try: sys.exit(1)
import playwright # noqa: F401 # 非 Windows:退回 PATH 上的常见命令名
except ImportError: browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
print(
"ERROR:需要 playwright Python 包。"
)
return
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return sys.exit(1)
os.makedirs(profile_dir, exist_ok=True)
# Use account url, fall back to platform default profile_abs = os.path.abspath(profile_dir)
platform_spec = PLATFORMS.get(target["platform_key"], {}) os.makedirs(profile_abs, exist_ok=True)
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
conn = get_conn()
try:
url = resolve_account_browser_url(target, conn=conn)
finally:
conn.close()
# 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
profile_dir, browser_exe,
profile_abs,
url, url,
channel,
target.get("platform_key"), target.get("platform_key"),
) )
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try: try:
subprocess.run( # GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
[sys.executable, "-m", "service.open_child_runner", cfg_path], popen_kwargs: dict = {
cwd=_SCRIPTS_ROOT, "stdin": subprocess.DEVNULL,
env=subprocess_env_with_trace(), "stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if os.path.isabs(browser_exe):
popen_kwargs["cwd"] = os.path.dirname(browser_exe)
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
child = subprocess.Popen(cmd, **popen_kwargs)
except OSError as exc:
log.error("browser_open_error account_id=%s error=%s", account_id, str(exc))
print(f"ERROR:BROWSER_OPEN_FAILED {exc}")
sys.exit(1)
browser_name = (
"Microsoft Edge"
if os.path.basename(browser_exe).lower() == "msedge.exe"
else "Google Chrome"
)
log.info(
"browser_open_ok account_id=%s browser=%s pid=%s",
account_id,
browser_name,
child.pid,
)
print(
json.dumps(
{
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True,
"data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
"account_label": target.get("account_label"),
"browser": browser_name,
"browser_exe": browser_exe,
"profile_dir": profile_abs,
"url": url,
"pid": child.pid,
},
},
ensure_ascii=False,
) )
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)
except OSError:
pass

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,74 @@
# -*- coding: utf-8 -*-
"""Lease management CLI commands."""
from db.accounts_repo import (
cleanup_expired_leases,
list_active_leases,
release_lease,
release_lease_by_id,
)
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_force_release(lease_id: int) -> None:
"""lease force-release --id <lease_row_id> — data-management row action."""
log = get_skill_logger()
log.info("cli_start subcommand=lease_force_release lease_id=%s", lease_id)
try:
lid = int(lease_id)
except (TypeError, ValueError):
_say(_err_json("ERROR:CLI_BAD_ARGS", "租约编号必须是数字。"))
return
if lid <= 0:
_say(_err_json("ERROR:CLI_BAD_ARGS", "租约编号必须是正整数。"))
return
released = release_lease_by_id(lid)
if not released:
_say(
_err_json(
"ERROR:LEASE_NOT_FOUND",
"未找到可释放的活跃租约(可能已释放、已过期清理,或不存在)。",
)
)
return
_say(
_ok_json(
{
"id": released["id"],
"account_id": released["account_id"],
"holder": released.get("holder") or "",
"purpose": released.get("purpose") or "",
"status": "released",
"message": "已强制释放租约,该账号可被其它任务占用。",
}
)
)
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_KEY(base64-urlsafe 字符串,44 字符)
2. 文件 {DATA_ROOT}/{USER_ID}/account-manager/master.key
3. 都没有 → raise MasterKeyMissingError,错误码 ERR_MASTER_KEY_MISSING
不再自动生成——必须显式 `account init-master-key`。
"""
from __future__ import annotations
import base64
import os
from cryptography.fernet import Fernet
from util.runtime_paths import get_skill_data_dir
ENV_MASTER_KEY = "ACCOUNT_MANAGER_MASTER_KEY"
_MASTER_FILENAME = "master.key"
class MasterKeyMissingError(Exception):
"""Raised when no valid master key can be resolved."""
code = "ERR_MASTER_KEY_MISSING"
class MasterKeyExistsError(Exception):
"""Raised when master.key already exists and overwrite is not allowed."""
code = "ERR_MASTER_KEY_EXISTS"
class MasterKeyInvalidError(Exception):
"""Raised when a supplied master key string cannot be used as a Fernet key."""
code = "ERR_MASTER_KEY_INVALID"
def master_key_path() -> str:
"""Absolute path to master.key alongside account-manager.db."""
return os.path.join(get_skill_data_dir(), _MASTER_FILENAME)
def _try_fernet_key(key_material: bytes) -> bytes | None:
"""Return url-safe key bytes accepted by Fernet, or None if invalid."""
try:
raw = key_material.strip()
if not raw:
return None
# Accept stored form: ascii urlsafe base64 string as bytes
Fernet(raw)
return raw
except Exception:
return None
def _try_decode_manual_b64(s: str) -> bytes | None:
"""If string decodes to 32 raw bytes, re-encode as Fernet url-safe key."""
try:
pad = "=" * ((4 - len(s) % 4) % 4)
decoded = base64.urlsafe_b64decode(s + pad)
if len(decoded) != 32:
return None
key_bytes = base64.urlsafe_b64encode(decoded)
Fernet(key_bytes)
return key_bytes
except Exception:
return None
def _normalize_key_string(s: str) -> bytes | None:
s = (s or "").strip()
if not s:
return None
fk = _try_fernet_key(s.encode("ascii"))
if fk is not None:
return fk
return _try_decode_manual_b64(s)
def load_master_key() -> bytes:
"""Resolve master key (env → file); raises MasterKeyMissingError if unavailable."""
env_val = (os.environ.get(ENV_MASTER_KEY) or "").strip()
if env_val:
kb = _normalize_key_string(env_val)
if kb is not None:
return kb
path = master_key_path()
try:
with open(path, "rb") as f:
raw = f.read()
txt = raw.strip().decode("ascii", errors="strict")
kb = _normalize_key_string(txt)
if kb is not None:
return kb
except OSError:
pass
except (UnicodeDecodeError, ValueError):
pass
raise MasterKeyMissingError("No valid ACCOUNT_MANAGER_MASTER_KEY or master.key")
def normalize_master_key_input(s: str) -> bytes:
"""Validate user-supplied key text; raises MasterKeyInvalidError if unusable."""
kb = _normalize_key_string((s or "").strip())
if kb is None:
raise MasterKeyInvalidError("invalid master key material")
return kb
def generate_master_key() -> bytes:
"""Return a new Fernet key (44-char url-safe base64 as bytes)."""
return Fernet.generate_key()
def write_master_key_file(key: bytes, *, allow_overwrite: bool = False) -> str:
"""Write key bytes to master_key_path(); chmod 0o600 best-effort."""
path = master_key_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
if os.path.exists(path) and not allow_overwrite:
raise MasterKeyExistsError("master.key already exists; use --rotate to overwrite")
data = key.strip()
with open(path, "wb") as f:
f.write(data)
try:
os.chmod(path, 0o600)
except OSError:
pass
return path
def is_master_key_available() -> bool:
try:
load_master_key()
return True
except MasterKeyMissingError:
return False

View File

@@ -1,46 +0,0 @@
"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。"""
import json
import sys
from playwright.sync_api import sync_playwright
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
def main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
with sync_playwright() as p:
args, ignore_automation = persistent_context_launch_parts()
lc_kw = dict(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=args,
)
if ignore_automation is not None:
lc_kw["ignore_default_args"] = ignore_automation
ctx = p.chromium.launch_persistent_context(**lc_kw)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
ctx.wait_for_event("close", timeout=86400000)
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,153 @@
# -*- 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.constants import SKILL_SLUG
from util.logging_config import get_skill_logger
from util.platforms import DEFAULT_CAPABILITIES_JSON, seed_platforms
from util.runtime_paths import get_db_path
def cmd_init_db() -> int:
"""幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。"""
log = get_skill_logger()
log.info("cli_start subcommand=init_db")
try:
init_db()
except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败
log.exception("init_db_failure")
print(
json.dumps(
{"ok": False, "skill": SKILL_SLUG, "error": str(exc)},
ensure_ascii=False,
)
)
return 1
print(
json.dumps(
{
"ok": True,
"skill": SKILL_SLUG,
"db_path": get_db_path(),
},
ensure_ascii=False,
)
)
return 0
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 _aliases_for_ensure(aliases: Optional[str], key: str, display_name: str) -> tuple[list[str], str]:
from util.platforms import _parse_aliases_json
claimed: list[str] = []
extra = _parse_aliases_json(aliases) if aliases else []
for item in (display_name, key, *extra):
text = (item or "").strip()
if text and text not in claimed:
claimed.append(text)
if aliases:
return claimed, aliases
return claimed, json.dumps(claimed, ensure_ascii=False)
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"}
"""
from util.platform_validate import validate_platform_registration
log = get_skill_logger()
key = (key or "").strip()
display_name = (display_name or "").strip()
if not key:
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
return 1
init_db()
conn = get_conn()
try:
alias_list, aliases_json = _aliases_for_ensure(aliases, key, display_name)
check = validate_platform_registration(
key,
display_name,
aliases=alias_list,
conn=conn,
)
if not check.ok:
_say(_err_json(
f"ERROR:{check.error_code}",
check.message or "平台登记校验失败。",
))
return 1
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
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 + delay);locator 为 Playwright Locator。"""
locator.scroll_into_view_if_needed()
if pre_pause:
pause()
locator.click()
pause()
lo, hi = per_char_ms_range
delay_ms = random.uniform(float(lo), float(hi))
locator.type(text, delay=int(delay_ms))
def human_click(locator) -> None:
"""人类化点击:scroll_into_view → pause → click → pause"""
locator.scroll_into_view_if_needed()
pause()
locator.click()
pause()
def human_select(locator, value_or_label: str) -> None:
"""人类化下拉选择:scroll_into_view → pause → click → pause → select_option"""
locator.scroll_into_view_if_needed()
pause()
locator.click()
pause()
try:
locator.select_option(value=value_or_label)
except Exception:
locator.select_option(label=value_or_label)
def human_scroll_to(locator) -> None:
"""滚动到元素并稍作停顿。"""
locator.scroll_into_view_if_needed()
pause()

View File

@@ -135,6 +135,12 @@ def read_secret(storage: str, secret_ref: str) -> str:
) )
return val 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: else:
log.error("secret_resolve_failed unsupported storage=%s", storage) log.error("secret_resolve_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}") raise ValueError(f"Unsupported secret storage type: {storage}")
@@ -304,3 +310,14 @@ def _win_cred_delete_impl(target: str) -> None:
err = ctypes.get_last_error() err = ctypes.get_last_error()
if err != 1168: if err != 1168:
raise OSError(f"CredDeleteW failed with error {err}") 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

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""账号名称副本编号、登录标识类型推断。"""
from __future__ import annotations
import re
from typing import Iterable, Optional
_COPY_SUFFIX_RE = re.compile(r"^(.*?)(?: 副本(?: (\d+))?)?$")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_CN_MOBILE_RE = re.compile(r"^1\d{10}$")
_PHONE_RE = re.compile(r"^\+?\d{8,15}$")
_PHONE_STRIP_RE = re.compile(r"[\s\-()]")
KNOWN_LOGIN_ID_TYPES = frozenset(
{"email", "username", "phone", "customer_code", "api_account", "unknown"}
)
KNOWN_ACCOUNT_STATUSES = frozenset({"active", "disabled", "needs_review"})
def infer_login_id_type(login_id: Optional[str]) -> str:
"""从登录标识猜类型;空则 unknown。"""
text = (login_id or "").strip()
if not text:
return "unknown"
if _EMAIL_RE.match(text):
return "email"
compact = _PHONE_STRIP_RE.sub("", text)
if _CN_MOBILE_RE.match(compact) or _PHONE_RE.match(compact):
return "phone"
return "username"
def resolve_login_id_type(explicit: Optional[str], login_id: Optional[str]) -> str:
"""有明确类型(且不是 unknown)时用明确值,否则按 login_id 推断。"""
typed = (explicit or "").strip()
if typed and typed != "unknown":
return typed
return infer_login_id_type(login_id)
def next_copy_label(source_label: str, existing_labels: Iterable[str]) -> str:
"""在已有名称中生成「原名 副本」/「原名 副本 2」…,避免叠「副本 副本」。"""
raw = (source_label or "").strip() or "账号"
existing = {item.strip().lower() for item in existing_labels if (item or "").strip()}
match = _COPY_SUFFIX_RE.match(raw)
stem = (match.group(1).strip() if match else raw) or raw
n = 1
while n <= 9999:
candidate = f"{stem} 副本" if n == 1 else f"{stem} 副本 {n}"
if candidate.lower() not in existing:
return candidate
n += 1
return f"{stem} 副本 {n}"

View File

@@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
"""平台键 / 展示名校验:引用已有平台 vs 登记新平台。"""
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
from util.platforms import (
PLATFORMS,
PlatformResolveResult,
_parse_aliases_json,
is_valid_platform_key_format,
)
_ASCII_TOKEN = re.compile(r"[A-Za-z0-9]")
def msg_empty() -> str:
return "平台名不能为空。请传平台键(如 douyin、deepseek),不要传空字符串。"
def msg_invalid_key(raw: str) -> str:
return (
f"平台键不合法:「{raw}」。\n"
"平台键必须是小写英文/数字/下划线或连字符,例如 douyin、deepseek。\n"
"「抖音」「DeepSeek」是展示名,不能当键。请改成已有键,或先登记:\n"
"platform ensure --key <键> --display-name <中文名>\n"
"查看已有键:platform list"
)
def msg_unrecognized(raw: str) -> str:
return (
f"无法识别平台:「{raw}」。\n"
"请传平台键(如 douyin),不要自创中文键。\n"
"先执行 platform list 核对;若是新平台,用 "
"platform ensure --key <键> --display-name <中文名> 登记后再调 add-web / ensure-web。"
)
def msg_not_registered(key: str) -> str:
return (
f"平台 `{key}` 尚未注册,已拒绝写入,以免产生不规范平台。\n"
f"若确认是新平台:platform ensure --key {key} --display-name <中文名> [--url …]\n"
"若其实是已有站:请改用 platform list 里的平台键。"
)
def msg_ambiguous(raw: str, keys: Iterable[str]) -> str:
listed = "、".join(f"`{k}`" for k in keys)
return f"平台「{raw}」对应多个键({listed}),请改传明确的平台键。"
def msg_alias_conflict(alias: str, owner: str, key: str) -> str:
return (
f"别名「{alias}」已被平台 `{owner}` 占用,不能再登记到 `{key}`。"
"请换展示名/别名,或复用已有平台键。"
)
def msg_display_name_required() -> str:
return (
"登记平台必须同时提供 --display-name(给人看的名称,可以是中文)。\n"
"平台键与展示名要分开,例如 --key douyin --display-name 抖音。"
)
def msg_disabled(key: str) -> str:
return f"平台 {key} 已禁用。"
def _looks_like_attempted_key(raw: str) -> bool:
return bool(_ASCII_TOKEN.search(raw or "")) and not is_valid_platform_key_format(raw)
def _lookup_stored_key(conn, key_lower: str) -> Optional[tuple[str, int]]:
"""Return (platform_key, enabled) for an exact key, builtin or DB."""
needle = (key_lower or "").strip().lower()
if not needle:
return None
if conn is not None:
row = conn.execute(
"SELECT platform_key, enabled FROM platforms WHERE lower(platform_key) = ?",
(needle,),
).fetchone()
if row:
return str(row[0]), int(row[1] or 0)
if needle in PLATFORMS:
return needle, 1
return None
def _iter_enabled_name_map(conn) -> list[tuple[str, set[str]]]:
"""Each enabled platform with its display_name + aliases (not the key)."""
out: list[tuple[str, set[str]]] = []
seen: set[str] = set()
if conn is not None:
rows = conn.execute(
"SELECT platform_key, enabled, display_name, aliases_json FROM platforms"
).fetchall()
for platform_key, enabled, display_name, aliases_json in rows:
key = str(platform_key)
seen.add(key)
if not enabled:
continue
names: set[str] = set()
if display_name:
names.add(str(display_name).strip())
for alias in _parse_aliases_json(aliases_json):
names.add(alias)
out.append((key, {n for n in names if n}))
for key, spec in PLATFORMS.items():
if key in seen:
continue
names = {str(spec.get("display_name") or "").strip()}
for alias in spec.get("aliases") or []:
names.add(str(alias).strip())
out.append((key, {n for n in names if n}))
return out
def _alias_matches(raw: str, conn) -> list[str]:
needle = (raw or "").strip()
if not needle:
return []
lower = needle.lower()
matches: list[str] = []
for key, names in _iter_enabled_name_map(conn):
lowered = {n.lower() for n in names}
if needle in names or lower in lowered:
if key not in matches:
matches.append(key)
return matches
def _keys_claiming_label(label: str, conn) -> list[str]:
"""Keys that already use this string as key, display_name, or alias."""
text = (label or "").strip()
if not text:
return []
owners: list[str] = []
stored = _lookup_stored_key(conn, text.lower())
if stored and stored[0] not in owners:
owners.append(stored[0])
for key in _alias_matches(text, conn):
if key not in owners:
owners.append(key)
return owners
def resolve_platform_ref(input_name: str, conn=None) -> PlatformResolveResult:
"""引用已有平台:已有键优先,其次唯一展示名/别名。不创建平台行。"""
raw = (input_name or "").strip()
if not raw:
return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty())
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
if is_valid_platform_key_format(raw):
stored = _lookup_stored_key(conn, raw)
if stored:
key, enabled = stored
if not enabled:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=msg_disabled(key),
)
return PlatformResolveResult(platform_key=key)
folded = raw.lower()
if is_valid_platform_key_format(folded):
stored = _lookup_stored_key(conn, folded)
if stored:
key, enabled = stored
if not enabled:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=msg_disabled(key),
)
return PlatformResolveResult(platform_key=key)
matches = _alias_matches(raw, conn)
if len(matches) == 1:
return PlatformResolveResult(platform_key=matches[0])
if len(matches) > 1:
return PlatformResolveResult(
error_code="AMBIGUOUS_PLATFORM",
message=msg_ambiguous(raw, matches),
)
if is_valid_platform_key_format(raw):
return PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=msg_not_registered(raw),
)
if _looks_like_attempted_key(raw) or is_valid_platform_key_format(folded):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=msg_invalid_key(raw),
)
return PlatformResolveResult(
error_code="INVALID_PLATFORM",
message=msg_unrecognized(raw),
)
finally:
if close_conn:
conn.close()
def validate_platform_registration(
key: str,
display_name: str,
aliases: Optional[Iterable[Any]] = None,
conn=None,
) -> PlatformResolveResult:
"""登记/更新平台:键必须已是规范小写;展示名必填;别名不得抢别人的。"""
raw_key = (key or "").strip()
if not raw_key:
return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty())
if not is_valid_platform_key_format(raw_key):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=msg_invalid_key(raw_key),
)
name = (display_name or "").strip()
if not name:
return PlatformResolveResult(
error_code="INVALID_PLATFORM_DISPLAY_NAME",
message=msg_display_name_required(),
)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
claimed = [name]
if aliases:
for item in aliases:
text = str(item).strip()
if text:
claimed.append(text)
for label in claimed:
if label.lower() == raw_key:
continue
owners = [k for k in _keys_claiming_label(label, conn) if k != raw_key]
if owners:
return PlatformResolveResult(
error_code="PLATFORM_ALIAS_CONFLICT",
message=msg_alias_conflict(label, owners[0], raw_key),
)
return PlatformResolveResult(platform_key=raw_key)
finally:
if close_conn:
conn.close()

View File

@@ -6,11 +6,17 @@ Central authoritative registry for all platforms (logistics, LLM, content, etc.)
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms(). Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
""" """
import json import json
import re
import time import time
from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
from util.logging_config import get_skill_logger 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: # Platform specification fields:
# display_name – Human-readable name (e.g. "Maersk") # display_name – Human-readable name (e.g. "Maersk")
@@ -200,6 +206,89 @@ PLATFORMS: dict[str, dict[str, Any]] = {
"aliases": ["公众号", "微信", "微信公众号"], "aliases": ["公众号", "微信", "微信公众号"],
"capabilities": {"web": True, "api_key": False, "rpa": False}, "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},
},
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -226,7 +315,7 @@ _PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str) -> Optional[str]: def resolve_platform_key(name: str) -> Optional[str]:
""" """
Resolve user input to a canonical platform key. Resolve user input to a canonical platform key (built-in PLATFORMS only).
Supports: key, display_name, any alias. Case-insensitive. Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized. Returns None if unrecognized.
""" """
@@ -247,8 +336,78 @@ def resolve_platform_key(name: str) -> Optional[str]:
return None return None
def is_valid_platform_key_format(name: str) -> bool:
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$(不大小写折叠;登记必须本身已是小写)。"""
s = (name or "").strip()
if not s:
return False
return bool(PLATFORM_KEY_PATTERN.match(s))
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_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
"""引用已有平台(键 / 唯一展示名或别名)。实现见 util.platform_validate。"""
from util.platform_validate import resolve_platform_ref
return resolve_platform_ref(input_name, conn)
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]]: def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated key.""" """Return the full platform spec dict for a validated built-in key."""
return PLATFORMS.get(key) return PLATFORMS.get(key)
@@ -277,6 +436,8 @@ def seed_platforms(conn) -> None:
Upsert all platform definitions into the 'platforms' table. Upsert all platform definitions into the 'platforms' table.
Must be called after init_db() in the same connection lifecycle. 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 = get_skill_logger()
log.info("platform_seed_start") log.info("platform_seed_start")
cur = conn.cursor() cur = conn.cursor()
@@ -285,12 +446,13 @@ def seed_platforms(conn) -> None:
for key, spec in PLATFORMS.items(): for key, spec in PLATFORMS.items():
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False) aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False) capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
cur.execute( cur.execute(
""" """
INSERT INTO platforms (platform_key, display_name, domain, provider_code, INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json, default_url, aliases_json, capabilities_json,
enabled, created_at, updated_at) enabled, default_auth_strategy, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name, display_name = excluded.display_name,
domain = excluded.domain, domain = excluded.domain,
@@ -299,6 +461,7 @@ def seed_platforms(conn) -> None:
aliases_json = excluded.aliases_json, aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json, capabilities_json = excluded.capabilities_json,
enabled = 1, enabled = 1,
default_auth_strategy = excluded.default_auth_strategy,
updated_at = excluded.updated_at updated_at = excluded.updated_at
""", """,
( (
@@ -309,6 +472,7 @@ def seed_platforms(conn) -> None:
spec.get("default_url", ""), spec.get("default_url", ""),
aliases_json, aliases_json,
capabilities_json, capabilities_json,
default_auth_strategy,
now, now,
now, now,
), ),

View File

@@ -0,0 +1,88 @@
# -*- 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 (
human_chrome_shortcut_arguments,
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,
url: Optional[str] = None,
) -> Optional[str]:
"""
在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
非 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 = human_chrome_shortcut_arguments(profile_abs, url)
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

@@ -15,6 +15,13 @@ _scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
if _scripts not in sys.path: if _scripts not in sys.path:
sys.path.insert(0, _scripts) 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 import pytest

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""account copy / update 与数据管理短表单语义。"""
from __future__ import annotations
import json
import os
from db.accounts_repo import find_accounts, get_account_by_id
def _add(clean_db, **kwargs):
from service.account_service import cmd_account_add_web
defaults = dict(
platform_input="douyin",
login_id="13800138000",
login_id_type="phone",
label="抖音 默认",
tenant_id="t1",
environment="production",
role="default",
provider_code=None,
url="https://www.douyin.com",
extra_json_str='{"hook":"keep"}',
profile_dir_override=None,
auth_strategy="qr_code_manual",
session_persistent=1,
device_fingerprint=None,
)
defaults.update(kwargs)
cmd_account_add_web(**defaults)
class TestAccountCopy:
def test_copy_clones_platform_not_login_or_profile(self, clean_db, capsys):
from service.account_service import cmd_account_copy
_add(clean_db)
source = find_accounts(platform_key="douyin")[0]
cmd_account_copy(source["id"])
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["type"] == "result"
assert "已复制为" in payload["message"]
copy_id = payload["data"]["id"]
assert copy_id != source["id"]
copied = get_account_by_id(copy_id)
assert copied["platform_key"] == "douyin"
assert copied["account_label"] == "抖音 默认 副本"
assert copied["login_id"] == ""
assert copied["tenant_id"] == "t1"
assert copied["environment"] == "production"
assert copied["role"] == "default"
assert copied["url"] == "https://www.douyin.com"
assert copied["auth_strategy"] == "qr_code_manual"
assert copied["session_persistent"] == 1
assert copied["device_fingerprint"] is None
assert copied["status"] == "active"
assert copied["session_status"] == "unknown"
assert (copied.get("extra_json") or {}).get("hook") == "keep"
assert copied["profile_dir"]
assert copied["profile_dir"] != source["profile_dir"]
assert os.path.isdir(copied["profile_dir"])
def test_copy_second_increments_label(self, clean_db, capsys):
from service.account_service import cmd_account_copy
_add(clean_db)
source = find_accounts(platform_key="douyin")[0]
cmd_account_copy(source["id"])
capsys.readouterr()
cmd_account_copy(source["id"])
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert payload["data"]["account_label"] == "抖音 默认 副本 2"
def test_copy_missing_id(self, clean_db, capsys):
from service.account_service import cmd_account_copy
cmd_account_copy(99999)
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:ACCOUNT_NOT_FOUND"
class TestAccountUpdate:
def test_update_short_fields(self, clean_db, capsys):
from service.account_service import cmd_account_update
_add(clean_db)
source = find_accounts(platform_key="douyin")[0]
cmd_account_update(
source["id"],
label="抖音 备用",
login_id="backup@example.com",
login_id_type="unknown",
url="https://www.douyin.com/user",
status="disabled",
)
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert payload["success"] is True
assert payload["message"] == "已保存"
updated = get_account_by_id(source["id"])
assert updated["account_label"] == "抖音 备用"
assert updated["login_id"] == "backup@example.com"
assert updated["login_id_type"] == "email"
assert updated["url"] == "https://www.douyin.com/user"
assert updated["status"] == "disabled"
assert updated["auth_strategy"] == "qr_code_manual"
assert updated["profile_dir"] == source["profile_dir"]
def test_update_empty_login_clears(self, clean_db, capsys):
from service.account_service import cmd_account_update
_add(clean_db)
source = find_accounts(platform_key="douyin")[0]
cmd_account_update(source["id"], login_id="", login_id_type="unknown")
capsys.readouterr()
updated = get_account_by_id(source["id"])
assert updated["login_id"] == ""
assert updated["login_id_type"] == "unknown"
assert updated["account_label"] == "抖音 默认"
def test_update_rejects_bad_status(self, clean_db, capsys):
from service.account_service import cmd_account_update
_add(clean_db)
source = find_accounts(platform_key="douyin")[0]
cmd_account_update(source["id"], status="nope")
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert payload["success"] is False
class TestAddWebInfersLoginType:
def test_email_without_type(self, clean_db, capsys):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="deepseek",
login_id="a@b.com",
login_id_type=None,
label="ds",
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
acc = find_accounts(platform_key="deepseek")[0]
assert acc["login_id_type"] == "email"

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from util.account_identity import infer_login_id_type, next_copy_label, resolve_login_id_type
def test_infer_login_id_type():
assert infer_login_id_type(None) == "unknown"
assert infer_login_id_type("") == "unknown"
assert infer_login_id_type("demo@maersk.com") == "email"
assert infer_login_id_type("13800138000") == "phone"
assert infer_login_id_type("+8613800138000") == "phone"
assert infer_login_id_type("creator_01") == "username"
def test_resolve_login_id_type_keeps_explicit():
assert resolve_login_id_type("phone", "demo@x.com") == "phone"
assert resolve_login_id_type("unknown", "demo@x.com") == "email"
assert resolve_login_id_type(None, "13800138000") == "phone"
def test_next_copy_label_increments():
assert next_copy_label("抖音 默认", []) == "抖音 默认 副本"
assert next_copy_label("抖音 默认", ["抖音 默认 副本"]) == "抖音 默认 副本 2"
assert next_copy_label("抖音 默认 副本", ["抖音 默认 副本"]) == "抖音 默认 副本 2"
assert next_copy_label(
"抖音 默认",
["抖音 默认 副本", "抖音 默认 副本 2"],
) == "抖音 默认 副本 3"

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""Human-friendly account resolve / pick-web --login-id / ensure by login_id."""
from __future__ import annotations
import io
import json
import sys
def _capture(fn, *args, **kwargs):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
fn(*args, **kwargs)
finally:
sys.stdout = old
return buf.getvalue()
def _add_web(platform, login_id, label):
from service.account_service import cmd_account_add_web
payload = json.loads(
_capture(
cmd_account_add_web,
platform,
login_id,
"username",
label,
None,
"production",
"default",
None,
None,
None,
None,
)
)
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
return payload["data"]
return payload
class TestAccountResolve:
def test_resolve_by_login_id(self, clean_db):
from service.account_service import cmd_account_resolve
created = _add_web("maersk", "chendelian", "陈德联")
raw = _capture(
cmd_account_resolve,
platform_input="maersk",
login_id="ChenDelian",
)
acc = json.loads(raw)
assert acc.get("success") is not False
assert acc["id"] == created["id"]
assert acc["login_id"] == "chendelian"
def test_resolve_by_query_fallback_label(self, clean_db):
from service.account_service import cmd_account_resolve
created = _add_web("maersk", "u1", "我的马士基")
raw = _capture(
cmd_account_resolve,
platform_input="maersk",
query="我的马士基",
)
acc = json.loads(raw)
assert acc["id"] == created["id"]
def test_resolve_ambiguous(self, clean_db):
from service.account_service import cmd_account_resolve
_add_web("maersk", "same", "A")
_add_web("cma_cgm", "same", "B")
raw = _capture(cmd_account_resolve, login_id="same")
err = json.loads(raw)
assert err["success"] is False
assert err["error"]["code"] == "ERROR:AMBIGUOUS_ACCOUNT"
class TestPickByLoginId:
def test_pick_web_filters_login_id(self, clean_db):
from service.account_service import cmd_account_pick_web
a = _add_web("maersk", "alice", "A")
_add_web("maersk", "bob", "B")
raw = _capture(
cmd_account_pick_web,
"maersk",
login_id="bob",
)
picked = json.loads(raw)
assert picked["login_id"] == "bob"
assert picked["id"] != a["id"]
class TestEnsureByLoginId:
def test_ensure_creates_named_even_if_platform_has_others(self, clean_db):
from db.accounts_repo import find_accounts
from service.account_service import cmd_account_ensure_web
_add_web("maersk", "existing", "已有")
before = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(before) == 1
raw = _capture(
cmd_account_ensure_web,
"maersk",
login_id="chendelian",
label="陈德联",
)
result = json.loads(raw)
assert result.get("account_ensured") is True
assert result["login_id"] == "chendelian"
after = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(after) == 2

View File

@@ -2,6 +2,9 @@
""" """
Browser launch helper checks (no real browser started). Browser launch helper checks (no real browser started).
""" """
import json
from unittest import mock
import pytest import pytest
@@ -14,24 +17,100 @@ class TestPlaywrightLaunchHelpers:
args, _ignore = persistent_context_launch_parts() args, _ignore = persistent_context_launch_parts()
assert "--start-maximized" in args 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( class TestCmdOpenDirectBrowser:
os.path.dirname(__file__), """account open = same-process Chrome/Edge launch (like reveal-profile)."""
"..",
"scripts", def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
"service", from db.accounts_repo import insert_account
"open_child_runner.py", from service import browser_service
from util.platforms import seed_platforms
seed_platforms(clean_db)
profile = tmp_path / "prof"
aid = insert_account(
clean_db,
platform_key="sohu",
account_label="open-test",
login_id="1",
login_id_type="phone",
tenant_id=None,
environment="production",
role="default",
provider_code="sohu",
profile_dir=str(profile),
url="https://example.com",
extra_json=None,
now=1_700_000_000,
auth_strategy="password_auto",
) )
path = os.path.abspath(path) clean_db.commit()
with open(path, encoding="utf-8") as f:
src = f.read() fake_exe = str(tmp_path / "chrome.exe")
assert "launch_persistent_context" in src monkeypatch.setattr(
assert "headless=False" in src browser_service,
assert "no_viewport=True" in src "resolve_chromium_executable",
assert '"channel"' in src or "'channel'" in src lambda: fake_exe,
)
class FakeProc:
pid = 5252
spawned = {}
def fake_popen(cmd, **kwargs):
spawned["cmd"] = list(cmd)
spawned["kwargs"] = kwargs
return FakeProc()
monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen)
browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["message"] == ""
assert payload["type"] == "result"
assert payload["status"] == "success"
assert payload["data"]["pid"] == 5252
assert payload["data"]["browser_exe"] == fake_exe
assert spawned["cmd"][0] == fake_exe
assert spawned["cmd"][1].startswith("--user-data-dir=")
assert "--start-maximized" in spawned["cmd"]
assert "--no-first-run" in spawned["cmd"]
assert "--no-default-browser-check" in spawned["cmd"]
assert "--disable-search-engine-choice-screen" in spawned["cmd"]
assert spawned["cmd"][-1] == "https://example.com"
assert "--enable-automation" not in spawned["cmd"]
# must NOT spawn python / open_child_runner
joined = " ".join(spawned["cmd"])
assert "open_child_runner" not in joined
assert "python" not in joined.lower()
class TestHumanChromeLaunchArgs:
def test_flags_then_url(self, tmp_path):
from service.browser_service import HUMAN_CHROME_FLAGS, human_chrome_launch_args
profile = str(tmp_path / "prof")
args = human_chrome_launch_args(profile, "https://chat.deepseek.com")
assert args[0].startswith("--user-data-dir=")
for flag in HUMAN_CHROME_FLAGS:
assert flag in args
assert args[-1] == "https://chat.deepseek.com"
assert "--enable-automation" not in args
def test_shortcut_arguments_quote_profile_and_include_url(self, tmp_path):
from service.browser_service import human_chrome_shortcut_arguments
profile = tmp_path / "my prof"
profile.mkdir()
text = human_chrome_shortcut_arguments(str(profile), "https://example.com")
assert '--user-data-dir="' in text
assert "--start-maximized" in text
assert "--no-first-run" in text
assert text.endswith("https://example.com")
class TestResolveChromiumChannel: class TestResolveChromiumChannel:

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

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,451 @@
# -*- 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"
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")
def test_ensure_requires_display_name(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="hao_kan", display_name="")
assert rc == 1
payload = json.loads(capsys.readouterr().out.strip())
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_DISPLAY_NAME"
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
).fetchone()[0]
assert count == 0
def test_ensure_rejects_mixed_case_key(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="HaoKan", display_name="好看")
assert rc == 1
payload = json.loads(capsys.readouterr().out.strip())
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE lower(platform_key) = 'haokan'"
).fetchone()[0]
assert count == 0
def test_ensure_rejects_alias_owned_by_another_platform(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="hao_kan", display_name="抖音")
assert rc == 1
payload = json.loads(capsys.readouterr().out.strip())
assert payload["error"]["code"] == "ERROR:PLATFORM_ALIAS_CONFLICT"
assert "douyin" in payload["error"]["message"]
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
).fetchone()[0]
assert count == 0
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 ensure" in payload["error"]["message"]
assert "--platform-display-name" not 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 TestCallerMetaDoesNotAutoRegister:
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 ensure" in payload["error"]["message"]
assert "--platform-display-name" not in payload["error"]["message"]
def test_pick_web_unregistered_with_meta_does_not_insert(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:PLATFORM_NOT_REGISTERED"
assert "已拒绝写入" in payload["error"]["message"]
result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db)
assert not result.ok
assert result.error_code == "PLATFORM_NOT_REGISTERED"
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'auto_reg_plat'"
).fetchone()[0]
assert count == 0
def test_add_web_unregistered_with_meta_does_not_insert(self, clean_db, capsys):
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="example.com",
auth_strategy="qr_code_manual",
aliases=["unreg_meta_plat"],
)
cmd_account_add_web(
platform_input="unreg_meta_plat",
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,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[0])
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert find_accounts(platform_key="unreg_meta_plat") == []
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'unreg_meta_plat'"
).fetchone()[0]
assert count == 0
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_caller_meta_does_not_insert_idempotent(self, clean_db, capsys):
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 == 0
out = capsys.readouterr().out.strip().splitlines()[-1]
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"

View File

@@ -0,0 +1,357 @@
# -*- coding: utf-8 -*-
"""数据管理展示元数据:幂等初始化、中文名称、敏感字段权限。"""
from __future__ import annotations
import re
import pytest
from db.display_metadata import (
ACCOUNT_LEASES_TABLE,
ACCOUNTS_TABLE,
CREDENTIALS_TABLE,
PLATFORMS_TABLE,
USER_BUSINESS_TABLES,
get_table_readonly,
init_display_metadata_tables,
list_physical_column_names,
table_allows_manual_create,
upsert_column_metadata,
upsert_table_metadata,
)
def _has_cjk(text: str) -> bool:
return bool(re.search(r"[\u4e00-\u9fff]", text or ""))
@pytest.fixture
def metadata_db(clean_db):
"""clean_db already ran init_db(); return connection for metadata assertions."""
yield clean_db
def test_init_db_metadata_idempotent(metadata_db):
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
count_after_second = int(cur.fetchone()[0])
assert count_after_second == len(USER_BUSINESS_TABLES)
finally:
conn.close()
@pytest.mark.parametrize(
"table_name,expected_display",
[
(PLATFORMS_TABLE, "平台信息"),
(ACCOUNTS_TABLE, "账号信息"),
(CREDENTIALS_TABLE, "凭证信息"),
(ACCOUNT_LEASES_TABLE, "账号租约"),
],
)
def test_table_chinese_display_names(metadata_db, table_name, expected_display):
cur = metadata_db.cursor()
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cur.fetchone()
assert row is not None
assert row[0] == expected_display
def test_all_business_columns_have_chinese_display_names(metadata_db):
cur = metadata_db.cursor()
for table_name in USER_BUSINESS_TABLES:
physical = set(list_physical_column_names(cur, table_name))
cur.execute(
"""
SELECT column_name, display_name
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
meta_rows = cur.fetchall()
assert len(meta_rows) == len(physical), f"{table_name} 元数据字段数与物理表不一致"
for column_name, display_name in meta_rows:
assert column_name in physical
assert _has_cjk(str(display_name)), f"{table_name}.{column_name} 缺少中文 display_name"
def test_sensitive_credential_fields_not_editable(metadata_db):
cur = metadata_db.cursor()
sensitive = ("secret_ref", "secret_mask", "secret_ciphertext", "secret_storage")
for column_name in sensitive:
cur.execute(
"""
SELECT editable, searchable
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(CREDENTIALS_TABLE, column_name),
)
editable, searchable = cur.fetchone()
assert int(editable) == 0, column_name
if column_name in ("secret_ref", "secret_ciphertext"):
assert int(searchable) == 0, column_name
def test_account_and_credential_platform_lookups(metadata_db):
import json
cur = metadata_db.cursor()
for table_name, column_name in (
(ACCOUNTS_TABLE, "platform_key"),
(CREDENTIALS_TABLE, "platform_key"),
):
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup", table_name
payload = json.loads(options_json)
assert payload["ref_table"] == "platforms"
assert payload["value_column"] == "platform_key"
assert payload["label_column"] == "display_name"
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'account_id'
""",
(CREDENTIALS_TABLE,),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup"
payload = json.loads(options_json)
assert payload["ref_table"] == "accounts"
assert payload["value_column"] == "id"
def test_lease_token_not_editable(metadata_db):
cur = metadata_db.cursor()
cur.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'lease_token'
""",
(ACCOUNT_LEASES_TABLE,),
)
assert int(cur.fetchone()[0]) == 0
def test_account_leases_readonly(metadata_db):
cur = metadata_db.cursor()
assert int(get_table_readonly(cur, ACCOUNT_LEASES_TABLE)) == 1
assert table_allows_manual_create(cur, ACCOUNT_LEASES_TABLE) is False
def test_platforms_key_not_editable(metadata_db):
cur = metadata_db.cursor()
cur.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'platform_key'
""",
(PLATFORMS_TABLE,),
)
assert int(cur.fetchone()[0]) == 0
def test_profile_dir_not_editable(metadata_db):
cur = metadata_db.cursor()
cur.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'profile_dir'
""",
(ACCOUNTS_TABLE,),
)
assert int(cur.fetchone()[0]) == 0
def test_account_edit_form_columns_and_enums(metadata_db):
import json
cur = metadata_db.cursor()
expected_editable = {
"account_label",
"login_id",
"login_id_type",
"url",
"status",
}
readonly_runtime = {
"platform_key",
"tenant_id",
"environment",
"role",
"provider_code",
"session_status",
"auth_strategy",
"session_persistent",
"device_fingerprint",
}
for column_name in expected_editable:
cur.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(ACCOUNTS_TABLE, column_name),
)
assert int(cur.fetchone()[0]) == 1, column_name
for column_name in readonly_runtime:
cur.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(ACCOUNTS_TABLE, column_name),
)
assert int(cur.fetchone()[0]) == 0, column_name
cur.execute(
"""
SELECT options_json FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'status'
""",
(ACCOUNTS_TABLE,),
)
options = json.loads(cur.fetchone()[0])
values = {item["value"] for item in options}
labels = {item["label"] for item in options}
assert values == {"active", "disabled", "needs_review"}
assert "启用" in labels
assert "停用" in labels
def test_timestamp_columns_use_datetime_display_type(metadata_db):
cur = metadata_db.cursor()
for table_name in USER_BUSINESS_TABLES:
cur.execute(
"""
SELECT column_name, display_type, editable
FROM _jiangchang_columns
WHERE table_name = ? AND column_name IN ('created_at', 'updated_at')
""",
(table_name,),
)
for column_name, display_type, editable in cur.fetchall():
assert display_type == "datetime_unix_seconds", f"{table_name}.{column_name}"
assert int(editable) == 0, f"{table_name}.{column_name}"
def test_metadata_tables_hidden_from_user_tables(metadata_db):
from db.display_metadata import iter_user_tables
tables = list(iter_user_tables(metadata_db.cursor()))
assert "_jiangchang_tables" not in tables
assert "_jiangchang_columns" not in tables
assert "_schema_meta" not in tables
for name in USER_BUSINESS_TABLES:
assert name in tables
def test_reinit_clears_stale_column_metadata(metadata_db):
from db.connection import get_conn, init_db
conn = get_conn()
try:
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('accounts', 'stale_field', '陈旧字段', 1, 1, 0)
"""
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT COUNT(*) FROM _jiangchang_columns
WHERE table_name = 'accounts' AND column_name = 'stale_field'
"""
)
assert int(cur.fetchone()[0]) == 0
finally:
conn.close()
def test_missing_column_metadata_detected_in_memory_db():
import sqlite3
from db.display_metadata import init_display_metadata, list_physical_column_names
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE platforms (
platform_key TEXT PRIMARY KEY,
display_name TEXT NOT NULL
);
"""
)
init_display_metadata_tables(conn.cursor())
upsert_table_metadata(
conn.cursor(),
table_name="platforms",
display_name="平台信息",
)
upsert_column_metadata(
conn.cursor(),
table_name="platforms",
column_name="platform_key",
display_name="平台键",
editable=0,
)
conn.commit()
physical = list_physical_column_names(conn.cursor(), "platforms")
assert "display_name" in physical
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'platforms'"
)
assert int(cur.fetchone()[0]) < len(physical)
finally:
conn.close()
# Full seed should cover all physical columns
conn = sqlite3.connect(":memory:")
try:
from db.schema import ensure_schema
ensure_schema(conn)
init_display_metadata(conn.cursor())
conn.commit()
for table_name in USER_BUSINESS_TABLES:
physical = set(list_physical_column_names(conn.cursor(), table_name))
cur = conn.cursor()
cur.execute(
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
(table_name,),
)
meta_cols = {row[0] for row in cur.fetchall()}
assert meta_cols == physical, table_name
finally:
conn.close()

143
tests/test_ensure_web.py Normal file
View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Tests for account ensure-web (pick-or-create)."""
from __future__ import annotations
import io
import json
import sys
import pytest
def _capture(fn, *args, **kwargs):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
fn(*args, **kwargs)
finally:
sys.stdout = old
return buf.getvalue()
class TestEnsureWeb:
def test_ensure_creates_when_empty(self, clean_db):
from service.account_service import cmd_account_ensure_web
raw = _capture(
cmd_account_ensure_web,
"maersk",
label="马士基默认",
)
result = json.loads(raw)
assert result.get("success") is not False
assert result["platform_key"] == "maersk"
assert result.get("profile_dir")
assert result.get("account_ensured") is True
assert result.get("id")
def test_ensure_reuses_existing_no_duplicate(self, clean_db):
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
from db.accounts_repo import find_accounts
_capture(
cmd_account_add_web,
"maersk",
"a@test.com",
"email",
"已有",
None,
"production",
"default",
None,
None,
None,
None,
)
before = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(before) == 1
raw = _capture(cmd_account_ensure_web, "maersk")
result = json.loads(raw)
assert result.get("account_ensured") is not True
assert result["id"] == before[0]["id"]
after = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(after) == 1
def test_ensure_lease_busy_does_not_create(self, clean_db):
from db.accounts_repo import acquire_lease, find_account_ids, find_accounts
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
_capture(
cmd_account_add_web,
"cma_cgm",
"b@cma.com",
"email",
"CMA",
None,
"production",
"default",
None,
None,
None,
None,
)
ids = find_account_ids("cma_cgm")
assert len(ids) == 1
acquire_lease(
account_id=ids[0],
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
holder="other",
purpose="busy",
ttl_sec=900,
)
assert find_account_ids("cma_cgm") == []
raw = _capture(cmd_account_ensure_web, "cma_cgm", do_lease=True, holder="me")
err = json.loads(raw)
assert err["success"] is False
assert err["error"]["code"] == "ERROR:LEASE_CONFLICT"
still = find_accounts(platform_key="cma_cgm", status="active", limit=50)
assert len(still) == 1
def test_pick_web_ensure_flag_cli(self, clean_db, fake_env):
"""CLI account pick-web --ensure creates when empty."""
import os
import subprocess
scripts = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"
)
env = {
**os.environ,
"PYTHONPATH": scripts,
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
"JIANGCHANG_USER_ID": fake_env["user_id"],
}
r = subprocess.run(
[
sys.executable,
"-m",
"cli.app",
"account",
"pick-web",
"--platform",
"maersk",
"--ensure",
"--label",
"cli-default",
],
capture_output=True,
text=True,
env=env,
cwd=scripts,
)
assert r.returncode == 0, r.stderr + r.stdout
# last non-empty JSON line
lines = [ln for ln in r.stdout.splitlines() if ln.strip()]
result = json.loads(lines[-1])
assert result.get("platform_key") == "maersk"
assert result.get("account_ensured") is True
assert result.get("profile_dir")

View File

@@ -144,6 +144,67 @@ class TestLeaseRelease:
assert result["success"] is False assert result["success"] is False
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND" assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
def test_force_release_by_id(self, clean_db):
"""force-release by lease row id frees the account for pick-web."""
from db.accounts_repo import acquire_lease, find_account_ids, release_lease_by_id
from service.account_service import cmd_account_add_web, cmd_lease_force_release
import io, sys
cmd_account_add_web(
"maersk", "force-release@maersk.com", "email", "FR", "t1", "production", "booking",
None, None, None, None,
)
ids = find_account_ids("maersk")
acc_id = ids[0]
lease = acquire_lease(
account_id=acc_id,
lease_token="f1e2d3c4b5a697887766554433221100",
holder="stuck-holder",
purpose="download",
ttl_sec=900,
)
assert find_account_ids("maersk") == []
released = release_lease_by_id(int(lease["id"]))
assert released is not None
assert released["status"] == "released"
assert acc_id in find_account_ids("maersk")
# Re-acquire and exercise CLI command output shape
lease2 = acquire_lease(
account_id=acc_id,
lease_token="a0b1c2d3e4f5061728394a5b6c7d8e9f",
holder="stuck-2",
purpose="download",
ttl_sec=900,
)
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
cmd_lease_force_release(int(lease2["id"]))
finally:
sys.stdout = old
line = buf.getvalue().strip().splitlines()[-1]
result = json.loads(line)
assert result.get("success") is True
assert (result.get("data") or {}).get("status") == "released"
def test_force_release_inactive_fails(self, clean_db):
from service.account_service import cmd_lease_force_release
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_force_release(999999)
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: class TestLeaseCleanup:
"""lease cleanup / expired lease handling.""" """lease cleanup / expired lease handling."""

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,133 @@
# -*- 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"
class TestPlatformKeyFormatAndAmbiguity:
def test_format_rejects_mixed_case(self):
from util.platforms import is_valid_platform_key_format
assert is_valid_platform_key_format("haokan") is True
assert is_valid_platform_key_format("HaoKan") is False
assert is_valid_platform_key_format("hao_kan") is True
def test_deepseek_display_resolves_to_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("DeepSeek", conn=clean_db)
assert result.ok
assert result.platform_key == "deepseek"
def test_douyin_alias_still_unique(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("抖音", conn=clean_db)
assert result.platform_key == "douyin"
def test_wechat_key_wins(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("wechat", conn=clean_db)
assert result.ok
assert result.platform_key == "wechat"
def test_gongzhonghao_is_ambiguous(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 == "AMBIGUOUS_PLATFORM"
assert "wechat" in (result.message or "")
assert "wechat_mp" in (result.message or "")
def test_unregistered_lookalike_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("HaoKan", conn=clean_db)
assert result.error_code == "INVALID_PLATFORM_KEY"
assert "platform ensure" in (result.message or "")
def test_unregistered_valid_key_not_inserted(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("hao_kan", conn=clean_db)
assert result.error_code == "PLATFORM_NOT_REGISTERED"
assert "已拒绝写入" in (result.message or "")
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
).fetchone()[0]
assert count == 0

View File

@@ -0,0 +1,102 @@
# -*- 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
def test_windows_lnk_uses_human_chrome_args(self, tmp_path, monkeypatch) -> None:
captured = {}
def fake_run(argv, **kwargs):
captured["argv"] = list(argv)
class Result:
returncode = 0
return Result()
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
monkeypatch.setattr(
"util.profile_shortcut.resolve_chromium_executable",
lambda: r"C:\Program Files\Google\Chrome\Application\chrome.exe",
)
monkeypatch.setattr("util.profile_shortcut.subprocess.run", fake_run)
monkeypatch.setattr("util.profile_shortcut.os.path.isfile", lambda path: True)
out = create_profile_browser_shortcut(
str(tmp_path), "demo", url="https://chat.deepseek.com",
)
assert out is not None
ps = captured["argv"][-1]
assert "--start-maximized" in ps
assert "--no-first-run" in ps
assert "https://chat.deepseek.com" in ps
assert "--enable-automation" not in ps
@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,78 @@
# -*- coding: utf-8 -*-
"""account reveal-profile:打开 profile 目录(不启动浏览器)。"""
from __future__ import annotations
import json
import os
from unittest import mock
import pytest
def _insert_account(conn, *, profile_dir: str) -> int:
from db.accounts_repo import insert_account
from util.platforms import seed_platforms
seed_platforms(conn)
aid = insert_account(
conn,
platform_key="sohu",
account_label="reveal-test",
login_id="13800000000",
login_id_type="phone",
tenant_id=None,
environment="production",
role="default",
provider_code="sohu",
profile_dir=profile_dir,
url="https://mp.sohu.com",
extra_json=None,
now=1_700_000_000,
auth_strategy="password_auto",
session_persistent=1,
device_fingerprint=None,
)
conn.commit()
return aid
def test_reveal_profile_opens_dir(clean_db, tmp_path, capsys):
from service.browser_service import cmd_reveal_profile
profile = tmp_path / "profiles" / "sohu" / "reveal-test"
aid = _insert_account(clean_db, profile_dir=str(profile))
with mock.patch("service.browser_service.os.startfile") as startfile:
with mock.patch("service.browser_service.sys.platform", "win32"):
cmd_reveal_profile(str(aid))
startfile.assert_called_once()
called_path = startfile.call_args[0][0]
assert os.path.isdir(called_path)
assert os.path.abspath(str(profile)) == called_path
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["message"] == ""
assert payload["type"] == "result"
assert payload["status"] == "success"
assert payload["data"]["account_id"] == aid
assert payload["data"]["profile_dir"] == called_path
def test_reveal_profile_missing_account(clean_db):
from service.browser_service import cmd_reveal_profile
with pytest.raises(SystemExit) as ei:
cmd_reveal_profile("999999")
assert ei.value.code == 1
def test_reveal_profile_empty_dir(clean_db):
from service.browser_service import cmd_reveal_profile
aid = _insert_account(clean_db, profile_dir="")
with pytest.raises(SystemExit) as ei:
cmd_reveal_profile(str(aid))
assert ei.value.code == 1

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""数据管理行内打开类:label 对齐模板,不与宿主 CRUD 重复。"""
from __future__ import annotations
import json
import os
import unittest
def _skill_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_actions() -> list[dict]:
path = os.path.join(_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
manifest = json.load(f)
return list(manifest.get("actions") or [])
def _by_id(actions: list[dict], action_id: str) -> dict:
for item in actions:
if item.get("id") == action_id:
return item
raise AssertionError(f"missing action {action_id}")
class TestRowOpenActions(unittest.TestCase):
def test_reveal_and_open_use_template_labels(self) -> None:
actions = _load_actions()
reveal = _by_id(actions, "reveal-profile")
open_browser = _by_id(actions, "open-browser")
self.assertEqual(reveal.get("label"), "打开目录")
self.assertEqual(open_browser.get("label"), "打开浏览器")
self.assertEqual(reveal.get("placements"), ["row"])
self.assertEqual(open_browser.get("placements"), ["row"])
self.assertEqual(reveal.get("interaction"), "direct")
self.assertEqual(open_browser.get("interaction"), "direct")
self.assertFalse(reveal.get("replaces"))
self.assertFalse(open_browser.get("replaces"))
def test_platform_create_takes_over_host_add(self) -> None:
create = _by_id(_load_actions(), "platform-ensure")
self.assertEqual(create.get("replaces"), "create")
self.assertEqual(create.get("placements") or [], [])
self.assertEqual(create.get("label"), "新增平台")
self.assertEqual((create.get("bind") or {}).get("tables"), ["platforms"])
props = (create.get("inputSchema") or {}).get("properties") or {}
self.assertEqual(set(props), {"platform_key", "display_name", "default_url"})
args = (create.get("entrypoint") or {}).get("args") or []
self.assertIn("--key", args)
self.assertIn("--display-name", args)
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
create = _by_id(_load_actions(), "account-add-web")
self.assertEqual(create.get("replaces"), "create")
self.assertEqual(create.get("placements") or [], [])
self.assertEqual(create.get("label"), "新增账号")
props = (create.get("inputSchema") or {}).get("properties") or {}
self.assertEqual(set(props), {"platform_key", "account_label", "login_id"})
def test_update_takes_over_host_edit(self) -> None:
update = _by_id(_load_actions(), "account-update")
self.assertEqual(update.get("replaces"), "update")
self.assertEqual(update.get("placements") or [], [])
self.assertEqual(update.get("label"), "编辑账号")
props = (update.get("inputSchema") or {}).get("properties") or {}
self.assertEqual(
set(props),
{"accountId", "account_label", "login_id", "login_id_type", "url", "status"},
)
self.assertIn("enum", props["login_id_type"])
self.assertIn("enum", props["status"])
def test_copy_is_direct_row_action(self) -> None:
copy = _by_id(_load_actions(), "account-copy")
self.assertEqual(copy.get("label"), "复制")
self.assertEqual(copy.get("placements"), ["row"])
self.assertEqual(copy.get("interaction"), "direct")
self.assertFalse(copy.get("replaces"))
def test_lease_force_release_row_action(self) -> None:
action = _by_id(_load_actions(), "lease-force-release")
self.assertEqual(action.get("label"), "强制释放租约")
self.assertEqual(action.get("placements"), ["row"])
self.assertEqual(action.get("interaction"), "confirm")
self.assertEqual(action.get("executionProfile"), "sync")
bind = action.get("bind") or {}
self.assertEqual(bind.get("tables"), ["account_leases"])
self.assertEqual(bind.get("inputMapping"), {"leaseId": "$row.$pk"})
self.assertEqual(bind.get("visibleWhen"), {"status": "active"})
self.assertEqual(
(action.get("entrypoint") or {}).get("args"),
["force-release", "--id", "{{leaseId}}"],
)
steps = action.get("steps") or []
self.assertTrue(any(s.get("do") == "reloadTable" for s in steps))
if __name__ == "__main__":
unittest.main()

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"

82
tests/test_usage.py Normal file
View File

@@ -0,0 +1,82 @@
# -*- 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-db",
"init-master-key",
"export-credentials",
"import-credentials",
"copy",
"update",
"add-web",
"ensure-web",
"get-credential",
"pick-web",
"resolve",
"reveal-profile",
):
assert cmd in usage, f"USAGE 缺少 {cmd}"
assert "--ensure" in usage
assert "--login-id" in usage
def test_init_db_cli_succeeds(fake_env):
"""宿主调用的 init-db 必须成功并写入展示元数据。"""
import json
env = {
**os.environ,
"PYTHONPATH": _scripts,
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
"JIANGCHANG_USER_ID": fake_env["user_id"],
}
r = subprocess.run(
[sys.executable, "main.py", "init-db"],
capture_output=True,
text=True,
env=env,
cwd=_scripts,
)
assert r.returncode == 0, r.stdout + r.stderr
payload = json.loads(r.stdout.strip().splitlines()[-1])
assert payload.get("ok") is True
assert payload.get("skill") == "account-manager"
assert payload.get("db_path")
from db.connection import get_conn
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
assert int(cur.fetchone()[0]) > 0
cur.execute(
"""
SELECT display_name FROM _jiangchang_columns
WHERE table_name = 'accounts' AND column_name = 'role'
"""
)
assert cur.fetchone()[0] == "角色"
finally:
conn.close()

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)