Compare commits

...

56 Commits

Author SHA1 Message Date
549869b2bb Release v2.0.1: create Chrome shortcut in profile dir on add-web
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-07 16:22:59 +08:00
71bef19514 chore: auto release commit (2026-06-19 11:19:10)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-19 11:19:10 +08:00
75c7ea67d5 chore: auto release commit (2026-06-19 11:07:44)
Some checks failed
技能自动化发布 / release (push) Failing after 3s
2026-06-19 11:07:44 +08:00
23f4a2c11a chore: auto release commit (2026-06-19 09:53:29)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-19 09:53:29 +08:00
cb980704bf chore: auto release commit (2026-05-30 19:51:46)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-05-30 19:51:46 +08:00
a7bd6ff9d8 chore: auto release commit (2026-05-11 19:34:47)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-05-11 19:34:47 +08:00
4fe1351e1e test(compat): add v1 compatibility suite; docs: add V1_COMPATIBILITY.md
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:52:22 +08:00
fd5c432735 fix(db): sync default_auth_strategy on platform reseed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
2c7e648a72 fix(cli): add encryption commands to USAGE string
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:51:56 +08:00
6fc4f1af1d docs: add RPA_HELPERS.md for same-process integration guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:56 +08:00
dadf89af3d docs: update SCHEMA.md / ERRORS.md / PLATFORMS.md / RUNTIME.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:49 +08:00
1792ba2a0d docs: update SKILL.md / README.md / CLI.md / INTEGRATION.md for v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:42 +08:00
dbf5604090 fix(rpa_helpers): require positive evidence in _is_already_logged_in to avoid false success
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:44:46 +08:00
769ccec601 test(rpa_helpers): add mock-based tests for browser/human/auth_flows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
cfca82ed0c feat(rpa_helpers): export public API in package __init__
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:33 +08:00
104aa91364 feat(rpa_helpers): add auth_flows.py with 9 ensure_logged_in_* and dispatcher
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
c23240d0ea feat(rpa_helpers): add human.py interaction primitives (pause/type/click/select/scroll)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
5d4425c060 feat(rpa_helpers): add browser.launch_browser_with_profile and close_browser
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:56:32 +08:00
ef80d64f30 chore(platforms): align PLATFORMS registry with PLATFORM_DEFAULT_AUTH_STRATEGY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:48:27 +08:00
65d939dc5b test(cli): add stage C CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
04cafae3d7 feat(cli): argv routing and USAGE for stage C commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:53 +08:00
a74157ff4a feat(service): extend add-web/add-secret/pick-web; add get-credential
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
4c7e4f9769 feat(db): add get_active_lease_by_token helper to accounts_repo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:25:52 +08:00
e7f41d6e27 test(crypto+db+cli): add master key, crypto, credentials_repo, and CLI tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:29 +08:00
07adc98e8c feat(cli): add init-master-key / export-credentials / import-credentials commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
5f8c93dee9 feat(db): add credentials_repo with encrypted insert/read API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
127e9b6019 feat(db): migrate credentials to v2.1 (add secret_ciphertext column)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:28 +08:00
ee851f719b feat(crypto): add master key loader and Fernet encrypt/decrypt helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:51:27 +08:00
06aed39661 test(db): add schema migration tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
00e3116339 feat(db): migrate accounts/credentials/platforms to v2 schema
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:49 +08:00
27d687becd feat(db): add auth_strategy constants and platform defaults
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:53:48 +08:00
91057fa3b7 Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-05-05 18:51:00 +08:00
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
5afad3285a chore: auto release commit (2026-04-06 12:57:26)
Some checks failed
技能自动化发布 / release (push) Failing after 21s
2026-04-06 12:57:26 +08:00
39daeaca59 chore: auto release commit (2026-04-06 12:10:31)
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-04-06 12:10:31 +08:00
c428bedfd5 chore: auto release commit (2026-04-06 10:37:07)
All checks were successful
技能自动化发布 / release (push) Successful in 34s
2026-04-06 10:37:08 +08:00
5082da78ab chore: auto release commit (2026-04-06 10:16:00)
Some checks failed
技能自动化发布 / release (push) Failing after 13s
2026-04-06 10:16:00 +08:00
bfb9c15f13 ci: use kit @main + pass PYARMOR_REG_B64 for licensed CI obfuscation
Some checks failed
技能自动化发布 / release (push) Failing after 12s
Made-with: Cursor
2026-04-05 09:28:58 +08:00
6b4cdec1ec ci: pin reusable workflow to jiangchang-platform-kit@3ee09bb (avoid stale @main)
Some checks failed
技能自动化发布 / release (push) Failing after 12s
Made-with: Cursor
2026-04-05 09:25:05 +08:00
52cef5a7c0 chore: sync from OpenClaw workspace (2026-04-05 09:17)
Some checks failed
技能自动化发布 / release (push) Failing after 11s
2026-04-05 09:17:20 +08:00
80e7de6183 chore: merge origin/main; resolve conflicts
Some checks failed
技能自动化发布 / release (push) Failing after 11s
2026-04-05 08:53:50 +08:00
27cfe1018e chore: initialize account-manager skill repository 2026-04-05 08:36:41 +08:00
ba1c21c0b6 chore: auto release commit (2026-03-31 18:37:04)
All checks were successful
技能自动化发布 / release (push) Successful in 20s
2026-03-31 18:37:04 +08:00
58dc81449d chore: auto release commit (2026-03-31 18:19:36)
All checks were successful
技能自动化发布 / release (push) Successful in 14s
2026-03-31 18:19:37 +08:00
86 changed files with 12032 additions and 578 deletions

View File

@@ -6,6 +6,8 @@ on:
jobs:
release:
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
secrets:
PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }}
with:
artifact_platform: windows
pyarmor_platform: windows.x86_64

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.env

View File

@@ -1,34 +1,69 @@
---
name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.2
name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.1
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** 才能解密。
### 添加账号仅需平台可选手机号ID 数据库自增名称自动生成如「搜狐1号」
```bash
python3 {baseDir}/scripts/account.py add sohu
python3 {baseDir}/scripts/account.py add sohu 189xxxxxxxx
```
## 何时使用本技能
### 列出某平台所有账号
```bash
python3 {baseDir}/scripts/account.py list <platform>
1. **登记网页/RPA 账号**`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`
2. **登记凭据API Key / Token / 加密密码)**`account add-secret``none` / `env` / `windows_credential` / `local_encrypted`
3. **查看/列出账号**`account get/list` — JSON 输出。
4. **为 RPA 挑选账号**`account pick-web` — 按环境/租户/角色筛选,可带 lease。
5. **获取 API Key凭据表**`credential pick` — 默认隐藏 secret`--reveal` 时才返回。
6. **浏览器打开查看**`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
7. **管理 lease**`lease release/list/cleanup` — 防止 RPA 并发。
8. **初始化加密**`account init-master-key` — 生成 Fernet master.key。
9. **加密备份与迁移**`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
10. **按 lease 取明文密码**`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
11. **业务 skill 同进程登录**`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
## CLI 调用
语法、默认值、错误约定见 **`references/CLI.md`**。
## 支持的平台
见 [`references/PLATFORMS.md`](references/PLATFORMS.md);代码注册表按业务域分为 logistics / llm / content / dev / sim实际数量与别名以 `scripts/util/platforms.py` 为准)。
## 数据与环境
库路径、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` 等)保留。

7
assets/README.md Normal file
View File

@@ -0,0 +1,7 @@
# assets
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/``get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema`additionalProperties: true` 允许扩展字段)。
- 面向用户的介绍见 `references/README.md`
- 面向编排/CLI 的细节见 `references/CLI.md``ERRORS.md``INTEGRATION.md`(由 `SKILL.md` 渐进引用)。

View File

@@ -0,0 +1,6 @@
# 示例 JSON
- `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

@@ -0,0 +1,20 @@
{
"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

@@ -0,0 +1,36 @@
[
{
"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": 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

@@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
"title": "AccountRecord",
"description": "account-manager account get / pick-web / list 返回的单条账号对象v2 schema",
"type": "object",
"required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"],
"properties": {
"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", "description": "账号默认入口 URL" },
"status": { "type": "string", "enum": ["active", "disabled", "needs_review"] },
"session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] },
"last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" },
"last_login_check_at": { "type": ["string", "null"] },
"last_error_code": { "type": "string" },
"last_error_message": { "type": "string" },
"created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" },
"updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" },
"credentials": {
"type": "array",
"description": "关联凭据(仅 account get --with-credentials 时返回)",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"credential_label": { "type": "string" },
"credential_type": { "type": "string" },
"secret_storage": { "type": "string" },
"secret_ref": { "type": "string" },
"secret_mask": { "type": "string" },
"status": { "type": "string" }
}
}
},
"lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" },
"lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" }
},
"additionalProperties": true
}

208
references/CLI.md Normal file
View File

@@ -0,0 +1,208 @@
# account-manager CLI 速查
`{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` 中的英文键、`display_name`、任一 alias不区分大小写。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码**:见 [ERRORS.md](ERRORS.md)。
## 内置 `_USAGE`(阶段 F
无参数运行 CLI例如 `python scripts/main.py` / `python -m cli.app`cwd=`scripts/`)打印的内置 `_USAGE` 已与 `main()` 路由对齐,包含 **`account init-master-key` / `account export-credentials` / `account import-credentials`**。面向外部仓库的契约摘要见 [V1_COMPATIBILITY.md](V1_COMPATIBILITY.md)。

150
references/ERRORS.md Normal file
View File

@@ -0,0 +1,150 @@
# 终端错误码account-manager
约定:**集成解析必须读取 stdout**;多数失败后仍为单行 JSON`success=false`。老旧别名路径可能出现 **`ERROR:` 文本前缀**——仍以「是否能解析 JSON」为首要判定。
## 机器 JSON 失败格式(常见)
```json
{
"success": false,
"error": {
"code": "……",
"message": "……"
}
}
```
部分 CLI例如遗留 `ERROR:INVALID_PLATFORM`)仍可能在成功行前额外打印 hint——请以 [`INTEGRATION.md`](INTEGRATION.md) 的建议解析顺序为准。
---
## ERROR:CLI — CLI 参数 / 路由
| 前缀 | 含义 |
|---|---|
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
---
## ERROR: — 数据库 / 通用
| 前缀 | 含义 |
|---|---|
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
---
## ERROR: — 平台与账号
| 前缀 | 含义 |
|---|---|
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
---
## ERROR: — 租约
| 前缀 | 含义 |
|---|---|
| `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` 为空 |
---
## 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:` 文本。

212
references/INTEGRATION.md Normal file
View File

@@ -0,0 +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`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`)必须与最终用户会话一致——否则会写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)。
## 机器可读命令摘要
| 目的 | 命令 | 成功时 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**;若有多行,**集成应以首行为准**export/import 例外:`export` 整块 stdout 即 JSON`import` 读 stderr
2. **优先尝试 `json.loads`**
- 若为 dict 且 `success is False` → 读取 `error.code` / `error.message`
- 若 dict 且 `success is True` 且存在 `data` → 业务字段通常在 `data` 内(`add-web` / `add-secret` / `platform`)。
3. 若 JSON 首字段不是上述统一格式(例如 `pick-web`)→ 直接按字段读取。
4. **遗留文本失败**:首行以 **`ERROR:`** 开头 → 视为失败(旧别名路径仍可能出现)。
## JSON 对象字段
### `account pick-web`(典型)
```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"
}
```
`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)
```
## 退出码
请以 **stdout JSON / `ERROR:` 前缀** 为准;不要仅依赖退出码。

