Compare commits

...

39 Commits

Author SHA1 Message Date
4fe1351e1e test(compat): add v1 compatibility suite; docs: add V1_COMPATIBILITY.md
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:52:22 +08:00
fd5c432735 fix(db): sync default_auth_strategy on platform reseed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
2c7e648a72 fix(cli): add encryption commands to USAGE string
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
6fc4f1af1d docs: add RPA_HELPERS.md for same-process integration guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:56 +08:00
dadf89af3d docs: update SCHEMA.md / ERRORS.md / PLATFORMS.md / RUNTIME.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:49 +08:00
1792ba2a0d docs: update SKILL.md / README.md / CLI.md / INTEGRATION.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:42 +08:00
dbf5604090 fix(rpa_helpers): require positive evidence in _is_already_logged_in to avoid false success
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:44:46 +08:00
769ccec601 test(rpa_helpers): add mock-based tests for browser/human/auth_flows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
cfca82ed0c feat(rpa_helpers): export public API in package __init__
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
104aa91364 feat(rpa_helpers): add auth_flows.py with 9 ensure_logged_in_* and dispatcher
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
c23240d0ea feat(rpa_helpers): add human.py interaction primitives (pause/type/click/select/scroll)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
5d4425c060 feat(rpa_helpers): add browser.launch_browser_with_profile and close_browser
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
ef80d64f30 chore(platforms): align PLATFORMS registry with PLATFORM_DEFAULT_AUTH_STRATEGY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:48:27 +08:00
65d939dc5b test(cli): add stage C CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
04cafae3d7 feat(cli): argv routing and USAGE for stage C commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
a74157ff4a feat(service): extend add-web/add-secret/pick-web; add get-credential
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
4c7e4f9769 feat(db): add get_active_lease_by_token helper to accounts_repo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
e7f41d6e27 test(crypto+db+cli): add master key, crypto, credentials_repo, and CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:29 +08:00
07adc98e8c feat(cli): add init-master-key / export-credentials / import-credentials commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
5f8c93dee9 feat(db): add credentials_repo with encrypted insert/read API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
127e9b6019 feat(db): migrate credentials to v2.1 (add secret_ciphertext column)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
ee851f719b feat(crypto): add master key loader and Fernet encrypt/decrypt helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:27 +08:00
06aed39661 test(db): add schema migration tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
00e3116339 feat(db): migrate accounts/credentials/platforms to v2 schema
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
27d687becd feat(db): add auth_strategy constants and platform defaults
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:48 +08:00
91057fa3b7 Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-05-05 18:51:00 +08:00
6b6d2969c3 release: v1.0.55 SKILL description for routing, CLI mapping table, when-to-use cleanup
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:41:54 +08:00
82720ef2fa release: v1.0.54 SKILL/README: add account via chat, auto profile_dir
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:08:33 +08:00
3a1ea6d97a Release v1.0.53: account docs split, remove login flow, SCHEMA reference
All checks were successful
技能自动化发布 / release (push) Successful in 37s
2026-04-12 10:56:50 +08:00
75a332e7b8 release: docs, browser_service, runtime_env, SKILL v1.0.52
All checks were successful
技能自动化发布 / release (push) Successful in 28s
2026-04-08 14:00:42 +08:00
4605d3d0e4 release: runtime_env marker, SKILL v1.0.51
All checks were successful
技能自动化发布 / release (push) Successful in 25s
2026-04-08 12:05:14 +08:00
beb95c1415 release: v1.0.50 playwright stealth gemini
All checks were successful
技能自动化发布 / release (push) Successful in 34s
2026-04-07 15:18:14 +08:00
e2eafb7407 feat: remove wait-login; pick-web only; docs sync
All checks were successful
技能自动化发布 / release (push) Successful in 33s
2026-04-07 11:58:27 +08:00
96e5b9ea14 fix: spawn login/open child with python -m for PyArmor runtime path
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-07 10:58:32 +08:00
97d970dd1b chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s
2026-04-07 10:36:40 +08:00
e3da478116 feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-06 18:38:09 +08:00
ac76f89dc8 feat: wait-login 支持 --force,供搜狐发布等 Cookie 失效仍开窗检测
All checks were successful
技能自动化发布 / release (push) Successful in 31s
Made-with: Cursor
2026-04-06 17:30:50 +08:00
c83841cf90 chore: auto release commit (2026-04-06 16:44:58)
All checks were successful
技能自动化发布 / release (push) Successful in 39s
2026-04-06 16:44:59 +08:00
d938885a49 chore: auto release commit (2026-04-06 14:32:01)
All checks were successful
技能自动化发布 / release (push) Successful in 27s
2026-04-06 14:32:01 +08:00
65 changed files with 9786 additions and 1564 deletions

View File

@@ -1,68 +1,69 @@
---
name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.4
name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.0
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: account-manager
emoji: "👤"
emoji: "🔐"
category: "通用"
allowed-tools:
- bash
---
# 账号管理
# 账号与凭据管理account-manager
## 使用时机
通用账号、凭据与浏览器 Profile 管理器。在本地 SQLite 管理平台注册、账号身份、凭据元数据;可选 Fernet 加密密码入库提供租约lease防并发供其它技能通过 CLI 调用 `scripts/main.py` 或通过 **同进程** [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md) 完成登录流程。
当用户发送以下内容的时候触发本Skill
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
- 说"列出账号"、"查看所有账号"、"有哪些账号"
- 说"添加账号"、"删除账号"、"获取账号信息"
## 核心原则
## 执行步骤
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己散落存储账号、密码、API Key、Token。
2. **SQLite 主要保存元数据**`secret_ref``secret_mask`;非 `local_encrypted` 路径不在库中存明文。
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session。
4. **业务 skill 优先通过 CLI 获取结构化账号 JSON** — 需要时再组合同进程 rpa_helpers见集成文档
5. **9 种登录策略统一接口**`auth_strategy` 标识账号登录方式,业务 skill 可调用 `ensure_logged_in` 自动分派。
6. **加密 secret 双钥分离**master key 文件默认与数据库同目录,也可用环境变量覆盖;**数据库密文 + 合法 master key** 才能解密。
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号同平台下同手机号不可重复ID 自增名称如「搜狐1号」
```bash
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
python3 {baseDir}/scripts/main.py add 知乎 13800138000
```
支持的平台称呼示例搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝亦支持英文键如 sohu、toutiao
## 何时使用本技能
### 仅打开浏览器核对是否已登录(不写数据库)
```bash
python3 {baseDir}/scripts/main.py open <id>
```
1. **登记网页/RPA 账号**`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`
2. **登记凭据API Key / Token / 加密密码)**`account add-secret``none` / `env` / `windows_credential` / `local_encrypted`
3. **查看/列出账号**`account get/list` — JSON 输出。
4. **为 RPA 挑选账号**`account pick-web` — 按环境/租户/角色筛选,可带 lease。
5. **获取 API Key凭据表**`credential pick` — 默认隐藏 secret`--reveal` 时才返回。
6. **浏览器打开查看**`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
7. **管理 lease**`lease release/list/cleanup` — 防止 RPA 并发。
8. **初始化加密**`account init-master-key` — 生成 Fernet master.key。
9. **加密备份与迁移**`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
10. **按 lease 取明文密码**`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
11. **业务 skill 同进程登录**`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
### 登录并自动检测、写入数据库
```bash
python3 {baseDir}/scripts/main.py login <id>
```
## CLI 调用
### 删除账号(同时删库里的记录与 profile 用户数据目录)
```bash
python3 {baseDir}/scripts/main.py delete id <id>
python3 {baseDir}/scripts/main.py delete platform <平台>
python3 {baseDir}/scripts/main.py delete platform <平台> <手机号>
```
语法、默认值、错误约定见 **`references/CLI.md`**。
### 列出某平台所有账号platform 可为中文名、all 或 全部)
```bash
python3 {baseDir}/scripts/main.py list all
python3 {baseDir}/scripts/main.py list 搜狐号
```
## 支持的平台
### 跨技能:单行 JSONget / list-json / pick-web / pick-logged-in / set-login-status
```bash
python3 {baseDir}/scripts/main.py get <id>
python3 {baseDir}/scripts/main.py list-json all --limit 50
python3 {baseDir}/scripts/main.py pick-web 搜狐号
python3 {baseDir}/scripts/main.py set-login-status <id> 1
```
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`
见 [`references/PLATFORMS.md`](references/PLATFORMS.md);代码注册表按业务域分为 logistics / llm / content / dev / sim实际数量与别名以 `scripts/util/platforms.py` 为准)。
## 扩展文档(便于 AI 精确调用)
## 数据与环境
- **references/**:完整 CLI、平台表、运行时环境、错误码、集成协议 — 见 [references/README.md](references/README.md)。
- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。
库路径、profile 布局、环境变量见 [`references/RUNTIME.md`](references/RUNTIME.md);表结构见 [`references/SCHEMA.md`](references/SCHEMA.md)。
## 跨技能集成
stdout 协议与同进程 import 见 [`references/INTEGRATION.md`](references/INTEGRATION.md);登录 helper 细节见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
## Secret 存储
- `none`:无 secret浏览器 profile 登录态)
- `env`SQLite 保存环境变量名,运行时从 `os.environ` 读取
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
- `local_encrypted`Fernet 加密后存 SQLite`secret_ciphertext`),密钥从 `ACCOUNT_MANAGER_MASTER_KEY``master.key` 读取(须先 `account init-master-key`
详见 [`references/RUNTIME.md`](references/RUNTIME.md)。
## 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,6 +1,7 @@
# assets
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/``get` / `list-json` / `pick-*` 单条对象的轻量 JSON Schema`additionalProperties: true` 允许扩展字段)。
- `schemas/``get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema`additionalProperties: true` 允许扩展字段)。
详细命令与错误码见仓库根目录 `references/`
- 面向用户的介绍见 `references/README.md`
- 面向编排/CLI 的细节见 `references/CLI.md``ERRORS.md``INTEGRATION.md`(由 `SKILL.md` 渐进引用)。

View File

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

View File

@@ -1,12 +1,20 @@
{
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"login_status": 1,
"last_login_at": "2026-04-01T12:00:00",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"success": true,
"data": {
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
}

View File

@@ -1,26 +1,36 @@
[
{
"id": 2,
"name": "知乎1号",
"platform": "zhihu",
"phone": "13900001111",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
"url": "https://www.zhihu.com",
"login_status": 0,
"last_login_at": null,
"created_at": "2026-04-02T09:00:00",
"updated_at": "2026-04-02T09:00:00"
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
},
{
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"login_status": 1,
"last_login_at": "2026-04-01T12:00:00",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"id": 2,
"platform_key": "deepseek",
"account_label": "DeepSeek production key",
"login_id": "",
"login_id_type": "unknown",
"tenant_id": "",
"environment": "production",
"role": "api",
"provider_code": "deepseek",
"profile_dir": "",
"url": "https://chat.deepseek.com",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T10:00:00",
"updated_at": "2026-05-01T10:00:00"
}
]

View File

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

View File

@@ -1,116 +1,208 @@
# account-manager CLI 参考
# account-manager CLI 速查
入口:`{skillRoot}/scripts/main.py`(宿主可`{baseDir}` 技能根目录代入)。下文用 `python main.py` 表示在 `scripts/` 目录执行,或写成 `python {skillRoot}/scripts/main.py`
`{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py`,表示在 `{baseDir}/scripts/` 下执行
> **说明**:内置 `_USAGE`(无参数启动 CLI已列出加密三类子命令更完整的 JSON 约定仍以本文与源码为准。
## stdout / JSON 约定(集成必读)
- 若干命令输出 **单层 JSON 对象**(例如 `account pick-web``account get``account get-credential` 成功时),**没有**统一的 `{"success":true}` 外层包裹。
- `account add-web``account add-secret`(等多数字命令)成功时为 **`{"success": true, "data": {...}}`**。
- `credential pick` / `credential resolve` 成功时为 **`{"success": true, ...字段}`**。
- `platform list` / `platform get` 成功时为 **`{"success": true, "data": ...}`**。
- 失败行多为 **`{"success": false, "error": {"code": "...", "message": "..."}}`**(个别老旧路径仍可能打印 `ERROR:` 前缀文本,首行解析逻辑见 [INTEGRATION.md](INTEGRATION.md))。
---
## 子命令速查(按业务流程)
### A. 平台管理
| CLI | 说明 |
|---|---|
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
| `platform get <platform>` | 获取数据库中的平台详情 |
### B. 加密初始化v2
| CLI | 说明 |
|---|---|
| `account init-master-key` | 生成 master.key写入 `{数据根}/{用户}/account-manager/master.key` |
| `account init-master-key --print` | 生成密钥并 **单行 JSON** 打印完整 key备份用不落盘 |
| `account init-master-key --from-env` | 从 `ACCOUNT_MANAGER_MASTER_KEY` 读取合法 Fernet key 并写入文件 |
| `account init-master-key --rotate` | 覆盖现有 master.keystderr 会先打印警告;应先有 export 备份) |
### C. 账号管理v2 扩展)
| CLI | 说明 |
|---|---|
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
| `account get <id> [--with-credentials]` | 输出账号 JSON若带 `--with-credentials` 则附加关联凭据摘要) |
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
| `account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] [--role <role>] [--lease] [--ttl-sec 900] [--holder <h>] [--purpose <purpose>]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` |
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile仅查看 |
### D. 加密备份与迁移v2
| CLI | 说明 |
|---|---|
| `account export-credentials --plaintext` | 将所有凭据行导出为 JSON **数组** 打到 stdoutstderr 有人类可读警告);**必须**显式 `--plaintext` |
| `account import-credentials [--replace-all]` | 从 stdin 读 JSON 数组,仅对带 `plaintext` 的行调用加密插入;结果摘要打到 **stderr** |
### E. 凭据 / 租约 / 浏览器v1 兼容入口保留)
以下命令仍推荐使用,行为与 v1 时期一致,适合「只拿 API Key」或脚本兼容
| CLI | 说明 |
|---|---|
| `credential pick --platform <p> [--type <type>] [--environment <env>] [--role <role>] [--reveal]` | v1 兼容:挑选凭据 |
| `credential resolve --id <credential_id> [--reveal]` | v1 兼容:按凭据 id 解析 |
| `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 |
| `account open <id>` / `open <id>` | 浏览器查看 |
### 兼容入口(旧版别名)
| CLI | 等价 / 说明 |
|---|---|
| `get <id>` | `account get <id>` |
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
| `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
| `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | 映射到 `account add-web``login_id_type=phone`),不传 v2 扩展参数 |
| `delete id\|platform <...>` | 删除账号 |
---
## account add-web扩参示例
```bash
# 扫码登录的抖音账号
python main.py account add-web \
--platform douyin \
--label "douyin-creator-1" \
--auth-strategy qr_code_manual \
--session-persistent 1
```
- `--auth-strategy``password_auto` / `password_with_captcha` / `password_plus_2fa` / `qr_code_manual` / `per_session_manual` / `device_bound` / `client_certificate` / `api_token` / `sso_redirect`;省略时按平台默认(`seed_platforms` + `platforms.default_auth_strategy`,回填规则见 `db/platform_defaults.py`);非法值返回 **`ERR_AUTH_STRATEGY_NOT_SUPPORTED`**。
- `--session-persistent``0``1`;省略时由代码推导:`qr_code_manual``password_plus_2fa``device_bound``sso_redirect``1``per_session_manual``client_certificate``0`;其余默认 `1`
- `--device-fingerprint`**仅当** `--auth-strategy=device_bound` 时允许设置;否则 **`ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE`**。
- 成功 stdout`{"success": true, "data": { ... }}``data` 内含 `id``platform_key``profile_dir``auth_strategy``session_persistent``device_fingerprint` 等。
---
## account add-secret`local_encrypted` 示例)
```bash
echo "my-icbc-password" | python main.py account add-secret \
--platform icbc_sim \
--credential-type password \
--secret-storage local_encrypted \
--secret-stdin \
--label "工行仿真主账号"
```
- `local_encrypted` **必须** `--secret-stdin`(否则 **`ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN`**)。
- 允许的 `credential_type``password``api_key``api_token``bearer_token``refresh_token``oauth_client``browser_profile` / `note` 会被 **`ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED`** 拒绝)。
- stdin 为空:**`ERR_LOCAL_ENCRYPTED_EMPTY_STDIN`**。
- master key 未就绪:**`ERR_MASTER_KEY_MISSING`**。
- 成功:`{"success": true, "data": {"credential_id", "account_id", "platform_key", "credential_type", "secret_storage", "secret_mask"}}`
---
## account get-credential示例
```bash
# 不 reveal仅返回 metadata + mask
python main.py account get-credential 42
# reveal必须带合法 lease-token通常来自 pick-web --lease
LEASE=$(python main.py account pick-web --platform icbc_sim --lease --holder my-rpa | jq -r .lease_token)
python main.py account get-credential 42 --reveal --lease-token "$LEASE"
```
-`--reveal`:单行 JSON`success`/`account_id`/`credential_id`/`secret_storage`/`secret_mask` 等。
- `--reveal``secret_storage=local_encrypted` 等在 lease 校验通过后追加 **`plaintext`**;若底层存储为 `none`,返回 **`plaintext: null`** 及说明字段(并非抛错)。
- 常见错误:`ERR_LEASE_INVALID``ERR_CREDENTIAL_MISSING``ERR_MASTER_KEY_MISSING``ERR_CREDENTIAL_DECRYPT_FAILED`(见 [ERRORS.md](ERRORS.md))。
---
## account export-credentials / import-credentials示例
```bash
# 备份stdout 为 JSON 数组,含解密后的 plaintext务必妥善销毁
python main.py account export-credentials --plaintext > backup-2026-05-11.json
# 在新机器恢复(需先 init-master-key使 master key 可用)
python main.py account import-credentials --replace-all < backup-2026-05-11.json
```
- 未带 `--plaintext`**`ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG`**(单行 JSON
- `import-credentials` 成功时 **stdout 通常为空**,进度与计数在 **stderr**
---
## account init-master-key示例
```bash
# 首次初始化(写入 master.key
python main.py account init-master-key
# 打印 key 备份到密码管理器(不落盘)
python main.py account init-master-key --print
# 从环境变量恢复写入文件
ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-master-key --from-env
```
- 文件已存在且未 `--rotate`**`ERR_MASTER_KEY_EXISTS`**JSON
- `--from-env` 字符串非法:**`ERR_MASTER_KEY_INVALID`**。
---
## account pick-web字段
成功时为 **单行 JSON 对象**(非 `_ok_json` 包裹)。核心字段:
- `id`, `platform_key`, `account_label`, `login_id`, `profile_dir`, `url`, `tenant_id`, `environment`, `role`, `provider_code`, `session_status`
- **v2**`auth_strategy`, `session_persistent`, `device_fingerprint`
-`--lease``lease_token`, `lease_expires_at`ISO8601 **本地时间**字符串,由内部 Unix 秒字段转换而来)
---
## 9 种 `auth_strategy` 简表(参考)
| 策略键 | 适用场景 | 是否要 plaintext典型 | 是否要人介入 |
|---|---|---|---|
| `password_auto` | 标准账密表单 | 是 | 否 |
| `password_with_captcha` | 带图形验证码 | 是 | 是(填码阶段) |
| `password_plus_2fa` | 短信/邮件/OTP 2FA | 是 | 是OTP 阶段) |
| `qr_code_manual` | 扫码登录 | 否 | 是(扫码阶段) |
| `per_session_manual` | 银行/政务等每次手动 | 否 | 是 |
| `device_bound` | 绑机器 / U-Key 等 | 可选(有密码则自动填) | 视情况 |
| `client_certificate` | 客户端证书 + PIN | — | 不可自动化rpa_helpers 抛错) |
| `api_token` | 无浏览器登录 | 否 | 否 |
| `sso_redirect` | OAuth / SSO 跳转 | 否 | 是SSO 页阶段) |
业务 skill 同进程调用详见 [RPA_HELPERS.md](RPA_HELPERS.md)。
---
## 通用说明
- **平台参数**:可为 `scripts/util/platforms.py`配置的**中文展示名、别名或英文键**(如 `sohu``搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读子命令**`get``list-json``pick-logged-in``pick-web``set-login-status` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
- **平台参数**:可为 `scripts/util/platforms.py`的英文键、`display_name`、任一 alias不区分大小写)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
- **机器可读集成**见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码**:见 [ERRORS.md](ERRORS.md)。
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
## 内置 `_USAGE`(阶段 F
---
## 子命令一览
### `add` — 添加账号
```bash
python main.py add <平台> <手机号>
```
- 手机号为**中国大陆 11 位**`1` 开头,第二位 `39`),可含空格/分隔符,入库前会归一成数字。
- 同一 `platform` 下同一手机号不可重复。
### `list` — 列出账号(人类可读多行块)
```bash
python main.py list [平台|all|全部]
```
- 省略第二参数时等价于 `all`
- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`
### 简写:`python main.py <平台>` / `python main.py <平台> list`
- 若第一个参数能解析为平台,则等价于 `list <平台>`
### `get` — 按 id 输出**单行 JSON**
```bash
python main.py get <id>
```
- 成功:`stdout` **仅一行** JSON 对象UTF-8
- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。
### `list-json` — 列表的**单行 JSON 数组**(跨技能)
```bash
python main.py list-json <平台|all|全部> [--limit N]
```
- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。
- 默认 `limit=200`,实际 clamp 为 `1..500`
- 排序:按 `updated_at` 倒序。
### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON
```bash
python main.py pick-logged-in <平台>
```
- 筛选:`login_status = 1`,按 `last_login_at` 优先。
- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。
### `pick-web` — 网页自动化候选账号(单行 JSON
```bash
python main.py pick-web <平台>
```
- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条。
- 失败:首行 `ERROR:`
### `set-login-status` — 回写登录态(跨技能)
```bash
python main.py set-login-status <id> <0|1>
```
- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS``OK:CLEARED_LOGIN_STATUS`,退出码 `0`
- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`
### `open` — 仅打开浏览器(**不写库**
```bash
python main.py open <id>
```
- 需本机 Chrome/Edge + Playwright详见运行环境说明。
### `login` — 打开浏览器并**自动检测登录**,写回 `login_status`
```bash
python main.py login <id>
```
- 依赖 Playwright超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
### `delete` — 删除库记录并尽量删除 profile 目录
```bash
python main.py delete id <id>
python main.py delete platform <平台>
python main.py delete platform <平台> <手机号>
```
- 第二段必须为 `id``platform`(小写不敏感)。
---
## 无参数 / 未知子命令
- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`
无参数运行 CLI例如 `python scripts/main.py` / `python -m cli.app`cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。

View File

@@ -1,56 +1,150 @@
# 终端前缀约定account-manager
# 终端错误码account-manager
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:``OK:` 开头;多行说明可忽略或仅作展示
约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定
## `OK:`(成功标记
## 机器 JSON 失败格式(常见
| 首行 | 场景 |
|------|------|
| `OK:SET_LOGIN_STATUS` | `set-login-status <id> 1` 成功 |
| `OK:CLEARED_LOGIN_STATUS` | `set-login-status <id> 0` 成功 |
```json
{
"success": false,
"error": {
"code": "……",
"message": "……"
}
}
```
## `ERROR:` — CLI 参数 / 路由
部分 CLI例如遗留 `ERROR:INVALID_PLATFORM`)仍可能在成功行前额外打印 hint——请以 [`INTEGRATION.md`](INTEGRATION.md) 的建议解析顺序为准。
---
## ERROR:CLI — CLI 参数 / 路由
| 前缀 | 含义 |
|------|------|
|---|---|
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS` | `set-login-status` 参数不足 |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS` | `pick-logged-in` 缺少平台 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
## `ERROR:` — 业务与数据
---
## ERROR: — 数据库 / 通用
| 前缀 | 含义 |
|------|------|
| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 |
| `ERROR:PHONE_REQUIRED` | 缺少手机号 |
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in``login_status=1` 的账号 |
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 |
| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 |
|---|---|
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
## `ERROR:` — 浏览器 / 环境
---
## ERROR: — 平台与账号
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwrightpip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|---|---|
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
---
## ERROR: — 租约
| 前缀 | 含义 |
|---|---|
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
---
## ERROR: — 凭据与 Secret
| 前缀 | 含义 |
|---|---|
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
---
## ERROR: — 浏览器 / 环境
| 前缀 | 含义 |
|---|---|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
## 非前缀输出
---
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
- **`get` / `list-json` / `pick-*` 成功**`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON
## v2 新增错误码(机器可读 JSON`code` 字段通常 **无** `ERROR:` 前缀)
### 加密 / Master Key
| 错误码 | 触发场景 |
|---|---|
| `ERR_MASTER_KEY_MISSING` | 环境变量与 `master.key` 均未解析到合法密钥 |
| `ERR_MASTER_KEY_EXISTS` | `init-master-key` 时文件已存在且未 `--rotate` |
| `ERR_MASTER_KEY_INVALID` | `--from-env` / 环境变量提供字符串无法归一成 Fernet key |
| `ERR_CREDENTIAL_DECRYPT_FAILED` | Fernet 解密失败(`CredentialDecryptError`,典型为密文损坏或 key 不匹配) |
| `ERR_CREDENTIAL_NOT_ENCRYPTED` | `db.credentials_repo.read_credential_plaintext``secret_storage='none'` 抛出 **`CredentialNotEncryptedError`**CLI `get-credential --reveal` **会捕获并返回 `plaintext: null`**(非致命 JSON 错误) |
### 凭据导入导出
| 错误码 | 触发场景 |
|---|---|
| `ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG` | `export-credentials` 未带 `--plaintext` |
### `add-web` 扩参
| 错误码 | 触发场景 |
|---|---|
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `--auth-strategy` 不在允许集合 / 账号缺少策略(同进程 dispatcher 亦使用) |
| `ERR_INVALID_SESSION_PERSISTENT` | `--session-persistent` 不是 `0``1` |
| `ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE` | 非 `device_bound` 策略却传入 `--device-fingerprint` |
### `add-secret` + `local_encrypted`
| 错误码 | 触发场景 |
|---|---|
| `ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN` | `local_encrypted` 未配合 `--secret-stdin` |
| `ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED` | `credential_type` 不允许加密(如 `browser_profile` / `note` |
| `ERR_LOCAL_ENCRYPTED_EMPTY_STDIN` | stdin 无内容 |
### `get-credential` / 访问控制
| 错误码 | 触发场景 |
|---|---|
| `ERR_LEASE_INVALID` | `--reveal` 时 lease 缺失、过期或不匹配账号 |
| `ERR_CREDENTIAL_MISSING` | 账号下无 `status=active` 的凭据 |
---
## rpa_helpers同进程异常非 CLI
详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。`AuthStrategyError.code` 取值:
| 错误码 | 触发场景 |
|---|---|
| `ERR_AUTH_STRATEGY_NOT_SUPPORTED` | `auth_strategy` 为空或 dispatcher 不识别的字符串 |
| `ERR_CREDENTIAL_MISSING` | 策略需要 `credentials["plaintext"]` 但未提供 |
| `ERR_AUTH_STRATEGY_NOT_AUTOMATABLE` | `client_certificate` |
| `ERR_DEVICE_FINGERPRINT_MISMATCH` | `device_bound` 机器指纹与账号不一致 |
---
## 非前缀输出提示
- **`pick-web` / `account get` / `account list`** 成功stdout 多为单行 JSON不一定含 `success` 字段)。
- **失败**:优先解析 JSON否则兼容 `ERROR:` 文本。