101
references/PLATFORMS.md Normal file
View File

@@ -0,0 +1,101 @@
# 平台与别名account-manager
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias大小写不敏感
---
## 物流平台(`domain = logistics`9
| 英文键 | 展示名 | 供应商代码 | 别名(节选) | 默认 URL |
|---|---|---|---|---|
| `maersk` | Maersk | maersk | 马士基 | https://www.maersk.com |
| `cosco` | COSCO | cosco | 中远海运 | (空) |
| `msc` | MSC | msc | 地中海航运 | (空) |
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 | (空) |
| `evergreen` | Evergreen | evergreen | 长荣 | (空) |
| `cargo_wise` | CargoWise | cargo_wise | CargoWise, Cargo Wise | (空) |
| `freightos` | Freightos | freightos | Freightos | (空) |
| `webcargo` | WebCargo | webcargo | WebCargo, web cargo | (空) |
| `sinotrans` | Sinotrans | sinotrans | 中外运 | (空) |
---
## LLM / AI 平台(`domain = llm`10
其中 `openai``anthropic` 在注册表中 **`capabilities.web = false`**,以 API Key 场景为主;其余条目多为 Web + API。
| 英文键 | 展示名 | 别名(节选) | 默认 URL |
|---|---|---|---|
| `deepseek` | DeepSeek | DeepSeek | https://chat.deepseek.com |
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
| `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))。

61
references/README.md Normal file
View File

@@ -0,0 +1,61 @@
---
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略与 Fernet 加密入库;可选 CLI + rpa_helpers 同进程自动登录。涵盖物流、LLM、内容、开发、仿真等平台。"
---
# 🔐 账号与凭据管理
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
> 本技能是**唯一**账号/凭据管理入口。业务 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)。

81
references/RUNTIME.md Normal file
View File

@@ -0,0 +1,81 @@
# 运行环境与数据路径account-manager
## 数据落盘位置
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(兼容 **`JIANGCHANG_*` / `CLAW_*` 别名**`scripts/util/runtime_paths.py` 在此基础上拼接技能子目录。
- **数据根目录**:默认读取宿主注入变量;开发模式下未注入时可能落到 Windows `D:\jiangchang-data` 等(详见 runtime_env 实现)。
- **用户 / 工作空间 id**:默认 `_anon`(以实际注入为准)。
- **技能数据目录**`{数据根}/{用户id}/account-manager/`
- **SQLite**`account-manager.db`
- **Master Key**`master.key`(仅当执行过 `account init-master-key` 且未仅用环境变量场景)
- **Profile**`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
## 调试为什么「list 没数据」
- 若终端与网关注入的数据根 / 用户 id 不一致,会写到**不同目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**或宿主等价开关CLI 会在 **stderr** 打印实际路径。
## 本地 CLI 默认值
各技能 `main.py` 调用 `apply_cli_local_defaults()`
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
- 发版前改为 `False` 或依赖宿主注入。
## Secret 存储
| 方式 | 说明 | 适用场景 |
|---|---|---|
| `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 存变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | secret 存 Windows Credential Manager | Windows 本地部署 |
| `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)。
## 日志
| 变量 | 作用 |
|---|---|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 浏览器(`account open` / rpa_helpers
- 本机需安装 Chrome 或 Edge。
- Python 侧依赖 **`playwright`**(匠厂共享 runtime 已内置)。
- 使用 **channel 模式**`chrome` / `msedge`**不下载 Chromium**。
- **不要执行** `playwright install chromium`
### 关键浏览器参数(摘录)
- `headless=False`
- `no_viewport=True`
- `args` 包含 `--start-maximized`
- stealth 是否启用由 `OPENCLAW_PLAYWRIGHT_STEALTH` 控制(见 `launch_browser_with_profile`
### rpa_helpers
登录编排封装见 [`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,3 +1,44 @@
# Vendored from jiangchang-platform-kit `tools/release.ps1`.
# Source: https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit/-/blob/main/tools/release.ps1
# The PyPI wheel does not ship this script; sync periodically when upstream workflow changes.
<#
.SYNOPSIS
One-command release script for skill repos.
.DESCRIPTION
- Optional auto-commit
- Push current branch
- Auto-increment semantic tag (vX.Y.Z)
- Create & push tag
- Fail fast on unsafe states
.EXAMPLES
# Safe mode (recommended): requires clean working tree
.\release.ps1
# Auto commit tracked/untracked changes then release
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
# Dry run (show what would happen)
.\release.ps1 -DryRun
# Custom tag prefix
.\release.ps1 -Prefix "v" -Message "正式发布"
.NOTES
Requires: git, PowerShell 5+
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
- 加密 scripts/(递归 PyArmor -r输出到包内 scripts/,与源码目录树一致)
- 复制 SKILL.md
- 复制 references/(排除 REQUIREMENTS.md
- 复制 assets/
- 复制 tests/
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
#>
[CmdletBinding()]
param(
[string]$Prefix = "v",
@@ -11,13 +52,218 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (-not (Test-Path $sharedScript)) {
throw "Shared release script not found: $sharedScript"
function Invoke-Git {
param([Parameter(Mandatory = $true)][string]$Args)
Write-Host ">> git $Args" -ForegroundColor DarkGray
# 将 git 的 stderr 合并进 stdout避免 PowerShell 在 $ErrorActionPreference=Stop 下
# 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
& cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
}
& $sharedScript @PSBoundParameters
exit $LASTEXITCODE
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
return @($output)
}
function Test-Repo {
& git rev-parse --is-inside-work-tree *> $null
return ($LASTEXITCODE -eq 0)
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Assert-SkillReleasePackagingSources {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$scriptsDir = Join-Path $SkillRoot "scripts"
$mainPy = Join-Path $scriptsDir "main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
return
}
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$need = @()
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
foreach ($p in ($need | Select-Object -Unique)) {
$folder = Join-Path $scriptsDir $p
if (-not (Test-Path -LiteralPath $folder)) {
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
}
}
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
& git status --short
throw "Abort: dirty working tree."
}
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
Invoke-Git "fetch --tags --prune origin"
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
# 用 cmd /c + 2>&1 将 stderr 合并进 stdout避免 PowerShell 在 $ErrorActionPreference=Stop
# 下把首次发布时 "fatal: ambiguous argument '@{u}'" 之类的 stderr 误判为异常。
$upstream = & cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1'
$hasUpstream = ($LASTEXITCODE -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
} else {
Invoke-Git "push -u origin $branch"
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@@ -0,0 +1,7 @@
# Optional: install shared SDK from Gitea Package Registry (matches vendored scripts/jiangchang_skill_core at release time).
#
# pip install -r requirements-platform-kit.txt ^
# --index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/ ^
# --extra-index-url https://pypi.org/simple
#
jiangchang-platform-kit>=0.1.0

View File

@@ -1,545 +0,0 @@
import sys
import json
import os
import sqlite3
import subprocess
import tempfile
import time
from datetime import datetime
# Windows GBK 编码兼容修复
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILL_SLUG = "account-manager"
PLATFORM_URLS = {
"sohu": "https://mp.sohu.com",
"zhihu": "https://www.zhihu.com",
"wechat": "https://mp.weixin.qq.com",
"kimi": "https://kimi.moonshot.cn",
"deepseek": "https://chat.deepseek.com",
"doubao": "https://www.doubao.com",
"qianwen": "https://tongyi.aliyun.com",
"yiyan": "https://yiyan.baidu.com",
"yuanbao": "https://yuanbao.tencent.com",
}
# 新账号默认名称搜狐1号 / sohu_2 等同平台序号
_PLATFORM_NAME_CN = {
"sohu": "搜狐",
"zhihu": "知乎",
"wechat": "微信",
"kimi": "Kimi",
"deepseek": "DeepSeek",
"doubao": "豆包",
"qianwen": "通义",
"yiyan": "文心",
"yuanbao": "元宝",
}
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():
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path():
return os.path.join(get_skill_data_dir(), "account-manager.db")
def get_default_profile_dir(account_id):
path = os.path.join(get_skill_data_dir(), "profiles", str(account_id))
os.makedirs(path, exist_ok=True)
return path
def get_conn():
return sqlite3.connect(get_db_path())
def _ensure_column(cur, table_name, col_name, ddl):
cur.execute(f"PRAGMA table_info({table_name})")
cols = [row[1] for row in cur.fetchall()]
if col_name not in cols:
cur.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {ddl}")
def _has_text_primary_id(cur):
cur.execute("PRAGMA table_info(accounts)")
for row in cur.fetchall():
if row[1] == "id":
t = (row[2] or "").upper()
return "TEXT" in t or "CHAR" in t
return False
def _migrate_text_id_to_integer(conn):
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
platform TEXT NOT NULL,
phone TEXT,
profile_dir TEXT,
url TEXT,
login_status INTEGER DEFAULT 0,
last_login_at TEXT,
extra_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
cur.execute(
"SELECT name, platform, phone, profile_dir, login_status, last_login_at, extra_json, created_at, updated_at FROM accounts ORDER BY rowid"
)
rows = cur.fetchall()
for row in rows:
name, platform, phone, profile_dir, login_status, last_login_at, extra_json, created_at, updated_at = row
plat = (platform or "").strip().lower()
url = PLATFORM_URLS.get(plat, "")
cur.execute(
"""
INSERT INTO accounts_new
(name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
plat,
phone or "",
profile_dir or "",
url,
int(login_status or 0),
last_login_at,
extra_json,
created_at,
updated_at,
),
)
cur.execute("DROP TABLE accounts")
cur.execute("ALTER TABLE accounts_new RENAME TO accounts")
def init_db():
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.execute(
"""
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
platform TEXT NOT NULL,
phone TEXT,
profile_dir TEXT,
url TEXT,
login_status INTEGER DEFAULT 0,
last_login_at TEXT,
extra_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
else:
if _has_text_primary_id(cur):
_migrate_text_id_to_integer(conn)
cur = conn.cursor()
_ensure_column(cur, "accounts", "url", "TEXT")
cur.execute(
"SELECT id, platform FROM accounts WHERE url IS NULL OR TRIM(COALESCE(url,'')) = ''"
)
for rid, plat in cur.fetchall():
pu = PLATFORM_URLS.get((plat or "").strip().lower(), "")
if pu:
cur.execute("UPDATE accounts SET url = ? WHERE id = ?", (pu, rid))
conn.commit()
finally:
conn.close()
def _normalize_account_id(account_id):
if account_id is None:
return None
s = str(account_id).strip()
if s.isdigit():
return int(s)
return s
def get_account_by_id(account_id):
init_db()
aid = _normalize_account_id(account_id)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json FROM accounts WHERE id = ?",
(aid,),
)
row = cur.fetchone()
if not row:
return None
acc = {
"id": row[0],
"name": row[1],
"platform": row[2],
"phone": row[3] or "",
"profile_dir": row[4] or get_default_profile_dir(row[0]),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"login_status": int(row[6] or 0),
"last_login_at": row[7],
}
if row[8]:
try:
extra = json.loads(row[8])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
pass
return acc
finally:
conn.close()
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}"
def cmd_list(platform="all"):
init_db()
conn = get_conn()
try:
cur = conn.cursor()
if platform == "all":
cur.execute(
"SELECT id, name, phone, platform, login_status FROM accounts ORDER BY platform, id"
)
else:
cur.execute(
"SELECT id, name, phone, platform, login_status FROM accounts WHERE platform = ? ORDER BY id",
(platform,),
)
rows = cur.fetchall()
finally:
conn.close()
found = False
for row in rows:
phone = row[2] or "未绑定手机"
login_text = "已登录" if int(row[4] or 0) == 1 else "未登录"
print(f"账号ID:{row[0]} | 名称:{row[1]} | 手机号:{phone} | 平台:{row[3]} | 状态:{login_text}")
found = True
if not found:
print("ERROR:NO_ACCOUNTS_FOUND")
def cmd_get(account_id):
acc = get_account_by_id(account_id)
if acc:
print(json.dumps(acc, ensure_ascii=False))
return
print("ERROR:ACCOUNT_NOT_FOUND")
def cmd_add(platform: str, phone: str = ""):
"""添加账号:仅平台 + 可选手机号ID 自增,名称按平台序号自动生成。"""
init_db()
platform = (platform or "").strip().lower()
if platform not in PLATFORM_URLS:
print(f"ERROR:INVALID_PLATFORM (支持: {', '.join(sorted(PLATFORM_URLS.keys()))})")
return
phone = (phone or "").strip()
url = PLATFORM_URLS[platform]
now = datetime.now().isoformat(timespec="seconds")
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform,))
next_idx = cur.fetchone()[0] + 1
name = _default_name_for_platform(platform, next_idx)
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, ?, ?)
""",
(name, platform, phone, url, now, now),
)
new_id = cur.lastrowid
profile_dir = get_default_profile_dir(new_id)
cur.execute(
"UPDATE accounts SET profile_dir = ?, updated_at = ? WHERE id = ?",
(profile_dir, now, new_id),
)
conn.commit()
finally:
conn.close()
print(f"✅ 已保存账号ID {new_id} | {name} | {platform}")
print(f" 登录请执行python account.py login {new_id}")
def _win_find_exe(candidates):
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LocalAppData", "")
chrome = _win_find_exe(
[
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
]
)
if chrome:
return "chrome"
edge = _win_find_exe(
[
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
]
)
if edge:
return "msedge"
return None
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装后再登录")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def _login_timeout_seconds():
try:
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
except ValueError:
return 300
def _verify_login(profile_dir: str, platform: str, start_url: str, channel: str) -> bool:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("⚠️ 未安装 playwright跳过登录校验请: pip install playwright && playwright install")
return False
try:
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
channel=channel,
args=["--disable-blink-features=AutomationControlled"],
)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(start_url, wait_until="domcontentloaded", timeout=45000)
time.sleep(2)
u = (page.url or "").lower()
if platform == "sohu":
if "passport" in u or "/login" in u:
return False
return True
if "login" in u or "signin" in u or "passport" in u:
return False
return True
finally:
ctx.close()
except Exception:
return False
def cmd_login(account_id):
target = get_account_by_id(account_id)
if not target:
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["profile_dir"]
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()
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"访问地址:{url}")
print("请在窗口内完成登录(扫码/输入密码)。到达等待时间后将自动关闭窗口,您也可提前手动关闭。")
print(f"最长等待:{timeout_sec}")
# Playwright 同步 API 宜在独立子进程中跑浏览器,避免线程与事件循环问题
runner = r"""import json, sys
from playwright.sync_api import sync_playwright
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(
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)
try:
ctx.wait_for_event("close", timeout=c["timeout_sec"] * 1000)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
except Exception as e:
print(e, file=sys.stderr)
raise
"""
cfg = {
"channel": channel,
"profile_dir": profile_dir,
"url": url,
"timeout_sec": timeout_sec,
}
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
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as pf:
pf.write(runner)
py_path = pf.name
try:
r = subprocess.run(
[sys.executable, py_path, cfg_path],
timeout=timeout_sec + 180,
)
if r.returncode != 0:
print("⚠️ 浏览器进程异常退出,仍将尝试检测登录状态")
except subprocess.TimeoutExpired:
print("⚠️ 等待浏览器超时,仍将尝试检测登录状态")
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
try:
os.unlink(py_path)
except OSError:
pass
time.sleep(1.5)
print("正在检测登录状态…")
ok = _verify_login(profile_dir, platform, url, channel)
_mark_login_status(target["id"], ok)
if ok:
print("✅ 检测到已登录,状态已写入数据库")
else:
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
def _mark_login_status(account_id, success: bool):
now = datetime.now().isoformat(timespec="seconds")
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),
)
conn.commit()
finally:
conn.close()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(
"用法python account.py add <platform> [phone] | list [platform] | get <id> | login <id>"
)
sys.exit(1)
cmd = sys.argv[1]
if len(sys.argv) >= 3 and sys.argv[2] == "list" and cmd in PLATFORM_URLS:
cmd_list(cmd)
elif cmd in PLATFORM_URLS and len(sys.argv) == 2:
cmd_list(cmd)
elif cmd == "add" and len(sys.argv) >= 3:
plat = sys.argv[2].lower()
phone_arg = sys.argv[3] if len(sys.argv) >= 4 else ""
cmd_add(plat, phone_arg)
elif cmd == "list":
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
elif cmd == "get" and len(sys.argv) >= 3:
cmd_get(sys.argv[2])
elif cmd == "login" and len(sys.argv) >= 3:
cmd_login(sys.argv[2])
else:
print("参数错误")
sys.exit(1)

1
scripts/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Command-line entry and argv dispatch."""

592
scripts/cli/app.py Normal file
View File

@@ -0,0 +1,592 @@
# -*- coding: utf-8 -*-
"""
CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
platform list/get/ensure
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
init-master-key|export-credentials|import-credentials
credential pick|resolve
lease release|list|cleanup
(legacy) add|list|get|list-json|pick-web|open|delete
Machine commands output single-line JSON where appropriate.
"""
import sys
from typing import Optional
from service.account_crypto_commands import (
cmd_account_export_credentials,
cmd_account_import_credentials,
cmd_account_init_master_key,
)
from service.account_service import (
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
cmd_account_mark_error,
cmd_account_mark_session,
cmd_account_mark_used,
cmd_account_pick_web,
cmd_credential_pick,
cmd_credential_resolve,
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_get,
cmd_lease_cleanup,
cmd_lease_list,
cmd_lease_release,
cmd_list,
cmd_list_json,
cmd_pick_web,
cmd_platform_ensure,
cmd_platform_get,
cmd_platform_list,
)
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
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
# =========================================================================
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
平台管理:
platform list [--domain logistics|llm|content]
platform get <platform>
platform ensure --key <key> --display-name <name> [--domain <d>]
[--url <url>] [--auth-strategy <s>]
账号管理:
account add-web --platform <p> --login-id <id> --login-id-type <type>
[--label <label>] [--tenant-id <t>] [--environment <env>]
[--role <role>] [--provider-code <code>] [--url <url>]
[--extra-json <json>] [--profile-dir <path>]
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
account add-secret --platform <p> --credential-type <type>
--secret-storage env|windows_credential|none|local_encrypted
[--secret-ref <env_var>] [--secret-stdin]
[--label <label>] [--account-id <id>]
[--environment <env>] [--role <role>]
account get <id> [--with-credentials]
account get-credential <account_id> [--reveal --lease-token <token>]
account list [--platform <p>] [--environment <env>]
[--tenant-id <t>] [--role <role>] [--limit N]
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
[--role <role>] [--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>]
account mark-session <id> --session-status <status>
account mark-error <id> --code <code> --message <msg>
account mark-used <id>
account open <id>
account init-master-key [--print|--from-env|--rotate]
account export-credentials --plaintext
account import-credentials [--replace-all] < backup.json
凭据管理:
credential pick --platform <p> [--type <type>] [--environment <env>]
[--role <role>] [--reveal]
credential resolve --id <credential_id> [--reveal]
租约管理:
lease release <lease_token>
lease list [--active]
lease cleanup
浏览器:
account open <id>
open <id> # 兼容别名
兼容入口(旧版):
get <id>
list-json <platform|all> [--limit N]
pick-web <platform>
list [platform|all]
add <platform> <phone>
delete id|platform <...>
"""
# =========================================================================
# 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 _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
v = _get_opt(argv, flag)
if v is not None:
try:
return int(v)
except ValueError:
return default
return default
def _get_all_opts(argv: list[str], flag: str) -> list[str]:
"""Collect all values for a repeatable flag (e.g. --platform-alias)."""
vals: list[str] = []
i = 0
while i < len(argv):
if argv[i] == flag and i + 1 < len(argv):
vals.append(argv[i + 1])
i += 2
else:
i += 1
return vals
def _parse_platform_aliases(argv: list[str]) -> list[str]:
aliases: list[str] = []
for raw in _get_all_opts(argv, "--platform-alias"):
for part in (raw or "").split(","):
s = part.strip()
if s and s not in aliases:
aliases.append(s)
return aliases
def _parse_platform_caller_meta(argv: list[str]):
from service.account_service import PlatformCallerMeta
display_name = _get_opt(argv, "--platform-display-name")
url = _get_opt(argv, "--platform-url")
domain = _get_opt(argv, "--platform-domain")
auth_strategy = _get_opt(argv, "--platform-auth-strategy")
aliases = _parse_platform_aliases(argv)
if not any([display_name, url, domain, auth_strategy, aliases]):
return None
return PlatformCallerMeta(
display_name=display_name,
url=url,
domain=domain,
auth_strategy=auth_strategy,
aliases=aliases,
)
# =========================================================================
# Main dispatch
# =========================================================================
def main() -> None:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
log = get_skill_logger()
if len(sys.argv) < 2:
print(_USAGE)
sys.exit(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)
# ---- platform subcommand ----
if cmd == "platform":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get | ensure")
sys.exit(1)
sub = av[2]
if sub == "list":
domain = _get_opt(av, "--domain")
cmd_platform_list(domain)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1)
cmd_platform_get(av[3])
elif sub == "ensure":
import argparse as _ap
p = _ap.ArgumentParser(prog="platform ensure")
p.add_argument("--key", required=True)
p.add_argument("--display-name", default="")
p.add_argument("--domain", default="generic")
p.add_argument("--url", default="")
p.add_argument("--auth-strategy", default=None)
p.add_argument("--aliases", default=None,
help="JSON 数组字符串,如 '[\"1688\",\"阿里巴巴1688\"]'")
p.add_argument("--capabilities", default=None,
help='JSON 对象,如 \'{"web":true,"api_key":false,"rpa":true}\'')
args = p.parse_args(av[3:])
sys.exit(cmd_platform_ensure(
key=args.key,
display_name=args.display_name or args.key,
domain=args.domain,
url=args.url,
auth_strategy=args.auth_strategy,
aliases=args.aliases,
capabilities=args.capabilities,
))
else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list、get 或 ensure。")
sys.exit(1)
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_cmd_add_web(av)
elif sub == "add-secret":
_cmd_add_secret(av)
elif sub == "get-credential":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get-credential 需要 <account_id>。")
sys.exit(1)
_cmd_get_credential(av)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
sys.exit(1)
aid = av[3]
with_creds = _has_flag(av, "--with-credentials")
cmd_account_get(aid, include_credentials=with_creds)
elif sub == "list":
_cmd_account_list(av)
elif sub == "pick-web":
_cmd_pick_web_new(av)
elif sub == "mark-session":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
sys.exit(1)
aid = av[3]
ss = _get_opt(av, "--session-status", "unknown")
cmd_account_mark_session(aid, ss)
elif sub == "mark-error":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
sys.exit(1)
aid = av[3]
ec = _get_opt(av, "--code", "")
em = _get_opt(av, "--message", "")
cmd_account_mark_error(aid, ec, em)
elif sub == "mark-used":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
sys.exit(1)
aid = av[3]
cmd_account_mark_used(aid)
elif sub == "open":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
sys.exit(1)
cmd_open(av[3])
elif sub == "init-master-key":
cmd_account_init_master_key(av)
elif sub == "export-credentials":
cmd_account_export_credentials(av)
elif sub == "import-credentials":
cmd_account_import_credentials(av)
else:
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
print(_USAGE)
sys.exit(1)
# ---- credential subcommand ----
elif cmd == "credential":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS credential 需要子命令pick | resolve")
sys.exit(1)
sub = av[2]
if sub == "pick":
_cmd_cred_pick(av)
elif sub == "resolve":
_cmd_cred_resolve(av)
else:
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
sys.exit(1)
# ---- lease subcommand ----
elif cmd == "lease":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令release | list | cleanup")
sys.exit(1)
sub = av[2]
if sub == "release":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1)
cmd_lease_release(av[3])
elif sub == "list":
active = _has_flag(av, "--active")
cmd_lease_list(active)
elif sub == "cleanup":
cmd_lease_cleanup()
else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
sys.exit(1)
# ---- Legacy aliases ----
elif cmd == "get":
if len(sys.argv) < 3:
print("ERROR:CLI_GET_MISSING_ID")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "list-json":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
sys.exit(1)
av = sys.argv[2:]
platform_arg = av[0]
lim = 200
if "--limit" in av:
i = av.index("--limit")
if i + 1 < len(av):
try:
lim = int(av[i + 1])
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
if len(sys.argv) < 3:
print("ERROR:CLI_OPEN_MISSING_ID")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "list":
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
cmd_list(platform_arg)
elif cmd == "add":
if len(sys.argv) < 4:
print("ERROR:CLI_ADD_MISSING_ARGS")
sys.exit(1)
# Legacy add maps to add-web for backward compat
cmd_account_add_web(
platform_input=sys.argv[2],
login_id=sys.argv[3],
login_id_type="phone",
label=None,
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
elif cmd == "delete":
if len(sys.argv) < 4:
print("ERROR:CLI_DELETE_MISSING_ARGS")
sys.exit(1)
mode = (sys.argv[2] or "").strip().lower()
if mode == "id":
cmd_delete_by_id(sys.argv[3])
elif mode == "platform":
if len(sys.argv) >= 5:
cmd_delete_by_platform_phone(sys.argv[3], sys.argv[4])
else:
cmd_delete_by_platform(sys.argv[3])
else:
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()
print(_USAGE)
sys.exit(1)
# =========================================================================
# Sub-parsers for complex commands
# =========================================================================
def _cmd_add_web(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
login_id = _get_opt(argv, "--login-id")
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "default")
provider_code = _get_opt(argv, "--provider-code")
url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir")
auth_strategy = _get_opt(argv, "--auth-strategy")
session_persistent_raw = _get_opt(argv, "--session-persistent")
device_fingerprint = _get_opt(argv, "--device-fingerprint")
session_persistent: Optional[int] = None
if session_persistent_raw is not None:
try:
session_persistent = int(session_persistent_raw)
except ValueError:
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数0 或 1")
return
if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_add_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
url=url,
extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
)
def _cmd_get_credential(argv: list[str]) -> None:
aid_raw = argv[3]
try:
aid = int(aid_raw)
except ValueError:
print(f"ERROR:CLI_BAD_ARGS account_id 必须是整数:{aid_raw}")
return
reveal = _has_flag(argv, "--reveal")
lease_token = _get_opt(argv, "--lease-token")
cmd_account_get_credential(aid, reveal=reveal, lease_token=lease_token)
def _cmd_add_secret(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--credential-type", "api_key")
label = _get_opt(argv, "--label")
storage = _get_opt(argv, "--secret-storage", "env")
stdin_mode = _has_flag(argv, "--secret-stdin")
sref = _get_opt(argv, "--secret-ref")
aid = _get_int_opt(argv, "--account-id", 0) or None
env = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "api")
extra_json_str = _get_opt(argv, "--extra-json")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
return
cmd_account_add_secret(
platform_input=plat,
credential_type=ctype,
label=label,
secret_storage=storage,
secret_stdin=stdin_mode,
secret_ref=sref,
account_id=aid,
environment=env,
role=role,
extra_json_str=extra_json_str,
)
def _cmd_account_list(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
limit=limit,
)
def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_pick_web(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")
env = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
reveal = _has_flag(argv, "--reveal")
if not plat:
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
return
cmd_credential_pick(
platform_input=plat,
credential_type=ctype,
environment=env,
role=role,
reveal=reveal,
)
def _cmd_cred_resolve(argv: list[str]) -> None:
cid = _get_int_opt(argv, "--id", 0)
reveal = _has_flag(argv, "--reveal")
if cid <= 0:
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
return
cmd_credential_resolve(cid, reveal=reveal)
if __name__ == "__main__":
main()

5
scripts/db/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Persistence layer (SQLite)."""
from db.connection import get_conn, init_db
__all__ = ["get_conn", "init_db"]

928
scripts/db/accounts_repo.py Normal file
View File

@@ -0,0 +1,928 @@
# -*- 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 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]:
"""Convert Unix timestamp to local ISO8601 string, or None."""
if ts is None:
return None
try:
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except (ValueError, OSError, OverflowError):
return None
_UNSET = object()
def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str:
"""Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto."""
cur = conn.cursor()
cur.execute(
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
(platform_key,),
)
row = cur.fetchone()
if row and row[0]:
s = str(row[0])
if s in ALL_AUTH_STRATEGIES:
return s
mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key)
if mapped and mapped in ALL_AUTH_STRATEGIES:
return mapped
return AUTH_STRATEGY_PASSWORD_AUTO
def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
dev_fp = row[20]
return {
"id": row[0],
"platform_key": row[1],
"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
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]),
}
# ============================================================================
# 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) -> 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, 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 = ?
""",
(int(account_id),),
)
row = cur.fetchone()
if not row:
return None
return _acc_row_to_dict(row)
finally:
conn.close()
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()
cur.execute(
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_session_status(account_id: int, session_status: str) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
(session_status, _now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_error(
account_id: int,
error_code: str,
error_message: str,
) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
(error_code, error_message, _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account(
conn,
account_id: int,
*,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: Any = _UNSET,
) -> None:
"""Optional field updates for accounts v2 columns."""
sets: list[str] = []
params: list[Any] = []
if auth_strategy is not None:
if auth_strategy not in ALL_AUTH_STRATEGIES:
raise ValueError(f"invalid auth_strategy: {auth_strategy!r}")
sets.append("auth_strategy = ?")
params.append(auth_strategy)
if session_persistent is not None:
if session_persistent not in (0, 1):
raise ValueError("session_persistent must be 0 or 1")
sets.append("session_persistent = ?")
params.append(session_persistent)
if device_fingerprint is not _UNSET:
sets.append("device_fingerprint = ?")
params.append(device_fingerprint)
if not sets:
return
sets.append("updated_at = ?")
params.append(_now_unix())
params.append(int(account_id))
cur = conn.cursor()
cur.execute(
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
tuple(params),
)
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor()
cur.execute(
"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
# ============================================================================
# Credentials
# ============================================================================
def insert_credential(
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,
secret_ciphertext: Optional[str] = None,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, secret_ciphertext, expires_at, status,
extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
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 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, account_id, lease_token, holder, purpose, expires_at, status
FROM account_leases
WHERE lease_token = ? AND status = 'active' AND expires_at > ?
""",
(lease_token, now),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"lease_token": row[2],
"holder": row[3],
"purpose": row[4],
"expires_at": int(row[5]),
"status": row[6],
}
def upsert_platform(
conn,
key: str,
display_name: str,
domain: str = "generic",
provider_code: str | None = None,
default_url: str = "",
aliases_json: str = "[]",
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
default_auth_strategy: str | None = None,
) -> None:
"""幂等地注册或更新平台到 DB。"""
now = int(time.time())
if provider_code is None:
provider_code = key
conn.execute(
"""
INSERT INTO platforms (
platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name,
domain = excluded.domain,
provider_code = excluded.provider_code,
default_url = excluded.default_url,
aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json,
enabled = 1,
default_auth_strategy = COALESCE(excluded.default_auth_strategy,
platforms.default_auth_strategy),
updated_at = excluded.updated_at
""",
(
key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
default_auth_strategy, now, now,
),
)
conn.commit()
def ensure_platform_from_caller_meta(
conn,
key: str,
*,
display_name: str,
domain: str = "generic",
default_url: str = "",
aliases: list[str] | None = None,
default_auth_strategy: str | None = None,
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
) -> str:
"""
调用方元数据自动注册平台:不存在则创建;已存在则仅填充空字段,不覆盖非空自定义值。
返回 created | updated | unchanged。
"""
now = int(time.time())
provider_code = key
alias_list: list[str] = []
for item in [key, display_name, *(aliases or [])]:
s = str(item).strip()
if s and s not in alias_list:
alias_list.append(s)
aliases_json = json.dumps(alias_list, ensure_ascii=False)
row = conn.execute(
"""
SELECT display_name, domain, provider_code, default_url, aliases_json,
capabilities_json, default_auth_strategy, enabled
FROM platforms WHERE platform_key = ?
""",
(key,),
).fetchone()
if row is None:
upsert_platform(
conn,
key=key,
display_name=display_name,
domain=domain,
provider_code=provider_code,
default_url=default_url,
aliases_json=aliases_json,
capabilities_json=capabilities_json,
default_auth_strategy=default_auth_strategy,
)
return "created"
cur_display, cur_domain, cur_provider, cur_url, cur_aliases_json, cur_caps, cur_auth, enabled = row
if not enabled:
return "unchanged"
merged_display = (cur_display or "").strip() or display_name
merged_domain = (cur_domain or "").strip() or domain
merged_provider = (cur_provider or "").strip() or provider_code
merged_url = (cur_url or "").strip() or (default_url or "").strip()
merged_auth = cur_auth or default_auth_strategy
existing_aliases = _safe_json_loads(cur_aliases_json, [])
if not isinstance(existing_aliases, list):
existing_aliases = []
merged_aliases: list[str] = []
for item in list(existing_aliases) + alias_list:
s = str(item).strip()
if s and s not in merged_aliases:
merged_aliases.append(s)
merged_aliases_json = json.dumps(merged_aliases, ensure_ascii=False)
merged_caps = cur_caps or capabilities_json
unchanged = (
merged_display == (cur_display or "")
and merged_domain == (cur_domain or "")
and merged_provider == (cur_provider or "")
and merged_url == (cur_url or "")
and merged_aliases_json == (cur_aliases_json or "[]")
and merged_auth == cur_auth
)
if unchanged:
return "unchanged"
conn.execute(
"""
UPDATE platforms SET
display_name = ?,
domain = ?,
provider_code = ?,
default_url = ?,
aliases_json = ?,
capabilities_json = ?,
default_auth_strategy = ?,
updated_at = ?
WHERE platform_key = ?
""",
(
merged_display,
merged_domain,
merged_provider,
merged_url,
merged_aliases_json,
merged_caps,
merged_auth,
now,
key,
),
)
conn.commit()
return "updated"
# ============================================================================
# Internal helpers
# ============================================================================
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,
}
)

115
scripts/db/connection.py Normal file
View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""
SQLite connection with schema migration (v1 -> v2).
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() -> 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 _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("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",
}

292
scripts/db/schema.py Normal file
View File

@@ -0,0 +1,292 @@
# -*- 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

27
scripts/main.py Normal file
View File

@@ -0,0 +1,27 @@
"""
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
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
from cli.app import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
"""Application services (use cases)."""

View File

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

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*-
"""Account creation CLI commands (add-web, add-secret)."""
import json
import os
import sys
import time
from typing import Optional
from db.accounts_repo import (
get_account_by_id,
insert_account,
insert_credential,
resolve_auth_strategy_for_platform,
)
from db.auth_strategy import (
ALL_AUTH_STRATEGIES,
AUTH_STRATEGY_DEVICE_BOUND,
)
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted
from service._svc_common import (
PlatformCallerMeta,
_derive_session_persistent,
_err_json,
_insert_secret_holder_account,
_ok_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
from service.crypto import CredentialDecryptError
from service.master_key import MasterKeyMissingError, is_master_key_available
from service.secret_store import (
mask_secret,
make_windows_credential_target,
write_secret,
)
from util.logging_config import get_skill_logger
from util.platforms import get_platform_spec, seed_platforms
from util.profile_shortcut import create_profile_browser_shortcut
from util.runtime_paths import get_default_profile_dir_for_web
def cmd_account_add_web(
platform_input: str,
login_id: Optional[str],
login_id_type: str,
label: Optional[str],
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
url: Optional[str],
extra_json_str: Optional[str],
profile_dir_override: Optional[str],
*,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""account add-web — create a web/RPA account record."""
log = get_skill_logger()
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
platform_input, login_id_type, environment, role)
key = _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
init_db()
now = int(time.time())
conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}"
extra = {}
if extra_json_str:
try:
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
return
if profile_dir_override:
resolved_profile_dir = os.path.abspath(profile_dir_override)
else:
resolved_profile_dir = get_default_profile_dir_for_web(
platform_key=key,
label=safe_label,
login_id=login_id,
domain=platform_spec.get("domain", "generic"),
)
os.makedirs(resolved_profile_dir, exist_ok=True)
resolved_url = (url or "").strip() or platform_spec.get("default_url", "")
resolved_provider = provider_code or platform_spec.get("provider_code") or key
resolved_extra = json.dumps(extra, ensure_ascii=False)
conn = get_conn()
try:
seed_platforms(conn)
if auth_strategy is None:
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
else:
if auth_strategy not in ALL_AUTH_STRATEGIES:
_say(_err_json(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"不支持的 auth_strategy{auth_strategy}",
))
return
resolved_auth = auth_strategy
if session_persistent is None:
sp = _derive_session_persistent(resolved_auth)
else:
if session_persistent not in (0, 1):
_say(_err_json(
"ERR_INVALID_SESSION_PERSISTENT",
"session_persistent 必须是 0 或 1。",
))
return
sp = session_persistent
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
_say(_err_json(
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
))
return
new_id = insert_account(
conn=conn,
platform_key=key,
account_label=safe_label,
login_id=login_id or None,
login_id_type=login_id_type,
tenant_id=tenant_id or None,
environment=environment,
role=role,
provider_code=resolved_provider,
profile_dir=resolved_profile_dir,
url=resolved_url,
extra_json=resolved_extra,
now=now,
auth_strategy=resolved_auth,
session_persistent=sp,
device_fingerprint=device_fingerprint,
)
conn.commit()
except Exception:
conn.rollback()
log.exception("account_add_web_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log)
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
ok_payload = {
"id": new_id,
"platform_key": key,
"account_label": safe_label,
"profile_dir": resolved_profile_dir,
"url": resolved_url,
"environment": environment,
"role": role,
"tenant_id": tenant_id or "",
"provider_code": resolved_provider,
"status": "active",
"session_status": "unknown",
"auth_strategy": resolved_auth,
"session_persistent": sp,
"device_fingerprint": device_fingerprint,
}
if shortcut_path:
ok_payload["profile_shortcut"] = shortcut_path
_say(_ok_json(ok_payload))
def cmd_account_add_secret(
platform_input: str,
credential_type: str,
label: Optional[str],
secret_storage: str,
secret_stdin: bool,
secret_ref: Optional[str],
account_id: Optional[int],
environment: Optional[str],
role: Optional[str],
extra_json_str: Optional[str],
) -> None:
"""account add-secret — create a credential record with masked secret."""
log = get_skill_logger()
log.info("account_add_secret_attempt platform=%s type=%s storage=%s",
platform_input, credential_type, secret_storage)
key = _resolve_or_fail(platform_input)
if not key:
return
storage_n = secret_storage.strip().lower()
if storage_n not in ("none", "env", "windows_credential", "local_encrypted"):
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
f"不支持的存储方式:{secret_storage}。支持none / env / windows_credential / local_encrypted"))
return
if storage_n == "none" and credential_type not in ("browser_profile", "note"):
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
"storage=none 仅允许 credential_type=browser_profile 或 note。"))
return
if storage_n == "env" and not secret_ref:
_say(_err_json("ERROR:SECRET_REF_MISSING",
"env 存储时必须提供 --secret-ref 环境变量名。"))
return
init_db()
conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
now = int(time.time())
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
if storage_n == "local_encrypted":
if not is_master_key_available():
_say(_err_json(
"ERR_MASTER_KEY_MISSING",
"master key 未初始化。先跑 `account init-master-key`。",
))
return
if not secret_stdin:
_say(_err_json(
"ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN",
"local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。",
))
return
if credential_type not in (
"password",
"api_key",
"api_token",
"bearer_token",
"refresh_token",
"oauth_client",
):
_say(_err_json(
"ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED",
f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。",
))
return
if secret_ref:
_say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).")
extra: dict = {}
if extra_json_str:
try:
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
extra = {}
resolved_env = environment or "production"
resolved_role = role or "api"
extra["environment"] = resolved_env
extra["role"] = resolved_role
resolved_extra = json.dumps(extra, ensure_ascii=False)
resolved_url = (platform_spec.get("default_url") or "").strip() or None
resolved_provider = platform_spec.get("provider_code") or key
plaintext = sys.stdin.readline().rstrip("\r\n")
if not plaintext:
_say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。"))
return
secret_mask_out = mask_secret(plaintext)
conn = get_conn()
cred_id: Optional[int] = None
holder_id: Optional[int] = None
try:
seed_platforms(conn)
holder_id = account_id
if holder_id is not None:
acc = get_account_by_id(holder_id)
if not acc:
_say(_err_json(
"ERROR:ACCOUNT_NOT_FOUND",
f"account_id={holder_id} 不存在。",
))
return
if holder_id is None:
holder_id = _insert_secret_holder_account(
conn,
platform_key=key,
account_label=safe_label,
environment=resolved_env,
role=resolved_role,
provider_code=resolved_provider,
url=resolved_url,
now=now,
)
cred_id = insert_credential_encrypted(
conn,
account_id=holder_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
plaintext=plaintext,
expires_at=None,
extra_json=resolved_extra,
now=now,
)
conn.commit()
except MasterKeyMissingError as e:
conn.rollback()
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
return
except CredentialDecryptError as e:
conn.rollback()
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
return
except Exception:
conn.rollback()
log.exception("account_add_secret_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
log.info(
"account_add_secret_encrypted_success cred_id=%s account_id=%s",
cred_id,
holder_id,
)
_say(_ok_json({
"credential_id": cred_id,
"account_id": holder_id,
"platform_key": key,
"credential_type": credential_type,
"secret_storage": "local_encrypted",
"secret_mask": secret_mask_out,
}))
return
if storage_n == "env":
secret_ref_value = secret_ref.strip()
secret_mask_value = mask_secret(f"env:{secret_ref_value}")
extra = {}
if extra_json_str:
try:
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
extra = {}
resolved_env = environment or "production"
resolved_role = role or "api"
extra["environment"] = resolved_env
extra["role"] = resolved_role
extra_json_final = json.dumps(extra, ensure_ascii=False)
resolved_url = (platform_spec.get("default_url") or "").strip() or None
resolved_provider = platform_spec.get("provider_code") or key
conn = get_conn()
try:
seed_platforms(conn)
holder_id = account_id
if holder_id is None:
holder_id = _insert_secret_holder_account(
conn,
platform_key=key,
account_label=safe_label,
environment=resolved_env,
role=resolved_role,
provider_code=resolved_provider,
url=resolved_url,
now=now,
)
cred_id = insert_credential(
conn=conn,
account_id=holder_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=secret_ref_value,
secret_mask=secret_mask_value,
expires_at=None,
extra_json=extra_json_final,
now=now,
)
conn.commit()
except Exception:
conn.rollback()
log.exception("account_add_secret_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
log.info("account_add_secret_success id=%s platform=%s storage=env ref=%s",
cred_id, key, secret_ref_value)
_say(_ok_json({
"account_id": holder_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": secret_ref_value,
"secret_mask": secret_mask_value,
"warning": "env 存储方式:请确保环境变量在运行时可用。",
}))
return
if storage_n == "windows_credential":
if not secret_stdin:
_say(_err_json("ERROR:CLI_BAD_ARGS",
"windows_credential 需要 --secret-stdin 从 stdin 读取 secret。"))
return
secret_value = sys.stdin.readline().strip()
if not secret_value:
_say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。"))
return
secret_mask_value = mask_secret(secret_value)
cred_label_for_target = safe_label
target = make_windows_credential_target(key, credential_type, cred_label_for_target)
try:
write_secret("windows_credential", target, secret_value)
except (OSError, ValueError) as e:
log.error("secret_write_failed storage=windows_credential error=%s", str(e))
_say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}"))
return
extra = {}
if extra_json_str:
try:
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
extra = {}
resolved_env = environment or "production"
resolved_role = role or "api"
extra["environment"] = resolved_env
extra["role"] = resolved_role
extra_json_final = json.dumps(extra, ensure_ascii=False)
resolved_url = (platform_spec.get("default_url") or "").strip() or None
resolved_provider = platform_spec.get("provider_code") or key
conn = get_conn()
try:
seed_platforms(conn)
holder_id = account_id
if holder_id is None:
holder_id = _insert_secret_holder_account(
conn,
platform_key=key,
account_label=safe_label,
environment=resolved_env,
role=resolved_role,
provider_code=resolved_provider,
url=resolved_url,
now=now,
)
cred_id = insert_credential(
conn=conn,
account_id=holder_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=target,
secret_mask=secret_mask_value,
expires_at=None,
extra_json=extra_json_final,
now=now,
)
conn.commit()
except Exception:
conn.rollback()
log.exception("account_add_secret_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
log.info("account_add_secret_success id=%s platform=%s storage=windows_credential",
cred_id, key)
secret_value = ""
_say(_ok_json({
"account_id": holder_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": target,
"secret_mask": secret_mask_value,
}))
return
conn = get_conn()
try:
seed_platforms(conn)
cred_id = insert_credential(
conn=conn,
account_id=account_id,
platform_key=key,
credential_label=safe_label,
credential_type=credential_type,
secret_storage=storage_n,
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json=extra_json_str or "{}",
now=now,
)
conn.commit()
except Exception:
conn.rollback()
log.exception("account_add_secret_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
finally:
conn.close()
log.info("account_add_secret_success id=%s platform=%s storage=none", cred_id, key)
_say(_ok_json({
"account_id": account_id,
"credential_id": cred_id,
"platform_key": key,
"credential_label": safe_label,
"credential_type": credential_type,
"secret_storage": storage_n,
"secret_ref": "",
"secret_mask": "",
}))

View File

@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
"""CLI handlers for master key init and credential export/import (stage B)."""
from __future__ import annotations
import json
import os
import sys
import time
from datetime import datetime
from typing import Any, Optional
from db.accounts_repo import _unix_to_iso_local
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted
from service.crypto import CredentialDecryptError, decrypt_secret
from service.master_key import (
ENV_MASTER_KEY,
MasterKeyExistsError,
MasterKeyInvalidError,
generate_master_key,
normalize_master_key_input,
write_master_key_file,
)
from service.secret_store import mask_secret, read_secret
def _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _get_opt(argv: list[str], flag: str, default=None):
if flag in argv:
idx = argv.index(flag)
if idx + 1 < len(argv):
return argv[idx + 1]
return default
def _key_preview(key_bytes: bytes) -> str:
s = key_bytes.strip().decode("ascii")
if len(s) <= 8:
return "****"
return s[:4] + "****" + s[-4:]
def cmd_account_init_master_key(argv: list[str]) -> None:
"""account init-master-key [--from-env] [--print] [--rotate]"""
from_env = _has_flag(argv, "--from-env")
do_print = _has_flag(argv, "--print")
rotate = _has_flag(argv, "--rotate")
try:
if from_env:
raw = (os.environ.get(ENV_MASTER_KEY) or "").strip()
if not raw:
raise MasterKeyInvalidError("ACCOUNT_MANAGER_MASTER_KEY is not set")
key = normalize_master_key_input(raw)
else:
key = generate_master_key()
if rotate:
print(
"[init-master-key] WARNING: --rotate overwrites master.key; "
"ensure you have an export backup.",
file=sys.stderr,
)
path_written: Optional[str] = None
if not do_print:
path_written = write_master_key_file(key, allow_overwrite=rotate)
if do_print:
line = json.dumps(
{"success": True, "key": key.decode("ascii")},
ensure_ascii=False,
)
print(line)
return
assert path_written is not None
preview = _key_preview(key)
print(
json.dumps(
{"success": True, "path": path_written, "key_preview": preview},
ensure_ascii=False,
)
)
except MasterKeyExistsError as e:
print(
json.dumps(
{
"success": False,
"error": {"code": e.code, "message": str(e)},
},
ensure_ascii=False,
)
)
except MasterKeyInvalidError as e:
print(
json.dumps(
{
"success": False,
"error": {"code": e.code, "message": str(e)},
},
ensure_ascii=False,
)
)
def cmd_account_export_credentials(argv: list[str]) -> None:
"""account export-credentials --plaintext"""
if not _has_flag(argv, "--plaintext"):
print(
json.dumps(
{
"success": False,
"error": {
"code": "ERR_EXPORT_REQUIRES_PLAINTEXT_FLAG",
"message": "export requires explicit --plaintext acknowledgement",
},
},
ensure_ascii=False,
)
)
return
print(
"[export] WARNING: plaintext credentials written to stdout. Handle with extreme care.",
file=sys.stderr,
)
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id, account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, secret_ciphertext,
expires_at, extra_json, created_at, updated_at
FROM credentials
ORDER BY id ASC
"""
)
rows = cur.fetchall()
out: list[dict[str, Any]] = []
for r in rows:
cid = r[0]
storage = (r[5] or "").strip().lower()
secret_ref = r[6] or ""
secret_mask = r[7] or ""
ct = r[8]
extra_raw = r[10]
try:
extra_obj = json.loads(extra_raw) if isinstance(extra_raw, str) else {}
except json.JSONDecodeError:
extra_obj = {}
plaintext: Optional[str] = None
extra_out = dict(extra_obj)
if storage == "none":
plaintext = None
elif storage == "local_encrypted":
try:
if not ct:
raise CredentialDecryptError("missing ciphertext")
plaintext = decrypt_secret(str(ct))
except CredentialDecryptError as e:
print(
f"[export] decrypt_failed id={cid} code={e.code} msg={e}",
file=sys.stderr,
)
plaintext = None
elif storage == "env":
val = os.environ.get(secret_ref)
if val is None:
plaintext = None
extra_out["export_note"] = "env var missing at export time"
else:
plaintext = val
elif storage == "windows_credential":
try:
plaintext = read_secret("windows_credential", secret_ref)
except (ValueError, OSError) as e:
plaintext = None
extra_out["export_note"] = f"windows_credential read failed: {e}"
else:
extra_out["export_note"] = f"unsupported storage at export: {storage}"
item = {
"id": cid,
"account_id": r[1],
"platform_key": r[2],
"credential_label": r[3],
"credential_type": r[4],
"secret_storage": r[5],
"plaintext": plaintext,
"secret_mask": secret_mask,
"expires_at": _unix_to_iso_local(r[9]),
"extra_json": extra_out,
"created_at": _unix_to_iso_local(r[11]),
"updated_at": _unix_to_iso_local(r[12]),
}
out.append(item)
print(json.dumps(out, ensure_ascii=False))
finally:
conn.close()
def _iso_maybe_to_unix(val: Any) -> Optional[int]:
if val is None:
return None
if isinstance(val, (int, float)):
return int(val)
s = str(val).strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s)
return int(dt.timestamp())
except ValueError:
return None
def cmd_account_import_credentials(argv: list[str]) -> None:
"""account import-credentials [--replace-all] < stdin JSON array"""
replace_all = _has_flag(argv, "--replace-all")
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"[import] fatal: invalid JSON stdin: {e}", file=sys.stderr)
return
if not isinstance(data, list):
print("[import] fatal: stdin JSON must be an array", file=sys.stderr)
return
init_db()
conn = get_conn()
inserted = 0
skipped = 0
try:
cur = conn.cursor()
if replace_all:
cur.execute(
"DELETE FROM credentials WHERE secret_storage = ?",
("local_encrypted",),
)
now = int(time.time())
for idx, row in enumerate(data):
if not isinstance(row, dict):
print(f"[import] skip line {idx}: not an object", file=sys.stderr)
skipped += 1
continue
pt = row.get("plaintext")
if pt is None or str(pt).strip() == "":
skipped += 1
continue
try:
plat = str(row.get("platform_key") or "").strip()
label = str(row.get("credential_label") or "").strip()
ctype = str(row.get("credential_type") or "").strip()
if not plat or not label or not ctype:
raise ValueError("missing platform_key/credential_label/credential_type")
account_id = row.get("account_id")
aid = int(account_id) if account_id is not None else None
expires_at = _iso_maybe_to_unix(row.get("expires_at"))
ex = row.get("extra_json")
if isinstance(ex, dict):
extra_json = json.dumps(ex, ensure_ascii=False)
elif isinstance(ex, str):
extra_json = ex
else:
extra_json = "{}"
insert_credential_encrypted(
conn,
account_id=aid,
platform_key=plat,
credential_label=label,
credential_type=ctype,
plaintext=str(pt),
expires_at=expires_at,
extra_json=extra_json,
now=now,
)
inserted += 1
except Exception as e:
print(f"[import] skip line {idx}: {e}", file=sys.stderr)
skipped += 1
conn.commit()
finally:
conn.close()
print(
f"[import] inserted {inserted} credentials, skipped {skipped} lines (no plaintext).",
file=sys.stderr,
)

View File

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

View File

@@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
"""Account query and pick CLI commands."""
import json
import uuid
from typing import Optional
from db.accounts_repo import (
acquire_lease,
find_account_ids,
find_accounts,
find_credentials,
get_account_by_id,
get_active_lease_by_token,
update_account_last_used,
)
from db.connection import get_conn, init_db
from db.credentials_repo import (
CredentialNotEncryptedError,
list_credentials_by_account,
read_credential_plaintext,
)
from service._svc_common import (
PlatformCallerMeta,
_err_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
from service.crypto import CredentialDecryptError
from service.master_key import MasterKeyMissingError
from util.logging_config import get_skill_logger
from util.platforms import seed_platforms
from util.runtime_paths import _runtime_paths_debug_text
def cmd_account_get(account_id, include_credentials: bool = False) -> None:
"""Get a single account by id. Returns JSON."""
log = get_skill_logger()
log.info("cli_start subcommand=account_get id=%r", account_id)
init_db()
acc = get_account_by_id(account_id)
if not acc:
log.warning("account_get_not_found id=%r", account_id)
_say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。")
_say_err(_runtime_paths_debug_text())
return
if include_credentials:
creds = find_credentials(
platform_key=acc["platform_key"],
limit=20,
)
linked_creds = [c for c in creds if c["account_id"] == acc["id"]]
if not linked_creds:
linked_creds = [c for c in creds if c["account_id"] is None]
acc["credentials"] = [
{
"id": c["id"],
"credential_label": c["credential_label"],
"credential_type": c["credential_type"],
"secret_storage": c["secret_storage"],
"secret_ref": c["secret_ref"],
"secret_mask": c["secret_mask"],
"status": c["status"],
}
for c in linked_creds
]
_say(json.dumps(acc, ensure_ascii=False))
def cmd_account_get_credential(
account_id: int,
*,
reveal: bool = False,
lease_token: Optional[str] = None,
) -> None:
"""account get-credential — metadata or plaintext when lease proves access."""
log = get_skill_logger()
log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal)
init_db()
acc = get_account_by_id(account_id)
if not acc:
log.warning("account_get_credential_not_found id=%s", account_id)
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
return
conn = get_conn()
try:
creds_all = list_credentials_by_account(conn, account_id)
creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"]
if not creds:
_say(_err_json(
"ERR_CREDENTIAL_MISSING",
f"账号 id={account_id} 没有活跃凭据。",
))
return
cred = creds[0]
base_out = {
"success": True,
"account_id": account_id,
"credential_id": cred["id"],
"credential_type": cred["credential_type"],
"secret_storage": cred["secret_storage"],
"secret_mask": cred["secret_mask"],
}
if not reveal:
_say(json.dumps(base_out, ensure_ascii=False))
return
tok = (lease_token or "").strip()
if not tok:
log.warning("account_get_credential_reveal_missing_token id=%s", account_id)
_say(_err_json(
"ERR_LEASE_INVALID",
"reveal 必须带 --lease-token。",
))
return
lease = get_active_lease_by_token(conn, tok)
lease_prefix = tok[:8]
if lease is None:
log.warning(
"account_get_credential_reveal_bad_lease id=%s lease_prefix=%s",
account_id,
lease_prefix,
)
_say(_err_json(
"ERR_LEASE_INVALID",
"lease token 无效或已过期",
))
return
if int(lease["account_id"]) != int(account_id):
log.warning(
"account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s",
account_id,
lease["account_id"],
lease_prefix,
)
_say(_err_json(
"ERR_LEASE_INVALID",
"lease token 不属于该 account",
))
return
try:
pt = read_credential_plaintext(conn, int(cred["id"]))
except MasterKeyMissingError as e:
log.warning("account_get_credential_master_missing id=%s", account_id)
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
return
except CredentialDecryptError as e:
log.warning(
"account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s",
account_id,
cred["id"],
lease_prefix,
)
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
return
except CredentialNotEncryptedError:
out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"}
log.info(
"account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s",
account_id,
cred["id"],
lease_prefix,
)
_say(json.dumps(out, ensure_ascii=False))
return
log.info(
"account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s",
account_id,
cred["id"],
lease_prefix,
)
_say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False))
finally:
conn.close()
def cmd_get(account_id) -> None:
"""Legacy: get <id> — same as account get <id>."""
cmd_account_get(account_id, include_credentials=False)
def cmd_account_list(
platform_input: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
limit: int = 200,
) -> None:
"""account list — output JSON array of accounts matching filters."""
log = get_skill_logger()
log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s",
platform_input, environment, tenant_id, role)
init_db()
key = None
if platform_input and platform_input not in ("all", "全部", ""):
key = _resolve_or_fail(platform_input)
if not key:
return
accounts = find_accounts(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
limit=limit,
)
_say(json.dumps(accounts, ensure_ascii=False))
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
"""Legacy: list-json <platform|all> [--limit N]"""
log = get_skill_logger()
log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit)
init_db()
raw = (platform_input or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = None
else:
key = _resolve_or_fail(raw)
if not key:
return
lim = max(1, min(int(limit), 500))
accounts = find_accounts(platform_key=key, limit=lim)
_say(json.dumps(accounts, ensure_ascii=False))
def cmd_account_pick_web(
platform_input: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
do_lease: bool = False,
ttl_sec: int = 900,
holder: Optional[str] = None,
purpose: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""Pick a web/RPA account for use. Optionally acquire a lease."""
log = get_skill_logger()
log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
platform_input, environment, tenant_id, role, do_lease)
key = _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
init_db()
conn = get_conn()
try:
seed_platforms(conn)
finally:
conn.close()
candidate_ids = find_account_ids(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
)
if not candidate_ids:
_say(_err_json("ERROR:NO_ACCOUNT",
"没有符合条件的可用账号status=active 且未被其他 lease 占用)。"))
return
picked_id = candidate_ids[0]
acc = get_account_by_id(picked_id)
if not acc:
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。"))
return
update_account_last_used(picked_id)
result = {
"id": acc["id"],
"platform_key": acc["platform_key"],
"account_label": acc["account_label"],
"login_id": acc["login_id"],
"profile_dir": acc["profile_dir"],
"url": acc["url"],
"tenant_id": acc["tenant_id"],
"environment": acc["environment"],
"role": acc["role"],
"provider_code": acc["provider_code"],
"session_status": acc["session_status"],
"auth_strategy": acc["auth_strategy"],
"session_persistent": acc["session_persistent"],
"device_fingerprint": acc.get("device_fingerprint"),
}
lease_info = None
if do_lease:
lease_token = uuid.uuid4().hex[:32]
resolved_holder = holder or "unknown"
try:
lease_info = acquire_lease(
account_id=picked_id,
lease_token=lease_token,
holder=resolved_holder,
purpose=purpose,
ttl_sec=ttl_sec,
)
result["lease_token"] = lease_info["lease_token"]
result["lease_expires_at"] = lease_info["expires_at"]
except ValueError as e:
log.warning("account_pick_web_lease_conflict id=%s", picked_id)
_say(_err_json("ERROR:LEASE_CONFLICT", str(e)))
return
log.info("account_pick_web_success id=%s token_prefix=%s",
picked_id, lease_info["lease_token"][:8] if lease_info else "none")
_say(json.dumps(result, ensure_ascii=False))
def cmd_pick_web(platform_input: str) -> None:
"""Legacy: pick-web <platform> — same as account pick-web without lease."""
cmd_account_pick_web(platform_input, do_lease=False)

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""
Account service layer — use cases invoked by CLI commands.
Each function handles:
1. Input validation
2. Logging key nodes
3. Calling the repository layer
4. Formatting JSON/stdout output
Raw secret is NEVER logged or printed in plaintext.
Implementation is split across service/*_commands.py modules to stay within
PyArmor trial per-file line limits. This module re-exports the public API.
"""
from service._svc_common import PlatformCallerMeta
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
from service.account_mark_commands import (
cmd_account_mark_error,
cmd_account_mark_session,
cmd_account_mark_used,
)
from service.account_query_commands import (
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
cmd_account_pick_web,
cmd_get,
cmd_list_json,
cmd_pick_web,
)
from service.credential_commands import cmd_credential_pick, cmd_credential_resolve
from service.legacy_commands import (
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_list,
)
from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release
from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list
__all__ = [
"PlatformCallerMeta",
"cmd_account_add_secret",
"cmd_account_add_web",
"cmd_account_get",
"cmd_account_get_credential",
"cmd_account_list",
"cmd_account_mark_error",
"cmd_account_mark_session",
"cmd_account_mark_used",
"cmd_account_pick_web",
"cmd_credential_pick",
"cmd_credential_resolve",
"cmd_delete_by_id",
"cmd_delete_by_platform",
"cmd_delete_by_platform_phone",
"cmd_get",
"cmd_lease_cleanup",
"cmd_lease_list",
"cmd_lease_release",
"cmd_list",
"cmd_list_json",
"cmd_pick_web",
"cmd_platform_ensure",
"cmd_platform_get",
"cmd_platform_list",
]

View File

@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates):
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def resolve_chromium_executable():
"""
Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None。
"""
if sys.platform != "win32":
return None
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LocalAppData", "")
chrome = _win_find_exe(
[
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
]
)
if chrome:
return chrome
edge = _win_find_exe(
[
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
]
)
if edge:
return edge
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
exe = resolve_chromium_executable()
if not exe:
return None
if os.path.basename(exe).lower() == "msedge.exe":
return "msedge"
return "chrome"
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def resolve_account_browser_url(account: dict, conn=None) -> str:
"""account.url 优先,其次 platforms 表 default_url最后内置 PLATFORMS。"""
url = (account.get("url") or "").strip()
if url:
return url
pk = (account.get("platform_key") or "").strip()
if not pk:
return ""
close_conn = False
if conn is None:
init_db()
conn = get_conn()
close_conn = True
try:
plat = get_platform_from_db(conn, pk)
if plat and (plat.get("default_url") or "").strip():
return plat["default_url"].strip()
finally:
if close_conn:
conn.close()
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
def cmd_open(account_id):
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
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:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包。"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not 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)
conn = get_conn()
try:
url = resolve_account_browser_url(target, conn=conn)
finally:
conn.close()
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
channel,
target.get("platform_key"),
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try:
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)
except OSError:
pass

View File

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

36
scripts/service/crypto.py Normal file
View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Fernet 加密 / 解密的薄封装。
不暴露 Fernet 对象;调用方只看 encrypt/decrypt 字符串。
"""
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken
from service.master_key import load_master_key
from service.secret_store import mask_secret
class CredentialDecryptError(Exception):
"""Decryption failed (bad key or corrupted ciphertext)."""
code = "ERR_CREDENTIAL_DECRYPT_FAILED"
def encrypt_secret(plaintext: str) -> str:
"""Encrypt using current master key; returns url-safe token string."""
key = load_master_key()
f = Fernet(key)
tok = f.encrypt(plaintext.encode("utf-8"))
return tok.decode("ascii")
def decrypt_secret(ciphertext: str) -> str:
"""Decrypt Fernet token string to plaintext."""
key = load_master_key()
f = Fernet(key)
try:
raw = f.decrypt(ciphertext.encode("ascii"))
return raw.decode("utf-8")
except InvalidToken as e:
raise CredentialDecryptError("Fernet decrypt failed") from e

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Platform management CLI commands."""
import json
from typing import Optional
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
from db.connection import get_conn, init_db
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
from util.logging_config import get_skill_logger
from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms
def cmd_platform_list(domain: Optional[str] = None) -> None:
"""platform list [--domain logistics|llm|content]"""
log = get_skill_logger()
log.info("cli_start subcommand=platform_list domain=%s", domain)
init_db()
conn = get_conn()
try:
seed_platforms(conn)
platforms = list_platforms_from_db(conn, domain=domain)
finally:
conn.close()
_say(_ok_json({"platforms": platforms}))
def cmd_platform_get(input_key: str) -> None:
"""platform get <platform>"""
log = get_skill_logger()
log.info("cli_start subcommand=platform_get key=%s", input_key)
key = _resolve_or_fail(input_key)
if not key:
return
init_db()
conn = get_conn()
try:
plat = get_platform_from_db(conn, key)
finally:
conn.close()
if plat:
_say(_ok_json(plat))
else:
_say(_err_json(
"ERROR:PLATFORM_NOT_REGISTERED",
f"Platform '{key}' not found in DB.",
))
def cmd_platform_ensure(
key: str,
display_name: str,
domain: str = "generic",
url: str = "",
auth_strategy: Optional[str] = None,
aliases: Optional[str] = None,
capabilities: Optional[str] = None,
) -> int:
"""
幂等地注册或更新平台到 DB。
输出单行 JSON{"ok": true, "platform_key": "xxx", "action": "created|updated"}
"""
log = get_skill_logger()
key = (key or "").strip()
display_name = (display_name or key).strip()
if not key:
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
return 1
if not is_valid_platform_key_format(key):
_say(_err_json(
"ERROR:INVALID_PLATFORM_KEY",
f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。",
))
return 1
aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False)
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
init_db()
conn = get_conn()
try:
existed = bool(
conn.execute(
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
).fetchone()
)
upsert_platform(
conn,
key=key,
display_name=display_name,
domain=domain,
default_url=url,
aliases_json=aliases_json,
capabilities_json=capabilities_json,
default_auth_strategy=auth_strategy,
)
finally:
conn.close()
action = "updated" if existed else "created"
log.info("platform_ensure key=%s action=%s", key, action)
_say(json.dumps({"ok": True, "platform_key": key, "action": action}, ensure_ascii=False))
return 0

View File

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

View File

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

View File

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

View File

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

View File

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

1
scripts/util/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared helpers for account-manager (paths, platforms, logging, constants)."""

View File

@@ -0,0 +1,9 @@
"""技能级常量。本地 CLI 默认值见 jiangchang_skill_core.runtime_envmain.py 最早调用 apply_cli_local_defaults"""
import os
# scripts/util/constants.py -> skill root = parents[2]
_BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASE_DIR = _BASE
SKILL_SLUG = "account-manager"
# 与其它 OpenClaw 技能对齐openclaw.skill.<slug_下划线>
LOG_LOGGER_NAME = "openclaw.skill.account_manager"

View File

@@ -0,0 +1,21 @@
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
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,
)
__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",
]

581
scripts/util/platforms.py Normal file
View File

@@ -0,0 +1,581 @@
# -*- coding: utf-8 -*-
"""
Platform definitions and resolution.
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
"""
import json
import re
import time
from dataclasses import dataclass
from typing import Any, Optional
from util.logging_config import get_skill_logger
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
# ---------------------------------------------------------------------------
# Platform specification fields:
# display_name Human-readable name (e.g. "Maersk")
# domain Business domain: logistics / llm / content / ecommerce / generic
# provider_code Supplier code (logistics); can be same as key
# default_url Default entry URL (empty string if none)
# aliases List of name aliases (Chinese/English, for CLI resolution)
# capabilities Dict of capability flags:
# {"web": bool, "api_key": bool, "rpa": bool}
# ---------------------------------------------------------------------------
PLATFORMS: dict[str, dict[str, Any]] = {
# =========================================================================
# Logistics platforms
# =========================================================================
"maersk": {
"display_name": "Maersk",
"domain": "logistics",
"provider_code": "maersk",
"default_url": "https://www.maersk.com",
"aliases": ["马士基", "maersk", "Maersk"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cosco": {
"display_name": "COSCO",
"domain": "logistics",
"provider_code": "cosco",
"default_url": "",
"aliases": ["中远海运", "COSCO", "cosco"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"msc": {
"display_name": "MSC",
"domain": "logistics",
"provider_code": "msc",
"default_url": "",
"aliases": ["MSC", "地中海航运"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"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},
},
"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": {
"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": {
"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": {
"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": {
"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": {
"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},
},
}
# ---------------------------------------------------------------------------
# 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():
# 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_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str) -> Optional[str]:
"""
Resolve user input to a canonical platform key (built-in PLATFORMS only).
Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized.
"""
if not name:
return None
s = str(name).strip()
if not s:
return None
sl = s.lower()
# Direct key match
if sl in PLATFORMS:
return sl
# 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 is_valid_platform_key_format(name: str) -> bool:
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
s = (name or "").strip().lower()
if not s:
return False
return bool(PLATFORM_KEY_PATTERN.match(s))
def _parse_aliases_json(val: Any) -> list[str]:
if val is None:
return []
try:
data = json.loads(val) if isinstance(val, str) else val
if isinstance(data, list):
return [str(a).strip() for a in data if str(a).strip()]
except (json.JSONDecodeError, TypeError, ValueError):
pass
return []
@dataclass(frozen=True)
class PlatformResolveResult:
platform_key: Optional[str] = None
error_code: Optional[str] = None
message: Optional[str] = None
@property
def ok(self) -> bool:
return bool(self.platform_key) and not self.error_code
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
"""在 platforms 表中解析:精确 keyenabled=1与 aliases_json。"""
raw_stripped = (raw or "").strip()
raw_lower = raw_stripped.lower()
if is_valid_platform_key_format(raw_stripped):
row = conn.execute(
"SELECT platform_key, enabled FROM platforms WHERE platform_key = ?",
(raw_lower,),
).fetchone()
if row:
if not row[1]:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=f"平台 {row[0]} 已禁用。",
)
return PlatformResolveResult(platform_key=row[0])
rows = conn.execute(
"SELECT platform_key, enabled, aliases_json FROM platforms"
).fetchall()
for platform_key, enabled, aliases_json in rows:
if not enabled:
continue
names = {platform_key.lower()}
for alias in _parse_aliases_json(aliases_json):
names.add(alias.lower())
names.add(alias)
if raw_lower in names or raw_stripped in names:
return PlatformResolveResult(platform_key=platform_key)
return PlatformResolveResult()
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
"""
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
"""
raw = (input_name or "").strip()
if not raw:
return PlatformResolveResult(
error_code="INVALID_PLATFORM",
message="平台名不能为空。",
)
builtin = resolve_platform_key(raw)
if builtin:
return PlatformResolveResult(platform_key=builtin)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
db_result = _resolve_platform_from_db(conn, raw)
if db_result.ok or db_result.error_code:
return db_result
finally:
if close_conn:
conn.close()
if not is_valid_platform_key_format(raw):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=(
f"平台 key 格式非法:「{raw}」。"
"建议使用小写字母、数字、下划线或连字符,例如 jiangchang。"
),
)
key_hint = raw.lower()
return PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {key_hint} 尚未注册,请先执行 "
f"account platform ensure --key {key_hint} --display-name <名称> [--url <url>]"
),
)
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
spec = PLATFORMS.get(platform_key)
if spec:
return dict(spec)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
from db.accounts_repo import get_platform_from_db
plat = get_platform_from_db(conn, platform_key)
if plat:
return {
"display_name": plat["display_name"],
"domain": plat.get("domain") or "generic",
"provider_code": plat.get("provider_code") or platform_key,
"default_url": plat.get("default_url") or "",
"aliases": plat.get("aliases") or [],
"capabilities": plat.get("capabilities") or {},
}
finally:
if close_conn:
conn.close()
return {}
def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated built-in key."""
return PLATFORMS.get(key)
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
"""List all platforms, optionally filtered by domain.
Returns a list of dicts with metadata."""
result = []
for key, spec in PLATFORMS.items():
if domain and spec["domain"] != domain:
continue
result.append({
"platform_key": key,
"display_name": spec["display_name"],
"domain": spec["domain"],
"provider_code": spec.get("provider_code"),
"default_url": spec.get("default_url", ""),
"aliases": spec.get("aliases", []),
"capabilities": spec.get("capabilities", {}),
"enabled": True,
})
return result
def seed_platforms(conn) -> None:
"""
Upsert all platform definitions into the 'platforms' table.
Must be called after init_db() in the same connection lifecycle.
"""
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
log = get_skill_logger()
log.info("platform_seed_start")
cur = conn.cursor()
now = int(time.time())
upsert_count = 0
for key, spec in PLATFORMS.items():
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
cur.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name,
domain = excluded.domain,
provider_code = excluded.provider_code,
default_url = excluded.default_url,
aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json,
enabled = 1,
default_auth_strategy = excluded.default_auth_strategy,
updated_at = excluded.updated_at
""",
(
key,
spec["display_name"],
spec["domain"],
spec.get("provider_code"),
spec.get("default_url", ""),
aliases_json,
capabilities_json,
default_auth_strategy,
now,
now,
),
)
upsert_count += 1
conn.commit()
log.info("platform_seed_done count=%s", upsert_count)
# ---------------------------------------------------------------------------
# Legacy compatibility helpers (for old account_service v1 code references)
# ---------------------------------------------------------------------------
# Kept for backward compatibility so old imports don't break immediately.
# New code should use PLATFORMS dict directly and resolve_platform_key().
_PLATFORM_PRIMARY_CN: dict[str, str] = {
k: spec["display_name"] for k, spec in PLATFORMS.items()
}
PLATFORM_URLS: dict[str, str] = {
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
}
def _platform_list_cn_for_help() -> str:
"""帮助文案:一行展示名,供 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

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

View File

@@ -0,0 +1,93 @@
# -*- 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
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path() -> str:
return os.path.join(get_skill_data_dir(), "account-manager.db")
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 (
"[account-manager] "
f"JIANGCHANG_DATA_ROOT={env_root} | "
f"JIANGCHANG_USER_ID={env_uid} | "
f"实际数据根={get_data_root()} | "
f"实际用户目录={get_user_id()} | "
f"数据库文件={get_db_path()}"
)
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)
_WIN_FS_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_WIN_RESERVED_NAMES = frozenset(
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
def _fs_safe_segment(name: str, fallback: str) -> str:
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
if not t:
t = fallback
if t.upper() in _WIN_RESERVED_NAMES:
t = f"_{t}"
return t
def get_default_profile_dir_for_web(
platform_key: str,
label: str,
login_id: Optional[str] = None,
domain: str = "generic",
) -> str:
"""
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_seg = _fs_safe_segment(label, platform_key)
login_suffix = ""
if login_id:
safe_login = _fs_safe_segment(login_id, "")
if safe_login:
login_suffix = f"_{safe_login[:16]}"
path = os.path.join(
get_skill_data_dir(),
"profiles",
_fs_safe_segment(domain, "generic"),
_fs_safe_segment(platform_key, "unknown"),
f"{label_seg}{login_suffix}",
)
os.makedirs(path, exist_ok=True)
return path

1
tests/__init__.py Normal file
View File

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

56
tests/conftest.py Normal file
View File

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

21
tests/run_tests.py Normal file
View File

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

View File

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

View File

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

View File

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

511
tests/test_cli_stage_c.py Normal file
View File

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

340
tests/test_credentials.py Normal file
View File

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

View File

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

47
tests/test_crypto.py Normal file
View File

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

View File

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

242
tests/test_lease.py Normal file
View File

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

60
tests/test_master_key.py Normal file
View File

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

View File

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

View File

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

83
tests/test_platforms.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

33
tests/test_usage.py Normal file
View File

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

View File

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