View File

@@ -1,39 +1,212 @@
# 与其它 Skill 集成account-manager
## 核心约束
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
## 两种集成方式
| 方式 | 适用场景 | 调用形态 |
|---|---|---|
| **A. CLI subprocessv1 起)** | 任意业务 skill一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
| **B. rpa_helpers 同进程 importv2** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
常见组合:**CLI** 负责 pick / lease / `get-credential --reveal`**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
## 推荐调用方式
使用 **`subprocess`** 调用技能根目录下的 `scripts/main.py`,工作目录可为技能根或 `scripts/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致否则写到错误库
使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`必须与最终用户会话一致——否则写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)
## 机器可读命令摘要
| 目的 | 命令 | 成功时 stdout |
|------|------|----------------|
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 |
| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 |
| 回写登录态 | `set-login-status <id> <0或1>` | 首行 `OK:...` |
| 目的 | 命令 | 成功时 stdout(摘要) |
|---|---|---|
| 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
| 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**stderr 有警告行) |
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr |
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
| 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` |
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
## 解析协议(强建议)
更完整的命令表见 [`CLI.md`](CLI.md)。
1. 读取子进程 **stdout** 全文或首行。
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`)。
4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
## 解析协议(稳妥做法)
## JSON 对象字段(与 `get` 一致)
1. 读取子进程 **stdout**;若有多行,**集成应以首行为准**export/import 例外:`export` 整块 stdout 即 JSON`import` 读 stderr
2. **优先尝试 `json.loads`**
- 若为 dict 且 `success is False` → 读取 `error.code` / `error.message`
- 若 dict 且 `success is True` 且存在 `data` → 业务字段通常在 `data` 内(`add-web` / `add-secret` / `platform`)。
3. 若 JSON 首字段不是上述统一格式(例如 `pick-web`)→ 直接按字段读取。
4. **遗留文本失败**:首行以 **`ERROR:`** 开头 → 视为失败(旧别名路径仍可能出现)。
典型字段见仓库 `assets/examples/get-response.json``assets/schemas/account-record.schema.json`
## JSON 对象字段
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `login_status`, `last_login_at`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
### `account pick-web`(典型)
## Publisher / 自动化流水线示例
```json
{
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"session_status": "unknown",
"auth_strategy": "qr_code_manual",
"session_persistent": 1,
"device_fingerprint": null,
"lease_token": "…………",
"lease_expires_at": "2026-05-11T12:34:56"
}
```
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url`
2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login <id>` 或先 `open <id>` 人工登录。
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
`lease_*` 仅在传入 `--lease` 时出现;时间与仓库 `_unix_to_iso_local` 一致
### `account get`
输出即数据库映射字典(字段与 [`SCHEMA.md`](SCHEMA.md) `accounts` 一致,`extra_json` 等为结构化 JSON
### `credential pick`
```json
{
"success": true,
"id": 1,
"platform_key": "deepseek",
"credential_label": "DeepSeek production key",
"credential_type": "api_key",
"secret_storage": "env",
"secret_ref": "DEEPSEEK_API_KEY",
"secret_mask": "****7890",
"status": "active"
}
```
`--reveal` 时附加 **`secret`**。请勿向终端用户打印 reveal 结果。
### `account get-credential`
成功示例:
```json
{
"success": true,
"account_id": 42,
"credential_id": 7,
"credential_type": "password",
"secret_storage": "local_encrypted",
"secret_mask": "****abcd",
"plaintext": "……"
}
```
失败时使用统一 JSON`{"success": false, "error": {"code": "...", "message": "..."}}`(亦可能夹杂历史 `ERROR:` 文本命令——见 [`ERRORS.md`](ERRORS.md))。
## rpa_helpers 同进程使用(摘要)
```python
import sys
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
sys.path.insert(0, f"{AM_ROOT}/scripts")
from service.rpa_helpers import (
launch_browser_with_profile,
close_browser,
ensure_logged_in,
)
```
**务必**`sys.path` 指向 **`account-manager/scripts`**,因此 import 形态为 **`from service.rpa_helpers ...`**(而不是 `from scripts.service.rpa_helpers ...`)。
完整 API、登录判定 hook、`LoginResult` / `AuthStrategyError` 说明见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
## 业务流程示例
### 物流 RPACLI
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
2. 解析 JSON → 取 `id``profile_dir``url``lease_token`
3.`profile_dir` 启动持久化浏览器上下文
4. 业务完成后:`lease release <lease_token>`
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
### LLM API 调用CLI
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
2. 解析 JSON → 取 `secret` 字段
3. 仅在内存中使用,不写磁盘、不打日志
### 加密密码 + 自动登录CLI + rpa_helpers
```python
import json
import subprocess
import sys
AM = r"D:\OpenClaw\client-commons\account-manager"
PY = sys.executable
MAIN = f"{AM}/scripts/main.py"
sys.path.insert(0, f"{AM}/scripts")
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
def am_json(argv: list[str]) -> dict:
r = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
line = (r.stdout or "").splitlines()[0] if r.stdout else ""
return json.loads(line)
# 1) pick 账号 + lease
acc = am_json(
[
"account",
"pick-web",
"--platform",
"icbc_sim",
"--lease",
"--holder",
"my-rpa",
]
)
# 2) 取明文密码(必须 reveal + lease
cred = am_json(
[
"account",
"get-credential",
str(acc["id"]),
"--reveal",
"--lease-token",
acc["lease_token"],
]
)
# 3) 浏览器 + 登录
ctx, page = launch_browser_with_profile(acc["profile_dir"])
try:
result = ensure_logged_in(page, acc, credentials={"plaintext": cred["plaintext"]})
if not result.ok:
print(result.message, file=sys.stderr)
sys.exit(1)
# …业务逻辑…
finally:
close_browser(ctx)
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
```
## 退出码
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀****JSON 可解析性** 为准,不要仅依赖退出码。
请以 **stdout JSON / `ERROR:` 前缀** 为准;不要仅依赖退出码。

View File

@@ -1,23 +1,101 @@
# 平台与别名account-manager
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
入库字段 `accounts.platform` 使用下表 **英文键**小写。CLI 与用户话术可使用 **展示名****别名**
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias大小写不敏感
| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL |
|---------------------|------------------|---------------------------|------------------|----------|
| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | (同 label | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com |
| `kimi` | Kimi | (同 label | 月之暗面 | https://kimi.moonshot.cn |
| `deepseek` | DeepSeek | (同 label | — | https://chat.deepseek.com |
| `doubao` | 豆包 | (同 label | — | https://www.doubao.com |
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
---
## 解析规则(摘要
## 物流平台(`domain = logistics`9
- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。
- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM``ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 URL |
|---|---|---|---|---|
| `maersk` | Maersk | maersk | 马士基 | https://www.maersk.com |
| `cosco` | COSCO | cosco | 中远海运 | (空) |
| `msc` | MSC | msc | 地中海航运 | (空) |
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 | (空) |
| `evergreen` | Evergreen | evergreen | 长荣 | (空) |
| `cargo_wise` | CargoWise | cargo_wise | CargoWise, Cargo Wise | (空) |
| `freightos` | Freightos | freightos | Freightos | (空) |
| `webcargo` | WebCargo | webcargo | WebCargo, web cargo | (空) |
| `sinotrans` | Sinotrans | sinotrans | 中外运 | (空) |
---
## LLM / AI 平台(`domain = llm`10
其中 `openai``anthropic` 在注册表中 **`capabilities.web = false`**,以 API Key 场景为主;其余条目多为 Web + API。
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `deepseek` | DeepSeek | DeepSeek | https://chat.deepseek.com |
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | Gemini、谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
| `minimax` | MiniMax | MiniMax | (空) |
| `openai` | OpenAI API | OpenAI、GPT、ChatGPT API | https://platform.openai.com |
| `anthropic` | Anthropic | Anthropic、Claude API | https://console.anthropic.com |
---
## 内容平台(`domain = content`8
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `sohu` | 搜狐号 | 搜狐、搜狐号 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条、头条号 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | 知乎、知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 公众号、微信、微信公众号 | https://mp.weixin.qq.com |
| `douyin` | 抖音 | 抖音、Douyin | https://www.douyin.com |
| `wechat_mp` | 微信公众号 | 微信公众号、公众号、WeChat MP | https://mp.weixin.qq.com |
| `xiaohongshu` | 小红书 | 小红书、Xiaohongshu、RED | https://www.xiaohongshu.com |
| `bilibili` | 哔哩哔哩 | B站、哔哩哔哩、Bilibili | https://www.bilibili.com |
---
## 开发控制台(`domain = dev`3
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `github` | GitHub | GitHub、github | https://github.com/login |
| `aws` | AWS | AWS、Amazon Web Services、亚马逊云 | https://console.aws.amazon.com |
| `gcp` | GCP | GCP、Google Cloud、谷歌云 | https://console.cloud.google.com |
---
## 仿真平台(`domain = sim`1
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `icbc_sim` | 工行仿真 | 工行仿真、ICBC Simulator、ICBC SIM | http://localhost:5180 |
---
## `platform list --domain` 说明
CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cmd_platform_list`)。`dev` / `sim` 域平台请 **省略 `--domain`** 获取完整列表后在 JSON 中筛选,或直接 `platform get <key>`
---
## 内置默认 `auth_strategy`
`db/platform_defaults.py` 中的 `PLATFORM_DEFAULT_AUTH_STRATEGY` 提供 **17** 个平台的初始映射(迁移时写入 `platforms.default_auth_strategy`,可被数据库手工覆盖):
| 平台键 | 默认 `auth_strategy` |
|---|---|
| `maersk`, `cosco`, `msc` | `qr_code_manual` |
| `douyin`, `wechat_mp`, `xiaohongshu`, `bilibili`, `toutiao`, `sohu` | `qr_code_manual` |
| `github`, `aws`, `gcp` | `password_plus_2fa` |
| `icbc_sim` | `password_auto` |
| `openai`, `anthropic`, `deepseek`, `doubao` | `api_token` |
其余注册平台若 DB 中 `default_auth_strategy` 为空,则在创建账号时回落到 `resolve_auth_strategy_for_platform`(内置映射 → 否则 `password_auto`)。业务可在 `add-web` 显式传入 `--auth-strategy`
---
## 平台注册到数据库
`seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。

View File

@@ -1,13 +1,61 @@
# referencesaccount-manager
---
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
---
供 Agent 按需加载的说明文档(与 `SKILL.md` 配合,渐进式披露)。
# 🔐 账号与凭据管理
| 文件 | 内容 |
|------|------|
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
| [ERRORS.md](ERRORS.md) | `ERROR:` / `OK:` 前缀一览 |
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
示例 JSON 与 Schema 见仓库 `assets/`
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己散落密码、API Key而是通过 CLI或与 CLI 组合的同进程 [`RPA_HELPERS.md`](RPA_HELPERS.md))获取。
## 它能帮你做什么
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`Fernet 密文入库)。
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发;输出含 `auth_strategy` / `session_persistent` / `device_fingerprint`
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
- **加密存储密码** — Fernet 加密,密码在 SQLite 中以密文形式存在;只有持有 master key 才能解密。
- **9 种登录策略** — 账密自动、账密+图形验证码、账密+2FA、扫码、每次手动登录、设备绑定、客户端证书、API Token、SSO 跳转;业务 skill 可调用统一入口 `ensure_logged_in`(见 [`RPA_HELPERS.md`](RPA_HELPERS.md))。
- **加密备份与迁移** — `export-credentials --plaintext` 导出 JSON`import-credentials` 在新机器重新加密写入(需先 `init-master-key`)。
## 你可以这样对它说
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
- 「添加 DeepSeek 的 API Key存在 Windows 凭据管理器里。」
- 「列出所有物流平台的账号。」
- 「用浏览器打开 3 号账号看看页面。」
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
- 「我要把工行仿真账号的密码加密保存,下次 RPA 自动登录用。」
- 「给我导出所有加密凭据,明天换台电脑要用。」
## 你需要提供什么
| 场景 | 你需要给的 |
|---|---|
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
| 添加 API Key / 加密密码 | **平台名称** + **凭据类型**api_key 等)+ **secret**stdin / 环境变量引用等) |
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge |
## 它不做什么
- 不在此处包办所有网站的 DOM 自动化细节(具体抓取逻辑在各业务 skill
- 不代替内容管理、发布、大模型调用等业务技能
- `windows_credential` / `env` 路径下不把明文写入 SQLite`local_encrypted` 仅存 Fernet 密文
## Secret 存储方式
| 方式 | 说明 |
|---|---|
| `none` | 无 secret浏览器 profile 登录态) |
| `env` | 引用环境变量名,运行时读取 |
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
| `local_encrypted` | 本地 Fernet 加密存 DB需先 `account init-master-key` |
命令细节见 [`CLI.md`](CLI.md);加密与环境变量见 [`RUNTIME.md`](RUNTIME.md)。
---
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0

218
references/RPA_HELPERS.md Normal file
View File

@@ -0,0 +1,218 @@
# rpa_helpers 库 — 业务 skill 同进程登录
`scripts/service/rpa_helpers/` 是 account-manager v2 提供给业务 skill 的 **同进程 Python 库**。业务代码调用 **`ensure_logged_in`**,库按账号记录的 **`auth_strategy`** 分发到对应登录流程(底层基于 Playwright
## 与 CLI 的关系
| 路径 | 职责 |
|---|---|
| **CLI subprocess** | 获取结构化账号 JSON、租约、`get-credential --reveal` 明文密码;进程隔离、易于跨语言 |
| **rpa_helpers** | 在已有 `BrowserContext`/`Page` 上执行登录编排与人类化输入 |
推荐组合:**CLI → pick / lease → get-credential → `launch_browser_with_profile` + `ensure_logged_in`**。
## 集成方式
### Import 路径
```python
import sys
AM_ROOT = r"D:\OpenClaw\client-commons\account-manager"
sys.path.insert(0, f"{AM_ROOT}/scripts")
from service.rpa_helpers import (
AuthStrategyError,
LoginResult,
close_browser,
ensure_logged_in,
launch_browser_with_profile,
)
```
**务必**`sys.path` 追加的是 **`…/account-manager/scripts`**,因此模块命名空间为 `service.rpa_helpers`**不要**写成 `from scripts.service.rpa_helpers …`)。
### 运行时依赖
- Python **3.10+**
- 已安装 **`playwright`** Python 包(宿主 runtime 提供)
- 本机已安装 **Chrome 或 Edge**(启动时使用 channel 模式,优先 Chrome失败回落 Edge——见 `service.browser_service.resolve_chromium_channel`
文档不提供 `pip install …` 指令;版本由匠厂宿主统一管理。
---
## 公开 API 一览
下列符号由 `service/rpa_helpers/__init__.py` **导出(共 19 个名字)**
| 分类 | 符号 |
|---|---|
| **dispatcher + 策略函数** | `ensure_logged_in`,以及 `ensure_logged_in_password_auto``ensure_logged_in_password_with_captcha``ensure_logged_in_password_plus_2fa``ensure_logged_in_qr_code_manual``ensure_logged_in_per_session_manual``ensure_logged_in_device_bound``ensure_logged_in_client_certificate``ensure_logged_in_api_token``ensure_logged_in_sso_redirect` |
| **结果 / 异常** | `LoginResult``AuthStrategyError` |
| **浏览器** | `launch_browser_with_profile``close_browser` |
| **人类化输入** | `pause``human_type``human_click``human_select``human_scroll_to` |
---
## `browser` 模块
### `launch_browser_with_profile(profile_dir, *, channel=None, headless=False, extra_args=None, locale="zh-CN") -> tuple[BrowserContext, Page]`
- 创建持久化上下文(`user_data_dir=profile_dir`),注入可选 stealth 脚本。
- `channel` 默认为宿主检测结果Chrome 优先)。
- **必须在 `finally` 中调用 `close_browser(context)`**,否则 playwright driver 可能泄漏。
### `close_browser(ctx) -> None`
关闭上下文并停止内部的 Playwright session manager。
---
## `human` 模块(人类化操作)
| 函数 | 行为 |
|---|---|
| `pause(min_ms=200, max_ms=800)` | 随机等待 |
| `human_type(locator, text, *, per_char_ms_range=(50, 200), pre_pause=True)` | 滚动进入视图 → 停顿 → `click` → 逐字符 `type` |
| `human_click(locator)` | 滚动 → 停顿 → `click` |
| `human_select(locator, value_or_label)` | 滚动 → 停顿 → `select_option`(依次尝试 value / label |
| `human_scroll_to(locator)` | 平滑滚动到元素 |
_locator_ 均为 Playwright `Locator`
---
## `auth_flows` 模块
### `ensure_logged_in(page, account, *, credentials=None, **kwargs) -> LoginResult`
1. 读取 `account["auth_strategy"]`。为空字符串 → 抛出 `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`
2. 若策略需要密码则要求 `credentials["plaintext"]`;缺失 → `ERR_CREDENTIAL_MISSING`
3. 分派到对应 `ensure_logged_in_*`
4. 未知策略 → `AuthStrategyError(ERR_AUTH_STRATEGY_NOT_SUPPORTED)`
附加 kwargs 会透传给具体策略(如 `captcha_value``wait_human_sec`)。
### `LoginResult`
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | `bool` | 流程是否达到判定条件 |
| `needs_human` | `bool` | 是否需要人工协助(扫码 / OTP / SSO 等) |
| `message` | `str` | 描述信息(便于日志) |
### `AuthStrategyError`
属性:`code: str``message: str`。错误码与 CLI / [`ERRORS.md`](ERRORS.md) 对齐。
### 九个 `ensure_logged_in_*` 入口
业务代码通常只调用 `ensure_logged_in`。如需针对性 reuse可直接 import `auth_flows` 中的同名函数;签名见源码 `scripts/service/rpa_helpers/auth_flows.py`
---
## 已登录检测(`_is_already_logged_in`
核心逻辑:
1. 读取当前 `page.url`;空白页 → **False**
2. 仅在账号 `extra_json`(可为 dict 或 JSON 字符串)中出现以下 hook 之一才可能返回 True
- **`logged_in_url_pattern`**:正则,匹配当前 URL。
- **`logged_in_selector`**CSS/XPath selector`locator.first.is_visible(timeout=500)`
3. 若无任一 hook → **默认 False****除非已有持久化 session**,否则会完整跑登录流程。
### 接入建议
1. 首次上线:`auth_strategy=qr_code_manual` + 无 hook → 用户扫码登录。
2. 登录成功后记录典型 URL 片段或 DOM selector。
3. 写入账号 `extra_json`,例如:
```python
{
"logged_in_url_pattern": r"/user/",
"logged_in_selector": "div.avatar-wrapper"
}
```
4. 下次启动 → `_is_already_logged_in` 命中 → `needs_human` 可能降低。
### `per_session_manual` 的差异
该策略 **开场不会调用 `_is_already_logged_in`**,总是导航到业务 URL 并等待人工完成登录(适合强制会话)。
---
## 设备绑定策略(`device_bound`
如账号设置了 `device_fingerprint`,库会使用 `_current_machine_fingerprint()`hostname + MAC 派生)比对;不一致抛出 **`ERR_DEVICE_FINGERPRINT_MISMATCH`**。
---
## 客户端证书策略(`client_certificate`
直接抛出 **`ERR_AUTH_STRATEGY_NOT_AUTOMATABLE`**;请在宿主机手动装载证书后使用其它路径衔接。
---
## 完整示例CLI + rpa_helpers
```python
import json
import subprocess
import sys
AM = r"D:\OpenClaw\client-commons\account-manager"
PY = sys.executable
MAIN = f"{AM}/scripts/main.py"
sys.path.insert(0, f"{AM}/scripts")
from service.rpa_helpers import launch_browser_with_profile, close_browser, ensure_logged_in
def am_json(argv: list[str]) -> dict:
proc = subprocess.run([PY, MAIN, *argv], capture_output=True, text=True, check=False)
line = (proc.stdout or "").splitlines()[0]
return json.loads(line)
acc = am_json(
["account", "pick-web", "--platform", "icbc_sim", "--lease", "--holder", "monitor-rates"]
)
cred = am_json(
[
"account",
"get-credential",
str(acc["id"]),
"--reveal",
"--lease-token",
acc["lease_token"],
]
)
plaintext = cred.get("plaintext")
if plaintext in (None, ""):
raise RuntimeError("no plaintext returned; check secret_storage / lease")
ctx, page = launch_browser_with_profile(acc["profile_dir"])
try:
result = ensure_logged_in(page, acc, credentials={"plaintext": plaintext})
if not result.ok:
raise RuntimeError(result.message)
# …业务代码…
finally:
close_browser(ctx)
subprocess.run([PY, MAIN, "lease", "release", acc["lease_token"]], check=False)
```
---
## 测试建议
- account-manager 仓库内置 **107** 个 pytest截至 v2.0.0 文档修订时),覆盖 CLI / schema / rpa_helpers mock 路径。
- 业务 skill
- **单元测试**patch `ensure_logged_in` → 返回 `LoginResult(ok=True, …)`
- **端到端**:在 Windows + 真浏览器环境标记 `@pytest.mark.integration`,禁止在 CI 泄露明文密码。
更多 CLI 细节参见 [`CLI.md`](CLI.md)、[`INTEGRATION.md`](INTEGRATION.md)。

View File

@@ -1,54 +1,81 @@
# 运行环境与数据路径account-manager
## 数据落盘位置(当前实现)
## 数据落盘位置
代码见 `scripts/util/runtime_paths.py``scripts/util/constants.py`
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(兼容 **`JIANGCHANG_*` / `CLAW_*` 别名**`scripts/util/runtime_paths.py` 在此基础上拼接技能子目录
- **数据根目录** `get_data_root()`:优先环境变量 **`JIANGCHANG_DATA_ROOT`**;未设置时 Windows 默认 `D:\jiangchang-data`,其它系统默认 `~/.jiangchang-data`
- **用户/工作空间 id** `get_user_id()`:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**
- **数据根目录**:默认读取宿主注入变量;开发模式下未注入时可能落到 Windows `D:\jiangchang-data` 等(详见 runtime_env 实现)
- **用户 / 工作空间 id**:默认 `_anon`(以实际注入为准)
- **技能数据目录**`{数据根}/{用户id}/account-manager/`
- **SQLite**`account-manager.db`
- **Profile**`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成
- **日志目录**`logs/`(见下)
> OpenClaw 工作区文档中的 **`CLAW_*`** 变量:本技能**当前仍以 `JIANGCHANG_*` 为准**读取数据根与用户 id若宿主已统一注入 `CLAW_*`,需在代码层做别名兼容(未做前请只依赖本文列出的变量)。
- **Master Key**`master.key`(仅当执行过 `account init-master-key` 且未仅用环境变量场景
- **Profile**`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
## 调试为什么「list 没数据」
- 本地终端未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID` 时,会落到 **默认盘路径****`_anon`**,与网关中运行的实例**不是同一个库文件**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际使用的根目录、用户 id 与数据库路径(由 `_maybe_print_paths_debug_cli` 输出)
- 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**或宿主等价开关CLI 会在 **stderr** 打印实际路径
## 本地 CLI 开发注入(慎用)
## 本地 CLI 默认值
`util/constants.py`**`_apply_cli_local_dev_env()`** 仅在 CLI 入口调用
各技能 `main.py` 调用 `apply_cli_local_defaults()`
- 若开启本地开发开关(代码内 `_ACCOUNT_MANAGER_CLI_LOCAL_DEV` 或环境变量 **`JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1`**),且当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入代码内配置的默认路径与用户 id
- **发版前务必关闭**,避免个人路径进入制品。详见该文件内注释
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值
- 发版前改为 `False` 或依赖宿主注入
## Secret 存储
| 方式 | 说明 | 适用场景 |
|---|---|---|
| `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
| `local_encrypted` | Fernet 密文存 SQLite`secret_ciphertext`),密钥来自 master key | RPA 密码落地;需先 `account init-master-key` |
- **禁止**把明文写入日志。
- `credential pick --reveal` / `credential resolve --reveal` 仍会输出明文 secret——仅限受控环境。
## Master Key
| 变量 | 作用 |
|---|---|
| `ACCOUNT_MANAGER_MASTER_KEY` | Fernet key典型为 url-safe Base64 **44** 字符);**优先级高于** `master.key` 文件 |
**加载优先级(解密路径)**:环境变量 → `master.key` 文件 → `ERR_MASTER_KEY_MISSING`
**安全建议**
- 生产环境优先 **环境变量 / Secret Manager** 注入,不在磁盘留 `master.key`
- 桌面环境可使用文件(默认尽力写入私有权限)。
- 备份 master key 至密码管理器;**丢失将导致所有 `local_encrypted` 凭据不可解密**。
详见 [`CLI.md`](CLI.md)`init-master-key`)、[`ERRORS.md`](ERRORS.md)。
## 日志
`scripts/util/logging_config.py`
| 变量 | 作用 |
|------|------|
|---|---|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{技能数据目录}/logs/account-manager.log` |
| `JIANGCHANG_LOG_FILE` | 覆盖日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 登录 / 浏览器子进程login
## 浏览器(`account open` / rpa_helpers
`service/browser_service.py` 与子进程 `service/login_child_runner.py`
- 本机需安装 Chrome 或 Edge。
- Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
- 使用 **channel 模式**`chrome` / `msedge`**不下载 Chromium**。
- **不要执行** `playwright install chromium`
| 变量 | 作用 |
|------|------|
| `JIANGCHANG_LOGIN_TIMEOUT_SECONDS` | 登录检测最长秒数,默认 `300`,不少于 `60` |
| `JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS` | DOM 轮询间隔,默认 `1.5`,限制在 `0.5..10` |
| `JIANGCHANG_LOGIN_DOM_GRACE_SECONDS` | 打开 URL 后等待再检测,默认 `4`,减轻跳转误判 |
### 关键浏览器参数(摘录)
依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright`** 且已执行 `playwright install chromium`(错误信息见 CLI 输出)。
- `headless=False`
- `no_viewport=True`
- `args` 包含 `--start-maximized`
- stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`
## 常见问题(简要)
### rpa_helpers
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看技能日志文件。
登录编排封装见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。

144
references/SCHEMA.md Normal file
View File

@@ -0,0 +1,144 @@
# 数据库表结构account-manager v2
## 数据库路径
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
(或宿主映射的 `CLAW_DATA_ROOT` / `CLAW_USER_ID`,解析细节见 [`RUNTIME.md`](RUNTIME.md)。)
## Schema 版本
- `PRAGMA user_version = 2`
- `_schema_meta.schema_version = "2.1"`(字符串;迁移完成后取值)
启动时 `ensure_schema` 自动执行 `migrate_v1_to_v2``migrate_v2_to_v2_1`,幂等。
---
## 1. `platforms` — 平台注册表
| 字段 | 类型 | 说明 |
|---|---|---|
| `platform_key` | TEXT PK | 平台唯一键(`maersk` / `deepseek` / `sohu` …) |
| `display_name` | TEXT NOT NULL | 展示名 |
| `domain` | TEXT NOT NULL | `logistics` / `llm` / `content` / `dev` / `sim` / …(以代码注册为准) |
| `provider_code` | TEXT | 供应商代码 |
| `default_url` | TEXT | 默认入口 URL |
| `aliases_json` | TEXT | 别名 JSON 数组 |
| `capabilities_json` | TEXT | `{"web": bool, "api_key": bool, "rpa": bool}` |
| `enabled` | INTEGER | 是否启用1/0 |
| `default_auth_strategy` | TEXT | 新账号默认 `auth_strategy`;可由迁移回填 |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_platforms_domain``idx_platforms_provider_code`
---
## 2. `accounts` — 账号身份
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `platform_key` | TEXT NOT NULL | 对应 `platforms.platform_key` |
| `account_label` | TEXT NOT NULL | 人可读名称 |
| `login_id` | TEXT | 登录标识(可空) |
| `login_id_type` | TEXT | `email` / `username` / `phone` / … |
| `tenant_id` | TEXT | 租户,可空 |
| `environment` | TEXT | `simulator` / `staging` / `production` / … |
| `role` | TEXT | `booking` / `tracking` / `api` / … |
| `provider_code` | TEXT | 冗余供应商码 |
| `profile_dir` | TEXT | Playwright profile 目录 |
| `url` | TEXT | 账号入口 URL |
| `status` | TEXT | `active` / `disabled` / … |
| `session_status` | TEXT | `unknown` / `likely_valid` / … |
| `last_used_at` | INTEGER | Unix 秒 |
| `last_login_check_at` | INTEGER | Unix 秒 |
| `last_error_code` | TEXT | 文本错误码 |
| `last_error_message` | TEXT | 错误说明 |
| `extra_json` | TEXT | 扩展 JSON可含 `logged_in_url_pattern` / `logged_in_selector` 等 hook |
| `auth_strategy` | TEXT NOT NULL | 九种策略之一(见 [`PLATFORMS.md`](PLATFORMS.md) / `db/auth_strategy.py` |
| `session_persistent` | INTEGER NOT NULL DEFAULT 1 | `0`/`1` |
| `device_fingerprint` | TEXT | 仅 `device_bound` |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_accounts_platform``idx_accounts_pick_web``idx_accounts_provider``idx_accounts_status`
---
## 3. `credentials` — 凭据API Key / Token / Password …)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER | 关联 `accounts.id`,可空(历史平台级 Key |
| `platform_key` | TEXT NOT NULL | 平台键 |
| `credential_label` | TEXT NOT NULL | 展示名称 |
| `credential_type` | TEXT NOT NULL | `api_key` / `password` / … |
| `secret_storage` | TEXT NOT NULL | `none` / `env` / `windows_credential` / `local_encrypted` |
| `secret_ref` | TEXT | `env` 变量名 / `windows_credential` target其它后端可为空 |
| `secret_mask` | TEXT | 打码展示 |
| `secret_ciphertext` | TEXT | `local_encrypted` 专用 Fernet tokenv2.1 增加) |
| `expires_at` | INTEGER | Unix 秒 |
| `status` | TEXT | `active` / `disabled` / … |
| `last_used_at` | INTEGER | Unix 秒 |
| `extra_json` | TEXT | JSON |
| `created_at` | INTEGER | Unix 秒 |
| `updated_at` | INTEGER | Unix 秒 |
**原则**:不把 **明文** secret 写入 SQLite`local_encrypted` 仅存 **密文**。外部后端env / Windows CM仍在运行时解析。
**索引:** `idx_credentials_platform_type``idx_credentials_status`**`idx_credentials_account``migrate_v1_to_v2``CREATE INDEX IF NOT EXISTS`**
---
## 4. `account_leases` — RPA 并发锁
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER NOT NULL | `accounts.id` |
| `lease_token` | TEXT UNIQUE | 随机 token |
| `holder` | TEXT NOT NULL | 持有者标识 |
| `purpose` | TEXT | 用途 |
| `expires_at` | INTEGER NOT NULL | Unix 秒 UTC |
| `heartbeat_at` | INTEGER | Unix 秒 |
| `released_at` | INTEGER | Unix 秒 |
| `status` | TEXT | `active` / `released` / `expired` |
| `created_at` | INTEGER | Unix 秒 |
| `updated_at` | INTEGER | Unix 秒 |
**索引:** `idx_leases_account_status``idx_leases_token``idx_leases_expires`
---
## 5. `_schema_meta` — Schema 元数据
| 字段 | 类型 | 说明 |
|---|---|---|
| `key` | TEXT PRIMARY KEY | 键名 |
| `value` | TEXT NOT NULL | 值 |
**已知键**
- `schema_version` — 逻辑 schema 版本字符串:`migrate_v1_to_v2` 完成后写入 `"2"``migrate_v2_to_v2_1` 完成后更新为 **`"2.1"`**。
---
## Profile 文件布局
```
{DATA_ROOT}/{USER_ID}/account-manager/
├── account-manager.db
├── master.key # 若执行过 init-master-key也可用环境变量代替
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
删除账号时实现会尽量同步删除对应目录。
## 与 JSON 输出的对应关系
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,125 +1,289 @@
"""CLI 入口:用法说明与 argv 分发。"""
import sys
# -*- coding: utf-8 -*-
"""
CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
platform list/get
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
init-master-key|export-credentials|import-credentials
credential pick|resolve
lease release|list|cleanup
(legacy) add|list|get|list-json|pick-web|open|delete
Machine commands output single-line JSON where appropriate.
"""
import sys
from typing import Optional
from service.account_crypto_commands import (
cmd_account_export_credentials,
cmd_account_import_credentials,
cmd_account_init_master_key,
)
from service.account_service import (
cmd_add,
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
cmd_account_mark_error,
cmd_account_mark_session,
cmd_account_mark_used,
cmd_account_pick_web,
cmd_credential_pick,
cmd_credential_resolve,
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_get,
cmd_lease_cleanup,
cmd_lease_list,
cmd_lease_release,
cmd_list,
cmd_list_json,
cmd_pick_logged_in,
cmd_pick_web,
cmd_set_login_status,
cmd_platform_get,
cmd_platform_list,
)
from service.browser_service import cmd_login, cmd_open
from util.constants import _apply_cli_local_dev_env
from util.logging_config import get_skill_logger
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
from util.runtime_paths import _maybe_print_paths_debug_cli
# =========================================================================
# Help text
# =========================================================================
def _cli_print_full_usage() -> None:
"""总览(未带子命令或需要完整说明时)。"""
print("用法概览(将 main.py 换为你的路径):")
print(" python main.py add <平台中文名> <手机号>")
print(" python main.py list [平台|all|全部]")
print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR")
print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号")
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
print(" python main.py get <id>")
print(" python main.py set-login-status <id> <0|1> # 跨技能回写登录态")
print(" python main.py open <id>")
print(" python main.py login <id>")
print(" python main.py delete id <id>")
print(" python main.py delete platform <平台>")
print(" python main.py delete platform <平台> <手机号>")
print(" python main.py <平台> # 等价于只列该平台")
print(" python main.py <平台> list # 同上")
print()
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu")
print(" " + _platform_list_cn_for_help())
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
平台管理:
platform list [--domain logistics|llm|content]
platform get <platform>
账号管理:
account add-web --platform <p> --login-id <id> --login-id-type <type>
[--label <label>] [--tenant-id <t>] [--environment <env>]
[--role <role>] [--provider-code <code>] [--url <url>]
[--extra-json <json>] [--profile-dir <path>]
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
account add-secret --platform <p> --credential-type <type>
--secret-storage env|windows_credential|none|local_encrypted
[--secret-ref <env_var>] [--secret-stdin]
[--label <label>] [--account-id <id>]
[--environment <env>] [--role <role>]
account get <id> [--with-credentials]
account get-credential <account_id> [--reveal --lease-token <token>]
account list [--platform <p>] [--environment <env>]
[--tenant-id <t>] [--role <role>] [--limit N]
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
[--role <role>] [--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>]
account mark-session <id> --session-status <status>
account mark-error <id> --code <code> --message <msg>
account mark-used <id>
account open <id>
account init-master-key [--print|--from-env|--rotate]
account export-credentials --plaintext
account import-credentials [--replace-all] < backup.json
凭据管理:
credential pick --platform <p> [--type <type>] [--environment <env>]
[--role <role>] [--reveal]
credential resolve --id <credential_id> [--reveal]
租约管理:
lease release <lease_token>
lease list [--active]
lease cleanup
浏览器:
account open <id>
open <id> # 兼容别名
兼容入口(旧版):
get <id>
list-json <platform|all> [--limit N]
pick-web <platform>
list [platform|all]
add <platform> <phone>
delete id|platform <...>
"""
def _cli_fail_add() -> None:
print("ERROR:CLI_ADD_MISSING_ARGS")
print()
print("【add 添加账号】参数不足。")
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
print(" 必填:中国大陆 11 位手机号1 开头,第二位 39同平台下不可重复")
print()
print("示例:")
print(" python main.py add 搜狐号 13800138000")
print(" python main.py add 知乎 13900001111")
print()
print("支持的平台:" + _platform_list_cn_for_help())
# =========================================================================
# Argument parsing helpers
# =========================================================================
def _get_opt(argv: list[str], flag: str, default=None):
"""Get value of --flag from argv, returning default if not found."""
if flag in argv:
idx = argv.index(flag)
if idx + 1 < len(argv):
return argv[idx + 1]
return default
def _cli_fail_need_account_id(subcmd: str) -> None:
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
print()
print(f"{subcmd}】缺少必填参数:账号 id数据库自增整数可先 list 查看)。")
print("示例:")
print(f" python main.py {subcmd} 1")
def _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _cli_fail_delete() -> None:
print("ERROR:CLI_DELETE_MISSING_ARGS")
print()
print("【delete 删除账号】参数不足。支持三种方式:")
print(" 按 idpython main.py delete id <id>")
print(" 按平台删该平台下全部python main.py delete platform <平台中文名或英文键>")
print(" 按平台+手机号python main.py delete platform <平台> <手机号>")
print()
print("示例:")
print(" python main.py delete id 3")
print(" python main.py delete platform 搜狐号")
print(" python main.py delete platform 头条号 18925203701")
print()
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
v = _get_opt(argv, flag)
if v is not None:
try:
return int(v)
except ValueError:
return default
return default
# =========================================================================
# Main dispatch
# =========================================================================
def main() -> None:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
log = get_skill_logger()
if len(sys.argv) < 2:
_cli_print_full_usage()
print(_USAGE)
sys.exit(1)
_apply_cli_local_dev_env()
get_skill_logger().info("cli_start argv=%s", sys.argv)
cmd = sys.argv[1]
log.info("cli_start argv=%s", sys.argv)
av = sys.argv
cmd = av[1]
_maybe_print_paths_debug_cli()
# Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd)
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
cmd_list(plat_from_first)
elif plat_from_first and len(sys.argv) == 2:
cmd_list(plat_from_first)
elif cmd == "add":
if len(sys.argv) < 4:
_cli_fail_add()
# ---- platform subcommand ----
if cmd == "platform":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get")
sys.exit(1)
cmd_add(sys.argv[2], sys.argv[3])
elif cmd == "list":
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
sub = av[2]
if sub == "list":
domain = _get_opt(av, "--domain")
cmd_platform_list(domain)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1)
cmd_platform_get(av[3])
else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
sys.exit(1)
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_cmd_add_web(av)
elif sub == "add-secret":
_cmd_add_secret(av)
elif sub == "get-credential":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get-credential 需要 <account_id>。")
sys.exit(1)
_cmd_get_credential(av)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
sys.exit(1)
aid = av[3]
with_creds = _has_flag(av, "--with-credentials")
cmd_account_get(aid, include_credentials=with_creds)
elif sub == "list":
_cmd_account_list(av)
elif sub == "pick-web":
_cmd_pick_web_new(av)
elif sub == "mark-session":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
sys.exit(1)
aid = av[3]
ss = _get_opt(av, "--session-status", "unknown")
cmd_account_mark_session(aid, ss)
elif sub == "mark-error":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
sys.exit(1)
aid = av[3]
ec = _get_opt(av, "--code", "")
em = _get_opt(av, "--message", "")
cmd_account_mark_error(aid, ec, em)
elif sub == "mark-used":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
sys.exit(1)
aid = av[3]
cmd_account_mark_used(aid)
elif sub == "open":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
sys.exit(1)
cmd_open(av[3])
elif sub == "init-master-key":
cmd_account_init_master_key(av)
elif sub == "export-credentials":
cmd_account_export_credentials(av)
elif sub == "import-credentials":
cmd_account_import_credentials(av)
else:
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
print(_USAGE)
sys.exit(1)
# ---- credential subcommand ----
elif cmd == "credential":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS credential 需要子命令pick | resolve")
sys.exit(1)
sub = av[2]
if sub == "pick":
_cmd_cred_pick(av)
elif sub == "resolve":
_cmd_cred_resolve(av)
else:
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
sys.exit(1)
# ---- lease subcommand ----
elif cmd == "lease":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令release | list | cleanup")
sys.exit(1)
sub = av[2]
if sub == "release":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1)
cmd_lease_release(av[3])
elif sub == "list":
active = _has_flag(av, "--active")
cmd_lease_list(active)
elif sub == "cleanup":
cmd_lease_cleanup()
else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
sys.exit(1)
# ---- Legacy aliases ----
elif cmd == "get":
if len(sys.argv) < 3:
_cli_fail_need_account_id("get")
print("ERROR:CLI_GET_MISSING_ID")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "set-login-status":
if len(sys.argv) < 4:
print("ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS")
print("用法python main.py set-login-status <account_id> <0|1>")
print("说明:供 llm-manager 等脚本回写登录态;成功时 stdout 首行以 OK: 开头。")
sys.exit(1)
cmd_set_login_status(sys.argv[2], sys.argv[3])
elif cmd == "list-json":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
print("用法python main.py list-json <平台|all|全部> [--limit N]")
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
sys.exit(1)
av = sys.argv[2:]
platform_arg = av[0]
@@ -132,33 +296,45 @@ def main() -> None:
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-logged-in":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS")
print("用法python main.py pick-logged-in <平台中文名或英文键>")
print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。")
sys.exit(1)
cmd_pick_logged_in(sys.argv[2])
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
print("用法python main.py pick-web <平台中文名或英文键>")
print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)。")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
if len(sys.argv) < 3:
_cli_fail_need_account_id("open")
print("ERROR:CLI_OPEN_MISSING_ID")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "login":
if len(sys.argv) < 3:
_cli_fail_need_account_id("login")
elif cmd == "list":
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
cmd_list(platform_arg)
elif cmd == "add":
if len(sys.argv) < 4:
print("ERROR:CLI_ADD_MISSING_ARGS")
sys.exit(1)
cmd_login(sys.argv[2])
# Legacy add maps to add-web for backward compat
cmd_account_add_web(
platform_input=sys.argv[2],
login_id=sys.argv[3],
login_id_type="phone",
label=None,
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
elif cmd == "delete":
if len(sys.argv) < 4:
_cli_fail_delete()
print("ERROR:CLI_DELETE_MISSING_ARGS")
sys.exit(1)
mode = (sys.argv[2] or "").strip().lower()
if mode == "id":
@@ -169,17 +345,177 @@ def main() -> None:
else:
cmd_delete_by_platform(sys.argv[3])
else:
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]},请使用 id 或 platform")
_cli_fail_delete()
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
sys.exit(1)
# ---- Platform shorthand: "python main.py <platform>" ----
elif plat_from_first:
cmd_list(sys.argv[1])
# ---- Unknown ----
else:
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法")
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
print()
_cli_print_full_usage()
print()
print("说明list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
print(_USAGE)
sys.exit(1)
# =========================================================================
# Sub-parsers for complex commands
# =========================================================================
def _cmd_add_web(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
login_id = _get_opt(argv, "--login-id")
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "default")
provider_code = _get_opt(argv, "--provider-code")
url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir")
auth_strategy = _get_opt(argv, "--auth-strategy")
session_persistent_raw = _get_opt(argv, "--session-persistent")
device_fingerprint = _get_opt(argv, "--device-fingerprint")
session_persistent: Optional[int] = None
if session_persistent_raw is not None:
try:
session_persistent = int(session_persistent_raw)
except ValueError:
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数0 或 1")
return
if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return
cmd_account_add_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
url=url,
extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
)
def _cmd_get_credential(argv: list[str]) -> None:
aid_raw = argv[3]
try:
aid = int(aid_raw)
except ValueError:
print(f"ERROR:CLI_BAD_ARGS account_id 必须是整数:{aid_raw}")
return
reveal = _has_flag(argv, "--reveal")
lease_token = _get_opt(argv, "--lease-token")
cmd_account_get_credential(aid, reveal=reveal, lease_token=lease_token)
def _cmd_add_secret(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--credential-type", "api_key")
label = _get_opt(argv, "--label")
storage = _get_opt(argv, "--secret-storage", "env")
stdin_mode = _has_flag(argv, "--secret-stdin")
sref = _get_opt(argv, "--secret-ref")
aid = _get_int_opt(argv, "--account-id", 0) or None
env = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "api")
extra_json_str = _get_opt(argv, "--extra-json")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
return
cmd_account_add_secret(
platform_input=plat,
credential_type=ctype,
label=label,
secret_storage=storage,
secret_stdin=stdin_mode,
secret_ref=sref,
account_id=aid,
environment=env,
role=role,
extra_json_str=extra_json_str,
)
def _cmd_account_list(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
limit=limit,
)
def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
cmd_account_pick_web(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")
env = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
reveal = _has_flag(argv, "--reveal")
if not plat:
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
return
cmd_credential_pick(
platform_input=plat,
credential_type=ctype,
environment=env,
role=role,
reveal=reveal,
)
def _cmd_cred_resolve(argv: list[str]) -> None:
cid = _get_int_opt(argv, "--id", 0)
reveal = _has_flag(argv, "--reveal")
if cid <= 0:
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
return
cmd_credential_resolve(cid, reveal=reveal)
if __name__ == "__main__":
main()

View File

@@ -1,19 +1,31 @@
"""Accounts table: CRUD and queries (no stdout)."""
# -*- coding: utf-8 -*-
"""
Accounts, Credentials, Leases, and Platforms CRUD (no stdout).
All functions operate on a connection (passed or opened locally).
Time fields are INTEGER Unix seconds (UTC).
"""
import json
import time
from datetime import datetime
from typing import Any, Optional
from db.auth_strategy import ALL_AUTH_STRATEGIES, AUTH_STRATEGY_PASSWORD_AUTO
from db.connection import get_conn, init_db
from util.platforms import PLATFORM_URLS, _PLATFORM_NAME_CN
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
"""对外 JSON本地时区 ISO8601 字符串,便于人读。"""
"""Convert Unix timestamp to local ISO8601 string, or None."""
if ts is None:
return None
try:
@@ -22,248 +34,743 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
return None
def normalize_account_id(account_id):
if account_id is None:
_UNSET = object()
def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str:
"""Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto."""
cur = conn.cursor()
cur.execute(
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
(platform_key,),
)
row = cur.fetchone()
if row and row[0]:
s = str(row[0])
if s in ALL_AUTH_STRATEGIES:
return s
mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key)
if mapped and mapped in ALL_AUTH_STRATEGIES:
return mapped
return AUTH_STRATEGY_PASSWORD_AUTO
def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
dev_fp = row[20]
return {
"id": row[0],
"platform_key": row[1],
"account_label": row[2],
"login_id": row[3] or "",
"login_id_type": row[4],
"tenant_id": row[5] or "",
"environment": row[6],
"role": row[7],
"provider_code": row[8] or "",
"profile_dir": (row[9] or "").strip(),
"url": row[10] or "",
"status": row[11],
"session_status": row[12],
"last_used_at": _unix_to_iso_local(row[13]),
"last_login_check_at": _unix_to_iso_local(row[14]),
"last_error_code": row[15] or "",
"last_error_message": row[16] or "",
"extra_json": _safe_json_loads(row[17]),
"auth_strategy": row[18],
"session_persistent": int(row[19]) if row[19] is not None else 1,
"device_fingerprint": dev_fp if dev_fp is not None else None,
"created_at": _unix_to_iso_local(row[21]),
"updated_at": _unix_to_iso_local(row[22]),
}
# ============================================================================
# Platforms
# ============================================================================
def platform_exists(conn, platform_key: str) -> bool:
cur = conn.cursor()
cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,))
return cur.fetchone() is not None
def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
cur = conn.cursor()
if domain:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"default_auth_strategy, created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
(domain,),
)
else:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"default_auth_strategy, created_at, updated_at FROM platforms ORDER BY platform_key"
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"default_auth_strategy": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
})
return result
def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
cur = conn.cursor()
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"default_auth_strategy, created_at, updated_at FROM platforms WHERE platform_key = ?",
(platform_key,),
)
r = cur.fetchone()
if not r:
return None
s = str(account_id).strip()
if s.isdigit():
return int(s)
return s
return {
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"default_auth_strategy": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
}
def default_name_for_platform(platform: str, index: int) -> str:
cn = _PLATFORM_NAME_CN.get(platform)
if cn:
return f"{cn}{index}"
return f"{platform}_{index}"
# ============================================================================
# Accounts — CRUD
# ============================================================================
def insert_account(
conn,
platform_key: str,
account_label: str,
login_id: Optional[str],
login_id_type: str,
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
profile_dir: Optional[str],
url: Optional[str],
extra_json: Optional[str],
now: int,
auth_strategy: str,
session_persistent: int = 1,
device_fingerprint: Optional[str] = None,
) -> int:
if auth_strategy not in ALL_AUTH_STRATEGIES:
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
if session_persistent not in (0, 1):
raise ValueError("session_persistent must be 0 or 1")
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts
(platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
status, session_status, extra_json,
auth_strategy, session_persistent, device_fingerprint,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?, ?, ?, ?)
""",
(
platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
extra_json or "{}", auth_strategy, session_persistent, device_fingerprint,
now, now,
),
)
return int(cur.lastrowid)
def get_account_by_id(account_id):
init_db()
aid = normalize_account_id(account_id)
def get_account_by_id(account_id) -> Optional[dict]:
"""Get a single account by id. Returns dict or None."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"""
SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json,
SELECT id, platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code,
profile_dir, url, status, session_status,
last_used_at, last_login_check_at,
last_error_code, last_error_message, extra_json,
auth_strategy, session_persistent, device_fingerprint,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(aid,),
(int(account_id),),
)
row = cur.fetchone()
if not row:
return None
acc: dict[str, Any] = {
"id": row[0],
"name": row[1],
"platform": row[2],
"phone": row[3] or "",
"profile_dir": (row[4] or "").strip(),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"login_status": int(row[6] or 0),
"last_login_at": _unix_to_iso_local(row[7]),
"created_at": _unix_to_iso_local(row[9]),
"updated_at": _unix_to_iso_local(row[10]),
}
if row[8]:
try:
extra = json.loads(row[8])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
pass
return acc
return _acc_row_to_dict(row)
finally:
conn.close()
def mark_login_status(account_id, success: bool):
now = _now_unix()
def find_accounts(
platform_key: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
status: Optional[str] = None,
provider_code: Optional[str] = None,
limit: int = 200,
) -> list[dict]:
"""Find accounts by optional filters. Returns list of dicts."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = []
params = []
if platform_key:
conditions.append("platform_key = ?")
params.append(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)
if status:
conditions.append("status = ?")
params.append(status)
if provider_code:
conditions.append("provider_code = ?")
params.append(provider_code)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
sql = (
"SELECT id, platform_key, account_label, login_id, login_id_type, "
"tenant_id, environment, role, provider_code, profile_dir, url, "
"status, session_status, last_used_at, last_login_check_at, "
"last_error_code, last_error_message, extra_json, "
"auth_strategy, session_persistent, device_fingerprint, "
"created_at, updated_at "
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
)
params.append(int(limit))
cur.execute(sql, params)
return [_acc_row_to_dict(row) for row in cur.fetchall()]
finally:
conn.close()
def find_account_ids(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
) -> list[int]:
"""Return account IDs matching filters, excluding those with active leases."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["a.status = 'active'", "a.platform_key = ?"]
params = [platform_key]
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if tenant_id:
conditions.append("a.tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("a.role = ?")
params.append(role)
# Exclude accounts with active non-expired leases
now = _now_unix()
conditions.append(
"a.id NOT IN (SELECT account_id FROM account_leases "
"WHERE status = 'active' AND expires_at > ?)"
)
params.append(now)
where = " AND ".join(conditions)
cur.execute(
f"SELECT a.id FROM accounts a WHERE {where} "
"ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, "
"a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC",
params,
)
return [int(row[0]) for row in cur.fetchall()]
finally:
conn.close()
def update_account_last_used(account_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
if success:
cur.execute(
"UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?",
(now, now, account_id),
)
else:
cur.execute(
"UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?",
(now, account_id),
)
cur.execute(
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def fetch_list_rows(platform_key: str, limit: int):
"""platform_key 'all' or platform slug; returns list of row tuples (full row)."""
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, name, platform, phone, profile_dir, url, login_status, "
"last_login_at, extra_json, created_at, updated_at FROM accounts "
)
if platform_key == "all":
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
else:
cur.execute(
sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?",
(platform_key, int(limit)),
)
return cur.fetchall()
finally:
conn.close()
def fetch_ids_for_list_json(platform_key: str, lim: int):
conn = get_conn()
try:
cur = conn.cursor()
if platform_key == "all":
cur.execute(
"SELECT id FROM accounts ORDER BY updated_at DESC, id DESC LIMIT ?",
(lim,),
)
else:
cur.execute(
"SELECT id FROM accounts WHERE platform = ? "
"ORDER BY updated_at DESC, id DESC LIMIT ?",
(platform_key, lim),
)
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
def fetch_pick_logged_in_id(platform_key: str) -> Optional[int]:
def update_account_session_status(account_id: int, session_status: str) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ? AND login_status = 1
ORDER BY (last_login_at IS NULL), last_login_at DESC
LIMIT 1
""",
(platform_key,),
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
(session_status, _now_unix(), _now_unix(), account_id),
)
row = cur.fetchone()
return int(row[0]) if row else None
conn.commit()
finally:
conn.close()
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
def update_account_error(
account_id: int,
error_code: str,
error_message: str,
) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ? AND login_status = 1
ORDER BY (last_login_at IS NULL), last_login_at DESC
LIMIT 1
""",
(platform_key,),
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
(error_code, error_message, _now_unix(), account_id),
)
row = cur.fetchone()
if not row:
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
return int(row[0]) if row else None
conn.commit()
finally:
conn.close()
def has_duplicate_phone_conn(conn, platform_key: str, phone_store: str) -> bool:
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(
"SELECT id FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_store),
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
tuple(params),
)
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?",
(platform_key, login_id, environment, role),
)
return cur.fetchone() is not None
def count_platform_accounts_conn(conn, platform_key: str) -> int:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform_key,))
return int(cur.fetchone()[0])
# ============================================================================
# Credentials
# ============================================================================
def insert_account_row(
def insert_credential(
conn,
name: str,
account_id: Optional[int],
platform_key: str,
phone_store: str,
profile_dir: str,
url: 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,
secret_ciphertext: Optional[str] = None,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)
INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, status,
extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(name, platform_key, phone_store, profile_dir, url, now, now),
(
account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def fetch_delete_row_by_id_conn(conn, aid: int):
def get_credential_by_id(credential_id: int) -> Optional[dict]:
conn = get_conn()
try:
conn.row_factory = None
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 id = ?",
(credential_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
}
finally:
conn.close()
def find_credentials(
platform_key: str,
credential_type: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
status: str = "active",
limit: int = 50,
) -> list[dict]:
"""Find credentials, optionally joining accounts for environment/role filter."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["c.status = ?"]
params = [status]
conditions.append("c.platform_key = ?")
params.append(platform_key)
if credential_type:
conditions.append("c.credential_type = ?")
params.append(credential_type)
# If environment/role filters provided, join accounts table
if environment or role:
conditions.append("c.account_id = a.id")
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if role:
conditions.append("a.role = ?")
params.append(role)
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c, accounts a WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
else:
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
rows = cur.fetchall()
result = []
for row in rows:
result.append({
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
})
return result
finally:
conn.close()
def update_credential_last_used(credential_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), credential_id),
)
conn.commit()
finally:
conn.close()
# ============================================================================
# Leases
# ============================================================================
def acquire_lease(
account_id: int,
lease_token: str,
holder: str,
purpose: Optional[str],
ttl_sec: int,
) -> dict:
"""
Acquire a lease for an account. Expires active leases first.
Returns the lease record dict on success.
Raises ValueError if a non-expired active lease already exists.
"""
log = get_skill_logger()
log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec)
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
now = _now_unix()
# Mark any expired active leases as expired
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE account_id = ? AND status = 'active' AND expires_at <= ?",
(now, account_id, now),
)
if cur.rowcount > 0:
log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount)
# Check for existing active non-expired lease
cur.execute(
"SELECT id, lease_token, holder FROM account_leases "
"WHERE account_id = ? AND status = 'active' AND expires_at > ?",
(account_id, now),
)
existing = cur.fetchone()
if existing:
log.warning(
"lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s",
account_id, existing[2], existing[1][:8],
)
raise ValueError(
f"Account {account_id} already has an active lease "
f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)"
)
expires_at = now + max(1, int(ttl_sec))
cur.execute(
"""
INSERT INTO account_leases
(account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
""",
(account_id, lease_token, holder, purpose, expires_at, now, now),
)
conn.commit()
lease_id = int(cur.lastrowid)
log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8])
return {
"id": lease_id,
"account_id": account_id,
"lease_token": lease_token,
"holder": holder,
"purpose": purpose,
"expires_at": _unix_to_iso_local(expires_at),
"status": "active",
"created_at": _unix_to_iso_local(now),
}
finally:
conn.close()
def release_lease(lease_token: str) -> bool:
"""Release a lease by token. Returns True if found and released."""
log = get_skill_logger()
log.info("lease_release_attempt token_prefix=%s", lease_token[:8])
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
"WHERE lease_token = ? AND status = 'active'",
(now, now, lease_token),
)
conn.commit()
if cur.rowcount == 0:
log.warning("lease_release_not_found token_prefix=%s", lease_token[:8])
return False
log.info("lease_release_success token_prefix=%s", lease_token[:8])
return True
finally:
conn.close()
def list_active_leases() -> list[dict]:
"""List all active (non-expired, non-released) leases."""
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"SELECT id, account_id, lease_token, holder, purpose, expires_at, "
"heartbeat_at, released_at, status, created_at, updated_at "
"FROM account_leases WHERE status = 'active' AND expires_at > ? "
"ORDER BY created_at DESC",
(now,),
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"id": r[0],
"account_id": r[1],
"lease_token": r[2],
"holder": r[3],
"purpose": r[4] or "",
"expires_at": _unix_to_iso_local(r[5]),
"heartbeat_at": _unix_to_iso_local(r[6]),
"released_at": _unix_to_iso_local(r[7]),
"status": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
})
return result
finally:
conn.close()
def cleanup_expired_leases() -> int:
"""Mark all expired active leases as expired. Returns count cleaned."""
log = get_skill_logger()
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE status = 'active' AND expires_at <= ?",
(now, now),
)
count = cur.rowcount
conn.commit()
if count > 0:
log.info("lease_cleanup_expired count=%s", count)
return count
finally:
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, name, platform, phone, profile_dir FROM accounts WHERE id = ?",
(aid,),
"""
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),
)
return cur.fetchone()
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 delete_account_by_id_conn(conn, rid: int) -> None:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE id = ?", (rid,))
# ============================================================================
# Internal helpers
# ============================================================================
def fetch_ids_profile_for_platform_conn(conn, platform_key: str):
cur = conn.cursor()
cur.execute(
"SELECT id, profile_dir FROM accounts WHERE platform = ?",
(platform_key,),
)
return cur.fetchall()
def delete_accounts_by_platform_conn(conn, platform_key: str) -> None:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE platform = ?", (platform_key,))
def fetch_row_platform_phone_conn(conn, platform_key: str, phone_norm: str):
cur = conn.cursor()
cur.execute(
"SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
)
return cur.fetchone()
def delete_account_platform_phone_conn(conn, platform_key: str, phone_norm: str) -> None:
cur = conn.cursor()
cur.execute(
"DELETE FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
)
def _safe_json_loads(val: Any, default: Any = None) -> Any:
if val is None:
return default
try:
return json.loads(val) if isinstance(val, str) else val
except (json.JSONDecodeError, TypeError):
return default

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""Auth strategy identifiers for accounts (v2 schema).
Imported by migration, repositories, and later CLI/service layers.
"""
AUTH_STRATEGY_PASSWORD_AUTO = "password_auto"
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA = "password_with_captcha"
AUTH_STRATEGY_PASSWORD_PLUS_2FA = "password_plus_2fa"
AUTH_STRATEGY_QR_CODE_MANUAL = "qr_code_manual"
AUTH_STRATEGY_PER_SESSION_MANUAL = "per_session_manual"
AUTH_STRATEGY_DEVICE_BOUND = "device_bound"
AUTH_STRATEGY_CLIENT_CERTIFICATE = "client_certificate"
AUTH_STRATEGY_API_TOKEN = "api_token"
AUTH_STRATEGY_SSO_REDIRECT = "sso_redirect"
ALL_AUTH_STRATEGIES = frozenset(
{
AUTH_STRATEGY_PASSWORD_AUTO,
AUTH_STRATEGY_PASSWORD_WITH_CAPTCHA,
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
AUTH_STRATEGY_QR_CODE_MANUAL,
AUTH_STRATEGY_PER_SESSION_MANUAL,
AUTH_STRATEGY_DEVICE_BOUND,
AUTH_STRATEGY_CLIENT_CERTIFICATE,
AUTH_STRATEGY_API_TOKEN,
AUTH_STRATEGY_SSO_REDIRECT,
}
)

View File

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

View File

@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
"""Credentials repository.
包含加密路径secret_storage='local_encrypted')与现存非加密路径
none/env/windows_credential的统一访问。
"""
from __future__ import annotations
import json
from typing import Any, Optional
from db.accounts_repo import _unix_to_iso_local, insert_credential as _insert_credential_raw
from service.crypto import CredentialDecryptError, decrypt_secret
from service.secret_store import delete_secret, mask_secret, read_secret
CREDENTIAL_TYPES = frozenset(
{
"password",
"api_key",
"api_token",
"bearer_token",
"oauth_client",
"refresh_token",
"browser_profile",
"note",
}
)
ALLOWED_SECRET_STORAGE = frozenset({"none", "env", "windows_credential", "local_encrypted"})
class CredentialNotEncryptedError(Exception):
"""Credential has no decryptable plaintext via this API (e.g. storage=none)."""
code = "ERR_CREDENTIAL_NOT_ENCRYPTED"
def _validate_credential_type(credential_type: str) -> None:
if credential_type not in CREDENTIAL_TYPES:
raise ValueError(f"invalid credential_type: {credential_type!r}")
def _validate_storage(secret_storage: str) -> None:
if secret_storage not in ALLOWED_SECRET_STORAGE:
raise ValueError(f"invalid secret_storage: {secret_storage!r}")
def insert_credential_encrypted(
conn,
*,
account_id: Optional[int],
platform_key: str,
credential_label: str,
credential_type: str,
plaintext: str,
expires_at: Optional[int],
extra_json: Optional[str],
now: int,
) -> int:
_validate_credential_type(credential_type)
if plaintext is None or str(plaintext).strip() == "":
raise ValueError("plaintext required for encrypted credential insert")
from service.crypto import encrypt_secret
ciphertext = encrypt_secret(str(plaintext))
mask = mask_secret(str(plaintext))
return _insert_credential_raw(
conn,
account_id,
platform_key,
credential_label,
credential_type,
"local_encrypted",
None,
mask,
expires_at,
extra_json,
now,
secret_ciphertext=ciphertext,
)
def insert_credential_external(
conn,
*,
account_id: Optional[int],
platform_key: str,
credential_label: str,
credential_type: str,
secret_storage: str,
secret_ref: Optional[str],
secret_mask: Optional[str],
expires_at: Optional[int],
extra_json: Optional[str],
now: int,
) -> int:
_validate_credential_type(credential_type)
_validate_storage(secret_storage)
if secret_storage == "local_encrypted":
raise ValueError("use insert_credential_encrypted for local_encrypted")
return _insert_credential_raw(
conn,
account_id,
platform_key,
credential_label,
credential_type,
secret_storage,
secret_ref,
secret_mask,
expires_at,
extra_json,
now,
secret_ciphertext=None,
)
def read_credential_plaintext(conn, credential_id: int) -> str:
cur = conn.cursor()
cur.execute(
"""
SELECT secret_storage, secret_ref, secret_ciphertext
FROM credentials WHERE id = ?
""",
(int(credential_id),),
)
row = cur.fetchone()
if not row:
raise ValueError(f"credential not found: id={credential_id}")
storage = (row[0] or "").strip().lower()
secret_ref = row[1] or ""
secret_ciphertext = row[2]
if storage == "local_encrypted":
if not secret_ciphertext:
raise CredentialDecryptError("missing secret_ciphertext")
return decrypt_secret(str(secret_ciphertext))
if storage == "none":
raise CredentialNotEncryptedError("credential storage is none")
if storage == "env":
return read_secret("env", secret_ref)
if storage == "windows_credential":
return read_secret("windows_credential", secret_ref)
raise ValueError(f"unsupported secret_storage: {storage!r}")
def list_credentials_by_account(conn, account_id: int) -> list[dict[str, Any]]:
cur = conn.cursor()
cur.execute(
"""
SELECT id, account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, status,
last_used_at, extra_json, created_at, updated_at
FROM credentials
WHERE account_id = ?
ORDER BY updated_at DESC, id DESC
""",
(int(account_id),),
)
rows = cur.fetchall()
out: list[dict[str, Any]] = []
for r in rows:
ex = r[11]
parsed_ex: Any
try:
parsed_ex = json.loads(ex) if isinstance(ex, str) else (ex or {})
except json.JSONDecodeError:
parsed_ex = {}
out.append(
{
"id": r[0],
"account_id": r[1],
"platform_key": r[2],
"credential_label": r[3],
"credential_type": r[4],
"secret_storage": r[5],
"secret_ref": r[6] or "",
"secret_mask": r[7] or "",
"expires_at": _unix_to_iso_local(r[8]),
"status": r[9],
"last_used_at": _unix_to_iso_local(r[10]),
"extra_json": parsed_ex,
"created_at": _unix_to_iso_local(r[12]),
"updated_at": _unix_to_iso_local(r[13]),
}
)
return out
def delete_credential(conn, credential_id: int) -> None:
cur = conn.cursor()
cur.execute(
"SELECT secret_storage, secret_ref FROM credentials WHERE id = ?",
(int(credential_id),),
)
row = cur.fetchone()
if not row:
return
storage = (row[0] or "").strip().lower()
secret_ref = row[1] or ""
if storage == "windows_credential" and secret_ref:
delete_secret("windows_credential", secret_ref)
cur.execute("DELETE FROM credentials WHERE id = ?", (int(credential_id),))

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""平台默认 auth_strategy 映射表。
只覆盖已知平台;其它平台需要时手工 UPDATE platforms SET default_auth_strategy=... WHERE platform_key=...
"""
PLATFORM_DEFAULT_AUTH_STRATEGY = {
# 物流仿真平台monitor-competitor-rates 在用)
"maersk": "qr_code_manual",
"cosco": "qr_code_manual",
"msc": "qr_code_manual",
# 内容平台(典型扫码)
"douyin": "qr_code_manual",
"wechat_mp": "qr_code_manual",
"xiaohongshu": "qr_code_manual",
"bilibili": "qr_code_manual",
"toutiao": "qr_code_manual",
"sohu": "qr_code_manual",
# 开发平台2FA 标配)
"github": "password_plus_2fa",
"aws": "password_plus_2fa",
"gcp": "password_plus_2fa",
# 内部演示/仿真(账密自动)
"icbc_sim": "password_auto",
# API 平台
"openai": "api_token",
"anthropic": "api_token",
"deepseek": "api_token",
"doubao": "api_token",
}

View File

@@ -1,18 +1,292 @@
# SQLite 无独立 DATETIME 类型:时间统一存 INTEGER Unix 秒UTC查询/JSON 再转 ISO8601。
# 下列 DDL 含注释,会原样写入 sqlite_master便于 Navicat / DBeaver 等查看建表语句。
ACCOUNTS_TABLE_SQL = """
/* 多平台账号表:与本地 Chromium/Edge 用户数据目录profile绑定供发布类技能读取登录态 */
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
name TEXT NOT NULL, -- 展示名称如「搜狐1号」
platform TEXT NOT NULL, -- 平台标识,与内置 PLATFORMS 键一致,如 sohu
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 用户数据目录(绝对路径);默认可读结构 profiles/<平台展示名>/<手机号>/
url TEXT, -- 平台入口或登录页 URL
login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录0 否 1 是(由脚本校验后写入)
last_login_at INTEGER, -- 最近一次登录成功时间Unix 秒 UTC未登录过为 NULL
extra_json TEXT, -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
# -*- coding: utf-8 -*-
"""
Database schema v2 — Credential & Browser Profile Manager.
Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
sqlite_master, visible in DBeaver / Navicat.
Schema version management:
- PRAGMA user_version = 2 (set in connection.py after ensure_schema).
- _schema_meta schema_version: "2" after migrate_v1_to_v2, "2.1" after migrate_v2_to_v2_1.
"""
import sqlite3
import sys
import textwrap
from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO
# ---------------------------------------------------------------------------
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
# ---------------------------------------------------------------------------
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS platforms (
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
domain TEXT NOT NULL, -- 业务域logistics / llm / content / ecommerce / generic
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
default_url TEXT, -- 默认入口 URL
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
default_auth_strategy TEXT, -- 新账号默认认证策略;可空,手工维护
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
""")
# ---------------------------------------------------------------------------
# accounts — An identity that can be used (web account, RPA profile, API account)
# ---------------------------------------------------------------------------
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
tenant_id TEXT, -- 租户/客户/项目标识,可空
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录web/rpa 账号使用
url TEXT, -- 账号默认入口 URL为空时用 platforms.default_url
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
last_error_code TEXT, -- 最近一次错误代码
last_error_message TEXT, -- 最近一次错误消息
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
auth_strategy TEXT NOT NULL, -- 认证策略(见 db.auth_strategy
session_persistent INTEGER NOT NULL DEFAULT 1, -- 是否需要 profile 持久化 (0/1)
device_fingerprint TEXT, -- device_bound 策略用;可空
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
""")
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
# Application-level checks are used when login_id is provided.
# ---------------------------------------------------------------------------
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
# ---------------------------------------------------------------------------
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
account_id INTEGER, -- 可空;关联 accounts.id有些 API Key 可直接挂平台,也可挂具体账号
platform_key TEXT NOT NULL, -- 平台唯一键
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
secret_storage TEXT NOT NULL, -- none / env / windows_credential / local_encrypted
secret_ref TEXT, -- env 时为环境变量名windows_credential 时为 target namenone/local_encrypted 时为空
secret_mask TEXT, -- 打码展示,例如 sk-****abcd不能反推出原文
secret_ciphertext TEXT, -- secret_storage='local_encrypted' 时为 Fernet 密文;其它后端为 NULL
expires_at INTEGER, -- 过期时间Unix 秒 UTC可空
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
last_used_at INTEGER, -- 最近使用时间
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status);
""")
# ---------------------------------------------------------------------------
# account_leases — RPA / profile concurrency locks
# Prevents multiple tasks from opening the same profile_dir simultaneously.
# ---------------------------------------------------------------------------
ACCOUNT_LEASES_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS account_leases (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 租约主键(自增)
account_id INTEGER NOT NULL, -- 关联 accounts.id
lease_token TEXT NOT NULL UNIQUE, -- 随机 token用于 release / heartbeat
holder TEXT NOT NULL, -- 持有者,例如 monitor-competitor-rates 或 run id
purpose TEXT, -- 用途,例如 maersk_sim_rpa / booking_collect
expires_at INTEGER NOT NULL, -- 租约过期时间Unix 秒 UTC
heartbeat_at INTEGER, -- 最近一次心跳时间
released_at INTEGER, -- 主动释放时间
status TEXT NOT NULL DEFAULT 'active', -- active / released / expired
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_leases_account_status ON account_leases(account_id, status);
CREATE INDEX IF NOT EXISTS idx_leases_token ON account_leases(lease_token);
CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at);
""")
# ---------------------------------------------------------------------------
# schema meta — logical schema version (v1→v2 migration marker)
# ---------------------------------------------------------------------------
SCHEMA_META_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS _schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""")
# ---------------------------------------------------------------------------
# Full DDL assembly
# ---------------------------------------------------------------------------
ALL_DDL = (
PLATFORMS_TABLE_SQL
+ PLATFORMS_INDEXES_SQL
+ ACCOUNTS_TABLE_SQL
+ ACCOUNTS_INDEXES_SQL
+ CREDENTIALS_TABLE_SQL
+ CREDENTIALS_INDEXES_SQL
+ ACCOUNT_LEASES_TABLE_SQL
+ ACCOUNT_LEASES_INDEXES_SQL
+ SCHEMA_META_TABLE_SQL
)
SCHEMA_VERSION = 2
def column_exists(conn: sqlite3.Connection, table: str, col: str) -> bool:
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == col for row in cur.fetchall())
def _schema_meta_get(conn: sqlite3.Connection, key: str) -> str | None:
cur = conn.cursor()
cur.execute("SELECT value FROM _schema_meta WHERE key = ?", (key,))
row = cur.fetchone()
return str(row[0]) if row and row[0] is not None else None
def _schema_meta_upsert(conn: sqlite3.Connection, key: str, value: str) -> None:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO _schema_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
"""Apply ALTER steps + backfills once; idempotent via _schema_meta.schema_version."""
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
cur = conn.cursor()
sv = _schema_meta_get(conn, "schema_version")
if sv == "2" or (sv and sv.startswith("2.")):
return
n_platform_updates = 0
# 12: platforms.default_auth_strategy
if not column_exists(conn, "platforms", "default_auth_strategy"):
cur.execute("ALTER TABLE platforms ADD COLUMN default_auth_strategy TEXT")
for pk, strat in PLATFORM_DEFAULT_AUTH_STRATEGY.items():
cur.execute(
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
(strat, pk),
)
n_platform_updates += cur.rowcount
# 3: accounts columns (nullable auth_strategy during ALTER for legacy rows)
if not column_exists(conn, "accounts", "auth_strategy"):
cur.execute("ALTER TABLE accounts ADD COLUMN auth_strategy TEXT")
if not column_exists(conn, "accounts", "session_persistent"):
cur.execute(
"ALTER TABLE accounts ADD COLUMN session_persistent INTEGER NOT NULL DEFAULT 1"
)
if not column_exists(conn, "accounts", "device_fingerprint"):
cur.execute("ALTER TABLE accounts ADD COLUMN device_fingerprint TEXT")
# 4: fill accounts.auth_strategy + session_persistent
cur.execute(
"""
UPDATE accounts SET
auth_strategy = COALESCE(
(SELECT p.default_auth_strategy FROM platforms p
WHERE p.platform_key = accounts.platform_key),
?
),
session_persistent = COALESCE(session_persistent, 1)
WHERE auth_strategy IS NULL OR TRIM(auth_strategy) = ''
""",
(AUTH_STRATEGY_PASSWORD_AUTO,),
)
m_accounts_updated = cur.rowcount
# 5: credentials.account_id (v1 may omit it)
if not column_exists(conn, "credentials", "account_id"):
cur.execute("ALTER TABLE credentials ADD COLUMN account_id INTEGER")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id)"
)
cur.execute("SELECT COUNT(*) FROM platforms WHERE default_auth_strategy IS NULL")
k_null_platforms = int(cur.fetchone()[0])
_schema_meta_upsert(conn, "schema_version", "2")
conn.commit()
print(
f"[migration] account-manager v1→v2: updated {n_platform_updates} platforms, "
f"{m_accounts_updated} accounts.",
file=sys.stderr,
)
print(
"[migration] platforms with default_auth_strategy=NULL: "
f"{k_null_platforms} (manually fix later)",
file=sys.stderr,
)
def migrate_v2_to_v2_1(conn: sqlite3.Connection) -> None:
"""v2 → v2.1: credentials 表加 secret_ciphertext 列。幂等。"""
cur = conn.cursor()
if _schema_meta_get(conn, "schema_version") == "2.1":
return
if column_exists(conn, "credentials", "secret_ciphertext"):
_schema_meta_upsert(conn, "schema_version", "2.1")
conn.commit()
return
cur.execute("ALTER TABLE credentials ADD COLUMN secret_ciphertext TEXT")
_schema_meta_upsert(conn, "schema_version", "2.1")
conn.commit()
print(
"[migration] account-manager v2→v2.1: credentials.secret_ciphertext column added.",
file=sys.stderr,
)
def ensure_schema(conn: sqlite3.Connection) -> None:
"""CREATE TABLE/INDEX IF NOT EXISTS, then run migrate_v1_to_v2."""
cur = conn.cursor()
cur.executescript(ALL_DDL)
conn.commit()
migrate_v1_to_v2(conn)
migrate_v2_to_v2_1(conn)

View File

@@ -0,0 +1 @@
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.

View File

@@ -0,0 +1,137 @@
"""
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
宿主在 skills.entries 与 Gateway 中注入:
- PATH 前缀:{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/(Scripts|bin)
- VIRTUAL_ENV、JIANGCHANG_PYTHON_EXE
Playwright 使用本机 Chrome/Edgelaunch 时 channel=chrome|msedge不依赖宿主下载的 Chromium 包。
技能勿在仓库内维护独立 .venv依赖以 jiangchang-platform-kit/python-runtime 锁文件为准。
"""
from __future__ import annotations
import os
import sys
# 发版/嵌入宿主前改为 False或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
CLI_LOCAL_DEV_ENABLED = True
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
DEFAULT_LOCAL_USER_ID = "10032"
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
def platform_default_data_root() -> str:
if sys.platform == "win32":
return WIN_DEFAULT_DATA_ROOT
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
def get_data_root() -> str:
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
if env:
return env
return platform_default_data_root()
def get_user_id() -> str:
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
def _looks_like_skills_root(path: str) -> bool:
if not path or not os.path.isdir(path):
return False
for marker in (
"llm-manager",
"content-manager",
"account-manager",
"sohu-publisher",
"toutiao-publisher",
"logistics-tracker",
"api-key-vault",
):
if os.path.isdir(os.path.join(path, marker)):
return True
return False
def get_skills_root() -> str:
"""
并列技能安装目录(其下为「技能 slug」子目录
优先级:
1) JIANGCHANG_SKILLS_ROOT
2) CLAW_SKILLS_ROOT
3) JIANGCHANG_APP_ROOT{APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
4) Windows默认 D:\\AI\\jiangchang 同上规则
5) 其他平台:~/.openclaw/skills
"""
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
if sys.platform == "win32" and not app:
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
if app:
nested = os.path.join(app, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(app):
return os.path.normpath(app)
if sys.platform == "win32":
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
if _looks_like_skills_root(nested):
return os.path.normpath(nested)
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
"""
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
推断失败时再读上述环境变量,最后回落 get_skills_root()。
"""
if skill_scripts_dir:
scripts = os.path.abspath(skill_scripts_dir)
skill_root = os.path.dirname(scripts)
inferred = os.path.dirname(skill_root)
if _looks_like_skills_root(inferred):
return os.path.normpath(inferred)
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
v = (os.getenv(key) or "").strip()
if v:
return os.path.normpath(v)
return get_skills_root()
def apply_cli_local_defaults() -> None:
"""
在 CLI 最早阶段调用main.py 在 import 业务包之前)。
宿主已设置 JIANGCHANG_* 时不会覆盖。
"""
enabled = CLI_LOCAL_DEV_ENABLED
if not enabled:
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
enabled = v in ("1", "true", "yes", "on")
if not enabled:
return
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()

View File

@@ -0,0 +1,158 @@
"""
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。
实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。
"""
from __future__ import annotations
import logging
import os
import sys
import uuid
from logging.handlers import TimedRotatingFileHandler
from typing import Optional
from .runtime_env import get_data_root, get_user_id
_skill_slug: str = ""
_logger_name: str = ""
def get_unified_logs_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), "logs")
os.makedirs(path, exist_ok=True)
return path
def get_skill_log_file_path() -> str:
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
if override:
parent = os.path.dirname(os.path.abspath(override))
if parent:
os.makedirs(parent, exist_ok=True)
return os.path.abspath(override)
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def ensure_trace_for_process() -> str:
"""本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承"""
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if existing:
return existing
tid = uuid.uuid4().hex[:12]
os.environ["JIANGCHANG_TRACE_ID"] = tid
return tid
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
ensure_trace_for_process()
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if not tid:
tid = ensure_trace_for_process()
base = os.environ if environ is None else environ
return {**base, "JIANGCHANG_TRACE_ID": tid}
class _SkillContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
record.skill_slug = _skill_slug or "-"
return True
_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
)
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def _backup_count() -> int:
try:
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
return max(1, min(n, 365))
except ValueError:
return 30
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
"""
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
须在进程早期调用(如 CLI main 在业务日志之前)。
"""
global _skill_slug, _logger_name
ensure_trace_for_process()
_skill_slug = skill_slug
_logger_name = logger_name
log = logging.getLogger(logger_name)
if log.handlers:
return
log.setLevel(_log_level_from_env())
path = get_skill_log_file_path()
fh = TimedRotatingFileHandler(
path,
when="midnight",
interval=1,
backupCount=_backup_count(),
encoding="utf-8",
delay=True,
)
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
log.addHandler(fh)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fmt)
sh.addFilter(_SkillContextFilter())
log.addHandler(sh)
log.propagate = False
def get_skill_logger() -> logging.Logger:
if not _logger_name:
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
return logging.getLogger(_logger_name)
def attach_unified_file_handler(
log_path: str,
*,
skill_slug: str,
logger_name: str,
level: int = logging.DEBUG,
) -> logging.Logger:
"""
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
"""
global _skill_slug
ensure_trace_for_process()
_skill_slug = skill_slug
lg = logging.getLogger(logger_name)
lg.handlers.clear()
lg.setLevel(level)
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
fh = logging.FileHandler(log_path, encoding="utf-8")
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
lg.addHandler(fh)
lg.propagate = False
return lg

View File

@@ -3,8 +3,17 @@ account-manager CLI 入口。
分层cliargv→ service用例→ dbSQLite共用工具在 util/。
"""
import os
import sys
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
apply_cli_local_defaults()
# Windows GBK 编码兼容修复(须在任何业务 import 与 print 之前)
if sys.platform == "win32":
import io

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,29 @@
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。"""
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlparse
from db.accounts_repo import get_account_by_id, mark_login_status
from util.logging_config import get_skill_logger, get_skill_log_file_path
from util.platforms import (
PLATFORMS,
PLATFORM_URLS,
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
from db.connection import init_db
from db.accounts_repo import get_account_by_id
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
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):
@@ -62,224 +69,63 @@ def resolve_chromium_channel():
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装后再登录")
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def _login_timeout_seconds():
try:
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
except ValueError:
return 300
def _login_poll_interval_seconds():
"""轮询间隔,默认 1.5s。"""
try:
v = float((os.getenv("JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS") or "1.5").strip())
return max(0.5, min(v, 10.0))
except ValueError:
return 1.5
def _login_dom_grace_seconds() -> float:
"""goto 后等待再开始 DOM 判定,减轻跳转中途误判。默认 4sJIANGCHANG_LOGIN_DOM_GRACE_SECONDS。"""
try:
v = float((os.getenv("JIANGCHANG_LOGIN_DOM_GRACE_SECONDS") or "4").strip())
return max(0.0, min(v, 60.0))
except ValueError:
return 4.0
def _login_out_bundle_for(platform_key: str) -> dict:
"""传给子进程anchor + 未登录可见的选择器列表。"""
spec = PLATFORMS.get(platform_key, {}) if platform_key else {}
anchor = (urlparse(PLATFORM_URLS.get(platform_key, "") or "").netloc or "").lower()
spec_lo_dom = [
str(s).strip()
for s in (spec.get("login_logged_out_selectors") or [])
if s is not None and str(s).strip()
]
if bool(spec.get("login_skip_generic_logged_out_dom")):
logged_out_dom_selectors = list(spec_lo_dom)
else:
logged_out_dom_selectors = list(_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS) + spec_lo_dom
return {
"logged_out_dom_selectors": logged_out_dom_selectors,
"anchor_host": anchor,
}
def cmd_login(account_id):
def cmd_open(account_id):
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
log.info("login_command account_id=%r", account_id)
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("login_aborted account_not_found account_id=%r", account_id)
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
log.warning("login_aborted no_chromium_channel")
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
log.error("login_aborted playwright_missing")
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
print(
"ERROR:需要 playwright Python 包。"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("login_aborted profile_dir_empty account_id=%s", target.get("id"))
print("ERROR:PROFILE_DIR_MISSING 中 profile_dir 为空。")
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com")
platform = (target.get("platform") or "").strip().lower()
timeout_sec = _login_timeout_seconds()
poll_sec = _login_poll_interval_seconds()
dom_grace_sec = _login_dom_grace_seconds()
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"访问地址:{url}")
print(
"请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;"
"成功后自动关闭窗口并写入状态(无需手动关浏览器)。"
)
print(
f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。"
)
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
# Use account url, fall back to platform default
platform_spec = PLATFORMS.get(target["platform_key"], {})
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
log_file = get_skill_log_file_path()
log.info(
"login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s "
"dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s",
target.get("id"),
platform,
channel,
timeout_sec,
poll_sec,
dom_grace_sec,
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
log_file,
channel,
target.get("platform_key"),
)
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
cfg = {
"channel": channel,
"profile_dir": profile_dir,
"url": url,
"timeout_sec": timeout_sec,
"poll_interval": poll_sec,
"dom_grace_sec": dom_grace_sec,
"login_detect_bundle": _login_out_bundle_for(platform),
"log_file": log_file,
}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as rf:
result_path = rf.name
cfg["result_path"] = result_path
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
proc_rc = None
try:
r = subprocess.run(
[sys.executable, login_runner_path, cfg_path],
timeout=timeout_sec + 180,
)
proc_rc = r.returncode
if proc_rc != 0:
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
except subprocess.TimeoutExpired as ex:
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_timeout err=%s", ex)
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
interactive_ok = False
try:
with open(result_path, encoding="utf-8") as rf:
interactive_ok = bool(json.load(rf).get("interactive_ok"))
except Exception:
pass
try:
os.unlink(result_path)
except OSError:
pass
time.sleep(0.5)
ok = interactive_ok
log.info(
"login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s",
target.get("id"),
interactive_ok,
proc_rc,
ok,
)
mark_login_status(target["id"], ok)
if ok:
print("✅ 已判定登录成功,状态已写入数据库")
else:
log.warning(
"login_not_detected account_id=%s platform=%s see_log=%s",
target.get("id"),
platform,
log_file,
)
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
print(f" 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。"""
get_skill_logger().info("open account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
get_skill_logger().warning("open_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束")
print("需要自动检测并写入数据库时请执行python main.py login <id>")
print("本命令不做登录检测")
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
@@ -287,7 +133,15 @@ def cmd_open(account_id):
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try:
subprocess.run([sys.executable, open_runner_path, cfg_path])
subprocess.run(
[sys.executable, "-m", "service.open_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
except Exception as e:
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
raise
finally:
try:
os.unlink(cfg_path)

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

@@ -1,223 +0,0 @@
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动argv[1] 为 JSON 配置路径。"""
import json
import logging
import sys
import time
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
def page_location_href(p):
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
try:
href = p.evaluate("() => location.href")
if href and str(href).strip():
return str(href).strip()
except Exception:
pass
try:
return (p.url or "").strip()
except Exception:
return ""
def host_matches_anchor(netloc, anchor):
h = (netloc or "").lower().strip()
a = (anchor or "").lower().strip()
if not a:
return True
if not h:
return False
return h == a or h.endswith("." + a) or a.endswith("." + h)
def loc_first_visible(pg, selectors):
for sel in selectors:
s = str(sel).strip()
if not s:
continue
try:
loc = pg.locator(s).first
if loc.is_visible(timeout=500):
return True, s
except Exception:
pass
return False, None
def list_pages_on_anchor(ctx, main_page, bundle):
anchor = (bundle.get("anchor_host") or "").strip().lower()
seen_ids = set()
pages = []
for pg in list(ctx.pages or []):
try:
i = id(pg)
if i not in seen_ids:
seen_ids.add(i)
pages.append(pg)
except Exception:
pages.append(pg)
if main_page is not None:
try:
i = id(main_page)
if i not in seen_ids:
seen_ids.add(i)
pages.append(main_page)
except Exception:
pages.append(main_page)
out = []
for pg in pages:
try:
href = page_location_href(pg)
except Exception:
continue
if not href or str(href).lower().startswith("about:"):
continue
try:
host = urlparse(href).netloc.lower()
except Exception:
continue
if anchor and not host_matches_anchor(host, anchor):
continue
out.append(pg)
return out
def evaluate_logged_out_dom(ctx, main_page, bundle):
selectors = [
str(x).strip()
for x in (bundle.get("logged_out_dom_selectors") or [])
if x is not None and str(x).strip()
]
if not selectors:
return True, "no_logged_out_dom_selectors_configured"
for pg in list_pages_on_anchor(ctx, main_page, bundle):
try:
href = page_location_href(pg)
except Exception:
href = ""
hit, which = loc_first_visible(pg, selectors)
if hit:
return True, "logged_out_dom=%r url=%r" % (which, href)
return False, ""
def main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
bundle = c.get("login_detect_bundle") or {}
poll = float(c.get("poll_interval", 1.5))
try:
dom_grace = float(c.get("dom_grace_sec", 4.0))
except (TypeError, ValueError):
dom_grace = 4.0
if dom_grace < 0:
dom_grace = 0.0
t0 = time.time()
deadline = t0 + float(c["timeout_sec"])
interactive_ok = False
result_path = c.get("result_path") or ""
log_path = (c.get("log_file") or "").strip()
lg = logging.getLogger("openclaw.skill.account_manager.login_child")
if log_path:
lg.handlers.clear()
lg.setLevel(logging.DEBUG)
_fh = logging.FileHandler(log_path, encoding="utf-8")
_fh.setFormatter(
logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
)
lg.addHandler(_fh)
lg.propagate = False
last_eval_detail = ""
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=["--start-maximized"],
)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
if dom_grace > 0:
time.sleep(dom_grace)
if lg.handlers:
lg.info(
"goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s",
len(ctx.pages or []),
poll,
dom_grace,
)
while time.time() < deadline:
iter_start = time.time()
try:
still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle)
ok = not still_logged_out
last_eval_detail = detail
if lg.handlers:
parts = []
for tab in list(ctx.pages or []):
try:
href = page_location_href(tab)
pu = (tab.url or "").strip()
parts.append("href=%r playwright_url=%r" % (href, pu))
except Exception:
parts.append("(tab_read_error)")
lg.debug(
"poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
iter_start - t0,
deadline - iter_start,
len(ctx.pages or []),
poll,
still_logged_out,
ok,
detail,
" ; ".join(parts) if parts else "no_tabs",
)
if ok:
interactive_ok = True
if lg.handlers:
lg.info("login_detected_ok %s", detail)
break
except Exception as ex:
if lg.handlers:
lg.warning("poll_exception err=%s", ex, exc_info=True)
spent = time.time() - iter_start
time.sleep(max(0.0, poll - spent))
if not interactive_ok:
if lg.handlers:
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
rem = max(0.0, deadline - time.time())
if rem > 0:
try:
ctx.wait_for_event("close", timeout=rem * 1000)
except Exception:
pass
else:
if lg.handlers:
lg.info("login_success_closing_browser_immediately")
except Exception as e:
if lg.handlers:
lg.exception("login_runner_fatal err=%s", e)
print(e, file=sys.stderr)
raise
finally:
if result_path:
try:
with open(result_path, "w", encoding="utf-8") as rf:
json.dump({"interactive_ok": interactive_ok}, rf, ensure_ascii=False)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""master key 加载与生成。
加载优先级(高到低):
1. 环境变量 ACCOUNT_MANAGER_MASTER_KEYbase64-urlsafe 字符串44 字符)
2. 文件 {DATA_ROOT}/{USER_ID}/account-manager/master.key
3. 都没有 → raise MasterKeyMissingError错误码 ERR_MASTER_KEY_MISSING
不再自动生成——必须显式 `account init-master-key`。
"""
from __future__ import annotations
import base64
import os
from cryptography.fernet import Fernet
from util.runtime_paths import get_skill_data_dir
ENV_MASTER_KEY = "ACCOUNT_MANAGER_MASTER_KEY"
_MASTER_FILENAME = "master.key"
class MasterKeyMissingError(Exception):
"""Raised when no valid master key can be resolved."""
code = "ERR_MASTER_KEY_MISSING"
class MasterKeyExistsError(Exception):
"""Raised when master.key already exists and overwrite is not allowed."""
code = "ERR_MASTER_KEY_EXISTS"
class MasterKeyInvalidError(Exception):
"""Raised when a supplied master key string cannot be used as a Fernet key."""
code = "ERR_MASTER_KEY_INVALID"
def master_key_path() -> str:
"""Absolute path to master.key alongside account-manager.db."""
return os.path.join(get_skill_data_dir(), _MASTER_FILENAME)
def _try_fernet_key(key_material: bytes) -> bytes | None:
"""Return url-safe key bytes accepted by Fernet, or None if invalid."""
try:
raw = key_material.strip()
if not raw:
return None
# Accept stored form: ascii urlsafe base64 string as bytes
Fernet(raw)
return raw
except Exception:
return None
def _try_decode_manual_b64(s: str) -> bytes | None:
"""If string decodes to 32 raw bytes, re-encode as Fernet url-safe key."""
try:
pad = "=" * ((4 - len(s) % 4) % 4)
decoded = base64.urlsafe_b64decode(s + pad)
if len(decoded) != 32:
return None
key_bytes = base64.urlsafe_b64encode(decoded)
Fernet(key_bytes)
return key_bytes
except Exception:
return None
def _normalize_key_string(s: str) -> bytes | None:
s = (s or "").strip()
if not s:
return None
fk = _try_fernet_key(s.encode("ascii"))
if fk is not None:
return fk
return _try_decode_manual_b64(s)
def load_master_key() -> bytes:
"""Resolve master key (env → file); raises MasterKeyMissingError if unavailable."""
env_val = (os.environ.get(ENV_MASTER_KEY) or "").strip()
if env_val:
kb = _normalize_key_string(env_val)
if kb is not None:
return kb
path = master_key_path()
try:
with open(path, "rb") as f:
raw = f.read()
txt = raw.strip().decode("ascii", errors="strict")
kb = _normalize_key_string(txt)
if kb is not None:
return kb
except OSError:
pass
except (UnicodeDecodeError, ValueError):
pass
raise MasterKeyMissingError("No valid ACCOUNT_MANAGER_MASTER_KEY or master.key")
def normalize_master_key_input(s: str) -> bytes:
"""Validate user-supplied key text; raises MasterKeyInvalidError if unusable."""
kb = _normalize_key_string((s or "").strip())
if kb is None:
raise MasterKeyInvalidError("invalid master key material")
return kb
def generate_master_key() -> bytes:
"""Return a new Fernet key (44-char url-safe base64 as bytes)."""
return Fernet.generate_key()
def write_master_key_file(key: bytes, *, allow_overwrite: bool = False) -> str:
"""Write key bytes to master_key_path(); chmod 0o600 best-effort."""
path = master_key_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
if os.path.exists(path) and not allow_overwrite:
raise MasterKeyExistsError("master.key already exists; use --rotate to overwrite")
data = key.strip()
with open(path, "wb") as f:
f.write(data)
try:
os.chmod(path, 0o600)
except OSError:
pass
return path
def is_master_key_available() -> bool:
try:
load_master_key()
return True
except MasterKeyMissingError:
return False

View File

@@ -4,18 +4,30 @@ 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:
ctx = p.chromium.launch_persistent_context(
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=["--start-maximized"],
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)

View File

@@ -0,0 +1,20 @@
"""account-manager 提供给业务 skill 的 RPA helper 库。
业务 skill 同进程 import 使用,不通过 CLI subprocess。
"""
from .auth_flows import ( # noqa: F401
AuthStrategyError,
LoginResult,
ensure_logged_in,
ensure_logged_in_api_token,
ensure_logged_in_client_certificate,
ensure_logged_in_device_bound,
ensure_logged_in_password_auto,
ensure_logged_in_password_plus_2fa,
ensure_logged_in_password_with_captcha,
ensure_logged_in_per_session_manual,
ensure_logged_in_qr_code_manual,
ensure_logged_in_sso_redirect,
)
from .browser import close_browser, launch_browser_with_profile # noqa: F401
from .human import human_click, human_scroll_to, human_select, human_type, pause # noqa: F401

View File

@@ -0,0 +1,451 @@
# -*- coding: utf-8 -*-
"""Auth strategy helpers: ensure_logged_in_* + dispatcher (no DB / no CLI)."""
from __future__ import annotations
import hashlib
import json
import re
import socket
import sys
import time
import uuid
from dataclasses import dataclass
from urllib.parse import urlparse
from .human import human_click, human_type
def _poll_until(
predicate,
timeout_sec: int,
interval_sec: float = 2.0,
) -> bool:
"""轮询 predicate 直到返回 True 或超时。"""
deadline = time.time() + timeout_sec
while time.time() < deadline:
try:
if predicate():
return True
except Exception:
pass
time.sleep(interval_sec)
return False
def _stderr_prompt(msg: str) -> None:
sys.stderr.write(f"[auth_flows] {msg}\n")
sys.stderr.flush()
@dataclass
class LoginResult:
ok: bool
needs_human: bool
message: str
class AuthStrategyError(Exception):
"""auth_strategy 相关错误(不可自动化 / 不支持 / fingerprint 不匹配等)。"""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
def _account_extra(account: dict) -> dict:
ex = account.get("extra_json")
if ex is None:
return {}
if isinstance(ex, dict):
return ex
if isinstance(ex, str):
try:
return json.loads(ex)
except json.JSONDecodeError:
return {}
return {}
def _is_already_logged_in(page, account: dict) -> bool:
"""是否已登录:仅当 extra_json 提供正向证据URL 正则或可见 selector时为 True。"""
try:
cur = page.url or ""
except Exception:
return False
cur_stripped = cur.strip()
if not cur_stripped:
return False
ul = cur_stripped.lower()
if ul == "about:blank" or ul.startswith("about:blank"):
return False
extra = _account_extra(account)
pattern = extra.get("logged_in_url_pattern")
if isinstance(pattern, str) and pattern.strip():
try:
if re.search(pattern, cur_stripped):
return True
except re.error:
pass
sel = extra.get("logged_in_selector")
if isinstance(sel, str) and sel.strip():
try:
if page.locator(sel.strip()).first.is_visible(timeout=500):
return True
except Exception:
pass
return False
def _current_machine_fingerprint() -> str:
"""hostname + MAC 的 sha256 前 16 位。"""
h = hashlib.sha256()
h.update(socket.gethostname().encode("utf-8"))
h.update(str(uuid.getnode()).encode("utf-8"))
return h.hexdigest()[:16]
def _login_selectors(account: dict) -> tuple[str, str, str]:
extra = _account_extra(account)
selectors = extra.get("login_selectors") or {}
login_id_sel = (
selectors.get("login_id")
or "input[name*='user'], input[name*='login'], input[name*='account'], input[type='email']"
)
password_sel = selectors.get("password") or "input[type='password']"
submit_sel = (
selectors.get("submit")
or "button[type='submit'], button:has-text('登录'), button:has-text('Sign in')"
)
return str(login_id_sel), str(password_sel), str(submit_sel)
def _require_login_id(account: dict) -> str | None:
lid = account.get("login_id")
if lid is None:
return None
s = str(lid).strip()
return s or None
def ensure_logged_in_password_auto(page, account: dict, password: str, **_kwargs) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(
ok=True,
needs_human=False,
message="already logged in (profile reused)",
)
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
human_click(page.locator(sub_sel).first)
ok = _poll_until(lambda: _is_already_logged_in(page, account), 30, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=False,
message="login succeeded" if ok else "login timeout",
)
def ensure_logged_in_password_with_captcha(
page,
account: dict,
password: str,
*,
captcha_value: str | None = None,
wait_human_sec: int = 60,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
extra = _account_extra(account)
selectors = extra.get("login_selectors") or {}
captcha_sel = (
selectors.get("captcha") or "input[name*='captcha'], input[name*='verify_code']"
)
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
if captcha_value is not None:
human_type(page.locator(captcha_sel).first, str(captcha_value))
human_click(page.locator(sub_sel).first)
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=False,
message="login succeeded" if ok else "captcha flow timeout",
)
_stderr_prompt("请输入图形验证码后回车继续CTRL+C 取消)")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=True,
message="captcha human assist completed" if ok else "captcha human assist timeout",
)
def _is_2fa_page(page) -> bool:
try:
u = (page.url or "").lower()
if any(x in u for x in ("2fa", "verify", "mfa")):
return True
otp = page.locator("input[name*='code'], input[name*='otp']")
if otp.count() > 0:
return True
except Exception:
pass
return False
def ensure_logged_in_password_plus_2fa(
page,
account: dict,
password: str,
*,
wait_human_sec: int = 120,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
human_click(page.locator(sub_sel).first)
_poll_until(lambda: _is_2fa_page(page) or _is_already_logged_in(page, account), 30, interval_sec=1.0)
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="login succeeded without 2FA")
_stderr_prompt("请输入 2FA 验证码并提交")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
msg = "2FA completed" if ok else "2FA timeout ERR_LOGIN_2FA_TIMEOUT"
return LoginResult(ok=ok, needs_human=True, message=msg)
def _qr_locator(page):
return page.locator("canvas, img[src*='qr'], img[alt*='二维码']")
def ensure_logged_in_qr_code_manual(page, account: dict, *, wait_human_sec: int = 300, **_kwargs) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
qr = _qr_locator(page)
try:
qr.first.wait_for(state="visible", timeout=30000)
except Exception:
return LoginResult(ok=False, needs_human=True, message="qr code not found")
_stderr_prompt("请用手机扫描浏览器中的二维码")
def _qr_done() -> bool:
if _is_already_logged_in(page, account):
return True
try:
av = page.locator("img[alt*='avatar']")
if av.count() > 0 and av.first.is_visible(timeout=300):
return True
except Exception:
pass
return False
ok = _poll_until(_qr_done, wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="qr scan completed" if ok else "qr scan timeout",
)
def ensure_logged_in_per_session_manual(
page,
account: dict,
*,
wait_human_sec: int = 600,
**_kwargs,
) -> LoginResult:
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
_stderr_prompt(f"请完整登录后等待 RPA 继续(最长 {wait_human_sec} 秒)")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="per-session manual completed" if ok else "per-session manual timeout",
)
def ensure_logged_in_device_bound(
page,
account: dict,
password: str | None = None,
*,
wait_human_sec: int = 120,
**_kwargs,
) -> LoginResult:
expected_fp = account.get("device_fingerprint")
if expected_fp:
cur = _current_machine_fingerprint()
if cur != str(expected_fp):
raise AuthStrategyError(
"ERR_DEVICE_FINGERPRINT_MISMATCH",
f"current machine fingerprint {cur!r} does not match account's {expected_fp!r}",
)
if password:
return ensure_logged_in_password_auto(page, account, password)
return ensure_logged_in_per_session_manual(page, account, wait_human_sec=wait_human_sec)
def ensure_logged_in_client_certificate(page, account: dict, **_kwargs) -> LoginResult:
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_AUTOMATABLE",
"client_certificate strategy requires manual cert + PIN; not automatable by rpa_helpers",
)
def ensure_logged_in_api_token(page, account: dict, **_kwargs) -> LoginResult:
return LoginResult(ok=True, needs_human=False, message="api_token: no browser login required")
def _default_sso_hosts(account: dict) -> list[str]:
ex = _account_extra(account)
hosts = ex.get("sso_redirect_hosts")
if isinstance(hosts, list) and hosts:
return [str(h) for h in hosts if h]
return ["accounts.google.com", "login.microsoftonline.com", "okta.com"]
def _url_has_host_fragment(url: str, hosts: list[str]) -> bool:
u = (url or "").lower()
return any(h.lower() in u for h in hosts)
def ensure_logged_in_sso_redirect(
page,
account: dict,
*,
wait_human_sec: int = 300,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if not url:
return LoginResult(ok=False, needs_human=True, message="account url missing for SSO flow")
target_netloc = (urlparse(url).netloc or "").lower()
page.goto(url, wait_until="domcontentloaded", timeout=60000)
hosts = _default_sso_hosts(account)
seen_sso = _poll_until(lambda: _url_has_host_fragment(page.url or "", hosts), min(60, wait_human_sec), 2.0)
if not seen_sso:
return LoginResult(ok=False, needs_human=True, message="sso redirect not detected")
_stderr_prompt("请在弹出的 SSO 页面完成登录")
def _back_home() -> bool:
try:
cur = (urlparse(page.url).netloc or "").lower()
if target_netloc and cur == target_netloc:
return True
except Exception:
pass
return _is_already_logged_in(page, account)
ok = _poll_until(_back_home, wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="sso completed" if ok else "sso timeout",
)
def ensure_logged_in(
page,
account: dict,
*,
credentials: dict | None = None,
**kwargs,
) -> LoginResult:
"""根据 account['auth_strategy'] 分派到对应 helper。"""
strat = (account.get("auth_strategy") or "").strip()
if not strat:
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
"account has no auth_strategy set",
)
def _need_password() -> str:
if not credentials or not credentials.get("plaintext"):
raise AuthStrategyError(
"ERR_CREDENTIAL_MISSING",
f"auth_strategy={strat!r} requires credentials.plaintext",
)
return str(credentials["plaintext"])
if strat == "password_auto":
return ensure_logged_in_password_auto(page, account, _need_password(), **kwargs)
if strat == "password_with_captcha":
return ensure_logged_in_password_with_captcha(page, account, _need_password(), **kwargs)
if strat == "password_plus_2fa":
return ensure_logged_in_password_plus_2fa(page, account, _need_password(), **kwargs)
if strat == "qr_code_manual":
return ensure_logged_in_qr_code_manual(page, account, **kwargs)
if strat == "per_session_manual":
return ensure_logged_in_per_session_manual(page, account, **kwargs)
if strat == "device_bound":
password = None
if credentials and credentials.get("plaintext"):
password = str(credentials["plaintext"])
return ensure_logged_in_device_bound(page, account, password, **kwargs)
if strat == "client_certificate":
return ensure_logged_in_client_certificate(page, account)
if strat == "api_token":
return ensure_logged_in_api_token(page, account)
if strat == "sso_redirect":
return ensure_logged_in_sso_redirect(page, account, **kwargs)
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"unknown auth_strategy: {strat!r}",
)

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""Unified Chromium launch with stealth + persistent profile for RPA helpers."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from playwright.sync_api import sync_playwright
from service.browser_service import resolve_chromium_channel
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
if TYPE_CHECKING:
from playwright.sync_api import BrowserContext, Page
def launch_browser_with_profile(
profile_dir: str,
*,
channel: str | None = None,
headless: bool = False,
extra_args: list[str] | None = None,
locale: str = "zh-CN",
) -> tuple["BrowserContext", "Page"]:
"""启动浏览器Chrome 优先 / Edge fallback返回 (context, page)。
调用方应在 finally 中调用 close_browser(context)。
"""
os.makedirs(profile_dir, exist_ok=True)
resolved_channel = channel if channel is not None else resolve_chromium_channel()
if not resolved_channel:
raise RuntimeError("no chrome/edge found on system")
_pw = sync_playwright().start()
try:
args, ignore_automation = persistent_context_launch_parts(extra_args=extra_args)
launch_kwargs: dict = dict(
user_data_dir=profile_dir,
headless=headless,
channel=resolved_channel,
no_viewport=True,
locale=locale,
args=args,
)
if ignore_automation is not None:
launch_kwargs["ignore_default_args"] = ignore_automation
ctx = _pw.chromium.launch_persistent_context(**launch_kwargs)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
setattr(ctx, "_openclaw_pw", _pw)
return ctx, page
except Exception:
_pw.stop()
raise
def close_browser(ctx) -> None:
"""关闭 context 并清理 Playwright manager。"""
pw = getattr(ctx, "_openclaw_pw", None)
try:
ctx.close()
except Exception:
pass
if pw is not None:
try:
pw.stop()
except Exception:
pass

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""Human-like interaction primitives for Playwright locators (no logging)."""
from __future__ import annotations
import random
import time
def pause(min_ms: int = 200, max_ms: int = 800) -> None:
"""随机停顿 min_ms ~ max_ms 毫秒。"""
lo = min(min_ms, max_ms) / 1000.0
hi = max(min_ms, max_ms) / 1000.0
time.sleep(random.uniform(lo, hi))
def human_type(
locator,
text: str,
*,
per_char_ms_range: tuple[int, int] = (50, 200),
pre_pause: bool = True,
) -> None:
"""逐字符输入locator.type + delaylocator 为 Playwright Locator。"""
locator.scroll_into_view_if_needed()
if pre_pause:
pause()
locator.click()
pause()
lo, hi = per_char_ms_range
delay_ms = random.uniform(float(lo), float(hi))
locator.type(text, delay=int(delay_ms))
def human_click(locator) -> None:
"""人类化点击scroll_into_view → pause → click → pause"""
locator.scroll_into_view_if_needed()
pause()
locator.click()
pause()
def human_select(locator, value_or_label: str) -> None:
"""人类化下拉选择scroll_into_view → pause → click → pause → select_option"""
locator.scroll_into_view_if_needed()
pause()
locator.click()
pause()
try:
locator.select_option(value=value_or_label)
except Exception:
locator.select_option(label=value_or_label)
def human_scroll_to(locator) -> None:
"""滚动到元素并稍作停顿。"""
locator.scroll_into_view_if_needed()
pause()

View File

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

View File

@@ -1,4 +1,4 @@
"""技能级常量本地 CLI 调试环境注入(仅 CLI 入口显式调用 _apply_cli_local_dev_env 时生效)。"""
"""技能级常量本地 CLI 默认值见 jiangchang_skill_core.runtime_envmain.py 最早调用 apply_cli_local_defaults)。"""
import os
# scripts/util/constants.py -> skill root = parents[2]
@@ -7,29 +7,3 @@ BASE_DIR = _BASE
SKILL_SLUG = "account-manager"
# 与其它 OpenClaw 技能对齐openclaw.skill.<slug_下划线>
LOG_LOGGER_NAME = "openclaw.skill.account_manager"
# ---------------------------------------------------------------------------
# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。
# - 仅当 CLI 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。
# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。
# - 开启方式(二选一):
# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True
# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1无需改代码
# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。
# ---------------------------------------------------------------------------
_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True
_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data"
_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032"
def _apply_cli_local_dev_env() -> None:
enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV
if not enabled:
v = (os.getenv("JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV") or "").strip().lower()
enabled = v in ("1", "true", "yes", "on")
if not enabled:
return
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
os.environ["JIANGCHANG_DATA_ROOT"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip()
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip()

View File

@@ -1,69 +1,21 @@
"""技能级文件日志。"""
import logging
import os
import sys
from logging.handlers import TimedRotatingFileHandler
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.runtime_paths import get_skill_logs_dir
from jiangchang_skill_core.unified_logging import (
attach_unified_file_handler,
ensure_trace_for_process,
get_skill_log_file_path,
get_skill_logger,
get_unified_logs_dir,
setup_skill_logging,
subprocess_env_with_trace,
)
def _skill_log_file_path() -> str:
"""主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。"""
override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip()
if override:
parent = os.path.dirname(os.path.abspath(override))
if parent:
os.makedirs(parent, exist_ok=True)
return os.path.abspath(override)
return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log")
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def get_skill_log_file_path() -> str:
"""主日志文件绝对路径(与 get_skill_logger 写入文件一致)。"""
return _skill_log_file_path()
def get_skill_logger() -> logging.Logger:
"""
技能级日志单文件按日切分TimedRotatingFileHandler midnightUTF-8。
环境变量JIANGCHANG_LOG_LEVEL默认 INFO、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、
JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。
"""
log = logging.getLogger(LOG_LOGGER_NAME)
if log.handlers:
return log
log.setLevel(_log_level_from_env())
path = _skill_log_file_path()
fh = TimedRotatingFileHandler(
path,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
delay=True,
)
fh.setFormatter(
logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
)
log.addHandler(fh)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fh.formatter)
log.addHandler(sh)
log.propagate = False
return log
__all__ = [
"attach_unified_file_handler",
"ensure_trace_for_process",
"get_skill_log_file_path",
"get_skill_logger",
"get_unified_logs_dir",
"setup_skill_logging",
"subprocess_env_with_trace",
]

View File

@@ -1,139 +1,429 @@
"""平台配置、别名解析、手机号校验。"""
import re
# -*- coding: utf-8 -*-
"""
Platform definitions and resolution.
# 平台唯一配置表:只改这里即可。键 = 入库的 platformlabel = 帮助/提示里的展示名;
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
# - login_logged_out_selectors按平台追加误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
PLATFORMS = {
"sohu": {
"url": "https://mp.sohu.com",
"label": "搜狐号",
"prefix": "搜狐",
"aliases": ["搜狐"],
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
"""
import json
import time
from typing import Any, Optional
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Platform specification fields:
# display_name Human-readable name (e.g. "Maersk")
# domain Business domain: logistics / llm / content / ecommerce / generic
# provider_code Supplier code (logistics); can be same as key
# default_url Default entry URL (empty string if none)
# aliases List of name aliases (Chinese/English, for CLI resolution)
# capabilities Dict of capability flags:
# {"web": bool, "api_key": bool, "rpa": bool}
# ---------------------------------------------------------------------------
PLATFORMS: dict[str, dict[str, Any]] = {
# =========================================================================
# Logistics platforms
# =========================================================================
"maersk": {
"display_name": "Maersk",
"domain": "logistics",
"provider_code": "maersk",
"default_url": "https://www.maersk.com",
"aliases": ["马士基", "maersk", "Maersk"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"toutiao": {
"url": "https://mp.toutiao.com/",
"label": "头条号",
"prefix": "头条",
"aliases": ["头条"],
"cosco": {
"display_name": "COSCO",
"domain": "logistics",
"provider_code": "cosco",
"default_url": "",
"aliases": ["中远海运", "COSCO", "cosco"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"zhihu": {
"url": "https://www.zhihu.com",
"label": "知乎",
"aliases": ["知乎号"],
"msc": {
"display_name": "MSC",
"domain": "logistics",
"provider_code": "msc",
"default_url": "",
"aliases": ["MSC", "地中海航运"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"wechat": {
"url": "https://mp.weixin.qq.com",
"label": "微信公众号",
"prefix": "微信",
"aliases": ["公众号", "微信"],
"cma_cgm": {
"display_name": "CMA CGM",
"domain": "logistics",
"provider_code": "cma_cgm",
"default_url": "",
"aliases": ["达飞", "CMA CGM", "cma cgm"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"kimi": {
"url": "https://kimi.moonshot.cn",
"label": "Kimi",
"aliases": ["月之暗面"],
"evergreen": {
"display_name": "Evergreen",
"domain": "logistics",
"provider_code": "evergreen",
"default_url": "",
"aliases": ["长荣", "Evergreen", "evergreen"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cargo_wise": {
"display_name": "CargoWise",
"domain": "logistics",
"provider_code": "cargo_wise",
"default_url": "",
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"freightos": {
"display_name": "Freightos",
"domain": "logistics",
"provider_code": "freightos",
"default_url": "",
"aliases": ["Freightos", "freightos"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"webcargo": {
"display_name": "WebCargo",
"domain": "logistics",
"provider_code": "webcargo",
"default_url": "",
"aliases": ["WebCargo", "web cargo"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"sinotrans": {
"display_name": "Sinotrans",
"domain": "logistics",
"provider_code": "sinotrans",
"default_url": "",
"aliases": ["中外运", "Sinotrans", "sinotrans"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
# =========================================================================
# LLM / AI platforms
# =========================================================================
"deepseek": {
"url": "https://chat.deepseek.com",
"label": "DeepSeek",
"display_name": "DeepSeek",
"domain": "llm",
"provider_code": "deepseek",
"default_url": "https://chat.deepseek.com",
"aliases": ["DeepSeek", "deepseek"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"doubao": {
"url": "https://www.doubao.com",
"label": "豆包",
"display_name": "Doubao",
"domain": "llm",
"provider_code": "doubao",
"default_url": "https://www.doubao.com",
"aliases": ["豆包", "Doubao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"kimi": {
"display_name": "Kimi",
"domain": "llm",
"provider_code": "kimi",
"default_url": "https://kimi.moonshot.cn",
"aliases": ["Kimi", "月之暗面"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"qianwen": {
"url": "https://tongyi.aliyun.com",
"label": "通义千问",
"prefix": "通义",
"aliases": ["通义", "千问"],
"display_name": "Qianwen",
"domain": "llm",
"provider_code": "qianwen",
"default_url": "https://tongyi.aliyun.com",
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yiyan": {
"url": "https://yiyan.baidu.com",
"label": "文心一言",
"prefix": "文心",
"aliases": ["文心", "一言"],
"display_name": "YiYan",
"domain": "llm",
"provider_code": "yiyan",
"default_url": "https://yiyan.baidu.com",
"aliases": ["文心一言", "文心", "一言", "YiYan"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yuanbao": {
"url": "https://yuanbao.tencent.com",
"label": "腾讯元宝",
"prefix": "元宝",
"aliases": ["元宝"],
"display_name": "Yuanbao",
"domain": "llm",
"provider_code": "yuanbao",
"default_url": "https://yuanbao.tencent.com",
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"gemini": {
"display_name": "Gemini",
"domain": "llm",
"provider_code": "gemini",
"default_url": "https://gemini.google.com",
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"minimax": {
"display_name": "MiniMax",
"domain": "llm",
"provider_code": "minimax",
"default_url": "",
"aliases": ["MiniMax", "minimax"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
# =========================================================================
# Content platforms (carried forward from v1)
# =========================================================================
"sohu": {
"display_name": "搜狐号",
"domain": "content",
"provider_code": "sohu",
"default_url": "https://mp.sohu.com",
"aliases": ["搜狐", "搜狐号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"toutiao": {
"display_name": "头条号",
"domain": "content",
"provider_code": "toutiao",
"default_url": "https://mp.toutiao.com/",
"aliases": ["头条", "头条号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"zhihu": {
"display_name": "知乎",
"domain": "content",
"provider_code": "zhihu",
"default_url": "https://www.zhihu.com",
"aliases": ["知乎", "知乎号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"wechat": {
"display_name": "微信公众号",
"domain": "content",
"provider_code": "wechat",
"default_url": "https://mp.weixin.qq.com",
"aliases": ["公众号", "微信", "微信公众号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
# =========================================================================
# Aligned with db.platform_defaults.PLATFORM_DEFAULT_AUTH_STRATEGY keys
# =========================================================================
"douyin": {
"display_name": "抖音",
"domain": "content",
"provider_code": "douyin",
"default_url": "https://www.douyin.com",
"aliases": ["抖音", "Douyin"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"wechat_mp": {
"display_name": "微信公众号",
"domain": "content",
"provider_code": "wechat_mp",
"default_url": "https://mp.weixin.qq.com",
"aliases": ["微信公众号", "公众号", "WeChat MP"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"xiaohongshu": {
"display_name": "小红书",
"domain": "content",
"provider_code": "xiaohongshu",
"default_url": "https://www.xiaohongshu.com",
"aliases": ["小红书", "Xiaohongshu", "RED"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"bilibili": {
"display_name": "哔哩哔哩",
"domain": "content",
"provider_code": "bilibili",
"default_url": "https://www.bilibili.com",
"aliases": ["B站", "哔哩哔哩", "Bilibili"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"github": {
"display_name": "GitHub",
"domain": "dev",
"provider_code": "github",
"default_url": "https://github.com/login",
"aliases": ["GitHub", "github"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"aws": {
"display_name": "AWS",
"domain": "dev",
"provider_code": "aws",
"default_url": "https://console.aws.amazon.com",
"aliases": ["AWS", "Amazon Web Services", "亚马逊云"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"gcp": {
"display_name": "GCP",
"domain": "dev",
"provider_code": "gcp",
"default_url": "https://console.cloud.google.com",
"aliases": ["GCP", "Google Cloud", "谷歌云"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"icbc_sim": {
"display_name": "工行仿真",
"domain": "sim",
"provider_code": "icbc_sim",
"default_url": "http://localhost:5180",
"aliases": ["工行仿真", "ICBC Simulator", "ICBC SIM"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"openai": {
"display_name": "OpenAI API",
"domain": "llm",
"provider_code": "openai",
"default_url": "https://platform.openai.com",
"aliases": ["OpenAI", "openai", "GPT", "ChatGPT API"],
"capabilities": {"web": False, "api_key": True, "rpa": False},
},
"anthropic": {
"display_name": "Anthropic",
"domain": "llm",
"provider_code": "anthropic",
"default_url": "https://console.anthropic.com",
"aliases": ["Anthropic", "anthropic", "Claude API"],
"capabilities": {"web": False, "api_key": True, "rpa": False},
},
}
# 未登录页共性主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等Playwright :has-text 可覆盖多数站点。
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
'button:has-text("登录")',
'role=button[name="登录"]',
'a:has-text("登录")',
'h4:has-text("登录")',
'span.semi-button-content:has-text("登录")',
)
def _build_platform_derived():
urls = {}
name_prefix = {}
primary_cn = {}
alias_to_key = {}
def _register_alias(alias: str, key: str) -> None:
if not alias or not str(alias).strip():
return
a = str(alias).strip()
if a not in alias_to_key:
alias_to_key[a] = key
lo = a.lower()
if lo not in alias_to_key:
alias_to_key[lo] = key
# ---------------------------------------------------------------------------
# Derived alias map
# ---------------------------------------------------------------------------
def _build_alias_map() -> dict[str, str]:
"""Build a case-insensitive flat alias -> key map."""
m: dict[str, str] = {}
for key, spec in PLATFORMS.items():
urls[key] = spec["url"]
primary_cn[key] = spec["label"]
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
_register_alias(key, key)
_register_alias(spec["label"], key)
for a in spec.get("aliases") or []:
_register_alias(a, key)
return urls, name_prefix, primary_cn, alias_to_key
# Register the key itself (lowercase)
m[key.lower()] = key
# Register all aliases (original + lowercase)
for alias in [spec["display_name"]] + spec.get("aliases", []):
a = str(alias).strip()
if a:
m[a] = key
m[a.lower()] = key
return m
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
_PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str):
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
if name is None:
def resolve_platform_key(name: str) -> Optional[str]:
"""
Resolve user input to a canonical platform key.
Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized.
"""
if not name:
return None
s = str(name).strip()
if not s:
return None
sl = s.lower()
if sl in PLATFORM_URLS:
# Direct key match
if sl in PLATFORMS:
return sl
return _PLATFORM_ALIAS_TO_KEY.get(s)
# Alias map
if sl in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[sl]
if s in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[s]
return None
def _normalize_phone_digits(phone: str) -> str:
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
d = re.sub(r"\D", "", (phone or "").strip())
if d.startswith("86") and len(d) >= 13:
d = d[2:]
return d
def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated key."""
return PLATFORMS.get(key)
def _is_valid_cn_mobile11(digits: str) -> bool:
"""中国大陆 11 位手机号1 开头,第二位 39共 11 位数字。"""
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
"""List all platforms, optionally filtered by domain.
Returns a list of dicts with metadata."""
result = []
for key, spec in PLATFORMS.items():
if domain and spec["domain"] != domain:
continue
result.append({
"platform_key": key,
"display_name": spec["display_name"],
"domain": spec["domain"],
"provider_code": spec.get("provider_code"),
"default_url": spec.get("default_url", ""),
"aliases": spec.get("aliases", []),
"capabilities": spec.get("capabilities", {}),
"enabled": True,
})
return result
def seed_platforms(conn) -> None:
"""
Upsert all platform definitions into the 'platforms' table.
Must be called after init_db() in the same connection lifecycle.
"""
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
log = get_skill_logger()
log.info("platform_seed_start")
cur = conn.cursor()
now = int(time.time())
upsert_count = 0
for key, spec in PLATFORMS.items():
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
cur.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name,
domain = excluded.domain,
provider_code = excluded.provider_code,
default_url = excluded.default_url,
aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json,
enabled = 1,
default_auth_strategy = excluded.default_auth_strategy,
updated_at = excluded.updated_at
""",
(
key,
spec["display_name"],
spec["domain"],
spec.get("provider_code"),
spec.get("default_url", ""),
aliases_json,
capabilities_json,
default_auth_strategy,
now,
now,
),
)
upsert_count += 1
conn.commit()
log.info("platform_seed_done count=%s", upsert_count)
# ---------------------------------------------------------------------------
# Legacy compatibility helpers (for old account_service v1 code references)
# ---------------------------------------------------------------------------
# Kept for backward compatibility so old imports don't break immediately.
# New code should use PLATFORMS dict directly and resolve_platform_key().
_PLATFORM_PRIMARY_CN: dict[str, str] = {
k: spec["display_name"] for k, spec in PLATFORMS.items()
}
PLATFORM_URLS: dict[str, str] = {
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
}
def _platform_list_cn_for_help() -> str:
"""帮助文案:一行中文展示名(不重复内部键)"""
parts = []
for k in sorted(PLATFORM_URLS.keys()):
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
return "".join(parts)
"""帮助文案:一行展示名,供 CLI 用法提示"""
return "".join(
spec["display_name"] for _k, spec in sorted(
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
)
)

View File

@@ -0,0 +1,40 @@
"""
淡化 Playwright 启动时的自动化指纹,减轻 Google 账号页「此浏览器或应用可能不安全」类拦截。
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0或 false/off/no
"""
from __future__ import annotations
import os
STEALTH_INIT_SCRIPT = """
(() => {
try {
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
} catch (e) {}
})();
"""
def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts(
*,
extra_args: list[str] | None = None,
) -> tuple[list[str], list[str] | None]:
"""
返回 (args, ignore_default_args)。
ignore_default_args 为 None 时不应传给 launch_persistent_context。
"""
args = ["--start-maximized"]
if extra_args:
args = args + list(extra_args)
if not stealth_enabled():
return args, None
if "--disable-blink-features=AutomationControlled" not in args:
args.append("--disable-blink-features=AutomationControlled")
return args, ["--enable-automation"]

View File

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

1
tests/__init__.py Normal file
View File

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

56
tests/conftest.py Normal file
View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""
Test fixtures for account-manager tests.
All tests use an isolated temporary database under a fake JIANGCHANG_DATA_ROOT
so they never touch real user data.
"""
import os
import sys
import tempfile
import shutil
# Ensure scripts/ is on the path
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
try:
import cryptography # noqa: F401
except ImportError as e:
raise RuntimeError(
"Stage B requires 'cryptography'. pip install cryptography --break-system-packages"
) from e
import pytest
@pytest.fixture(scope="session")
def fake_env():
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
fake_uid = "99999"
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
os.environ["JIANGCHANG_USER_ID"] = fake_uid
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
@pytest.fixture(scope="function")
def clean_db(fake_env):
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
# Import after env vars are set
from db.connection import get_conn, init_db
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
init_db()
conn = get_conn()
conn.execute("DELETE FROM account_leases")
conn.execute("DELETE FROM credentials")
conn.execute("DELETE FROM accounts")
conn.commit()
yield conn
conn.close()

21
tests/run_tests.py Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
MAIN_PY = os.path.join(_scripts, "main.py")
def _iso_cli_env(data_root: str, uid: str = "cli_u") -> dict:
"""Align skill roots so subprocess + in-process tests hit the same DB."""
return {
"JIANGCHANG_DATA_ROOT": data_root,
"JIANGCHANG_USER_ID": uid,
"CLAW_DATA_ROOT": data_root,
"CLAW_USER_ID": uid,
}
@dataclass
class _ProcOut:
returncode: int
stdout: str
stderr: str
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
merged = {**os.environ, **env}
merged.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
inp = stdin.encode("utf-8") if stdin is not None else None
r = subprocess.run(
[sys.executable, MAIN_PY, *args],
input=inp,
capture_output=True,
env=merged,
cwd=os.path.dirname(_scripts),
)
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
def test_init_master_key_writes_file(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_mk_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
proc = _run_cli(env, ["account", "init-master-key"])
assert proc.returncode == 0
payload = json.loads(proc.stdout.strip().splitlines()[0])
assert payload["success"] is True
assert os.path.isfile(payload["path"])
def test_init_master_key_rejects_duplicate(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_mk_d_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
p1 = _run_cli(env, ["account", "init-master-key"])
assert p1.returncode == 0
p2 = _run_cli(env, ["account", "init-master-key"])
assert p2.returncode == 0
payload = json.loads(p2.stdout.strip().splitlines()[0])
assert payload["success"] is False
assert payload["error"]["code"] == "ERR_MASTER_KEY_EXISTS"
def test_export_import_roundtrip(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_ex_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
init = _run_cli(env, ["account", "init-master-key"])
assert init.returncode == 0
# Insert encrypted credential via repo in-process (same paths as CLI)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "cli_u")
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
monkeypatch.setenv("CLAW_USER_ID", "cli_u")
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_cli_inline")
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted, read_credential_plaintext
init_db()
conn = get_conn()
try:
cid = insert_credential_encrypted(
conn,
account_id=None,
platform_key="icbc_sim",
credential_label="ICBC password label",
credential_type="password",
plaintext="roundtrip-secret",
expires_at=None,
extra_json="{}",
now=1_750_000_000,
)
conn.commit()
finally:
conn.close()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM credentials")
assert int(cur.fetchone()[0]) == 1
finally:
conn.close()
ex = _run_cli(env, ["account", "export-credentials", "--plaintext"])
assert ex.returncode == 0
arr = json.loads(ex.stdout.strip())
assert len(arr) == 1
assert arr[0]["plaintext"] == "roundtrip-secret"
imp = _run_cli(
env,
["account", "import-credentials", "--replace-all"],
stdin=json.dumps(arr, ensure_ascii=False),
)
assert imp.returncode == 0
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM credentials WHERE secret_storage='local_encrypted'")
assert int(cur.fetchone()[0]) == 1
cur.execute("SELECT id FROM credentials LIMIT 1")
new_id = int(cur.fetchone()[0])
pt = read_credential_plaintext(conn, new_id)
assert pt == "roundtrip-secret"
finally:
conn.close()

511
tests/test_cli_stage_c.py Normal file
View File

@@ -0,0 +1,511 @@
# -*- coding: utf-8 -*-
"""Stage C: CLI — add-web v2, local_encrypted add-secret, pick-web fields, get-credential."""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from dataclasses import dataclass
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
MAIN_PY = os.path.join(_scripts, "main.py")
from util.constants import SKILL_SLUG # noqa: E402
def _cli_env(roots: dict, **extra: str) -> dict:
e = {**os.environ, **extra}
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
e["JIANGCHANG_USER_ID"] = roots["user_id"]
e["CLAW_DATA_ROOT"] = roots["data_root"]
e["CLAW_USER_ID"] = roots["user_id"]
return e
def _db_path(roots: dict) -> str:
return os.path.join(roots["data_root"], roots["user_id"], SKILL_SLUG, "account-manager.db")
@dataclass
class _ProcOut:
returncode: int
stdout: str
stderr: str
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
inp = stdin.encode("utf-8") if stdin is not None else None
r = subprocess.run(
[sys.executable, MAIN_PY, *args],
input=inp,
capture_output=True,
env=env,
cwd=os.path.dirname(_scripts),
)
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
def _parse_ok_data(line: str) -> dict:
j = json.loads(line.strip().splitlines()[0])
assert j.get("success") is True
return j["data"]
@pytest.fixture()
def iso_cli_env():
"""Function-scoped data root for subprocess CLI tests."""
tmp = tempfile.mkdtemp(prefix="cli_stage_c_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
uid = "77701"
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
shutil.rmtree(tmp, ignore_errors=True)
@pytest.fixture()
def with_master_key(monkeypatch):
from cryptography.fernet import Fernet
key = Fernet.generate_key().decode("ascii")
monkeypatch.setenv("ACCOUNT_MANAGER_MASTER_KEY", key)
yield key
class TestAddWebV2:
def test_add_web_explicit_auth_strategy(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-web",
"--platform",
"maersk",
"--label",
"demo",
"--auth-strategy",
"qr_code_manual",
"--session-persistent",
"1",
],
)
assert proc.returncode == 0
data = _parse_ok_data(proc.stdout)
assert data["auth_strategy"] == "qr_code_manual"
assert data["session_persistent"] == 1
conn = sqlite3.connect(_db_path(iso_cli_env))
try:
row = conn.execute(
"SELECT auth_strategy, session_persistent FROM accounts WHERE id = ?",
(data["id"],),
).fetchone()
assert row == ("qr_code_manual", 1)
finally:
conn.close()
def test_add_web_platform_default_maersk(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "demo2"],
)
assert proc.returncode == 0
data = _parse_ok_data(proc.stdout)
assert data["auth_strategy"] == "qr_code_manual"
def test_add_web_bad_auth_strategy(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-web",
"--platform",
"maersk",
"--label",
"x",
"--auth-strategy",
"foo",
],
)
assert proc.returncode == 0
j = json.loads(proc.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
conn = sqlite3.connect(_db_path(iso_cli_env))
try:
n = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
assert n == 0
finally:
conn.close()
def test_add_web_device_fingerprint_wrong_strategy(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-web",
"--platform",
"maersk",
"--label",
"x",
"--auth-strategy",
"password_auto",
"--device-fingerprint",
"fp1",
],
)
assert proc.returncode == 0
j = json.loads(proc.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE"
class TestAddSecretLocalEncrypted:
def test_add_secret_local_encrypted_success(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--label",
"pw1",
],
stdin="my-pw\n",
)
assert proc.returncode == 0
data = _parse_ok_data(proc.stdout)
assert data["secret_storage"] == "local_encrypted"
conn = sqlite3.connect(_db_path(iso_cli_env))
try:
row = conn.execute(
"SELECT secret_storage, secret_mask, length(secret_ciphertext) FROM credentials WHERE id = ?",
(data["credential_id"],),
).fetchone()
assert row is not None
assert row[0] == "local_encrypted"
assert row[1] # mask non-empty
assert row[2] and row[2] > 0
finally:
conn.close()
def test_add_secret_local_encrypted_requires_stdin(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--label",
"x",
],
)
assert proc.returncode == 0
j = json.loads(proc.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN"
def test_add_secret_local_encrypted_type_not_allowed(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"browser_profile",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
],
stdin="x\n",
)
assert proc.returncode == 0
j = json.loads(proc.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED"
def test_add_secret_local_encrypted_master_missing(self, iso_cli_env, monkeypatch):
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
env = _cli_env(iso_cli_env)
env.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
proc = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
],
stdin="pw\n",
)
assert proc.returncode == 0
j = json.loads(proc.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_MASTER_KEY_MISSING"
class TestPickWebReturns:
def test_pick_web_has_v2_fields(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
aw = _run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "pw"],
)
assert aw.returncode == 0
aid = _parse_ok_data(aw.stdout)["id"]
sec = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(aid),
],
stdin="secret-line\n",
)
assert sec.returncode == 0
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
assert pick.returncode == 0
payload = json.loads(pick.stdout.strip().splitlines()[0])
assert "auth_strategy" in payload
assert "session_persistent" in payload
assert "device_fingerprint" in payload
def test_pick_web_legacy_keys_present(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aw = _run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "legacy"],
)
assert aw.returncode == 0
pick = _run_cli(env, ["account", "pick-web", "--platform", "maersk"])
assert pick.returncode == 0
payload = json.loads(pick.stdout.strip().splitlines()[0])
for k in ("id", "platform_key", "login_id", "profile_dir", "url"):
assert k in payload
class TestGetCredential:
def test_get_credential_no_reveal_no_plaintext(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
aw = _run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "g1"],
)
aid = _parse_ok_data(aw.stdout)["id"]
sec = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(aid),
],
stdin="hidden-pw\n",
)
assert sec.returncode == 0
g = _run_cli(env, ["account", "get-credential", str(aid)])
assert g.returncode == 0
j = json.loads(g.stdout.strip().splitlines()[0])
assert j["success"] is True
assert "plaintext" not in j
def test_get_credential_reveal_missing_lease_token(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
aw = _run_cli(env, ["account", "add-web", "--platform", "maersk", "--label", "g2"])
aid = _parse_ok_data(aw.stdout)["id"]
sec = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(aid),
],
stdin="p\n",
)
assert sec.returncode == 0
g = _run_cli(env, ["account", "get-credential", str(aid), "--reveal"])
j = json.loads(g.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_LEASE_INVALID"
def test_get_credential_wrong_lease_account(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
a1 = _parse_ok_data(
_run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "acc1"],
).stdout
)["id"]
sec1 = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(a1),
],
stdin="s1\n",
)
assert sec1.returncode == 0
lease_out = _run_cli(
env,
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "t"],
)
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
a2 = _parse_ok_data(
_run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "acc2"],
).stdout
)["id"]
sec2 = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(a2),
],
stdin="s2\n",
)
assert sec2.returncode == 0
bad = _run_cli(
env,
[
"account",
"get-credential",
str(a2),
"--reveal",
"--lease-token",
tok,
],
)
j = json.loads(bad.stdout.strip().splitlines()[0])
assert j["success"] is False
assert j["error"]["code"] == "ERR_LEASE_INVALID"
def test_get_credential_reveal_success(self, iso_cli_env, with_master_key):
env = _cli_env(iso_cli_env)
a1 = _parse_ok_data(
_run_cli(
env,
["account", "add-web", "--platform", "maersk", "--label", "okc"],
).stdout
)["id"]
secret_text = "correct-horse-battery"
sec = _run_cli(
env,
[
"account",
"add-secret",
"--platform",
"maersk",
"--credential-type",
"password",
"--secret-storage",
"local_encrypted",
"--secret-stdin",
"--account-id",
str(a1),
],
stdin=secret_text + "\n",
)
assert sec.returncode == 0
lease_out = _run_cli(
env,
["account", "pick-web", "--platform", "maersk", "--lease", "--holder", "h"],
)
tok = json.loads(lease_out.stdout.strip().splitlines()[0])["lease_token"]
g = _run_cli(
env,
[
"account",
"get-credential",
str(a1),
"--reveal",
"--lease-token",
tok,
],
)
j = json.loads(g.stdout.strip().splitlines()[0])
assert j["success"] is True
assert j["plaintext"] == secret_text

340
tests/test_credentials.py Normal file
View File

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

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
import pytest
from cryptography.fernet import Fernet
from db.accounts_repo import insert_credential
class TestCredentialsRepo:
def test_encrypted_insert_and_read(self, monkeypatch, clean_db):
from db.credentials_repo import (
insert_credential_encrypted,
list_credentials_by_account,
read_credential_plaintext,
)
from service.master_key import ENV_MASTER_KEY
key = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
conn = clean_db
now = 1_700_000_000
aid = insert_credential_encrypted(
conn,
account_id=7,
platform_key="icbc_sim",
credential_label="工行密码",
credential_type="password",
plaintext="my-password",
expires_at=None,
extra_json="{}",
now=now,
)
conn.commit()
pt = read_credential_plaintext(conn, aid)
assert pt == "my-password"
assert "my-password" not in "".join(
str(v) for v in list_credentials_by_account(conn, 7)
)
def test_insert_validation(self, monkeypatch, clean_db):
from db.credentials_repo import insert_credential_encrypted, insert_credential_external
from service.master_key import ENV_MASTER_KEY
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
conn = clean_db
now = 1
with pytest.raises(ValueError, match="credential_type"):
insert_credential_encrypted(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="weird",
plaintext="x",
expires_at=None,
extra_json="{}",
now=now,
)
with pytest.raises(ValueError, match="plaintext"):
insert_credential_encrypted(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="password",
plaintext="",
expires_at=None,
extra_json="{}",
now=now,
)
with pytest.raises(ValueError, match="secret_storage"):
insert_credential_external(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="password",
secret_storage="bogus",
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json="{}",
now=now,
)
def test_list_has_no_sensitive_keys(self, monkeypatch, clean_db):
from db.credentials_repo import insert_credential_encrypted, list_credentials_by_account
from service.master_key import ENV_MASTER_KEY
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
conn = clean_db
now = 2
insert_credential_encrypted(
conn,
account_id=9,
platform_key="p",
credential_label="a",
credential_type="api_key",
plaintext="k1",
expires_at=None,
extra_json="{}",
now=now,
)
insert_credential_encrypted(
conn,
account_id=9,
platform_key="p",
credential_label="b",
credential_type="api_token",
plaintext="k2",
expires_at=None,
extra_json="{}",
now=now + 1,
)
conn.commit()
rows = list_credentials_by_account(conn, 9)
assert len(rows) == 2
for d in rows:
assert "plaintext" not in d
assert "secret_ciphertext" not in d
def test_none_storage_read_fails(self, clean_db):
from db.credentials_repo import CredentialNotEncryptedError, read_credential_plaintext
conn = clean_db
now = 3
cid = insert_credential(
conn,
account_id=None,
platform_key="p",
credential_label="n",
credential_type="note",
secret_storage="none",
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json="{}",
now=now,
)
conn.commit()
with pytest.raises(CredentialNotEncryptedError) as ei:
read_credential_plaintext(conn, cid)
assert ei.value.code == "ERR_CREDENTIAL_NOT_ENCRYPTED"

47
tests/test_crypto.py Normal file
View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
import os
import sys
import tempfile
import pytest
from cryptography.fernet import Fernet
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from service.crypto import CredentialDecryptError, decrypt_secret, encrypt_secret # noqa: E402
from service.master_key import ENV_MASTER_KEY # noqa: E402
@pytest.fixture()
def crypto_env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="crypto_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root, exist_ok=True)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "c_uid")
key = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
yield key
def test_encrypt_decrypt_roundtrip(crypto_env):
pt = "my-password-123"
ct = encrypt_secret(pt)
assert decrypt_secret(ct) == pt
def test_decrypt_invalid_token(crypto_env):
with pytest.raises(CredentialDecryptError) as ei:
decrypt_secret("AAAA-invalid-token-AAAA")
assert ei.value.code == "ERR_CREDENTIAL_DECRYPT_FAILED"
def test_decrypt_wrong_master_key(monkeypatch, crypto_env):
pt = "secret-value"
ct = encrypt_secret(pt)
other = Fernet.generate_key().decode("ascii")
monkeypatch.setenv(ENV_MASTER_KEY, other)
with pytest.raises(CredentialDecryptError):
decrypt_secret(ct)

242
tests/test_lease.py Normal file
View File

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

60
tests/test_master_key.py Normal file
View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
import os
import sys
import tempfile
import pytest
from cryptography.fernet import Fernet
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from service.master_key import ( # noqa: E402
ENV_MASTER_KEY,
MasterKeyMissingError,
load_master_key,
write_master_key_file,
)
@pytest.fixture()
def iso_mk(monkeypatch):
tmp = tempfile.mkdtemp(prefix="mk_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root, exist_ok=True)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "mk_uid")
monkeypatch.delenv(ENV_MASTER_KEY, raising=False)
yield tmp
def test_env_priority_over_file(iso_mk, monkeypatch):
key_env = Fernet.generate_key()
key_file = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key_env.decode("ascii"))
write_master_key_file(key_file, allow_overwrite=True)
assert load_master_key() == key_env
def test_file_fallback(iso_mk):
key_file = Fernet.generate_key()
write_master_key_file(key_file, allow_overwrite=True)
assert load_master_key() == key_file
def test_missing_raises(iso_mk):
with pytest.raises(MasterKeyMissingError) as ei:
load_master_key()
assert ei.value.code == "ERR_MASTER_KEY_MISSING"
def test_invalid_env_falls_back_to_file(iso_mk, monkeypatch):
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
with pytest.raises(MasterKeyMissingError):
load_master_key()
key_file = Fernet.generate_key()
write_master_key_file(key_file, allow_overwrite=True)
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
assert load_master_key() == key_file

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Contract tests: PLATFORM_DEFAULT_AUTH_STRATEGY keys must resolve via PLATFORMS."""
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
from util.platforms import resolve_platform_key
def test_platform_defaults_all_resolvable():
for key in PLATFORM_DEFAULT_AUTH_STRATEGY:
assert resolve_platform_key(key) == key
def test_new_platform_aliases_work():
assert resolve_platform_key("抖音") == "douyin"
assert resolve_platform_key("douyin") == "douyin"
assert resolve_platform_key("GitHub") == "github"
assert resolve_platform_key("github") == "github"
assert resolve_platform_key("OpenAI") == "openai"
assert resolve_platform_key("openai") == "openai"

83
tests/test_platforms.py Normal file
View File

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

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
from unittest.mock import MagicMock
import pytest
def test_ensure_logged_in_dispatches_password_auto(monkeypatch):
from service.rpa_helpers import auth_flows
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: False)
monkeypatch.setattr(auth_flows, "_poll_until", lambda *a, **k: True)
loc = MagicMock()
page = MagicMock()
page.url = "http://example.com"
page.locator.return_value.first = loc
account = {"auth_strategy": "password_auto", "login_id": "u", "url": "http://x"}
r = auth_flows.ensure_logged_in(page, account, credentials={"plaintext": "pw"})
assert r.ok is True
page.goto.assert_called()
assert loc.type.call_count >= 2
loc.click.assert_called()
def test_ensure_logged_in_dispatches_qr_code_manual(monkeypatch):
from service.rpa_helpers import auth_flows
monkeypatch.setattr(auth_flows, "_is_already_logged_in", lambda p, a: True)
page = MagicMock()
account = {"auth_strategy": "qr_code_manual", "url": "http://x"}
r = auth_flows.ensure_logged_in(page, account)
assert r.ok is True
def test_ensure_logged_in_client_certificate_raises():
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
page = MagicMock()
account = {"auth_strategy": "client_certificate"}
with pytest.raises(AuthStrategyError) as ei:
ensure_logged_in(page, account)
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_AUTOMATABLE"
def test_ensure_logged_in_api_token_no_op():
from service.rpa_helpers.auth_flows import ensure_logged_in
page = MagicMock()
account = {"auth_strategy": "api_token"}
r = ensure_logged_in(page, account)
assert r.ok is True
page.goto.assert_not_called()
def test_ensure_logged_in_missing_credentials():
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
page = MagicMock()
account = {"auth_strategy": "password_auto", "login_id": "u"}
with pytest.raises(AuthStrategyError) as ei:
ensure_logged_in(page, account, credentials=None)
assert ei.value.code == "ERR_CREDENTIAL_MISSING"
def test_ensure_logged_in_unknown_strategy():
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
page = MagicMock()
account = {"auth_strategy": "bogus"}
with pytest.raises(AuthStrategyError) as ei:
ensure_logged_in(page, account)
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
def test_ensure_logged_in_no_strategy():
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
page = MagicMock()
account = {"auth_strategy": ""}
with pytest.raises(AuthStrategyError) as ei:
ensure_logged_in(page, account)
assert ei.value.code == "ERR_AUTH_STRATEGY_NOT_SUPPORTED"
def test_device_bound_fingerprint_mismatch(monkeypatch):
from service.rpa_helpers.auth_flows import AuthStrategyError, ensure_logged_in
monkeypatch.setattr(
"service.rpa_helpers.auth_flows._current_machine_fingerprint",
lambda: "right_fp",
)
page = MagicMock()
account = {
"auth_strategy": "device_bound",
"device_fingerprint": "wrong_fp",
"login_id": "u",
"url": "http://x",
}
with pytest.raises(AuthStrategyError) as ei:
ensure_logged_in(page, account, credentials={"plaintext": "pw"})
assert ei.value.code == "ERR_DEVICE_FINGERPRINT_MISMATCH"
def test_is_already_logged_in_default_false():
from service.rpa_helpers.auth_flows import _is_already_logged_in
p = MagicMock()
p.url = "about:blank"
assert _is_already_logged_in(p, {}) is False
p.url = "https://www.douyin.com"
assert _is_already_logged_in(p, {}) is False
p.url = "https://www.douyin.com/user/abc"
assert _is_already_logged_in(
p,
{"extra_json": {"logged_in_url_pattern": r"/user/"}},
) is True
loc_first = MagicMock()
loc_first.is_visible.return_value = True
loc = MagicMock()
loc.first = loc_first
p2 = MagicMock()
p2.url = "https://example.com/home"
p2.locator.return_value = loc
assert _is_already_logged_in(
p2,
{"extra_json": {"logged_in_selector": "#avatar"}},
) is True
p2.locator.assert_called_with("#avatar")
def test_qr_code_manual_default_no_hooks():
from service.rpa_helpers.auth_flows import ensure_logged_in
qr_first = MagicMock()
qr_first.wait_for.side_effect = TimeoutError("mock qr timeout")
qr_loc = MagicMock()
qr_loc.first = qr_first
page = MagicMock()
page.url = "about:blank"
page.locator.return_value = qr_loc
account = {"auth_strategy": "qr_code_manual", "url": "http://login.example"}
r = ensure_logged_in(page, account)
page.goto.assert_called_once_with(
"http://login.example",
wait_until="domcontentloaded",
timeout=60000,
)
qr_first.wait_for.assert_called_once()
assert r.ok is False
assert r.message == "qr code not found"

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_playwright(monkeypatch):
pw = MagicMock()
ctx = MagicMock()
page = MagicMock()
ctx.pages = [page]
pw.chromium.launch_persistent_context.return_value = ctx
sp = MagicMock()
sp.start.return_value = pw
monkeypatch.setattr("service.rpa_helpers.browser.sync_playwright", lambda: sp)
return {"pw": pw, "ctx": ctx, "page": page, "sp": sp}
def test_launch_browser_uses_chrome_channel_default(mock_playwright, monkeypatch, tmp_path):
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
monkeypatch.setattr(
"service.rpa_helpers.browser.resolve_chromium_channel",
lambda: "chrome",
)
prof = tmp_path / "p1"
from service.rpa_helpers.browser import launch_browser_with_profile
ctx, page = launch_browser_with_profile(str(prof))
pw = mock_playwright["pw"]
kw = pw.chromium.launch_persistent_context.call_args.kwargs
assert kw["channel"] == "chrome"
assert "--disable-blink-features=AutomationControlled" in kw["args"]
assert ctx is mock_playwright["ctx"]
assert page is mock_playwright["page"]
def test_launch_browser_respects_explicit_channel(mock_playwright, monkeypatch, tmp_path):
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
prof = tmp_path / "p2"
from service.rpa_helpers.browser import launch_browser_with_profile
launch_browser_with_profile(str(prof), channel="msedge")
kw = mock_playwright["pw"].chromium.launch_persistent_context.call_args.kwargs
assert kw["channel"] == "msedge"
def test_launch_browser_creates_profile_dir(mock_playwright, monkeypatch, tmp_path):
monkeypatch.setenv("OPENCLAW_PLAYWRIGHT_STEALTH", "1")
monkeypatch.setattr(
"service.rpa_helpers.browser.resolve_chromium_channel",
lambda: "chrome",
)
prof = tmp_path / "new_prof"
assert not prof.is_dir()
from service.rpa_helpers.browser import launch_browser_with_profile
launch_browser_with_profile(str(prof))
assert prof.is_dir()

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from unittest.mock import MagicMock
import pytest
def test_pause_respects_range(monkeypatch):
sleeps = []
def _rec(s):
sleeps.append(s)
monkeypatch.setattr("service.rpa_helpers.human.time.sleep", _rec)
from service.rpa_helpers.human import pause
for _ in range(10):
pause(50, 100)
assert len(sleeps) == 10
for s in sleeps:
assert 0.04 <= s <= 0.12
def test_human_type_calls_locator_methods():
from service.rpa_helpers.human import human_type
loc = MagicMock()
human_type(loc, "hello")
loc.scroll_into_view_if_needed.assert_called()
loc.click.assert_called()
loc.type.assert_called_once()
args, kwargs = loc.type.call_args
assert args[0] == "hello"
assert "delay" in kwargs
assert 50 <= kwargs["delay"] <= 200
def test_human_click_basic():
from service.rpa_helpers.human import human_click
loc = MagicMock()
human_click(loc)
loc.scroll_into_view_if_needed.assert_called()
loc.click.assert_called()
def test_human_select_basic():
from service.rpa_helpers.human import human_select
loc = MagicMock()
human_select(loc, "opt1")
loc.scroll_into_view_if_needed.assert_called()
loc.click.assert_called()
assert loc.select_option.call_count >= 1

View File

@@ -0,0 +1,377 @@
# -*- coding: utf-8 -*-
"""Tests for v2 schema DDL and migrate_v1_to_v2 / ensure_schema."""
import os
import sqlite3
import sys
import tempfile
import time
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from util.constants import SKILL_SLUG # noqa: E402
# Minimal v1-like DDL (no auth_strategy / session_persistent / device_fingerprint /
# platforms.default_auth_strategy; credentials without account_id).
_V1_DDL = """
CREATE TABLE platforms (
platform_key TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
domain TEXT NOT NULL,
provider_code TEXT,
default_url TEXT,
aliases_json TEXT NOT NULL DEFAULT '[]',
capabilities_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_key TEXT NOT NULL,
account_label TEXT NOT NULL,
login_id TEXT,
login_id_type TEXT NOT NULL DEFAULT 'unknown',
tenant_id TEXT,
environment TEXT NOT NULL DEFAULT 'production',
role TEXT NOT NULL DEFAULT 'default',
provider_code TEXT,
profile_dir TEXT,
url TEXT,
status TEXT NOT NULL DEFAULT 'active',
session_status TEXT NOT NULL DEFAULT 'unknown',
last_used_at INTEGER,
last_login_check_at INTEGER,
last_error_code TEXT,
last_error_message TEXT,
extra_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_key TEXT NOT NULL,
credential_label TEXT NOT NULL,
credential_type TEXT NOT NULL,
secret_storage TEXT NOT NULL,
secret_ref TEXT,
secret_mask TEXT,
expires_at INTEGER,
status TEXT NOT NULL DEFAULT 'active',
last_used_at INTEGER,
extra_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE account_leases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
lease_token TEXT NOT NULL UNIQUE,
holder TEXT NOT NULL,
purpose TEXT,
expires_at INTEGER NOT NULL,
heartbeat_at INTEGER,
released_at INTEGER,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"""
def _db_path_for_env(root: str, uid: str) -> str:
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
def _prep_logging():
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_migration_test")
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
return [row[1] for row in cur.fetchall()]
@pytest.fixture()
def iso_env(monkeypatch):
with tempfile.TemporaryDirectory(prefix="acc_mgr_schema_", ignore_cleanup_errors=True) as tmp:
data_root = os.path.join(tmp, "data-root")
os.makedirs(data_root)
uid = "migration_uid"
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
db_path = _db_path_for_env(data_root, uid)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_prep_logging()
yield {"tmp": tmp, "data_root": data_root, "uid": uid, "db_path": db_path}
class TestFreshSchema:
def test_fresh_schema_has_v2_columns_and_meta(self, iso_env):
from db.connection import init_db, get_conn
if os.path.isfile(iso_env["db_path"]):
os.remove(iso_env["db_path"])
init_db()
conn = get_conn()
try:
cols_accounts = _column_names(conn, "accounts")
assert "auth_strategy" in cols_accounts
assert "session_persistent" in cols_accounts
assert "device_fingerprint" in cols_accounts
cols_plat = _column_names(conn, "platforms")
assert "default_auth_strategy" in cols_plat
cols_cred = _column_names(conn, "credentials")
assert "account_id" in cols_cred
assert "secret_ciphertext" in cols_cred
cur = conn.cursor()
cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",),
)
row = cur.fetchone()
assert row is not None and row[0] == "2.1"
finally:
conn.close()
class TestMigrateFromV1:
def test_v1_to_v2_migrates_and_preserves_rows(self, iso_env, capsys):
db_path = iso_env["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
raw = sqlite3.connect(db_path)
try:
raw.executescript(_V1_DDL)
now = int(time.time())
raw.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
VALUES ('keep_me', 'Keep', 'generic', NULL, '', '[]', '{}', 1, ?, ?)
""",
(now, now),
)
raw.execute(
"""
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at)
VALUES ('keep_me', 'acc', 'u@test.com', 'email', NULL, 'production',
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
""",
(now, now),
)
raw.commit()
finally:
raw.close()
from db.connection import init_db, get_conn
init_db()
err = capsys.readouterr().err
assert "[migration] account-manager v1→v2:" in err
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE login_id = ?", ("u@test.com",))
assert int(cur.fetchone()[0]) == 1
cur.execute(
"SELECT auth_strategy, session_persistent FROM accounts WHERE login_id = ?",
("u@test.com",),
)
strat, sp = cur.fetchone()
assert strat == "password_auto"
assert int(sp) == 1
finally:
conn.close()
def test_migration_idempotent(self, iso_env, capsys):
db_path = iso_env["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
raw = sqlite3.connect(db_path)
try:
raw.executescript(_V1_DDL)
raw.commit()
finally:
raw.close()
from db.connection import init_db
init_db()
capsys.readouterr()
init_db()
err = capsys.readouterr().err
assert "v1→v2" not in err
conn = sqlite3.connect(db_path)
try:
cols = _column_names(conn, "accounts")
assert cols.count("auth_strategy") == 1
finally:
conn.close()
def test_platform_default_mapping_douyin(self, iso_env):
db_path = iso_env["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
raw = sqlite3.connect(db_path)
now = int(time.time())
try:
raw.executescript(_V1_DDL)
raw.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
VALUES ('douyin', 'Douyin', 'content', NULL, '', '[]', '{}', 1, ?, ?)
""",
(now, now),
)
raw.commit()
finally:
raw.close()
from db.connection import init_db, get_conn
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
("douyin",),
)
assert cur.fetchone()[0] == "qr_code_manual"
finally:
conn.close()
def test_accounts_auth_strategy_priority(self, iso_env):
db_path = iso_env["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
raw = sqlite3.connect(db_path)
now = int(time.time())
try:
raw.executescript(_V1_DDL)
for pk, dn in (
("maersk", "Maersk"),
("unknown_platform", "Unknown"),
):
raw.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
VALUES (?, ?, 'generic', NULL, '', '[]', '{}', 1, ?, ?)
""",
(pk, dn, now, now),
)
raw.execute(
"""
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at)
VALUES ('maersk', 'm', NULL, 'unknown', NULL, 'production',
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
""",
(now, now),
)
raw.execute(
"""
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at)
VALUES ('unknown_platform', 'u', NULL, 'unknown', NULL, 'production',
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
""",
(now, now),
)
raw.commit()
finally:
raw.close()
from db.connection import init_db, get_conn
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
("maersk",),
)
assert cur.fetchone()[0] == "qr_code_manual"
cur.execute(
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
("unknown_platform",),
)
assert cur.fetchone()[0] == "password_auto"
finally:
conn.close()
class TestInsertAccountValidation:
def test_insert_account_rejects_invalid_strategy(self, iso_env):
from db.connection import init_db, get_conn
from db.accounts_repo import insert_account
from db.auth_strategy import AUTH_STRATEGY_QR_CODE_MANUAL
if os.path.isfile(iso_env["db_path"]):
os.remove(iso_env["db_path"])
init_db()
conn = get_conn()
try:
now = int(time.time())
with pytest.raises(ValueError, match="invalid auth_strategy"):
insert_account(
conn,
platform_key="x",
account_label="l",
login_id=None,
login_id_type="unknown",
tenant_id=None,
environment="production",
role="booking",
provider_code=None,
profile_dir=None,
url=None,
extra_json="{}",
now=now,
auth_strategy="not_a_strategy",
)
aid = insert_account(
conn,
platform_key="x",
account_label="l",
login_id=None,
login_id_type="unknown",
tenant_id=None,
environment="production",
role="booking",
provider_code=None,
profile_dir=None,
url=None,
extra_json="{}",
now=now,
auth_strategy=AUTH_STRATEGY_QR_CODE_MANUAL,
)
assert isinstance(aid, int) and aid > 0
conn.commit()
finally:
conn.close()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
import os
import sqlite3
import sys
import tempfile
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from util.constants import SKILL_SLUG # noqa: E402
def _db_path(root: str, uid: str) -> str:
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
def _prep_logging():
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_schema_v21")
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
return [row[1] for row in cur.fetchall()]
@pytest.fixture()
def iso_v21(monkeypatch):
with tempfile.TemporaryDirectory(prefix="acc_mgr_v21_", ignore_cleanup_errors=True) as tmp:
data_root = os.path.join(tmp, "data-root")
os.makedirs(data_root)
uid = "v21_uid"
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
db_path = _db_path(data_root, uid)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_prep_logging()
yield {"tmp": tmp, "db_path": db_path}
class TestSchemaV21:
def test_fresh_has_secret_ciphertext_and_meta(self, iso_v21):
from db.connection import get_conn, init_db
if os.path.isfile(iso_v21["db_path"]):
os.remove(iso_v21["db_path"])
init_db()
conn = get_conn()
try:
assert "secret_ciphertext" in _column_names(conn, "credentials")
cur = conn.cursor()
cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",),
)
assert cur.fetchone()[0] == "2.1"
finally:
conn.close()
def test_v2_to_v21_idempotent(self, iso_v21, capsys):
from db.connection import get_conn, init_db
db_path = iso_v21["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
init_db()
conn = get_conn()
try:
conn.execute("ALTER TABLE credentials DROP COLUMN secret_ciphertext")
conn.execute(
"UPDATE _schema_meta SET value = ? WHERE key = ?",
("2", "schema_version"),
)
conn.commit()
finally:
conn.close()
capsys.readouterr()
init_db()
err = capsys.readouterr().err
assert "v2→v2.1" in err
conn = get_conn()
try:
assert "secret_ciphertext" in _column_names(conn, "credentials")
cur = conn.cursor()
cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",),
)
assert cur.fetchone()[0] == "2.1"
finally:
conn.close()
capsys.readouterr()
init_db()
err2 = capsys.readouterr().err
assert "v2→v2.1" not in err2

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import shutil
import sys
import tempfile
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from db.connection import get_conn, init_db # noqa: E402
from util.platforms import seed_platforms # noqa: E402
@pytest.fixture()
def iso_seed_env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="seed_plat_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
uid = "seed_user_1"
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
monkeypatch.setenv("CLAW_USER_ID", uid)
yield
shutil.rmtree(tmp, ignore_errors=True)
def _default_auth_maersk() -> str | None:
conn = get_conn()
try:
row = conn.execute(
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
("maersk",),
).fetchone()
return row[0] if row else None
finally:
conn.close()
def test_default_auth_strategy_filled_on_first_seed(iso_seed_env):
init_db()
conn = get_conn()
try:
seed_platforms(conn)
finally:
conn.close()
assert _default_auth_maersk() == "qr_code_manual"
def test_default_auth_strategy_updated_on_reseed(iso_seed_env):
init_db()
conn = get_conn()
try:
seed_platforms(conn)
conn.execute(
"UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?",
("password_auto", "maersk"),
)
conn.commit()
seed_platforms(conn)
finally:
conn.close()
assert _default_auth_maersk() == "qr_code_manual"

33
tests/test_usage.py Normal file
View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""Smoke test: built-in CLI usage lists routed account subcommands."""
from __future__ import annotations
import os
import subprocess
import sys
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
def test_usage_includes_all_account_subcommands():
env = {**os.environ, "PYTHONPATH": _scripts}
r = subprocess.run(
[sys.executable, "-m", "cli.app"],
capture_output=True,
text=True,
env=env,
cwd=_scripts,
)
assert r.returncode == 1
usage = r.stdout
for cmd in (
"init-master-key",
"export-credentials",
"import-credentials",
"add-web",
"get-credential",
"pick-web",
):
assert cmd in usage, f"USAGE 缺少 {cmd}"

View File

@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""v1 CLI compatibility — subprocess-only, mirrors monitor-competitor-rates calling patterns."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, wait
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
MAIN_PY = os.path.join(_scripts, "main.py")
def _cli_env(roots: dict) -> dict:
e = {**os.environ}
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
e["JIANGCHANG_USER_ID"] = roots["user_id"]
e["CLAW_DATA_ROOT"] = roots["data_root"]
e["CLAW_USER_ID"] = roots["user_id"]
return e
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess:
inp = stdin.encode("utf-8") if stdin is not None else None
return subprocess.run(
[sys.executable, MAIN_PY, *args],
input=inp,
capture_output=True,
env=env,
cwd=os.path.dirname(_scripts),
)
def _parse_last_json(stdout: str) -> dict:
lines = [ln for ln in (stdout or "").strip().split("\n") if ln.strip()]
assert lines, "stdout had no non-empty lines"
return json.loads(lines[-1])
def run_cli_json_last(env: dict, args: list[str], *, stdin: str | None = None) -> dict:
"""Parse **last** stdout line as JSON (monitor `_parse_stdout_json` semantics)."""
r = _run_cli(env, args, stdin=stdin)
assert r.returncode == 0, (r.stdout, r.stderr)
return _parse_last_json(r.stdout.decode("utf-8"))
@pytest.fixture()
def iso_cli_env():
tmp = tempfile.mkdtemp(prefix="v1_compat_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
uid = "compat_user"
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
shutil.rmtree(tmp, ignore_errors=True)
def _add_monitor_account(env: dict) -> dict:
out = run_cli_json_last(
env,
[
"account",
"add-web",
"--platform",
"maersk",
"--label",
"monitor-test",
"--environment",
"simulator",
"--role",
"booking",
"--tenant-id",
"tenant-test",
],
)
assert out.get("success") is True
return out["data"]
class TestV1PickWeb:
def test_pick_web_returns_v1_shape(self, iso_cli_env):
env = _cli_env(iso_cli_env)
add_data = _add_monitor_account(env)
aid = add_data["id"]
assert isinstance(aid, int)
pick = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--purpose",
"maersk_sim_rpa",
"--ttl-sec",
"1800",
],
)
for key in ("id", "platform_key", "environment", "role", "profile_dir", "lease_token"):
assert key in pick, f"pick-web 缺少 v1 字段 {key}"
assert isinstance(pick["id"], int)
assert isinstance(pick["lease_token"], str) and len(pick["lease_token"]) > 0
assert pick["platform_key"] == "maersk"
assert pick["environment"] == "simulator"
assert pick["role"] == "booking"
for v2_key in ("auth_strategy", "session_persistent", "device_fingerprint"):
assert v2_key in pick, f"v2 字段 {v2_key} 应在 pick-web 返回中"
assert len(pick) >= 10
def test_pick_web_no_account_returns_legacy_error_code(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
],
)
assert proc.returncode == 0
payload = _parse_last_json(proc.stdout.decode("utf-8"))
assert payload.get("success") is False
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_pick_web_lease_race_returns_legacy_error_shape(self, iso_cli_env):
"""Parallel picks share one account — second outcome may be NO_ACCOUNT or LEASE_CONFLICT."""
env = _cli_env(iso_cli_env)
_add_monitor_account(env)
args = [
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"3600",
]
def _pick():
return _run_cli(env, args)
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_pick)
f2 = ex.submit(_pick)
wait([f1, f2])
proc_a = f1.result()
proc_b = f2.result()
payloads = []
for proc in (proc_a, proc_b):
assert proc.returncode == 0
payloads.append(_parse_last_json(proc.stdout.decode("utf-8")))
codes = []
successes = 0
for p in payloads:
if p.get("success") is False:
codes.append(p["error"]["code"])
elif "lease_token" in p:
successes += 1
assert successes >= 1
assert "ERROR:LEASE_CONFLICT" in codes or "ERROR:NO_ACCOUNT" in codes
class TestV1LeaseAndMarks:
def test_lease_release_returns_success_json(self, iso_cli_env):
env = _cli_env(iso_cli_env)
_add_monitor_account(env)
pick = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"900",
],
)
tok = pick["lease_token"]
rel = run_cli_json_last(env, ["lease", "release", tok])
assert rel.get("success") is True
assert rel["data"]["status"] == "released"
def test_mark_used_returns_success(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
out = run_cli_json_last(env, ["account", "mark-used", str(aid)])
assert out.get("success") is True
assert int(out["data"]["id"]) == aid
def test_mark_session_accepts_v1_status_values(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
for status in ("likely_valid", "needs_login", "unknown"):
out = run_cli_json_last(
env,
["account", "mark-session", str(aid), "--session-status", status],
)
assert out.get("success") is True
assert out["data"]["session_status"] == status
def test_mark_error_accepts_long_message(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
long_msg = "x" * 600
out = run_cli_json_last(
env,
[
"account",
"mark-error",
str(aid),
"--code",
"ERR_TEST",
"--message",
long_msg,
],
)
assert out.get("success") is True
assert out["data"]["error_code"] == "ERR_TEST"
class TestV1EndToEnd:
def test_full_monitor_sequence_end_to_end(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
pick1 = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--purpose",
"maersk_sim_rpa",
"--ttl-sec",
"900",
],
)
tok = pick1["lease_token"]
assert pick1["id"] == aid
run_cli_json_last(env, ["account", "mark-used", str(aid)])
run_cli_json_last(
env,
["account", "mark-session", str(aid), "--session-status", "likely_valid"],
)
run_cli_json_last(env, ["lease", "release", tok])
pick2 = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"900",
],
)
assert "lease_token" in pick2
acc = run_cli_json_last(env, ["account", "get", str(aid)])
assert acc.get("session_status") == "likely_valid"
class TestStdoutIsolation:
def test_stderr_does_not_pollute_stdout_last_line(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(env, ["account", "export-credentials", "--plaintext"])
assert proc.returncode == 0
assert b"[export] WARNING" in proc.stderr
lines = [ln for ln in proc.stdout.decode("utf-8").strip().split("\n") if ln.strip()]
payload = json.loads(lines[-1])
assert isinstance(payload, list)